react-linear-feedback 0.1.1 β 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/README.md +53 -14
- package/dist/react/index.cjs +27 -11
- package/dist/react/index.d.cts +3 -1
- package/dist/react/index.d.ts +3 -1
- package/dist/react/index.js +27 -11
- package/dist/server/index.cjs +2 -1
- package/dist/server/index.d.cts +10 -2
- package/dist/server/index.d.ts +10 -2
- package/dist/server/index.js +2 -1
- package/dist/vite/index.cjs +147 -0
- package/dist/vite/index.d.cts +27 -0
- package/dist/vite/index.d.ts +27 -0
- package/dist/vite/index.js +145 -0
- package/package.json +13 -7
package/README.md
CHANGED
|
@@ -13,7 +13,7 @@ A trusted user opens it with `?feedback`, **drags a box** over the page, picks a
|
|
|
13
13
|
- π **Works in any React app** β Next.js, Vite, Remix, CRAβ¦ (no `next` dependency; `"use client"` is built in)
|
|
14
14
|
- πΌοΈ **Annotated screenshots** via [`modern-screenshot`](https://github.com/qq15725/modern-screenshot) (handles Tailwind v4 / `oklch()`)
|
|
15
15
|
- π·οΈ **Labels by name, self-healing** β resolved at request time, so recoloring/recreating a label in Linear won't break it; applied best-effort
|
|
16
|
-
- π **Tiny server core** + Next.js
|
|
16
|
+
- π **Tiny server core** + Next.js, Node/Express, and Vite-dev adapters
|
|
17
17
|
|
|
18
18
|
## Contents
|
|
19
19
|
|
|
@@ -23,11 +23,9 @@ A trusted user opens it with `?feedback`, **drags a box** over the page, picks a
|
|
|
23
23
|
|
|
24
24
|
```bash
|
|
25
25
|
npm i react-linear-feedback
|
|
26
|
-
# the server entry needs the Linear SDK (optional peer):
|
|
27
|
-
npm i @linear/sdk
|
|
28
26
|
```
|
|
29
27
|
|
|
30
|
-
`react` / `react-dom` are peer dependencies. `@linear/sdk`
|
|
28
|
+
`react` / `react-dom` are peer dependencies. `@linear/sdk` ships as a dependency β it's imported only by the server entry, so it's tree-shaken out of client bundles. Do [Linear setup](#linear-setup) first to get your API key, team, and labels.
|
|
31
29
|
|
|
32
30
|
## Quick start (Next.js, App Router)
|
|
33
31
|
|
|
@@ -66,33 +64,73 @@ No `"use client"` needed β the package ships it, so you can mount `<FeedbackGa
|
|
|
66
64
|
|
|
67
65
|
## Use with Vite / any React app
|
|
68
66
|
|
|
69
|
-
|
|
67
|
+
Unlike Next.js, a Vite SPA has **no server of its own** β so the handler runs as a serverless function (e.g. on Vercel) in production, and as a dev-server plugin locally. Same widget either way.
|
|
68
|
+
|
|
69
|
+
**1. Mount the widget** once, in your root component:
|
|
70
70
|
|
|
71
71
|
```tsx
|
|
72
72
|
import { FeedbackGate } from "react-linear-feedback/react";
|
|
73
73
|
|
|
74
|
-
<FeedbackGate
|
|
74
|
+
<FeedbackGate brandColor="#7f56d9" />; // endpoint defaults to /api/feedback (same origin)
|
|
75
|
+
```
|
|
76
|
+
|
|
77
|
+
**2. Production β a Vercel serverless function** at `api/feedback.ts`:
|
|
78
|
+
|
|
79
|
+
```ts
|
|
80
|
+
import { createNodeHandler, cookieGate } from "react-linear-feedback/server";
|
|
81
|
+
|
|
82
|
+
// Node runtime (the default for /api functions) β Edge has no Buffer for the screenshot upload.
|
|
83
|
+
export default createNodeHandler({
|
|
84
|
+
apiKey: process.env.LINEAR_API_KEY!,
|
|
85
|
+
teamId: process.env.LINEAR_TEAM_ID!,
|
|
86
|
+
authorize: cookieGate("wh_feedback"),
|
|
87
|
+
});
|
|
88
|
+
```
|
|
89
|
+
|
|
90
|
+
Set `LINEAR_API_KEY` / `LINEAR_TEAM_ID` in your Vercel project's env (server-side β **not** `VITE_`-prefixed, so they never reach the bundle). The function is same-origin as the SPA, so no CORS needed.
|
|
91
|
+
|
|
92
|
+
**3. Local dev β the Vite plugin**, so `vite dev` serves the same endpoint (without it, `POST /api/feedback` 404s locally):
|
|
93
|
+
|
|
94
|
+
```ts
|
|
95
|
+
// vite.config.ts
|
|
96
|
+
import { defineConfig, loadEnv } from "vite";
|
|
97
|
+
import { linearFeedback } from "react-linear-feedback/vite";
|
|
98
|
+
import { cookieGate } from "react-linear-feedback/server";
|
|
99
|
+
|
|
100
|
+
export default defineConfig(({ mode }) => {
|
|
101
|
+
const env = loadEnv(mode, process.cwd(), ""); // reads .env (LINEAR_* are server-side, un-prefixed)
|
|
102
|
+
return {
|
|
103
|
+
plugins: [
|
|
104
|
+
linearFeedback({
|
|
105
|
+
apiKey: env.LINEAR_API_KEY,
|
|
106
|
+
teamId: env.LINEAR_TEAM_ID,
|
|
107
|
+
authorize: cookieGate("wh_feedback"),
|
|
108
|
+
}),
|
|
109
|
+
],
|
|
110
|
+
};
|
|
111
|
+
});
|
|
75
112
|
```
|
|
76
113
|
|
|
77
|
-
|
|
114
|
+
The plugin is dev-only (`apply: "serve"`) β it has no effect on the production build.
|
|
115
|
+
|
|
116
|
+
### Other Node servers (Express, Hono, β¦)
|
|
117
|
+
|
|
118
|
+
`createNodeHandler` is a plain `(req, res)` handler that reads the raw body itself (works with or without `express.json()`):
|
|
78
119
|
|
|
79
120
|
```ts
|
|
80
121
|
import express from "express";
|
|
81
|
-
import cors from "cors";
|
|
82
122
|
import { createNodeHandler, cookieGate } from "react-linear-feedback/server";
|
|
83
123
|
|
|
84
124
|
const app = express();
|
|
85
|
-
|
|
86
|
-
app.use("/feedback", cors({ origin: "https://your-site.com", credentials: true }));
|
|
87
|
-
app.post("/feedback", createNodeHandler({
|
|
125
|
+
app.post("/api/feedback", createNodeHandler({
|
|
88
126
|
apiKey: process.env.LINEAR_API_KEY!,
|
|
89
127
|
teamId: process.env.LINEAR_TEAM_ID!,
|
|
90
|
-
authorize: cookieGate("wh_feedback"),
|
|
128
|
+
authorize: cookieGate("wh_feedback"),
|
|
91
129
|
}));
|
|
92
130
|
app.listen(8787);
|
|
93
131
|
```
|
|
94
132
|
|
|
95
|
-
|
|
133
|
+
If the SPA and API are on **different origins**, set `allowedOrigin: "https://your-site.com"` and enable CORS (`credentials: true` so the cookie is sent).
|
|
96
134
|
|
|
97
135
|
## Linear setup
|
|
98
136
|
|
|
@@ -151,7 +189,7 @@ createNextRoute({
|
|
|
151
189
|
|
|
152
190
|
## Theming
|
|
153
191
|
|
|
154
|
-
Set `brandColor`, or override any CSS variable on `.lfb-
|
|
192
|
+
Set `brandColor`, or override any CSS variable on `.lfb-root`:
|
|
155
193
|
`--lfb-brand`, `--lfb-fg`, `--lfb-surface`, `--lfb-border`, `--lfb-radius`, `--lfb-rect`, `--lfb-z`, `--lfb-font`.
|
|
156
194
|
|
|
157
195
|
## Custom types
|
|
@@ -184,6 +222,7 @@ Submissions never throw in the UI β failures are logged to the browser console
|
|
|
184
222
|
- `endpoint` points at your route, and `LINEAR_API_KEY` / `LINEAR_TEAM_ID` are set.
|
|
185
223
|
- **CORS** is configured when the app and API are on different origins.
|
|
186
224
|
- `runtime = "nodejs"` is set on the Next.js route (Edge has no `Buffer`).
|
|
225
|
+
- On a **Vite SPA**, `POST /api/feedback` 404s under `vite dev` unless you add the [`linearFeedback` Vite plugin](#use-with-vite--any-react-app) (or run `vercel dev`). In production it's served by your deployed function.
|
|
187
226
|
- The expected Linear **labels exist** (otherwise the issue is created without a label, with a warning).
|
|
188
227
|
|
|
189
228
|
## License
|
package/dist/react/index.cjs
CHANGED
|
@@ -169,7 +169,13 @@ var TYPE_ICONS = {
|
|
|
169
169
|
// src/react/styles.ts
|
|
170
170
|
var STYLE_ID = "lfb-styles";
|
|
171
171
|
var CSS2 = `
|
|
172
|
-
|
|
172
|
+
/*
|
|
173
|
+
* Defaults live on .lfb-root (the element that also receives the inline brandColor
|
|
174
|
+
* override) \u2014 NOT on the layers. The layers/FAB inherit from here. If the defaults
|
|
175
|
+
* sat on .lfb-doc-layer/.lfb-fixed-layer, a direct rule on those elements would beat
|
|
176
|
+
* the brandColor inherited from .lfb-root, so the \`brandColor\` prop would never apply.
|
|
177
|
+
*/
|
|
178
|
+
.lfb-root {
|
|
173
179
|
--lfb-brand: #6366f1;
|
|
174
180
|
--lfb-fg: #181d27;
|
|
175
181
|
--lfb-fg-secondary: #414651;
|
|
@@ -250,12 +256,24 @@ var CSS2 = `
|
|
|
250
256
|
.lfb-stack--bottom-left { right: auto; left: 16px; align-items: flex-start; }
|
|
251
257
|
.lfb-stack--top-right { bottom: auto; top: 16px; }
|
|
252
258
|
.lfb-stack--top-left { bottom: auto; top: 16px; right: auto; left: 16px; align-items: flex-start; }
|
|
259
|
+
/* Edge tabs: anchored flush to a side, vertically centered. */
|
|
260
|
+
.lfb-stack--right { top: 50%; bottom: auto; right: 0; transform: translateY(-50%); align-items: flex-end; }
|
|
261
|
+
.lfb-stack--left { top: 50%; bottom: auto; left: 0; right: auto; transform: translateY(-50%); align-items: flex-start; }
|
|
262
|
+
/* Keep the transient name-prompt / sent-toast cards off the viewport edge (the tab stays flush). */
|
|
263
|
+
.lfb-stack--right > .lfb-card { margin-right: 12px; }
|
|
264
|
+
.lfb-stack--left > .lfb-card { margin-left: 12px; }
|
|
253
265
|
|
|
254
266
|
.lfb-fab { display: inline-flex; align-items: center; gap: 8px; border: 0; border-radius: 9999px; padding: 12px 16px; font-size: 14px; font-weight: 600; font-family: var(--lfb-font); cursor: pointer; background: var(--lfb-brand); color: #fff; box-shadow: 0 10px 25px rgba(0,0,0,0.18); transition: transform 0.1s, background 0.1s; }
|
|
255
267
|
.lfb-fab:hover { transform: scale(1.05); background: color-mix(in srgb, var(--lfb-brand) 88%, black); }
|
|
256
268
|
.lfb-fab--active { background: var(--lfb-surface); color: var(--lfb-fg); border: 1px solid var(--lfb-border); }
|
|
257
269
|
.lfb-fab--active:hover { background: var(--lfb-surface-hover); }
|
|
258
270
|
|
|
271
|
+
/* Edge-tab launcher: compact, icon-only, rounded on the inner side only, flush to the viewport edge. */
|
|
272
|
+
.lfb-fab--tab { gap: 0; padding: 14px 12px; border-radius: 12px 0 0 12px; box-shadow: -6px 0 20px rgba(0,0,0,0.18); }
|
|
273
|
+
.lfb-fab--tab:hover { transform: translateX(-2px); }
|
|
274
|
+
.lfb-stack--left .lfb-fab--tab { border-radius: 0 12px 12px 0; box-shadow: 6px 0 20px rgba(0,0,0,0.18); }
|
|
275
|
+
.lfb-stack--left .lfb-fab--tab:hover { transform: translateX(2px); }
|
|
276
|
+
|
|
259
277
|
.lfb-toast { display: flex; align-items: flex-start; gap: 10px; width: 300px; max-width: calc(100vw - 32px); }
|
|
260
278
|
.lfb-toast-icon { color: #17b26a; flex-shrink: 0; margin-top: 1px; }
|
|
261
279
|
.lfb-toast-body { min-width: 0; flex: 1; }
|
|
@@ -287,6 +305,7 @@ function FeedbackWidget({
|
|
|
287
305
|
nameStorageKey = "wh_feedback_name",
|
|
288
306
|
fabLabel = "Give feedback"
|
|
289
307
|
}) {
|
|
308
|
+
const isEdge = position === "right" || position === "left";
|
|
290
309
|
const [mode, setMode] = react.useState({ kind: "idle" });
|
|
291
310
|
const [name, setName] = react.useState("");
|
|
292
311
|
const [nameDraft, setNameDraft] = react.useState("");
|
|
@@ -563,21 +582,18 @@ function FeedbackWidget({
|
|
|
563
582
|
] }),
|
|
564
583
|
/* @__PURE__ */ jsxRuntime.jsx("button", { type: "button", className: "lfb-iconbtn", "aria-label": "Dismiss", onClick: () => setResult(null), children: /* @__PURE__ */ jsxRuntime.jsx(XIcon, {}) })
|
|
565
584
|
] }),
|
|
566
|
-
/* @__PURE__ */ jsxRuntime.
|
|
585
|
+
/* @__PURE__ */ jsxRuntime.jsxs(
|
|
567
586
|
"button",
|
|
568
587
|
{
|
|
569
588
|
type: "button",
|
|
570
|
-
className: `lfb-fab${mode.kind === "idle" ? "" : " lfb-fab--active"}`,
|
|
589
|
+
className: `lfb-fab${isEdge ? " lfb-fab--tab" : ""}${mode.kind === "idle" ? "" : " lfb-fab--active"}`,
|
|
571
590
|
"aria-label": mode.kind === "idle" ? fabLabel : "Cancel feedback",
|
|
591
|
+
title: isEdge ? mode.kind === "idle" ? fabLabel : "Cancel feedback" : void 0,
|
|
572
592
|
onClick: startFlow,
|
|
573
|
-
children:
|
|
574
|
-
/* @__PURE__ */ jsxRuntime.jsx(MessageIcon, { size: 18 }),
|
|
575
|
-
" "
|
|
576
|
-
|
|
577
|
-
] }) : /* @__PURE__ */ jsxRuntime.jsxs(jsxRuntime.Fragment, { children: [
|
|
578
|
-
/* @__PURE__ */ jsxRuntime.jsx(XIcon, { size: 18 }),
|
|
579
|
-
" Cancel"
|
|
580
|
-
] })
|
|
593
|
+
children: [
|
|
594
|
+
mode.kind === "idle" ? /* @__PURE__ */ jsxRuntime.jsx(MessageIcon, { size: isEdge ? 22 : 18 }) : /* @__PURE__ */ jsxRuntime.jsx(XIcon, { size: isEdge ? 22 : 18 }),
|
|
595
|
+
!isEdge && /* @__PURE__ */ jsxRuntime.jsx("span", { children: mode.kind === "idle" ? fabLabel : "Cancel" })
|
|
596
|
+
]
|
|
581
597
|
}
|
|
582
598
|
)
|
|
583
599
|
] })
|
package/dist/react/index.d.cts
CHANGED
|
@@ -66,7 +66,9 @@ type FeedbackResult = {
|
|
|
66
66
|
};
|
|
67
67
|
|
|
68
68
|
declare const DEFAULT_TYPES: FeedbackTypeOption[];
|
|
69
|
-
type FeedbackPosition = "bottom-right" | "bottom-left" | "top-right" | "top-left"
|
|
69
|
+
type FeedbackPosition = "bottom-right" | "bottom-left" | "top-right" | "top-left"
|
|
70
|
+
/** Edge tabs: compact, icon-only launcher flush to the side, vertically centered. */
|
|
71
|
+
| "right" | "left";
|
|
70
72
|
type FeedbackWidgetProps = {
|
|
71
73
|
/** Endpoint that runs the server handler (default "/api/feedback"). */
|
|
72
74
|
endpoint?: string;
|
package/dist/react/index.d.ts
CHANGED
|
@@ -66,7 +66,9 @@ type FeedbackResult = {
|
|
|
66
66
|
};
|
|
67
67
|
|
|
68
68
|
declare const DEFAULT_TYPES: FeedbackTypeOption[];
|
|
69
|
-
type FeedbackPosition = "bottom-right" | "bottom-left" | "top-right" | "top-left"
|
|
69
|
+
type FeedbackPosition = "bottom-right" | "bottom-left" | "top-right" | "top-left"
|
|
70
|
+
/** Edge tabs: compact, icon-only launcher flush to the side, vertically centered. */
|
|
71
|
+
| "right" | "left";
|
|
70
72
|
type FeedbackWidgetProps = {
|
|
71
73
|
/** Endpoint that runs the server handler (default "/api/feedback"). */
|
|
72
74
|
endpoint?: string;
|
package/dist/react/index.js
CHANGED
|
@@ -167,7 +167,13 @@ var TYPE_ICONS = {
|
|
|
167
167
|
// src/react/styles.ts
|
|
168
168
|
var STYLE_ID = "lfb-styles";
|
|
169
169
|
var CSS2 = `
|
|
170
|
-
|
|
170
|
+
/*
|
|
171
|
+
* Defaults live on .lfb-root (the element that also receives the inline brandColor
|
|
172
|
+
* override) \u2014 NOT on the layers. The layers/FAB inherit from here. If the defaults
|
|
173
|
+
* sat on .lfb-doc-layer/.lfb-fixed-layer, a direct rule on those elements would beat
|
|
174
|
+
* the brandColor inherited from .lfb-root, so the \`brandColor\` prop would never apply.
|
|
175
|
+
*/
|
|
176
|
+
.lfb-root {
|
|
171
177
|
--lfb-brand: #6366f1;
|
|
172
178
|
--lfb-fg: #181d27;
|
|
173
179
|
--lfb-fg-secondary: #414651;
|
|
@@ -248,12 +254,24 @@ var CSS2 = `
|
|
|
248
254
|
.lfb-stack--bottom-left { right: auto; left: 16px; align-items: flex-start; }
|
|
249
255
|
.lfb-stack--top-right { bottom: auto; top: 16px; }
|
|
250
256
|
.lfb-stack--top-left { bottom: auto; top: 16px; right: auto; left: 16px; align-items: flex-start; }
|
|
257
|
+
/* Edge tabs: anchored flush to a side, vertically centered. */
|
|
258
|
+
.lfb-stack--right { top: 50%; bottom: auto; right: 0; transform: translateY(-50%); align-items: flex-end; }
|
|
259
|
+
.lfb-stack--left { top: 50%; bottom: auto; left: 0; right: auto; transform: translateY(-50%); align-items: flex-start; }
|
|
260
|
+
/* Keep the transient name-prompt / sent-toast cards off the viewport edge (the tab stays flush). */
|
|
261
|
+
.lfb-stack--right > .lfb-card { margin-right: 12px; }
|
|
262
|
+
.lfb-stack--left > .lfb-card { margin-left: 12px; }
|
|
251
263
|
|
|
252
264
|
.lfb-fab { display: inline-flex; align-items: center; gap: 8px; border: 0; border-radius: 9999px; padding: 12px 16px; font-size: 14px; font-weight: 600; font-family: var(--lfb-font); cursor: pointer; background: var(--lfb-brand); color: #fff; box-shadow: 0 10px 25px rgba(0,0,0,0.18); transition: transform 0.1s, background 0.1s; }
|
|
253
265
|
.lfb-fab:hover { transform: scale(1.05); background: color-mix(in srgb, var(--lfb-brand) 88%, black); }
|
|
254
266
|
.lfb-fab--active { background: var(--lfb-surface); color: var(--lfb-fg); border: 1px solid var(--lfb-border); }
|
|
255
267
|
.lfb-fab--active:hover { background: var(--lfb-surface-hover); }
|
|
256
268
|
|
|
269
|
+
/* Edge-tab launcher: compact, icon-only, rounded on the inner side only, flush to the viewport edge. */
|
|
270
|
+
.lfb-fab--tab { gap: 0; padding: 14px 12px; border-radius: 12px 0 0 12px; box-shadow: -6px 0 20px rgba(0,0,0,0.18); }
|
|
271
|
+
.lfb-fab--tab:hover { transform: translateX(-2px); }
|
|
272
|
+
.lfb-stack--left .lfb-fab--tab { border-radius: 0 12px 12px 0; box-shadow: 6px 0 20px rgba(0,0,0,0.18); }
|
|
273
|
+
.lfb-stack--left .lfb-fab--tab:hover { transform: translateX(2px); }
|
|
274
|
+
|
|
257
275
|
.lfb-toast { display: flex; align-items: flex-start; gap: 10px; width: 300px; max-width: calc(100vw - 32px); }
|
|
258
276
|
.lfb-toast-icon { color: #17b26a; flex-shrink: 0; margin-top: 1px; }
|
|
259
277
|
.lfb-toast-body { min-width: 0; flex: 1; }
|
|
@@ -285,6 +303,7 @@ function FeedbackWidget({
|
|
|
285
303
|
nameStorageKey = "wh_feedback_name",
|
|
286
304
|
fabLabel = "Give feedback"
|
|
287
305
|
}) {
|
|
306
|
+
const isEdge = position === "right" || position === "left";
|
|
288
307
|
const [mode, setMode] = useState({ kind: "idle" });
|
|
289
308
|
const [name, setName] = useState("");
|
|
290
309
|
const [nameDraft, setNameDraft] = useState("");
|
|
@@ -561,21 +580,18 @@ function FeedbackWidget({
|
|
|
561
580
|
] }),
|
|
562
581
|
/* @__PURE__ */ jsx("button", { type: "button", className: "lfb-iconbtn", "aria-label": "Dismiss", onClick: () => setResult(null), children: /* @__PURE__ */ jsx(XIcon, {}) })
|
|
563
582
|
] }),
|
|
564
|
-
/* @__PURE__ */
|
|
583
|
+
/* @__PURE__ */ jsxs(
|
|
565
584
|
"button",
|
|
566
585
|
{
|
|
567
586
|
type: "button",
|
|
568
|
-
className: `lfb-fab${mode.kind === "idle" ? "" : " lfb-fab--active"}`,
|
|
587
|
+
className: `lfb-fab${isEdge ? " lfb-fab--tab" : ""}${mode.kind === "idle" ? "" : " lfb-fab--active"}`,
|
|
569
588
|
"aria-label": mode.kind === "idle" ? fabLabel : "Cancel feedback",
|
|
589
|
+
title: isEdge ? mode.kind === "idle" ? fabLabel : "Cancel feedback" : void 0,
|
|
570
590
|
onClick: startFlow,
|
|
571
|
-
children:
|
|
572
|
-
/* @__PURE__ */ jsx(MessageIcon, { size: 18 }),
|
|
573
|
-
" "
|
|
574
|
-
|
|
575
|
-
] }) : /* @__PURE__ */ jsxs(Fragment, { children: [
|
|
576
|
-
/* @__PURE__ */ jsx(XIcon, { size: 18 }),
|
|
577
|
-
" Cancel"
|
|
578
|
-
] })
|
|
591
|
+
children: [
|
|
592
|
+
mode.kind === "idle" ? /* @__PURE__ */ jsx(MessageIcon, { size: isEdge ? 22 : 18 }) : /* @__PURE__ */ jsx(XIcon, { size: isEdge ? 22 : 18 }),
|
|
593
|
+
!isEdge && /* @__PURE__ */ jsx("span", { children: mode.kind === "idle" ? fabLabel : "Cancel" })
|
|
594
|
+
]
|
|
579
595
|
}
|
|
580
596
|
)
|
|
581
597
|
] })
|
package/dist/server/index.cjs
CHANGED
|
@@ -121,7 +121,8 @@ function createNextRoute(config) {
|
|
|
121
121
|
}
|
|
122
122
|
function cookieGate(name, value = "1") {
|
|
123
123
|
return (req) => {
|
|
124
|
-
const
|
|
124
|
+
const { headers } = req;
|
|
125
|
+
const cookie = typeof headers.get === "function" ? headers.get("cookie") ?? "" : headers.cookie ?? "";
|
|
125
126
|
return cookie.split(";").map((c) => c.trim()).some((c) => c === `${name}=${value}`);
|
|
126
127
|
};
|
|
127
128
|
}
|
package/dist/server/index.d.cts
CHANGED
|
@@ -70,8 +70,16 @@ type NextRouteConfig = FeedbackServerConfig & {
|
|
|
70
70
|
authorize?: (req: Request) => boolean | Promise<boolean>;
|
|
71
71
|
};
|
|
72
72
|
declare function createNextRoute(config: NextRouteConfig): (req: Request) => Promise<Response>;
|
|
73
|
-
/**
|
|
74
|
-
|
|
73
|
+
/**
|
|
74
|
+
* Authorize helper: allow only requests carrying `name=value` in the Cookie header.
|
|
75
|
+
*
|
|
76
|
+
* Works with BOTH a Web `Request` (Next.js App Router via `createNextRoute`) and a Node
|
|
77
|
+
* `IncomingMessage` (Vercel / Express via `createNodeHandler`). The two runtimes expose
|
|
78
|
+
* headers differently β `headers.get("cookie")` vs the plain `headers.cookie` string β so
|
|
79
|
+
* we feature-detect instead of assuming one shape. (Previously this only handled the Web
|
|
80
|
+
* `Request`, so the documented `createNodeHandler` usage threw `headers.get is not a function`.)
|
|
81
|
+
*/
|
|
82
|
+
declare function cookieGate(name: string, value?: string): (req: Request | IncomingMessage) => boolean;
|
|
75
83
|
|
|
76
84
|
type NodeHandlerConfig = FeedbackServerConfig & {
|
|
77
85
|
allowedOrigin?: string;
|
package/dist/server/index.d.ts
CHANGED
|
@@ -70,8 +70,16 @@ type NextRouteConfig = FeedbackServerConfig & {
|
|
|
70
70
|
authorize?: (req: Request) => boolean | Promise<boolean>;
|
|
71
71
|
};
|
|
72
72
|
declare function createNextRoute(config: NextRouteConfig): (req: Request) => Promise<Response>;
|
|
73
|
-
/**
|
|
74
|
-
|
|
73
|
+
/**
|
|
74
|
+
* Authorize helper: allow only requests carrying `name=value` in the Cookie header.
|
|
75
|
+
*
|
|
76
|
+
* Works with BOTH a Web `Request` (Next.js App Router via `createNextRoute`) and a Node
|
|
77
|
+
* `IncomingMessage` (Vercel / Express via `createNodeHandler`). The two runtimes expose
|
|
78
|
+
* headers differently β `headers.get("cookie")` vs the plain `headers.cookie` string β so
|
|
79
|
+
* we feature-detect instead of assuming one shape. (Previously this only handled the Web
|
|
80
|
+
* `Request`, so the documented `createNodeHandler` usage threw `headers.get is not a function`.)
|
|
81
|
+
*/
|
|
82
|
+
declare function cookieGate(name: string, value?: string): (req: Request | IncomingMessage) => boolean;
|
|
75
83
|
|
|
76
84
|
type NodeHandlerConfig = FeedbackServerConfig & {
|
|
77
85
|
allowedOrigin?: string;
|
package/dist/server/index.js
CHANGED
|
@@ -119,7 +119,8 @@ function createNextRoute(config) {
|
|
|
119
119
|
}
|
|
120
120
|
function cookieGate(name, value = "1") {
|
|
121
121
|
return (req) => {
|
|
122
|
-
const
|
|
122
|
+
const { headers } = req;
|
|
123
|
+
const cookie = typeof headers.get === "function" ? headers.get("cookie") ?? "" : headers.cookie ?? "";
|
|
123
124
|
return cookie.split(";").map((c) => c.trim()).some((c) => c === `${name}=${value}`);
|
|
124
125
|
};
|
|
125
126
|
}
|
|
@@ -0,0 +1,147 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
var sdk = require('@linear/sdk');
|
|
4
|
+
|
|
5
|
+
// src/server/core.ts
|
|
6
|
+
var MAX_NOTE = 5e3;
|
|
7
|
+
async function createFeedbackIssue(payload, config) {
|
|
8
|
+
const { apiKey, teamId } = config;
|
|
9
|
+
if (!apiKey || !teamId) throw new Error("not_configured");
|
|
10
|
+
const note = payload?.annotation?.note?.trim();
|
|
11
|
+
if (!note) throw new Error("note_required");
|
|
12
|
+
if (note.length > MAX_NOTE) throw new Error("note_too_long");
|
|
13
|
+
const { annotation, context } = payload;
|
|
14
|
+
const typeLabel = annotation.typeLabel || capitalize(annotation.type || "Feedback");
|
|
15
|
+
const linear = new sdk.LinearClient({ apiKey });
|
|
16
|
+
let assetUrl = null;
|
|
17
|
+
if (payload.screenshot?.startsWith("data:image/")) {
|
|
18
|
+
try {
|
|
19
|
+
assetUrl = await uploadScreenshot(linear, payload.screenshot);
|
|
20
|
+
} catch (err) {
|
|
21
|
+
console.error("[feedback] screenshot upload failed", err);
|
|
22
|
+
}
|
|
23
|
+
}
|
|
24
|
+
const description = [
|
|
25
|
+
note,
|
|
26
|
+
"",
|
|
27
|
+
"---",
|
|
28
|
+
assetUrl ? `})` : "_No screenshot captured._",
|
|
29
|
+
"",
|
|
30
|
+
"**Context**",
|
|
31
|
+
`- Type: ${typeLabel}`,
|
|
32
|
+
context?.url ? `- Page: ${context.url}` : null,
|
|
33
|
+
annotation.name ? `- Reported by: ${annotation.name}` : null,
|
|
34
|
+
context?.userAgent ? `- User agent: ${context.userAgent}` : null,
|
|
35
|
+
context?.timestamp ? `- Submitted: ${context.timestamp}` : null
|
|
36
|
+
].filter(Boolean).join("\n");
|
|
37
|
+
const title = `${typeLabel}: ${note.slice(0, 60)}${note.length > 60 ? "\u2026" : ""}`;
|
|
38
|
+
const labelName = config.labels?.[annotation.type] ?? annotation.type;
|
|
39
|
+
const labelId = labelName ? await resolveLabelId(linear, labelName, teamId) : null;
|
|
40
|
+
if (labelName && !labelId) console.warn(`[feedback] label "${labelName}" not found \u2014 creating issue without it`);
|
|
41
|
+
const create = async (ids) => {
|
|
42
|
+
const created = await linear.createIssue({ teamId, title, description, labelIds: ids.length ? ids : void 0 });
|
|
43
|
+
return await created.issue;
|
|
44
|
+
};
|
|
45
|
+
const issue = await create(labelId ? [labelId] : []).catch((err) => {
|
|
46
|
+
if (!labelId) throw err;
|
|
47
|
+
console.warn("[feedback] create failed with label, retrying without it", err);
|
|
48
|
+
return create([]);
|
|
49
|
+
});
|
|
50
|
+
return { id: issue?.id, identifier: issue?.identifier, url: issue?.url };
|
|
51
|
+
}
|
|
52
|
+
function capitalize(s) {
|
|
53
|
+
return s.charAt(0).toUpperCase() + s.slice(1);
|
|
54
|
+
}
|
|
55
|
+
async function resolveLabelId(linear, name, teamId) {
|
|
56
|
+
try {
|
|
57
|
+
const { nodes } = await linear.issueLabels({ filter: { name: { eqIgnoreCase: name } }, first: 50 });
|
|
58
|
+
if (nodes.length === 0) return null;
|
|
59
|
+
if (nodes.length === 1) return nodes[0].id;
|
|
60
|
+
const scored = await Promise.all(
|
|
61
|
+
nodes.map(async (n) => {
|
|
62
|
+
try {
|
|
63
|
+
const team = await n.team;
|
|
64
|
+
return { id: n.id, teamId: team?.id ?? null };
|
|
65
|
+
} catch {
|
|
66
|
+
return { id: n.id, teamId: null };
|
|
67
|
+
}
|
|
68
|
+
})
|
|
69
|
+
);
|
|
70
|
+
const pick = scored.find((s) => s.teamId === teamId) ?? scored.find((s) => s.teamId === null) ?? scored[0];
|
|
71
|
+
return pick.id;
|
|
72
|
+
} catch (err) {
|
|
73
|
+
console.warn("[feedback] label lookup failed", name, err);
|
|
74
|
+
return null;
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
async function uploadScreenshot(linear, dataUrl) {
|
|
78
|
+
const [meta, b64] = dataUrl.split(",");
|
|
79
|
+
const contentType = /data:(.*?);base64/.exec(meta)?.[1] ?? "image/jpeg";
|
|
80
|
+
const bytes = Buffer.from(b64, "base64");
|
|
81
|
+
const filename = `feedback-${Date.now()}.jpg`;
|
|
82
|
+
const upload = await linear.fileUpload(contentType, filename, bytes.length);
|
|
83
|
+
if (!upload.success || !upload.uploadFile) throw new Error("failed to request upload URL");
|
|
84
|
+
const headers = new Headers();
|
|
85
|
+
headers.set("Content-Type", contentType);
|
|
86
|
+
headers.set("Cache-Control", "public, max-age=31536000");
|
|
87
|
+
upload.uploadFile.headers.forEach(({ key, value }) => headers.set(key, value));
|
|
88
|
+
const put = await fetch(upload.uploadFile.uploadUrl, { method: "PUT", headers, body: new Uint8Array(bytes) });
|
|
89
|
+
if (!put.ok) throw new Error(`upload PUT failed: ${put.status}`);
|
|
90
|
+
return upload.uploadFile.assetUrl;
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
// src/server/node.ts
|
|
94
|
+
var BAD_REQUEST = /* @__PURE__ */ new Set(["note_required", "note_too_long", "bad_type", "bad_json", "not_configured"]);
|
|
95
|
+
function send(res, status, body) {
|
|
96
|
+
res.statusCode = status;
|
|
97
|
+
res.setHeader("content-type", "application/json");
|
|
98
|
+
res.end(JSON.stringify(body));
|
|
99
|
+
}
|
|
100
|
+
async function readJson(req) {
|
|
101
|
+
const chunks = [];
|
|
102
|
+
for await (const chunk of req) chunks.push(Buffer.from(chunk));
|
|
103
|
+
const raw = Buffer.concat(chunks).toString("utf8");
|
|
104
|
+
if (!raw) throw new Error("bad_json");
|
|
105
|
+
try {
|
|
106
|
+
return JSON.parse(raw);
|
|
107
|
+
} catch {
|
|
108
|
+
throw new Error("bad_json");
|
|
109
|
+
}
|
|
110
|
+
}
|
|
111
|
+
function createNodeHandler(config) {
|
|
112
|
+
return async function handler(req, res) {
|
|
113
|
+
try {
|
|
114
|
+
if (config.authorize && !await config.authorize(req)) return send(res, 404, { error: "unauthorized" });
|
|
115
|
+
if (config.allowedOrigin) {
|
|
116
|
+
const origin = req.headers.origin ?? "";
|
|
117
|
+
if (origin && origin !== config.allowedOrigin) return send(res, 403, { error: "forbidden_origin" });
|
|
118
|
+
}
|
|
119
|
+
const payload = req.body ?? await readJson(req);
|
|
120
|
+
const issue = await createFeedbackIssue(payload, config);
|
|
121
|
+
send(res, 200, { ok: true, ...issue });
|
|
122
|
+
} catch (err) {
|
|
123
|
+
const message = err instanceof Error ? err.message : String(err);
|
|
124
|
+
if (BAD_REQUEST.has(message)) return send(res, message === "not_configured" ? 500 : 400, { error: message });
|
|
125
|
+
console.error("[feedback] issue create failed", err);
|
|
126
|
+
send(res, 502, { error: "issue_create_failed", message });
|
|
127
|
+
}
|
|
128
|
+
};
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
// src/vite/index.ts
|
|
132
|
+
function linearFeedback(config) {
|
|
133
|
+
const endpoint = config.endpoint ?? "/api/feedback";
|
|
134
|
+
const handler = createNodeHandler(config);
|
|
135
|
+
return {
|
|
136
|
+
name: "react-linear-feedback",
|
|
137
|
+
apply: "serve",
|
|
138
|
+
configureServer(server) {
|
|
139
|
+
server.middlewares.use(endpoint, (req, res, next) => {
|
|
140
|
+
if (req.method !== "POST") return next();
|
|
141
|
+
handler(req, res).catch(next);
|
|
142
|
+
});
|
|
143
|
+
}
|
|
144
|
+
};
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
exports.linearFeedback = linearFeedback;
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
import { Plugin } from 'vite';
|
|
2
|
+
import { IncomingMessage } from 'node:http';
|
|
3
|
+
|
|
4
|
+
type FeedbackServerConfig = {
|
|
5
|
+
/** Linear personal API key (server-side secret). */
|
|
6
|
+
apiKey: string;
|
|
7
|
+
/** Target team UUID. */
|
|
8
|
+
teamId: string;
|
|
9
|
+
/**
|
|
10
|
+
* Map a type id (e.g. "bug") to a Linear label NAME. Defaults to the type id itself, so a type
|
|
11
|
+
* "bug" looks for a label named "bug". Labels are resolved by name at request time.
|
|
12
|
+
*/
|
|
13
|
+
labels?: Record<string, string>;
|
|
14
|
+
};
|
|
15
|
+
|
|
16
|
+
type NodeHandlerConfig = FeedbackServerConfig & {
|
|
17
|
+
allowedOrigin?: string;
|
|
18
|
+
authorize?: (req: IncomingMessage) => boolean | Promise<boolean>;
|
|
19
|
+
};
|
|
20
|
+
|
|
21
|
+
type LinearFeedbackViteConfig = NodeHandlerConfig & {
|
|
22
|
+
/** Path the widget POSTs to; must match `<FeedbackGate endpoint>`. Default `/api/feedback`. */
|
|
23
|
+
endpoint?: string;
|
|
24
|
+
};
|
|
25
|
+
declare function linearFeedback(config: LinearFeedbackViteConfig): Plugin;
|
|
26
|
+
|
|
27
|
+
export { type LinearFeedbackViteConfig, linearFeedback };
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
import { Plugin } from 'vite';
|
|
2
|
+
import { IncomingMessage } from 'node:http';
|
|
3
|
+
|
|
4
|
+
type FeedbackServerConfig = {
|
|
5
|
+
/** Linear personal API key (server-side secret). */
|
|
6
|
+
apiKey: string;
|
|
7
|
+
/** Target team UUID. */
|
|
8
|
+
teamId: string;
|
|
9
|
+
/**
|
|
10
|
+
* Map a type id (e.g. "bug") to a Linear label NAME. Defaults to the type id itself, so a type
|
|
11
|
+
* "bug" looks for a label named "bug". Labels are resolved by name at request time.
|
|
12
|
+
*/
|
|
13
|
+
labels?: Record<string, string>;
|
|
14
|
+
};
|
|
15
|
+
|
|
16
|
+
type NodeHandlerConfig = FeedbackServerConfig & {
|
|
17
|
+
allowedOrigin?: string;
|
|
18
|
+
authorize?: (req: IncomingMessage) => boolean | Promise<boolean>;
|
|
19
|
+
};
|
|
20
|
+
|
|
21
|
+
type LinearFeedbackViteConfig = NodeHandlerConfig & {
|
|
22
|
+
/** Path the widget POSTs to; must match `<FeedbackGate endpoint>`. Default `/api/feedback`. */
|
|
23
|
+
endpoint?: string;
|
|
24
|
+
};
|
|
25
|
+
declare function linearFeedback(config: LinearFeedbackViteConfig): Plugin;
|
|
26
|
+
|
|
27
|
+
export { type LinearFeedbackViteConfig, linearFeedback };
|
|
@@ -0,0 +1,145 @@
|
|
|
1
|
+
import { LinearClient } from '@linear/sdk';
|
|
2
|
+
|
|
3
|
+
// src/server/core.ts
|
|
4
|
+
var MAX_NOTE = 5e3;
|
|
5
|
+
async function createFeedbackIssue(payload, config) {
|
|
6
|
+
const { apiKey, teamId } = config;
|
|
7
|
+
if (!apiKey || !teamId) throw new Error("not_configured");
|
|
8
|
+
const note = payload?.annotation?.note?.trim();
|
|
9
|
+
if (!note) throw new Error("note_required");
|
|
10
|
+
if (note.length > MAX_NOTE) throw new Error("note_too_long");
|
|
11
|
+
const { annotation, context } = payload;
|
|
12
|
+
const typeLabel = annotation.typeLabel || capitalize(annotation.type || "Feedback");
|
|
13
|
+
const linear = new LinearClient({ apiKey });
|
|
14
|
+
let assetUrl = null;
|
|
15
|
+
if (payload.screenshot?.startsWith("data:image/")) {
|
|
16
|
+
try {
|
|
17
|
+
assetUrl = await uploadScreenshot(linear, payload.screenshot);
|
|
18
|
+
} catch (err) {
|
|
19
|
+
console.error("[feedback] screenshot upload failed", err);
|
|
20
|
+
}
|
|
21
|
+
}
|
|
22
|
+
const description = [
|
|
23
|
+
note,
|
|
24
|
+
"",
|
|
25
|
+
"---",
|
|
26
|
+
assetUrl ? `})` : "_No screenshot captured._",
|
|
27
|
+
"",
|
|
28
|
+
"**Context**",
|
|
29
|
+
`- Type: ${typeLabel}`,
|
|
30
|
+
context?.url ? `- Page: ${context.url}` : null,
|
|
31
|
+
annotation.name ? `- Reported by: ${annotation.name}` : null,
|
|
32
|
+
context?.userAgent ? `- User agent: ${context.userAgent}` : null,
|
|
33
|
+
context?.timestamp ? `- Submitted: ${context.timestamp}` : null
|
|
34
|
+
].filter(Boolean).join("\n");
|
|
35
|
+
const title = `${typeLabel}: ${note.slice(0, 60)}${note.length > 60 ? "\u2026" : ""}`;
|
|
36
|
+
const labelName = config.labels?.[annotation.type] ?? annotation.type;
|
|
37
|
+
const labelId = labelName ? await resolveLabelId(linear, labelName, teamId) : null;
|
|
38
|
+
if (labelName && !labelId) console.warn(`[feedback] label "${labelName}" not found \u2014 creating issue without it`);
|
|
39
|
+
const create = async (ids) => {
|
|
40
|
+
const created = await linear.createIssue({ teamId, title, description, labelIds: ids.length ? ids : void 0 });
|
|
41
|
+
return await created.issue;
|
|
42
|
+
};
|
|
43
|
+
const issue = await create(labelId ? [labelId] : []).catch((err) => {
|
|
44
|
+
if (!labelId) throw err;
|
|
45
|
+
console.warn("[feedback] create failed with label, retrying without it", err);
|
|
46
|
+
return create([]);
|
|
47
|
+
});
|
|
48
|
+
return { id: issue?.id, identifier: issue?.identifier, url: issue?.url };
|
|
49
|
+
}
|
|
50
|
+
function capitalize(s) {
|
|
51
|
+
return s.charAt(0).toUpperCase() + s.slice(1);
|
|
52
|
+
}
|
|
53
|
+
async function resolveLabelId(linear, name, teamId) {
|
|
54
|
+
try {
|
|
55
|
+
const { nodes } = await linear.issueLabels({ filter: { name: { eqIgnoreCase: name } }, first: 50 });
|
|
56
|
+
if (nodes.length === 0) return null;
|
|
57
|
+
if (nodes.length === 1) return nodes[0].id;
|
|
58
|
+
const scored = await Promise.all(
|
|
59
|
+
nodes.map(async (n) => {
|
|
60
|
+
try {
|
|
61
|
+
const team = await n.team;
|
|
62
|
+
return { id: n.id, teamId: team?.id ?? null };
|
|
63
|
+
} catch {
|
|
64
|
+
return { id: n.id, teamId: null };
|
|
65
|
+
}
|
|
66
|
+
})
|
|
67
|
+
);
|
|
68
|
+
const pick = scored.find((s) => s.teamId === teamId) ?? scored.find((s) => s.teamId === null) ?? scored[0];
|
|
69
|
+
return pick.id;
|
|
70
|
+
} catch (err) {
|
|
71
|
+
console.warn("[feedback] label lookup failed", name, err);
|
|
72
|
+
return null;
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
async function uploadScreenshot(linear, dataUrl) {
|
|
76
|
+
const [meta, b64] = dataUrl.split(",");
|
|
77
|
+
const contentType = /data:(.*?);base64/.exec(meta)?.[1] ?? "image/jpeg";
|
|
78
|
+
const bytes = Buffer.from(b64, "base64");
|
|
79
|
+
const filename = `feedback-${Date.now()}.jpg`;
|
|
80
|
+
const upload = await linear.fileUpload(contentType, filename, bytes.length);
|
|
81
|
+
if (!upload.success || !upload.uploadFile) throw new Error("failed to request upload URL");
|
|
82
|
+
const headers = new Headers();
|
|
83
|
+
headers.set("Content-Type", contentType);
|
|
84
|
+
headers.set("Cache-Control", "public, max-age=31536000");
|
|
85
|
+
upload.uploadFile.headers.forEach(({ key, value }) => headers.set(key, value));
|
|
86
|
+
const put = await fetch(upload.uploadFile.uploadUrl, { method: "PUT", headers, body: new Uint8Array(bytes) });
|
|
87
|
+
if (!put.ok) throw new Error(`upload PUT failed: ${put.status}`);
|
|
88
|
+
return upload.uploadFile.assetUrl;
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
// src/server/node.ts
|
|
92
|
+
var BAD_REQUEST = /* @__PURE__ */ new Set(["note_required", "note_too_long", "bad_type", "bad_json", "not_configured"]);
|
|
93
|
+
function send(res, status, body) {
|
|
94
|
+
res.statusCode = status;
|
|
95
|
+
res.setHeader("content-type", "application/json");
|
|
96
|
+
res.end(JSON.stringify(body));
|
|
97
|
+
}
|
|
98
|
+
async function readJson(req) {
|
|
99
|
+
const chunks = [];
|
|
100
|
+
for await (const chunk of req) chunks.push(Buffer.from(chunk));
|
|
101
|
+
const raw = Buffer.concat(chunks).toString("utf8");
|
|
102
|
+
if (!raw) throw new Error("bad_json");
|
|
103
|
+
try {
|
|
104
|
+
return JSON.parse(raw);
|
|
105
|
+
} catch {
|
|
106
|
+
throw new Error("bad_json");
|
|
107
|
+
}
|
|
108
|
+
}
|
|
109
|
+
function createNodeHandler(config) {
|
|
110
|
+
return async function handler(req, res) {
|
|
111
|
+
try {
|
|
112
|
+
if (config.authorize && !await config.authorize(req)) return send(res, 404, { error: "unauthorized" });
|
|
113
|
+
if (config.allowedOrigin) {
|
|
114
|
+
const origin = req.headers.origin ?? "";
|
|
115
|
+
if (origin && origin !== config.allowedOrigin) return send(res, 403, { error: "forbidden_origin" });
|
|
116
|
+
}
|
|
117
|
+
const payload = req.body ?? await readJson(req);
|
|
118
|
+
const issue = await createFeedbackIssue(payload, config);
|
|
119
|
+
send(res, 200, { ok: true, ...issue });
|
|
120
|
+
} catch (err) {
|
|
121
|
+
const message = err instanceof Error ? err.message : String(err);
|
|
122
|
+
if (BAD_REQUEST.has(message)) return send(res, message === "not_configured" ? 500 : 400, { error: message });
|
|
123
|
+
console.error("[feedback] issue create failed", err);
|
|
124
|
+
send(res, 502, { error: "issue_create_failed", message });
|
|
125
|
+
}
|
|
126
|
+
};
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
// src/vite/index.ts
|
|
130
|
+
function linearFeedback(config) {
|
|
131
|
+
const endpoint = config.endpoint ?? "/api/feedback";
|
|
132
|
+
const handler = createNodeHandler(config);
|
|
133
|
+
return {
|
|
134
|
+
name: "react-linear-feedback",
|
|
135
|
+
apply: "serve",
|
|
136
|
+
configureServer(server) {
|
|
137
|
+
server.middlewares.use(endpoint, (req, res, next) => {
|
|
138
|
+
if (req.method !== "POST") return next();
|
|
139
|
+
handler(req, res).catch(next);
|
|
140
|
+
});
|
|
141
|
+
}
|
|
142
|
+
};
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
export { linearFeedback };
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "react-linear-feedback",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.2.0",
|
|
4
4
|
"description": "Drop-in React feedback widget: draw a box, write a note, and it captures a screenshot and opens a Linear issue. Framework-agnostic, self-contained styles, zero design-system dependencies.",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"author": "Oliver Odgaard",
|
|
@@ -40,6 +40,11 @@
|
|
|
40
40
|
"types": "./dist/server/index.d.ts",
|
|
41
41
|
"import": "./dist/server/index.js",
|
|
42
42
|
"require": "./dist/server/index.cjs"
|
|
43
|
+
},
|
|
44
|
+
"./vite": {
|
|
45
|
+
"types": "./dist/vite/index.d.ts",
|
|
46
|
+
"import": "./dist/vite/index.js",
|
|
47
|
+
"require": "./dist/vite/index.cjs"
|
|
43
48
|
}
|
|
44
49
|
},
|
|
45
50
|
"scripts": {
|
|
@@ -48,29 +53,30 @@
|
|
|
48
53
|
"prepublishOnly": "npm run build"
|
|
49
54
|
},
|
|
50
55
|
"dependencies": {
|
|
56
|
+
"@linear/sdk": "^86.0.0",
|
|
51
57
|
"modern-screenshot": "^4.7.0"
|
|
52
58
|
},
|
|
53
59
|
"peerDependencies": {
|
|
54
|
-
"@linear/sdk": ">=40",
|
|
55
60
|
"react": ">=18",
|
|
56
|
-
"react-dom": ">=18"
|
|
61
|
+
"react-dom": ">=18",
|
|
62
|
+
"vite": ">=5"
|
|
57
63
|
},
|
|
58
64
|
"peerDependenciesMeta": {
|
|
59
|
-
"
|
|
65
|
+
"react-dom": {
|
|
60
66
|
"optional": true
|
|
61
67
|
},
|
|
62
|
-
"
|
|
68
|
+
"vite": {
|
|
63
69
|
"optional": true
|
|
64
70
|
}
|
|
65
71
|
},
|
|
66
72
|
"devDependencies": {
|
|
67
|
-
"@linear/sdk": "^86.0.0",
|
|
68
73
|
"@types/node": "^22.0.0",
|
|
69
74
|
"@types/react": "^19.0.0",
|
|
70
75
|
"@types/react-dom": "^19.0.0",
|
|
71
76
|
"react": "^19.0.0",
|
|
72
77
|
"react-dom": "^19.0.0",
|
|
73
78
|
"tsup": "^8.5.0",
|
|
74
|
-
"typescript": "^5.7.0"
|
|
79
|
+
"typescript": "^5.7.0",
|
|
80
|
+
"vite": "^6.0.0"
|
|
75
81
|
}
|
|
76
82
|
}
|