feedtack 0.2.1 → 0.3.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +90 -0
- package/dist/react/index.d.ts +4 -2
- package/dist/react/index.js +66 -11
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -74,6 +74,96 @@ class MySupabaseAdapter implements FeedtackAdapter {
|
|
|
74
74
|
}
|
|
75
75
|
```
|
|
76
76
|
|
|
77
|
+
## Adapter recipes
|
|
78
|
+
|
|
79
|
+
The `FeedtackAdapter` interface has five methods. Here are copy-paste implementations for common backends.
|
|
80
|
+
|
|
81
|
+
### Disk / JSON files (Node.js)
|
|
82
|
+
|
|
83
|
+
Git-trackable feedback — each submission becomes a JSON file in `.feedback/`.
|
|
84
|
+
|
|
85
|
+
```ts
|
|
86
|
+
import type { FeedtackAdapter, FeedbackItem, FeedtackPayload } from 'feedtack'
|
|
87
|
+
import { readdir, readFile, writeFile, mkdir } from 'node:fs/promises'
|
|
88
|
+
import { join } from 'node:path'
|
|
89
|
+
|
|
90
|
+
const DIR = '.feedback'
|
|
91
|
+
|
|
92
|
+
class DiskAdapter implements FeedtackAdapter {
|
|
93
|
+
async submit(payload: FeedtackPayload) {
|
|
94
|
+
await mkdir(DIR, { recursive: true })
|
|
95
|
+
const item: FeedbackItem = { payload, replies: [], resolutions: [], archives: [] }
|
|
96
|
+
await writeFile(join(DIR, `${payload.id}.json`), JSON.stringify(item, null, 2))
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
async reply(feedbackId: string, reply: Omit<FeedbackItem['replies'][0], 'id' | 'feedbackId'>) {
|
|
100
|
+
const item = await this.read(feedbackId)
|
|
101
|
+
item.replies.push({ ...reply, id: crypto.randomUUID(), feedbackId })
|
|
102
|
+
await this.write(feedbackId, item)
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
async resolve(feedbackId: string, resolution: Omit<FeedbackItem['resolutions'][0], 'feedbackId'>) {
|
|
106
|
+
const item = await this.read(feedbackId)
|
|
107
|
+
item.resolutions.push({ ...resolution, feedbackId })
|
|
108
|
+
await this.write(feedbackId, item)
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
async archive(feedbackId: string, userId: string) {
|
|
112
|
+
const item = await this.read(feedbackId)
|
|
113
|
+
item.archives.push({ feedbackId, archivedBy: { id: userId, name: '', role: '' }, timestamp: new Date().toISOString() })
|
|
114
|
+
await this.write(feedbackId, item)
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
async loadFeedback() {
|
|
118
|
+
await mkdir(DIR, { recursive: true })
|
|
119
|
+
const files = (await readdir(DIR)).filter((f) => f.endsWith('.json'))
|
|
120
|
+
return Promise.all(files.map(async (f) => JSON.parse(await readFile(join(DIR, f), 'utf-8')) as FeedbackItem))
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
private async read(id: string) { return JSON.parse(await readFile(join(DIR, `${id}.json`), 'utf-8')) as FeedbackItem }
|
|
124
|
+
private async write(id: string, item: FeedbackItem) { await writeFile(join(DIR, `${id}.json`), JSON.stringify(item, null, 2)) }
|
|
125
|
+
}
|
|
126
|
+
```
|
|
127
|
+
|
|
128
|
+
### Supabase
|
|
129
|
+
|
|
130
|
+
```ts
|
|
131
|
+
import type { FeedtackAdapter, FeedbackItem, FeedtackFilter, FeedtackPayload } from 'feedtack'
|
|
132
|
+
import type { SupabaseClient } from '@supabase/supabase-js'
|
|
133
|
+
|
|
134
|
+
class SupabaseAdapter implements FeedtackAdapter {
|
|
135
|
+
constructor(private supabase: SupabaseClient) {}
|
|
136
|
+
|
|
137
|
+
async submit(payload: FeedtackPayload) {
|
|
138
|
+
await this.supabase.from('feedtack_submissions').insert({ id: payload.id, data: payload })
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
async reply(feedbackId: string, reply: Omit<FeedbackItem['replies'][0], 'id' | 'feedbackId'>) {
|
|
142
|
+
await this.supabase.from('feedtack_replies').insert({ feedback_id: feedbackId, ...reply })
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
async resolve(feedbackId: string, resolution: Omit<FeedbackItem['resolutions'][0], 'feedbackId'>) {
|
|
146
|
+
await this.supabase.from('feedtack_resolutions').insert({ feedback_id: feedbackId, ...resolution })
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
async archive(feedbackId: string, userId: string) {
|
|
150
|
+
await this.supabase.from('feedtack_archives').insert({ feedback_id: feedbackId, user_id: userId })
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
async loadFeedback(filter?: FeedtackFilter): Promise<FeedbackItem[]> {
|
|
154
|
+
let query = this.supabase.from('feedtack_submissions').select('*, feedtack_replies(*), feedtack_resolutions(*), feedtack_archives(*)')
|
|
155
|
+
if (filter?.pathname) query = query.eq('data->>page->>pathname', filter.pathname)
|
|
156
|
+
const { data } = await query
|
|
157
|
+
return (data ?? []).map((row) => ({
|
|
158
|
+
payload: row.data,
|
|
159
|
+
replies: row.feedtack_replies ?? [],
|
|
160
|
+
resolutions: row.feedtack_resolutions ?? [],
|
|
161
|
+
archives: row.feedtack_archives ?? [],
|
|
162
|
+
}))
|
|
163
|
+
}
|
|
164
|
+
}
|
|
165
|
+
```
|
|
166
|
+
|
|
77
167
|
## The payload
|
|
78
168
|
|
|
79
169
|
Every pin emits a versioned JSON payload:
|
package/dist/react/index.d.ts
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import * as react_jsx_runtime from 'react/jsx-runtime';
|
|
2
2
|
import React from 'react';
|
|
3
|
-
import { F as FeedtackAdapter, o as FeedtackUser, n as FeedtackTheme } from '../theme-C-uctIoI.js';
|
|
3
|
+
import { F as FeedtackAdapter, o as FeedtackUser, n as FeedtackTheme, e as FeedbackItem } from '../theme-C-uctIoI.js';
|
|
4
4
|
|
|
5
5
|
interface FeedtackClasses {
|
|
6
6
|
button?: string;
|
|
@@ -24,8 +24,10 @@ interface FeedtackProviderProps {
|
|
|
24
24
|
sentimentLabels?: FeedtackSentimentLabels;
|
|
25
25
|
onError?: (err: Error) => void;
|
|
26
26
|
disabled?: boolean;
|
|
27
|
+
/** Render custom content inside a submitted pin marker. Receives the feedback item. */
|
|
28
|
+
renderPinIcon?: (item: FeedbackItem) => React.ReactNode;
|
|
27
29
|
}
|
|
28
|
-
declare function FeedtackProvider({ children, adapter, currentUser, hotkey, adminOnly, theme, classes, sentimentLabels, onError, disabled, }: FeedtackProviderProps): react_jsx_runtime.JSX.Element;
|
|
30
|
+
declare function FeedtackProvider({ children, adapter, currentUser, hotkey, adminOnly, theme, classes, sentimentLabels, onError, disabled, renderPinIcon, }: FeedtackProviderProps): react_jsx_runtime.JSX.Element;
|
|
29
31
|
|
|
30
32
|
interface FeedtackContextValue {
|
|
31
33
|
activatePinMode: () => void;
|
package/dist/react/index.js
CHANGED
|
@@ -66,17 +66,27 @@ function CommentForm({
|
|
|
66
66
|
className: cx("feedtack-form", classes.form),
|
|
67
67
|
style: { position: "fixed", ...formPos },
|
|
68
68
|
children: [
|
|
69
|
+
/* @__PURE__ */ jsx("label", { htmlFor: "feedtack-comment", className: "feedtack-sr-only", children: "Feedback comment" }),
|
|
69
70
|
/* @__PURE__ */ jsx(
|
|
70
71
|
"textarea",
|
|
71
72
|
{
|
|
73
|
+
id: "feedtack-comment",
|
|
72
74
|
className: commentError ? "error" : "",
|
|
73
75
|
placeholder: "What's the issue? (required)",
|
|
74
76
|
value: comment,
|
|
75
77
|
onChange: (e) => onCommentChange(e.target.value),
|
|
76
|
-
|
|
78
|
+
onKeyDown: (e) => {
|
|
79
|
+
if (e.key === "Enter" && (e.metaKey || e.ctrlKey)) {
|
|
80
|
+
e.preventDefault();
|
|
81
|
+
onSubmit();
|
|
82
|
+
}
|
|
83
|
+
},
|
|
84
|
+
ref: (el) => el?.focus(),
|
|
85
|
+
"aria-describedby": commentError ? "feedtack-comment-error" : void 0,
|
|
86
|
+
"aria-invalid": commentError || void 0
|
|
77
87
|
}
|
|
78
88
|
),
|
|
79
|
-
commentError && /* @__PURE__ */ jsx("span", { className: "feedtack-error-msg", children: "Comment is required" }),
|
|
89
|
+
commentError && /* @__PURE__ */ jsx("span", { id: "feedtack-comment-error", className: "feedtack-error-msg", children: "Comment is required" }),
|
|
80
90
|
/* @__PURE__ */ jsxs("div", { className: "feedtack-sentiment", children: [
|
|
81
91
|
/* @__PURE__ */ jsx(
|
|
82
92
|
"button",
|
|
@@ -161,7 +171,7 @@ function ThreadPanel({
|
|
|
161
171
|
item.replies.map((r) => /* @__PURE__ */ jsxs2(
|
|
162
172
|
"div",
|
|
163
173
|
{
|
|
164
|
-
style: { borderTop: "1px solid
|
|
174
|
+
style: { borderTop: "1px solid var(--ft-border)", paddingTop: 8 },
|
|
165
175
|
children: [
|
|
166
176
|
/* @__PURE__ */ jsx2("span", { style: { fontSize: 12, fontWeight: 600 }, children: r.author.name }),
|
|
167
177
|
/* @__PURE__ */ jsx2("p", { style: { fontSize: 12 }, children: r.body })
|
|
@@ -180,7 +190,7 @@ function ThreadPanel({
|
|
|
180
190
|
fontSize: 12,
|
|
181
191
|
padding: 6,
|
|
182
192
|
borderRadius: 6,
|
|
183
|
-
border: "1px solid
|
|
193
|
+
border: "1px solid var(--ft-border)",
|
|
184
194
|
marginTop: 4
|
|
185
195
|
}
|
|
186
196
|
}
|
|
@@ -308,6 +318,22 @@ var FEEDTACK_STYLES = `
|
|
|
308
318
|
pointer-events: all;
|
|
309
319
|
}
|
|
310
320
|
|
|
321
|
+
.feedtack-pin-resolved { opacity: 0.6; }
|
|
322
|
+
|
|
323
|
+
.feedtack-pin-icon {
|
|
324
|
+
position: absolute;
|
|
325
|
+
inset: 0;
|
|
326
|
+
display: flex;
|
|
327
|
+
align-items: center;
|
|
328
|
+
justify-content: center;
|
|
329
|
+
transform: rotate(45deg);
|
|
330
|
+
font-size: 12px;
|
|
331
|
+
font-weight: 700;
|
|
332
|
+
color: #fff;
|
|
333
|
+
line-height: 1;
|
|
334
|
+
pointer-events: none;
|
|
335
|
+
}
|
|
336
|
+
|
|
311
337
|
.feedtack-pin-badge {
|
|
312
338
|
position: absolute;
|
|
313
339
|
top: -4px;
|
|
@@ -455,6 +481,11 @@ var FEEDTACK_STYLES = `
|
|
|
455
481
|
gap: 10px;
|
|
456
482
|
}
|
|
457
483
|
|
|
484
|
+
.feedtack-sr-only {
|
|
485
|
+
position: absolute; width: 1px; height: 1px; padding: 0; margin: -1px;
|
|
486
|
+
overflow: hidden; clip: rect(0,0,0,0); white-space: nowrap; border: 0;
|
|
487
|
+
}
|
|
488
|
+
|
|
458
489
|
.feedtack-loading {
|
|
459
490
|
position: fixed;
|
|
460
491
|
bottom: 70px;
|
|
@@ -503,7 +534,12 @@ function useFeedtackDom(theme, disabled) {
|
|
|
503
534
|
|
|
504
535
|
// src/react/usePinMode.ts
|
|
505
536
|
import { useCallback, useEffect as useEffect2, useState } from "react";
|
|
506
|
-
function usePinMode({
|
|
537
|
+
function usePinMode({
|
|
538
|
+
hotkey,
|
|
539
|
+
onDeactivate,
|
|
540
|
+
disabled,
|
|
541
|
+
isModalOpen
|
|
542
|
+
}) {
|
|
507
543
|
const [isActive, setIsActive] = useState(false);
|
|
508
544
|
const [pendingPins, setPendingPins] = useState([]);
|
|
509
545
|
const [selectedColor, setSelectedColor] = useState(PIN_PALETTE[0]);
|
|
@@ -532,7 +568,7 @@ function usePinMode({ hotkey, onDeactivate, disabled }) {
|
|
|
532
568
|
if (e.key === "Escape") {
|
|
533
569
|
deactivate();
|
|
534
570
|
}
|
|
535
|
-
if (isActive && (e.key === "ArrowLeft" || e.key === "ArrowRight")) {
|
|
571
|
+
if (isActive && !isModalOpen && !showForm && (e.key === "ArrowLeft" || e.key === "ArrowRight")) {
|
|
536
572
|
e.preventDefault();
|
|
537
573
|
setSelectedColor((prev) => {
|
|
538
574
|
const idx = PIN_PALETTE.indexOf(prev);
|
|
@@ -543,7 +579,7 @@ function usePinMode({ hotkey, onDeactivate, disabled }) {
|
|
|
543
579
|
};
|
|
544
580
|
window.addEventListener("keydown", handler);
|
|
545
581
|
return () => window.removeEventListener("keydown", handler);
|
|
546
|
-
}, [hotkey, deactivate, isActive, disabled]);
|
|
582
|
+
}, [hotkey, deactivate, isActive, disabled, isModalOpen, showForm]);
|
|
547
583
|
const handlePageClick = useCallback(
|
|
548
584
|
(e) => {
|
|
549
585
|
if (!isActive) return;
|
|
@@ -626,6 +662,7 @@ function useFeedtackState({
|
|
|
626
662
|
const pinMode = usePinMode({
|
|
627
663
|
hotkey,
|
|
628
664
|
disabled,
|
|
665
|
+
isModalOpen: openThreadId !== null,
|
|
629
666
|
onDeactivate: () => {
|
|
630
667
|
resetForm();
|
|
631
668
|
setOpenThreadId(null);
|
|
@@ -772,7 +809,8 @@ function FeedtackProvider({
|
|
|
772
809
|
classes = {},
|
|
773
810
|
sentimentLabels = {},
|
|
774
811
|
onError,
|
|
775
|
-
disabled = false
|
|
812
|
+
disabled = false,
|
|
813
|
+
renderPinIcon
|
|
776
814
|
}) {
|
|
777
815
|
const state = useFeedtackState({
|
|
778
816
|
adapter,
|
|
@@ -809,6 +847,8 @@ function FeedtackProvider({
|
|
|
809
847
|
),
|
|
810
848
|
onClick: () => state.isPinModeActive ? state.deactivatePinMode() : state.activatePinMode(),
|
|
811
849
|
title: "Toggle feedback pin mode",
|
|
850
|
+
"aria-label": "Toggle feedback pin mode",
|
|
851
|
+
"aria-pressed": state.isPinModeActive,
|
|
812
852
|
children: [
|
|
813
853
|
"Drop Pin [Shift+",
|
|
814
854
|
hotkey.toUpperCase(),
|
|
@@ -864,11 +904,15 @@ function FeedtackProvider({
|
|
|
864
904
|
),
|
|
865
905
|
!state.loading && state.feedbackItems.filter((item) => item.payload.page.pathname === state.pathname).filter((item) => !state.isArchivedForUser(item)).filter((item) => state.hasValidPins(item)).map((item) => {
|
|
866
906
|
const pin = item.payload.pins[0];
|
|
867
|
-
return /* @__PURE__ */
|
|
907
|
+
return /* @__PURE__ */ jsxs3(
|
|
868
908
|
"button",
|
|
869
909
|
{
|
|
870
910
|
type: "button",
|
|
871
|
-
className: cx(
|
|
911
|
+
className: cx(
|
|
912
|
+
"feedtack-pin-marker",
|
|
913
|
+
item.resolutions.length > 0 && "feedtack-pin-resolved",
|
|
914
|
+
classes.pinMarker
|
|
915
|
+
),
|
|
872
916
|
style: {
|
|
873
917
|
background: pin.color,
|
|
874
918
|
left: pin.x,
|
|
@@ -879,7 +923,18 @@ function FeedtackProvider({
|
|
|
879
923
|
onClick: () => state.setOpenThreadId(
|
|
880
924
|
state.openThreadId === item.payload.id ? null : item.payload.id
|
|
881
925
|
),
|
|
882
|
-
children:
|
|
926
|
+
children: [
|
|
927
|
+
renderPinIcon ? /* @__PURE__ */ jsx3("span", { className: "feedtack-pin-icon", children: renderPinIcon(item) }) : item.resolutions.length > 0 && /* @__PURE__ */ jsx3(
|
|
928
|
+
"span",
|
|
929
|
+
{
|
|
930
|
+
className: "feedtack-pin-icon",
|
|
931
|
+
role: "img",
|
|
932
|
+
"aria-label": "Resolved",
|
|
933
|
+
children: "\u2713"
|
|
934
|
+
}
|
|
935
|
+
),
|
|
936
|
+
state.hasUnread(item) && /* @__PURE__ */ jsx3("div", { className: "feedtack-pin-badge" })
|
|
937
|
+
]
|
|
883
938
|
},
|
|
884
939
|
item.payload.id
|
|
885
940
|
);
|