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 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:
@@ -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;
@@ -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
- ref: (el) => el?.focus()
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 #f3f4f6", paddingTop: 8 },
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 #e5e7eb",
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({ hotkey, onDeactivate, disabled }) {
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__ */ jsx3(
907
+ return /* @__PURE__ */ jsxs3(
868
908
  "button",
869
909
  {
870
910
  type: "button",
871
- className: cx("feedtack-pin-marker", classes.pinMarker),
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: state.hasUnread(item) && /* @__PURE__ */ jsx3("div", { className: "feedtack-pin-badge" })
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
  );
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "feedtack",
3
- "version": "0.2.1",
3
+ "version": "0.3.1",
4
4
  "description": "Click anywhere. Drop a pin. Get a payload a developer can act on.",
5
5
  "type": "module",
6
6
  "license": "MIT",