feedtack 0.3.0 → 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.js +11 -5
- 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.js
CHANGED
|
@@ -171,7 +171,7 @@ function ThreadPanel({
|
|
|
171
171
|
item.replies.map((r) => /* @__PURE__ */ jsxs2(
|
|
172
172
|
"div",
|
|
173
173
|
{
|
|
174
|
-
style: { borderTop: "1px solid
|
|
174
|
+
style: { borderTop: "1px solid var(--ft-border)", paddingTop: 8 },
|
|
175
175
|
children: [
|
|
176
176
|
/* @__PURE__ */ jsx2("span", { style: { fontSize: 12, fontWeight: 600 }, children: r.author.name }),
|
|
177
177
|
/* @__PURE__ */ jsx2("p", { style: { fontSize: 12 }, children: r.body })
|
|
@@ -190,7 +190,7 @@ function ThreadPanel({
|
|
|
190
190
|
fontSize: 12,
|
|
191
191
|
padding: 6,
|
|
192
192
|
borderRadius: 6,
|
|
193
|
-
border: "1px solid
|
|
193
|
+
border: "1px solid var(--ft-border)",
|
|
194
194
|
marginTop: 4
|
|
195
195
|
}
|
|
196
196
|
}
|
|
@@ -534,7 +534,12 @@ function useFeedtackDom(theme, disabled) {
|
|
|
534
534
|
|
|
535
535
|
// src/react/usePinMode.ts
|
|
536
536
|
import { useCallback, useEffect as useEffect2, useState } from "react";
|
|
537
|
-
function usePinMode({
|
|
537
|
+
function usePinMode({
|
|
538
|
+
hotkey,
|
|
539
|
+
onDeactivate,
|
|
540
|
+
disabled,
|
|
541
|
+
isModalOpen
|
|
542
|
+
}) {
|
|
538
543
|
const [isActive, setIsActive] = useState(false);
|
|
539
544
|
const [pendingPins, setPendingPins] = useState([]);
|
|
540
545
|
const [selectedColor, setSelectedColor] = useState(PIN_PALETTE[0]);
|
|
@@ -563,7 +568,7 @@ function usePinMode({ hotkey, onDeactivate, disabled }) {
|
|
|
563
568
|
if (e.key === "Escape") {
|
|
564
569
|
deactivate();
|
|
565
570
|
}
|
|
566
|
-
if (isActive && (e.key === "ArrowLeft" || e.key === "ArrowRight")) {
|
|
571
|
+
if (isActive && !isModalOpen && !showForm && (e.key === "ArrowLeft" || e.key === "ArrowRight")) {
|
|
567
572
|
e.preventDefault();
|
|
568
573
|
setSelectedColor((prev) => {
|
|
569
574
|
const idx = PIN_PALETTE.indexOf(prev);
|
|
@@ -574,7 +579,7 @@ function usePinMode({ hotkey, onDeactivate, disabled }) {
|
|
|
574
579
|
};
|
|
575
580
|
window.addEventListener("keydown", handler);
|
|
576
581
|
return () => window.removeEventListener("keydown", handler);
|
|
577
|
-
}, [hotkey, deactivate, isActive, disabled]);
|
|
582
|
+
}, [hotkey, deactivate, isActive, disabled, isModalOpen, showForm]);
|
|
578
583
|
const handlePageClick = useCallback(
|
|
579
584
|
(e) => {
|
|
580
585
|
if (!isActive) return;
|
|
@@ -657,6 +662,7 @@ function useFeedtackState({
|
|
|
657
662
|
const pinMode = usePinMode({
|
|
658
663
|
hotkey,
|
|
659
664
|
disabled,
|
|
665
|
+
isModalOpen: openThreadId !== null,
|
|
660
666
|
onDeactivate: () => {
|
|
661
667
|
resetForm();
|
|
662
668
|
setOpenThreadId(null);
|