feedtack 1.0.1 → 1.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.
@@ -1,33 +1,267 @@
1
1
  import {
2
- SCHEMA_VERSION,
3
- getDeviceMeta,
4
- getPageMeta,
5
- getPinCoords,
6
- getTargetMeta,
7
- getViewportMeta,
8
- themeToCSS
9
- } from "../chunk-2A5LLDLP.js";
2
+ FeedtackEngine,
3
+ PIN_PALETTE,
4
+ hashField,
5
+ isContentAdapter,
6
+ isContentEditAdapter,
7
+ scanFields,
8
+ warnIfNotContentAdapter,
9
+ warnIfNotContentEditAdapter
10
+ } from "../chunk-GD2SY64K.js";
11
+
12
+ // src/react/ContentEditToolbar.tsx
13
+ import { useState } from "react";
14
+
15
+ // src/react/toolbarStyles.ts
16
+ var styles = {
17
+ toolbar: {
18
+ position: "fixed",
19
+ bottom: 16,
20
+ right: 16,
21
+ zIndex: 9999,
22
+ background: "#1a1614",
23
+ border: "1px solid #3a3330",
24
+ borderRadius: 8,
25
+ padding: "8px 12px",
26
+ display: "flex",
27
+ alignItems: "center",
28
+ gap: 8,
29
+ fontFamily: "monospace",
30
+ fontSize: 13,
31
+ color: "#e5e0d8",
32
+ boxShadow: "0 4px 24px rgba(0,0,0,0.5)",
33
+ flexWrap: "wrap",
34
+ maxWidth: 480
35
+ },
36
+ fieldSection: {
37
+ display: "flex",
38
+ alignItems: "center",
39
+ gap: 6,
40
+ borderRight: "1px solid #3a3330",
41
+ paddingRight: 8
42
+ },
43
+ fieldPath: {
44
+ color: "#888",
45
+ fontSize: 11,
46
+ maxWidth: 160,
47
+ overflow: "hidden",
48
+ textOverflow: "ellipsis",
49
+ whiteSpace: "nowrap"
50
+ },
51
+ savingBadge: {
52
+ color: "#f59e0b",
53
+ fontSize: 11
54
+ },
55
+ btnPrimary: {
56
+ background: "#10b981",
57
+ color: "#fff",
58
+ border: "none",
59
+ borderRadius: 4,
60
+ padding: "3px 10px",
61
+ fontSize: 12,
62
+ cursor: "pointer"
63
+ },
64
+ btnSecondary: {
65
+ background: "transparent",
66
+ color: "#888",
67
+ border: "1px solid #3a3330",
68
+ borderRadius: 4,
69
+ padding: "3px 10px",
70
+ fontSize: 12,
71
+ cursor: "pointer"
72
+ },
73
+ btnGhost: {
74
+ background: "transparent",
75
+ color: "#e5e0d8",
76
+ border: "1px solid #3a3330",
77
+ borderRadius: 4,
78
+ padding: "3px 10px",
79
+ fontSize: 12,
80
+ cursor: "pointer"
81
+ },
82
+ btnSuccess: {
83
+ background: "#10b981",
84
+ color: "#fff",
85
+ border: "none",
86
+ borderRadius: 4,
87
+ padding: "3px 10px",
88
+ fontSize: 12,
89
+ cursor: "pointer"
90
+ },
91
+ btnDanger: {
92
+ background: "transparent",
93
+ color: "#ef4444",
94
+ border: "1px solid #ef4444",
95
+ borderRadius: 4,
96
+ padding: "2px 8px",
97
+ fontSize: 11,
98
+ cursor: "pointer"
99
+ },
100
+ panel: {
101
+ position: "absolute",
102
+ bottom: "100%",
103
+ right: 0,
104
+ marginBottom: 8,
105
+ background: "#1a1614",
106
+ border: "1px solid #3a3330",
107
+ borderRadius: 8,
108
+ padding: "10px 14px",
109
+ minWidth: 280,
110
+ maxWidth: 420,
111
+ boxShadow: "0 4px 24px rgba(0,0,0,0.5)"
112
+ },
113
+ panelTitle: {
114
+ fontSize: 11,
115
+ color: "#888",
116
+ marginBottom: 8,
117
+ textTransform: "uppercase",
118
+ letterSpacing: "0.05em"
119
+ },
120
+ changeRow: {
121
+ display: "flex",
122
+ alignItems: "center",
123
+ gap: 8,
124
+ padding: "4px 0",
125
+ borderBottom: "1px solid #2a2421"
126
+ },
127
+ changePath: {
128
+ fontSize: 11,
129
+ color: "#888",
130
+ minWidth: 100,
131
+ flexShrink: 0
132
+ },
133
+ changeValue: {
134
+ fontSize: 12,
135
+ color: "#e5e0d8",
136
+ flex: 1,
137
+ overflow: "hidden",
138
+ textOverflow: "ellipsis",
139
+ whiteSpace: "nowrap"
140
+ },
141
+ deployOk: {
142
+ color: "#10b981",
143
+ fontSize: 13
144
+ },
145
+ deployPendingTitle: {
146
+ color: "#f59e0b",
147
+ fontSize: 13,
148
+ marginBottom: 6
149
+ },
150
+ deployPendingItem: {
151
+ fontSize: 12,
152
+ color: "#888",
153
+ padding: "2px 0"
154
+ }
155
+ };
10
156
 
11
- // src/ui/colors.ts
12
- var PIN_PALETTE = [
13
- "#ef4444",
14
- // red
15
- "#3b82f6",
16
- // blue
17
- "#22c55e",
18
- // green
19
- "#f59e0b",
20
- // amber
21
- "#a855f7",
22
- // purple
23
- "#ec4899"
24
- // pink
25
- ];
157
+ // src/react/ContentEditToolbar.tsx
158
+ import { Fragment, jsx, jsxs } from "react/jsx-runtime";
159
+ function ContentEditToolbar({
160
+ focusedField,
161
+ approvalState,
162
+ changes,
163
+ saving,
164
+ onApprove,
165
+ onRevoke,
166
+ onRevert,
167
+ onCheckDeploy
168
+ }) {
169
+ const [showChanges, setShowChanges] = useState(false);
170
+ const [deployResult, setDeployResult] = useState(
171
+ null
172
+ );
173
+ const [checkingDeploy, setCheckingDeploy] = useState(false);
174
+ const handleCheckDeploy = async () => {
175
+ setCheckingDeploy(true);
176
+ try {
177
+ const result = await onCheckDeploy();
178
+ setDeployResult(result);
179
+ } finally {
180
+ setCheckingDeploy(false);
181
+ }
182
+ };
183
+ return /* @__PURE__ */ jsxs("div", { "data-feedtack-edit-ui": true, style: styles.toolbar, children: [
184
+ focusedField && /* @__PURE__ */ jsxs("div", { style: styles.fieldSection, children: [
185
+ /* @__PURE__ */ jsx("span", { style: styles.fieldPath, children: focusedField.fieldPath }),
186
+ saving === focusedField.fieldPath && /* @__PURE__ */ jsx("span", { style: styles.savingBadge, children: "saving\u2026" }),
187
+ approvalState?.stale === false ? /* @__PURE__ */ jsx(
188
+ "button",
189
+ {
190
+ type: "button",
191
+ style: styles.btnSecondary,
192
+ onClick: () => onRevoke(focusedField.fieldPath),
193
+ children: "Unaccept"
194
+ }
195
+ ) : /* @__PURE__ */ jsx(
196
+ "button",
197
+ {
198
+ type: "button",
199
+ style: styles.btnPrimary,
200
+ onClick: () => onApprove(focusedField.fieldPath),
201
+ disabled: saving === focusedField.fieldPath,
202
+ children: "Approve"
203
+ }
204
+ )
205
+ ] }),
206
+ /* @__PURE__ */ jsxs(
207
+ "button",
208
+ {
209
+ type: "button",
210
+ style: styles.btnGhost,
211
+ onClick: () => {
212
+ setShowChanges((v) => !v);
213
+ setDeployResult(null);
214
+ },
215
+ children: [
216
+ "Changes (",
217
+ changes.length,
218
+ ")"
219
+ ]
220
+ }
221
+ ),
222
+ /* @__PURE__ */ jsx(
223
+ "button",
224
+ {
225
+ type: "button",
226
+ style: deployResult?.approved ? styles.btnSuccess : styles.btnGhost,
227
+ onClick: handleCheckDeploy,
228
+ disabled: checkingDeploy,
229
+ children: checkingDeploy ? "Checking\u2026" : "Check deploy"
230
+ }
231
+ ),
232
+ showChanges && changes.length > 0 && /* @__PURE__ */ jsxs("div", { style: styles.panel, children: [
233
+ /* @__PURE__ */ jsx("div", { style: styles.panelTitle, children: "Session changes" }),
234
+ changes.map((c) => /* @__PURE__ */ jsxs("div", { style: styles.changeRow, children: [
235
+ /* @__PURE__ */ jsx("div", { style: styles.changePath, children: c.fieldPath }),
236
+ /* @__PURE__ */ jsxs("div", { style: styles.changeValue, title: c.to, children: [
237
+ c.to.slice(0, 60),
238
+ c.to.length > 60 ? "\u2026" : ""
239
+ ] }),
240
+ /* @__PURE__ */ jsx(
241
+ "button",
242
+ {
243
+ type: "button",
244
+ style: styles.btnDanger,
245
+ onClick: () => onRevert(c.fieldPath),
246
+ disabled: saving === c.fieldPath,
247
+ children: "Revert"
248
+ }
249
+ )
250
+ ] }, c.fieldPath))
251
+ ] }),
252
+ deployResult && /* @__PURE__ */ jsx("div", { style: styles.panel, children: deployResult.approved ? /* @__PURE__ */ jsx("div", { style: styles.deployOk, children: "All fields approved \u2014 ready to deploy" }) : /* @__PURE__ */ jsxs(Fragment, { children: [
253
+ /* @__PURE__ */ jsxs("div", { style: styles.deployPendingTitle, children: [
254
+ deployResult.pending.length,
255
+ " field",
256
+ deployResult.pending.length !== 1 ? "s" : "",
257
+ " need approval:"
258
+ ] }),
259
+ deployResult.pending.map((p) => /* @__PURE__ */ jsx("div", { style: styles.deployPendingItem, children: p }, p))
260
+ ] }) })
261
+ ] });
262
+ }
26
263
 
27
264
  // src/react/utils.ts
28
- function generateId() {
29
- return `ft_${Date.now().toString(36)}_${Math.random().toString(36).slice(2, 7)}`;
30
- }
31
265
  function getAnchoredPosition(x, y) {
32
266
  const FORM_HEIGHT = 220;
33
267
  const EDGE = 300;
@@ -46,7 +280,7 @@ function cx(...parts) {
46
280
  }
47
281
 
48
282
  // src/react/CommentForm.tsx
49
- import { jsx, jsxs } from "react/jsx-runtime";
283
+ import { jsx as jsx2, jsxs as jsxs2 } from "react/jsx-runtime";
50
284
  function CommentForm({
51
285
  comment,
52
286
  commentError,
@@ -60,14 +294,14 @@ function CommentForm({
60
294
  onSubmit,
61
295
  onCancel
62
296
  }) {
63
- return /* @__PURE__ */ jsxs(
297
+ return /* @__PURE__ */ jsxs2(
64
298
  "div",
65
299
  {
66
300
  className: cx("feedtack-form", classes.form),
67
301
  style: { position: "fixed", ...formPos },
68
302
  children: [
69
- /* @__PURE__ */ jsx("label", { htmlFor: "feedtack-comment", className: "feedtack-sr-only", children: "Feedback comment" }),
70
- /* @__PURE__ */ jsx(
303
+ /* @__PURE__ */ jsx2("label", { htmlFor: "feedtack-comment", className: "feedtack-sr-only", children: "Feedback comment" }),
304
+ /* @__PURE__ */ jsx2(
71
305
  "textarea",
72
306
  {
73
307
  id: "feedtack-comment",
@@ -86,9 +320,9 @@ function CommentForm({
86
320
  "aria-invalid": commentError || void 0
87
321
  }
88
322
  ),
89
- commentError && /* @__PURE__ */ jsx("span", { id: "feedtack-comment-error", className: "feedtack-error-msg", children: "Comment is required" }),
90
- /* @__PURE__ */ jsxs("div", { className: "feedtack-sentiment", children: [
91
- /* @__PURE__ */ jsx(
323
+ commentError && /* @__PURE__ */ jsx2("span", { id: "feedtack-comment-error", className: "feedtack-error-msg", children: "Comment is required" }),
324
+ /* @__PURE__ */ jsxs2("div", { className: "feedtack-sentiment", children: [
325
+ /* @__PURE__ */ jsx2(
92
326
  "button",
93
327
  {
94
328
  type: "button",
@@ -97,7 +331,7 @@ function CommentForm({
97
331
  children: sentimentLabels.satisfied ?? "Good"
98
332
  }
99
333
  ),
100
- /* @__PURE__ */ jsx(
334
+ /* @__PURE__ */ jsx2(
101
335
  "button",
102
336
  {
103
337
  type: "button",
@@ -107,8 +341,8 @@ function CommentForm({
107
341
  }
108
342
  )
109
343
  ] }),
110
- /* @__PURE__ */ jsxs("div", { className: "feedtack-form-actions", children: [
111
- /* @__PURE__ */ jsx(
344
+ /* @__PURE__ */ jsxs2("div", { className: "feedtack-form-actions", children: [
345
+ /* @__PURE__ */ jsx2(
112
346
  "button",
113
347
  {
114
348
  type: "button",
@@ -117,7 +351,7 @@ function CommentForm({
117
351
  children: "Cancel"
118
352
  }
119
353
  ),
120
- /* @__PURE__ */ jsx(
354
+ /* @__PURE__ */ jsx2(
121
355
  "button",
122
356
  {
123
357
  type: "button",
@@ -144,10 +378,10 @@ function useFeedtackContext() {
144
378
  }
145
379
 
146
380
  // src/react/FeedbackModal.tsx
147
- import { useEffect, useRef } from "react";
381
+ import { useCallback, useEffect, useRef } from "react";
148
382
 
149
383
  // src/react/ThreadView.tsx
150
- import { jsx as jsx2, jsxs as jsxs2 } from "react/jsx-runtime";
384
+ import { jsx as jsx3, jsxs as jsxs3 } from "react/jsx-runtime";
151
385
  function ThreadView({
152
386
  item,
153
387
  replyBody,
@@ -157,17 +391,17 @@ function ThreadView({
157
391
  onArchive,
158
392
  onBack
159
393
  }) {
160
- return /* @__PURE__ */ jsxs2("div", { className: "feedtack-modal-thread-view", children: [
161
- /* @__PURE__ */ jsx2("button", { type: "button", className: "feedtack-modal-back", onClick: onBack, children: "\u2190 Back" }),
162
- /* @__PURE__ */ jsxs2("div", { className: "feedtack-modal-thread-content", children: [
163
- /* @__PURE__ */ jsx2("strong", { children: item.payload.submittedBy.name }),
164
- /* @__PURE__ */ jsx2("p", { children: item.payload.comment })
394
+ return /* @__PURE__ */ jsxs3("div", { className: "feedtack-modal-thread-view", children: [
395
+ /* @__PURE__ */ jsx3("button", { type: "button", className: "feedtack-modal-back", onClick: onBack, children: "\u2190 Back" }),
396
+ /* @__PURE__ */ jsxs3("div", { className: "feedtack-modal-thread-content", children: [
397
+ /* @__PURE__ */ jsx3("strong", { children: item.payload.submittedBy.name }),
398
+ /* @__PURE__ */ jsx3("p", { children: item.payload.comment })
165
399
  ] }),
166
- item.replies.map((r) => /* @__PURE__ */ jsxs2("div", { className: "feedtack-modal-reply", children: [
167
- /* @__PURE__ */ jsx2("span", { className: "feedtack-reply-author", children: r.author.name }),
168
- /* @__PURE__ */ jsx2("p", { children: r.body })
400
+ item.replies.map((r) => /* @__PURE__ */ jsxs3("div", { className: "feedtack-modal-reply", children: [
401
+ /* @__PURE__ */ jsx3("span", { className: "feedtack-reply-author", children: r.author.name }),
402
+ /* @__PURE__ */ jsx3("p", { children: r.body })
169
403
  ] }, r.id)),
170
- /* @__PURE__ */ jsx2(
404
+ /* @__PURE__ */ jsx3(
171
405
  "textarea",
172
406
  {
173
407
  className: "feedtack-modal-textarea",
@@ -176,9 +410,9 @@ function ThreadView({
176
410
  onChange: (e) => onReplyBodyChange(e.target.value)
177
411
  }
178
412
  ),
179
- /* @__PURE__ */ jsxs2("div", { className: "feedtack-modal-actions", children: [
180
- /* @__PURE__ */ jsx2("button", { type: "button", className: "feedtack-btn-submit", onClick: onReply, children: "Reply" }),
181
- /* @__PURE__ */ jsx2(
413
+ /* @__PURE__ */ jsxs3("div", { className: "feedtack-modal-actions", children: [
414
+ /* @__PURE__ */ jsx3("button", { type: "button", className: "feedtack-btn-submit", onClick: onReply, children: "Reply" }),
415
+ /* @__PURE__ */ jsx3(
182
416
  "button",
183
417
  {
184
418
  type: "button",
@@ -187,7 +421,7 @@ function ThreadView({
187
421
  children: "Resolve"
188
422
  }
189
423
  ),
190
- /* @__PURE__ */ jsx2(
424
+ /* @__PURE__ */ jsx3(
191
425
  "button",
192
426
  {
193
427
  type: "button",
@@ -201,7 +435,7 @@ function ThreadView({
201
435
  }
202
436
 
203
437
  // src/react/FeedbackModal.tsx
204
- import { Fragment, jsx as jsx3, jsxs as jsxs3 } from "react/jsx-runtime";
438
+ import { Fragment as Fragment2, jsx as jsx4, jsxs as jsxs4 } from "react/jsx-runtime";
205
439
  function FeedbackModal({
206
440
  isOpen,
207
441
  onClose,
@@ -225,175 +459,183 @@ function FeedbackModal({
225
459
  openThreadId,
226
460
  onOpenThread
227
461
  }) {
228
- const panelRef = useRef(null);
462
+ const dialogRef = useRef(null);
229
463
  useEffect(() => {
230
- if (!isOpen) return;
231
- const onKey = (e) => {
232
- if (e.key === "Escape") onClose();
233
- };
234
- const onDown = (e) => {
235
- if (panelRef.current && !panelRef.current.contains(e.target)) {
236
- const btn = document.querySelector(".feedtack-btn");
237
- if (btn?.contains(e.target)) return;
464
+ const dialog = dialogRef.current;
465
+ if (!dialog) return;
466
+ if (isOpen && !dialog.open) {
467
+ dialog.showModal();
468
+ } else if (!isOpen && dialog.open) {
469
+ dialog.close();
470
+ }
471
+ }, [isOpen]);
472
+ const handleCancel = useCallback(
473
+ (e) => {
474
+ e.preventDefault();
475
+ onClose();
476
+ },
477
+ [onClose]
478
+ );
479
+ const handleBackdropClick = useCallback(
480
+ (e) => {
481
+ if (e.target === dialogRef.current) {
238
482
  onClose();
239
483
  }
240
- };
241
- window.addEventListener("keydown", onKey);
242
- document.addEventListener("mousedown", onDown);
243
- return () => {
244
- window.removeEventListener("keydown", onKey);
245
- document.removeEventListener("mousedown", onDown);
246
- };
247
- }, [isOpen, onClose]);
484
+ },
485
+ [onClose]
486
+ );
248
487
  if (!isOpen) return null;
249
488
  const threads = activeTab === "site" ? siteFeedback : pageFeedback;
250
489
  const openItem = openThreadId ? threads.find((i) => i.payload.id === openThreadId) : null;
251
- return /* @__PURE__ */ jsxs3(
252
- "div",
253
- {
254
- ref: panelRef,
255
- className: "feedtack-modal",
256
- role: "dialog",
257
- "aria-label": "Feedback",
258
- "aria-modal": "true",
259
- children: [
260
- /* @__PURE__ */ jsxs3("div", { className: "feedtack-modal-header", children: [
261
- /* @__PURE__ */ jsx3("span", { className: "feedtack-modal-title", children: "Feedback" }),
262
- /* @__PURE__ */ jsx3(
263
- "button",
264
- {
265
- type: "button",
266
- className: "feedtack-modal-close",
267
- onClick: onClose,
268
- "aria-label": "Close",
269
- children: "\xD7"
270
- }
271
- )
272
- ] }),
273
- /* @__PURE__ */ jsxs3("div", { className: "feedtack-modal-tabs", children: [
274
- /* @__PURE__ */ jsxs3(
275
- "button",
276
- {
277
- type: "button",
278
- className: cx("feedtack-modal-tab", activeTab === "site" && "active"),
279
- onClick: () => onTabChange("site"),
280
- children: [
281
- "Site",
282
- siteFeedback.length > 0 && /* @__PURE__ */ jsx3("span", { className: "feedtack-tab-count", children: siteFeedback.length })
283
- ]
284
- }
285
- ),
286
- /* @__PURE__ */ jsxs3(
287
- "button",
288
- {
289
- type: "button",
290
- className: cx("feedtack-modal-tab", activeTab === "page" && "active"),
291
- onClick: () => onTabChange("page"),
292
- children: [
293
- "Page",
294
- pageFeedback.length > 0 && /* @__PURE__ */ jsx3("span", { className: "feedtack-tab-count", children: pageFeedback.length })
295
- ]
296
- }
297
- )
298
- ] }),
299
- /* @__PURE__ */ jsx3("div", { className: "feedtack-modal-body", children: openItem ? /* @__PURE__ */ jsx3(
300
- ThreadView,
301
- {
302
- item: openItem,
303
- replyBody,
304
- onReplyBodyChange,
305
- onReply: () => onReply(openItem.payload.id),
306
- onResolve: () => onResolve(openItem.payload.id),
307
- onArchive: () => onArchive(openItem.payload.id),
308
- onBack: () => onOpenThread(null)
309
- }
310
- ) : /* @__PURE__ */ jsxs3(Fragment, { children: [
311
- threads.length > 0 && /* @__PURE__ */ jsx3("div", { className: "feedtack-modal-threads", children: threads.map((item) => /* @__PURE__ */ jsxs3(
312
- "button",
313
- {
314
- type: "button",
315
- className: "feedtack-modal-thread-item",
316
- onClick: () => onOpenThread(item.payload.id),
317
- children: [
318
- /* @__PURE__ */ jsx3("span", { className: "feedtack-thread-author", children: item.payload.submittedBy.name }),
319
- /* @__PURE__ */ jsx3("span", { className: "feedtack-thread-comment", children: item.payload.comment }),
320
- /* @__PURE__ */ jsxs3("span", { className: "feedtack-thread-meta", children: [
321
- item.replies.length > 0 && `${item.replies.length} ${item.replies.length === 1 ? "reply" : "replies"}`,
322
- item.resolutions.length > 0 && " \xB7 resolved"
323
- ] })
324
- ]
325
- },
326
- item.payload.id
327
- )) }),
328
- /* @__PURE__ */ jsxs3("div", { className: "feedtack-modal-compose", children: [
329
- /* @__PURE__ */ jsx3(
330
- "textarea",
490
+ return (
491
+ // biome-ignore lint/a11y/useKeyWithClickEvents: native <dialog> handles keyboard via onCancel (Escape)
492
+ /* @__PURE__ */ jsxs4(
493
+ "dialog",
494
+ {
495
+ ref: dialogRef,
496
+ className: "feedtack-modal",
497
+ "aria-label": "Feedback",
498
+ onCancel: handleCancel,
499
+ onClick: handleBackdropClick,
500
+ children: [
501
+ /* @__PURE__ */ jsxs4("div", { className: "feedtack-modal-header", children: [
502
+ /* @__PURE__ */ jsx4("span", { className: "feedtack-modal-title", children: "Feedback" }),
503
+ /* @__PURE__ */ jsx4(
504
+ "button",
331
505
  {
332
- className: cx(
333
- "feedtack-modal-textarea",
334
- commentError && "error"
335
- ),
336
- placeholder: "What's on your mind? (required)",
337
- value: comment,
338
- onChange: (e) => onCommentChange(e.target.value),
339
- onKeyDown: (e) => {
340
- if (e.key === "Enter" && (e.metaKey || e.ctrlKey)) {
341
- e.preventDefault();
342
- onSubmit();
343
- }
344
- },
345
- "aria-invalid": commentError || void 0
506
+ type: "button",
507
+ className: "feedtack-modal-close",
508
+ onClick: onClose,
509
+ "aria-label": "Close",
510
+ children: "\xD7"
511
+ }
512
+ )
513
+ ] }),
514
+ /* @__PURE__ */ jsxs4("div", { className: "feedtack-modal-tabs", children: [
515
+ /* @__PURE__ */ jsxs4(
516
+ "button",
517
+ {
518
+ type: "button",
519
+ className: cx("feedtack-modal-tab", activeTab === "site" && "active"),
520
+ onClick: () => onTabChange("site"),
521
+ children: [
522
+ "Site",
523
+ siteFeedback.length > 0 && /* @__PURE__ */ jsx4("span", { className: "feedtack-tab-count", children: siteFeedback.length })
524
+ ]
346
525
  }
347
526
  ),
348
- commentError && /* @__PURE__ */ jsx3("span", { className: "feedtack-error-msg", children: "Comment is required" }),
349
- /* @__PURE__ */ jsxs3("div", { className: "feedtack-sentiment", children: [
350
- /* @__PURE__ */ jsx3(
351
- "button",
527
+ /* @__PURE__ */ jsxs4(
528
+ "button",
529
+ {
530
+ type: "button",
531
+ className: cx("feedtack-modal-tab", activeTab === "page" && "active"),
532
+ onClick: () => onTabChange("page"),
533
+ children: [
534
+ "Page",
535
+ pageFeedback.length > 0 && /* @__PURE__ */ jsx4("span", { className: "feedtack-tab-count", children: pageFeedback.length })
536
+ ]
537
+ }
538
+ )
539
+ ] }),
540
+ /* @__PURE__ */ jsx4("div", { className: "feedtack-modal-body", children: openItem ? /* @__PURE__ */ jsx4(
541
+ ThreadView,
542
+ {
543
+ item: openItem,
544
+ replyBody,
545
+ onReplyBodyChange,
546
+ onReply: () => onReply(openItem.payload.id),
547
+ onResolve: () => onResolve(openItem.payload.id),
548
+ onArchive: () => onArchive(openItem.payload.id),
549
+ onBack: () => onOpenThread(null)
550
+ }
551
+ ) : /* @__PURE__ */ jsxs4(Fragment2, { children: [
552
+ threads.length > 0 && /* @__PURE__ */ jsx4("div", { className: "feedtack-modal-threads", children: threads.map((item) => /* @__PURE__ */ jsxs4(
553
+ "button",
554
+ {
555
+ type: "button",
556
+ className: "feedtack-modal-thread-item",
557
+ onClick: () => onOpenThread(item.payload.id),
558
+ children: [
559
+ /* @__PURE__ */ jsx4("span", { className: "feedtack-thread-author", children: item.payload.submittedBy.name }),
560
+ /* @__PURE__ */ jsx4("span", { className: "feedtack-thread-comment", children: item.payload.comment }),
561
+ /* @__PURE__ */ jsxs4("span", { className: "feedtack-thread-meta", children: [
562
+ item.replies.length > 0 && `${item.replies.length} ${item.replies.length === 1 ? "reply" : "replies"}`,
563
+ item.resolutions.length > 0 && " \xB7 resolved"
564
+ ] })
565
+ ]
566
+ },
567
+ item.payload.id
568
+ )) }),
569
+ /* @__PURE__ */ jsxs4("div", { className: "feedtack-modal-compose", children: [
570
+ /* @__PURE__ */ jsx4(
571
+ "textarea",
352
572
  {
353
- type: "button",
354
- className: sentiment === "good" ? "selected" : "",
355
- onClick: () => onSentimentChange(sentiment === "good" ? null : "good"),
356
- children: "Good"
573
+ className: cx(
574
+ "feedtack-modal-textarea",
575
+ commentError && "error"
576
+ ),
577
+ placeholder: "What's on your mind? (required)",
578
+ value: comment,
579
+ onChange: (e) => onCommentChange(e.target.value),
580
+ onKeyDown: (e) => {
581
+ if (e.key === "Enter" && (e.metaKey || e.ctrlKey)) {
582
+ e.preventDefault();
583
+ onSubmit();
584
+ }
585
+ },
586
+ "aria-invalid": commentError || void 0
357
587
  }
358
588
  ),
359
- /* @__PURE__ */ jsx3(
589
+ commentError && /* @__PURE__ */ jsx4("span", { className: "feedtack-error-msg", children: "Comment is required" }),
590
+ /* @__PURE__ */ jsxs4("div", { className: "feedtack-sentiment", children: [
591
+ /* @__PURE__ */ jsx4(
592
+ "button",
593
+ {
594
+ type: "button",
595
+ className: sentiment === "good" ? "selected" : "",
596
+ onClick: () => onSentimentChange(sentiment === "good" ? null : "good"),
597
+ children: "Good"
598
+ }
599
+ ),
600
+ /* @__PURE__ */ jsx4(
601
+ "button",
602
+ {
603
+ type: "button",
604
+ className: sentiment === "bad" ? "selected" : "",
605
+ onClick: () => onSentimentChange(sentiment === "bad" ? null : "bad"),
606
+ children: "Bad"
607
+ }
608
+ )
609
+ ] }),
610
+ /* @__PURE__ */ jsx4(
360
611
  "button",
361
612
  {
362
613
  type: "button",
363
- className: sentiment === "bad" ? "selected" : "",
364
- onClick: () => onSentimentChange(sentiment === "bad" ? null : "bad"),
365
- children: "Bad"
614
+ className: "feedtack-btn-submit",
615
+ onClick: onSubmit,
616
+ disabled: submitting,
617
+ children: submitting ? "Sending\u2026" : "Submit"
366
618
  }
367
619
  )
368
- ] }),
369
- /* @__PURE__ */ jsx3(
370
- "button",
371
- {
372
- type: "button",
373
- className: "feedtack-btn-submit",
374
- onClick: onSubmit,
375
- disabled: submitting,
376
- children: submitting ? "Sending\u2026" : "Submit"
377
- }
378
- )
379
- ] })
380
- ] }) }),
381
- /* @__PURE__ */ jsx3("div", { className: "feedtack-modal-footer", children: /* @__PURE__ */ jsx3(
382
- "button",
383
- {
384
- type: "button",
385
- className: "feedtack-modal-pin-btn",
386
- onClick: onPlacePin,
387
- children: "Place a pin"
388
- }
389
- ) })
390
- ]
391
- }
620
+ ] })
621
+ ] }) }),
622
+ /* @__PURE__ */ jsx4("div", { className: "feedtack-modal-footer", children: /* @__PURE__ */ jsx4(
623
+ "button",
624
+ {
625
+ type: "button",
626
+ className: "feedtack-modal-pin-btn",
627
+ onClick: onPlacePin,
628
+ children: "Place a pin"
629
+ }
630
+ ) })
631
+ ]
632
+ }
633
+ )
392
634
  );
393
635
  }
394
636
 
395
637
  // src/react/PinOverlay.tsx
396
- import { Fragment as Fragment2, jsx as jsx4, jsxs as jsxs4 } from "react/jsx-runtime";
638
+ import { Fragment as Fragment3, jsx as jsx5, jsxs as jsxs5 } from "react/jsx-runtime";
397
639
  function PinOverlay({
398
640
  feedbackItems,
399
641
  pathname,
@@ -406,10 +648,10 @@ function PinOverlay({
406
648
  renderPinIcon,
407
649
  pinMarkerClass
408
650
  }) {
409
- return /* @__PURE__ */ jsx4(Fragment2, { children: feedbackItems.filter((item) => item.payload.page.pathname === pathname).filter((item) => !isArchivedForUser(item)).filter((item) => hasValidPins(item)).map((item) => {
651
+ return /* @__PURE__ */ jsx5(Fragment3, { children: feedbackItems.filter((item) => item.payload.page.pathname === pathname).filter((item) => !isArchivedForUser(item)).filter((item) => hasValidPins(item)).map((item) => {
410
652
  const pin = item.payload.pins[0];
411
653
  const pos = getPosition(item.payload.id, pin);
412
- return /* @__PURE__ */ jsxs4(
654
+ return /* @__PURE__ */ jsxs5(
413
655
  "button",
414
656
  {
415
657
  type: "button",
@@ -429,7 +671,7 @@ function PinOverlay({
429
671
  openThreadId === item.payload.id ? null : item.payload.id
430
672
  ),
431
673
  children: [
432
- renderPinIcon ? /* @__PURE__ */ jsx4("span", { className: "feedtack-pin-icon", children: renderPinIcon(item) }) : item.resolutions.length > 0 && /* @__PURE__ */ jsx4(
674
+ renderPinIcon ? /* @__PURE__ */ jsx5("span", { className: "feedtack-pin-icon", children: renderPinIcon(item) }) : item.resolutions.length > 0 && /* @__PURE__ */ jsx5(
433
675
  "span",
434
676
  {
435
677
  className: "feedtack-pin-icon",
@@ -438,7 +680,7 @@ function PinOverlay({
438
680
  children: "\u2713"
439
681
  }
440
682
  ),
441
- hasUnread(item) && /* @__PURE__ */ jsx4("div", { className: "feedtack-pin-badge" })
683
+ hasUnread(item) && /* @__PURE__ */ jsx5("div", { className: "feedtack-pin-badge" })
442
684
  ]
443
685
  },
444
686
  item.payload.id
@@ -447,7 +689,7 @@ function PinOverlay({
447
689
  }
448
690
 
449
691
  // src/react/ThreadPanel.tsx
450
- import { jsx as jsx5, jsxs as jsxs5 } from "react/jsx-runtime";
692
+ import { jsx as jsx6, jsxs as jsxs6 } from "react/jsx-runtime";
451
693
  function ThreadPanel({
452
694
  item,
453
695
  replyBody,
@@ -463,26 +705,26 @@ function ThreadPanel({
463
705
  if (!pin) return null;
464
706
  const { x, y } = pinPosition ?? pin;
465
707
  const pos = getAnchoredPosition(x, y);
466
- return /* @__PURE__ */ jsxs5(
708
+ return /* @__PURE__ */ jsxs6(
467
709
  "div",
468
710
  {
469
711
  className: cx("feedtack-thread", className),
470
712
  style: { position: "fixed", ...pos },
471
713
  children: [
472
- /* @__PURE__ */ jsx5("strong", { style: { fontSize: 13 }, children: item.payload.submittedBy.name }),
473
- /* @__PURE__ */ jsx5("p", { style: { fontSize: 13 }, children: item.payload.comment }),
474
- item.replies.map((r) => /* @__PURE__ */ jsxs5(
714
+ /* @__PURE__ */ jsx6("strong", { style: { fontSize: 13 }, children: item.payload.submittedBy.name }),
715
+ /* @__PURE__ */ jsx6("p", { style: { fontSize: 13 }, children: item.payload.comment }),
716
+ item.replies.map((r) => /* @__PURE__ */ jsxs6(
475
717
  "div",
476
718
  {
477
719
  style: { borderTop: "1px solid var(--ft-border)", paddingTop: 8 },
478
720
  children: [
479
- /* @__PURE__ */ jsx5("span", { style: { fontSize: 12, fontWeight: 600 }, children: r.author.name }),
480
- /* @__PURE__ */ jsx5("p", { style: { fontSize: 12 }, children: r.body })
721
+ /* @__PURE__ */ jsx6("span", { style: { fontSize: 12, fontWeight: 600 }, children: r.author.name }),
722
+ /* @__PURE__ */ jsx6("p", { style: { fontSize: 12 }, children: r.body })
481
723
  ]
482
724
  },
483
725
  r.id
484
726
  )),
485
- /* @__PURE__ */ jsx5(
727
+ /* @__PURE__ */ jsx6(
486
728
  "textarea",
487
729
  {
488
730
  placeholder: "Reply\u2026",
@@ -502,8 +744,8 @@ function ThreadPanel({
502
744
  }
503
745
  }
504
746
  ),
505
- /* @__PURE__ */ jsxs5("div", { style: { display: "flex", gap: 6, flexWrap: "wrap" }, children: [
506
- /* @__PURE__ */ jsx5(
747
+ /* @__PURE__ */ jsxs6("div", { style: { display: "flex", gap: 6, flexWrap: "wrap" }, children: [
748
+ /* @__PURE__ */ jsx6(
507
749
  "button",
508
750
  {
509
751
  type: "button",
@@ -513,7 +755,7 @@ function ThreadPanel({
513
755
  children: "Reply"
514
756
  }
515
757
  ),
516
- /* @__PURE__ */ jsx5(
758
+ /* @__PURE__ */ jsx6(
517
759
  "button",
518
760
  {
519
761
  type: "button",
@@ -523,7 +765,7 @@ function ThreadPanel({
523
765
  children: "Mark Resolved"
524
766
  }
525
767
  ),
526
- /* @__PURE__ */ jsx5(
768
+ /* @__PURE__ */ jsx6(
527
769
  "button",
528
770
  {
529
771
  type: "button",
@@ -533,7 +775,7 @@ function ThreadPanel({
533
775
  children: "Archive"
534
776
  }
535
777
  ),
536
- /* @__PURE__ */ jsx5(
778
+ /* @__PURE__ */ jsx6(
537
779
  "button",
538
780
  {
539
781
  type: "button",
@@ -550,12 +792,12 @@ function ThreadPanel({
550
792
  }
551
793
 
552
794
  // src/react/useAnchoredPins.ts
553
- import { useCallback, useEffect as useEffect2, useState } from "react";
795
+ import { useCallback as useCallback2, useEffect as useEffect2, useState as useState2 } from "react";
554
796
  function useAnchoredPins(items, pathname) {
555
- const [positions, setPositions] = useState(
797
+ const [positions, setPositions] = useState2(
556
798
  /* @__PURE__ */ new Map()
557
799
  );
558
- const resolve = useCallback(() => {
800
+ const resolve = useCallback2(() => {
559
801
  const next = /* @__PURE__ */ new Map();
560
802
  for (const item of items) {
561
803
  if (item.payload.page.pathname !== pathname) continue;
@@ -583,7 +825,7 @@ function useAnchoredPins(items, pathname) {
583
825
  window.removeEventListener("scroll", handler);
584
826
  };
585
827
  }, [resolve]);
586
- const getPosition = useCallback(
828
+ const getPosition = useCallback2(
587
829
  (itemId, fallbackPin) => {
588
830
  return positions.get(itemId) ?? { x: fallbackPin.x, y: fallbackPin.y };
589
831
  },
@@ -611,822 +853,7 @@ function resolvePin(pin) {
611
853
  }
612
854
 
613
855
  // src/react/useFeedtackState.ts
614
- import { useCallback as useCallback5, useEffect as useEffect6, useState as useState3 } from "react";
615
-
616
- // src/react/useFeedtackActions.ts
617
- import { useCallback as useCallback2 } from "react";
618
- function useFeedtackActions(deps) {
619
- const { adapter, currentUser, onError } = deps;
620
- const updateItem = useCallback2(
621
- (id, fn) => deps.setFeedbackItems(
622
- (prev) => prev.map((i) => i.payload.id === id ? fn(i) : i)
623
- ),
624
- [deps.setFeedbackItems]
625
- );
626
- const handleSubmit = useCallback2(async () => {
627
- const comment = deps.getComment();
628
- if (!comment.trim()) {
629
- deps.setCommentError(true);
630
- return;
631
- }
632
- deps.setSubmitting(true);
633
- const payload = {
634
- schemaVersion: SCHEMA_VERSION,
635
- id: generateId(),
636
- timestamp: (/* @__PURE__ */ new Date()).toISOString(),
637
- scope: deps.getScope(),
638
- submittedBy: currentUser,
639
- comment: comment.trim(),
640
- sentiment: deps.getSentiment(),
641
- pins: deps.getPendingPins().map((p, i) => ({ ...p, index: i + 1 })),
642
- page: getPageMeta(),
643
- viewport: getViewportMeta(),
644
- device: getDeviceMeta()
645
- };
646
- try {
647
- await adapter.submit(payload);
648
- deps.setFeedbackItems((prev) => [
649
- ...prev,
650
- { payload, replies: [], resolutions: [], archives: [] }
651
- ]);
652
- deps.deactivatePinMode();
653
- } catch (err) {
654
- onError?.(err);
655
- } finally {
656
- deps.setSubmitting(false);
657
- }
658
- }, [adapter, currentUser, onError, deps]);
659
- const handleReply = useCallback2(
660
- async (feedbackId) => {
661
- const body = deps.getReplyBody().trim();
662
- if (!body) return;
663
- const ts = (/* @__PURE__ */ new Date()).toISOString();
664
- try {
665
- await adapter.reply(feedbackId, {
666
- author: currentUser,
667
- body,
668
- timestamp: ts
669
- });
670
- updateItem(feedbackId, (item) => {
671
- const updated = {
672
- ...item,
673
- replies: [
674
- ...item.replies,
675
- {
676
- id: generateId(),
677
- feedbackId,
678
- author: currentUser,
679
- body,
680
- timestamp: ts
681
- }
682
- ]
683
- };
684
- const rescope = deps.shouldRescope?.(currentUser.role) ?? currentUser.role !== "agent";
685
- if (rescope && updated.resolutions.length === 0 && deps.hasFlush) {
686
- deps.clearFlushed?.(deps.getPathname());
687
- }
688
- return updated;
689
- });
690
- deps.setReplyBody("");
691
- } catch (err) {
692
- onError?.(err);
693
- }
694
- },
695
- [adapter, currentUser, onError, updateItem, deps]
696
- );
697
- const handleResolve = useCallback2(
698
- async (feedbackId) => {
699
- const ts = (/* @__PURE__ */ new Date()).toISOString();
700
- try {
701
- await adapter.resolve(feedbackId, {
702
- resolvedBy: currentUser,
703
- timestamp: ts
704
- });
705
- updateItem(feedbackId, (item) => ({
706
- ...item,
707
- resolutions: [
708
- ...item.resolutions,
709
- { feedbackId, resolvedBy: currentUser, timestamp: ts }
710
- ]
711
- }));
712
- } catch (err) {
713
- onError?.(err);
714
- }
715
- },
716
- [adapter, currentUser, onError, updateItem]
717
- );
718
- const handleArchive = useCallback2(
719
- async (feedbackId) => {
720
- const ts = (/* @__PURE__ */ new Date()).toISOString();
721
- try {
722
- await adapter.archive(feedbackId, currentUser.id);
723
- updateItem(feedbackId, (item) => ({
724
- ...item,
725
- archives: [
726
- ...item.archives,
727
- { feedbackId, archivedBy: currentUser, timestamp: ts }
728
- ]
729
- }));
730
- deps.setOpenThreadId(null);
731
- } catch (err) {
732
- onError?.(err);
733
- }
734
- },
735
- [adapter, currentUser, onError, updateItem, deps]
736
- );
737
- return { handleSubmit, handleReply, handleResolve, handleArchive };
738
- }
739
-
740
- // src/react/useFeedtackDom.ts
741
- import { useEffect as useEffect3, useRef as useRef2 } from "react";
742
-
743
- // src/ui/modalStyles.ts
744
- var FEEDTACK_MODAL_STYLES = `
745
- .feedtack-loading {
746
- position: fixed;
747
- bottom: 70px;
748
- right: 24px;
749
- font-size: 12px;
750
- color: var(--ft-text-muted);
751
- z-index: 2147483640;
752
- }
753
-
754
- .feedtack-modal {
755
- position: fixed;
756
- bottom: 72px;
757
- right: 24px;
758
- width: 360px;
759
- max-height: 70vh;
760
- background: var(--ft-bg);
761
- border: 1px solid var(--ft-border);
762
- border-radius: calc(var(--ft-radius) + 4px);
763
- box-shadow: 0 8px 32px rgba(0,0,0,0.18);
764
- z-index: 2147483643;
765
- display: flex;
766
- flex-direction: column;
767
- overflow: hidden;
768
- }
769
-
770
- .feedtack-modal-header {
771
- display: flex;
772
- align-items: center;
773
- justify-content: space-between;
774
- padding: 14px 16px 0;
775
- }
776
-
777
- .feedtack-modal-title {
778
- font-size: 15px;
779
- font-weight: 600;
780
- color: var(--ft-text);
781
- }
782
-
783
- .feedtack-modal-close {
784
- background: none;
785
- border: none;
786
- font-size: 20px;
787
- cursor: pointer;
788
- color: var(--ft-text-muted);
789
- line-height: 1;
790
- padding: 0 4px;
791
- }
792
-
793
- .feedtack-modal-tabs {
794
- display: flex;
795
- gap: 0;
796
- padding: 12px 16px 0;
797
- border-bottom: 1px solid var(--ft-border);
798
- }
799
-
800
- .feedtack-modal-tab {
801
- flex: 1;
802
- padding: 8px 12px;
803
- border: none;
804
- background: none;
805
- font-size: 13px;
806
- font-weight: 500;
807
- cursor: pointer;
808
- color: var(--ft-text-muted);
809
- border-bottom: 2px solid transparent;
810
- margin-bottom: -1px;
811
- display: flex;
812
- align-items: center;
813
- justify-content: center;
814
- gap: 6px;
815
- }
816
-
817
- .feedtack-modal-tab.active {
818
- color: var(--ft-primary);
819
- border-bottom-color: var(--ft-primary);
820
- }
821
-
822
- .feedtack-tab-count {
823
- font-size: 11px;
824
- background: var(--ft-surface);
825
- color: var(--ft-text-muted);
826
- padding: 1px 6px;
827
- border-radius: 10px;
828
- }
829
-
830
- .feedtack-modal-body {
831
- flex: 1;
832
- overflow-y: auto;
833
- padding: 12px 16px;
834
- display: flex;
835
- flex-direction: column;
836
- gap: 12px;
837
- }
838
-
839
- .feedtack-modal-threads {
840
- display: flex;
841
- flex-direction: column;
842
- gap: 6px;
843
- }
844
-
845
- .feedtack-modal-thread-item {
846
- display: flex;
847
- flex-direction: column;
848
- gap: 2px;
849
- text-align: left;
850
- padding: 10px 12px;
851
- background: var(--ft-surface);
852
- border: 1px solid var(--ft-border);
853
- border-radius: var(--ft-radius);
854
- cursor: pointer;
855
- }
856
-
857
- .feedtack-modal-thread-item:hover {
858
- border-color: var(--ft-primary);
859
- }
860
-
861
- .feedtack-thread-author {
862
- font-size: 12px;
863
- font-weight: 600;
864
- color: var(--ft-text);
865
- }
866
-
867
- .feedtack-thread-comment {
868
- font-size: 13px;
869
- color: var(--ft-text);
870
- overflow: hidden;
871
- text-overflow: ellipsis;
872
- white-space: nowrap;
873
- }
874
-
875
- .feedtack-thread-meta {
876
- font-size: 11px;
877
- color: var(--ft-text-muted);
878
- }
879
-
880
- .feedtack-modal-compose {
881
- display: flex;
882
- flex-direction: column;
883
- gap: 8px;
884
- }
885
-
886
- .feedtack-modal-textarea {
887
- width: 100%;
888
- border: 1.5px solid var(--ft-border);
889
- border-radius: var(--ft-radius);
890
- padding: 8px;
891
- font-size: 13px;
892
- resize: vertical;
893
- min-height: 72px;
894
- outline: none;
895
- background: var(--ft-surface);
896
- color: var(--ft-text);
897
- }
898
-
899
- .feedtack-modal-textarea:focus {
900
- border-color: var(--ft-primary);
901
- }
902
-
903
- .feedtack-modal-textarea.error {
904
- border-color: var(--ft-error);
905
- }
906
-
907
- .feedtack-modal-footer {
908
- padding: 10px 16px 14px;
909
- border-top: 1px solid var(--ft-border);
910
- }
911
-
912
- .feedtack-modal-pin-btn {
913
- width: 100%;
914
- padding: 8px 14px;
915
- border: 1.5px solid var(--ft-border);
916
- border-radius: var(--ft-radius);
917
- background: var(--ft-bg);
918
- color: var(--ft-text);
919
- font-size: 13px;
920
- font-weight: 500;
921
- cursor: pointer;
922
- transition: border-color 0.15s;
923
- }
924
-
925
- .feedtack-modal-pin-btn:hover {
926
- border-color: var(--ft-primary);
927
- color: var(--ft-primary);
928
- }
929
-
930
- .feedtack-modal-thread-view {
931
- display: flex;
932
- flex-direction: column;
933
- gap: 10px;
934
- }
935
-
936
- .feedtack-modal-back {
937
- background: none;
938
- border: none;
939
- font-size: 13px;
940
- color: var(--ft-primary);
941
- cursor: pointer;
942
- padding: 0;
943
- text-align: left;
944
- }
945
-
946
- .feedtack-modal-thread-content {
947
- display: flex;
948
- flex-direction: column;
949
- gap: 4px;
950
- font-size: 13px;
951
- }
952
-
953
- .feedtack-modal-reply {
954
- border-top: 1px solid var(--ft-border);
955
- padding-top: 8px;
956
- font-size: 12px;
957
- }
958
-
959
- .feedtack-reply-author {
960
- font-weight: 600;
961
- }
962
-
963
- .feedtack-modal-actions {
964
- display: flex;
965
- gap: 6px;
966
- flex-wrap: wrap;
967
- }
968
-
969
- @media (max-width: 480px) {
970
- .feedtack-modal {
971
- right: 0;
972
- bottom: 64px;
973
- width: 100vw;
974
- max-height: 85vh;
975
- border-radius: var(--ft-radius) var(--ft-radius) 0 0;
976
- border-left: none;
977
- border-right: none;
978
- border-bottom: none;
979
- }
980
- }
981
- `;
982
-
983
- // src/ui/styles.ts
984
- var FEEDTACK_DEFAULT_TOKENS = `
985
- #feedtack-root, .feedtack-form, .feedtack-thread, .feedtack-modal {
986
- --ft-primary: #2563eb;
987
- --ft-primary-hover: #1d4ed8;
988
- --ft-bg: #ffffff;
989
- --ft-surface: #f9fafb;
990
- --ft-text: #111827;
991
- --ft-text-muted: #6b7280;
992
- --ft-border: #e5e7eb;
993
- --ft-radius: 8px;
994
- --ft-error: #ef4444;
995
- --ft-badge: #f59e0b;
996
- }
997
- `;
998
- var FEEDTACK_STYLES = `
999
- #feedtack-root * {
1000
- box-sizing: border-box;
1001
- margin: 0;
1002
- padding: 0;
1003
- font-family: system-ui, -apple-system, sans-serif;
1004
- line-height: 1.5;
1005
- }
1006
-
1007
- .feedtack-btn {
1008
- position: fixed;
1009
- bottom: 24px;
1010
- right: 24px;
1011
- z-index: 2147483640;
1012
- background: var(--ft-text);
1013
- color: var(--ft-bg);
1014
- border: none;
1015
- border-radius: var(--ft-radius);
1016
- padding: 8px 14px;
1017
- font-size: 13px;
1018
- font-weight: 500;
1019
- cursor: pointer;
1020
- box-shadow: 0 2px 8px rgba(0,0,0,0.25);
1021
- display: flex;
1022
- align-items: center;
1023
- gap: 6px;
1024
- transition: background 0.15s;
1025
- }
1026
-
1027
- .feedtack-btn:hover {
1028
- opacity: 0.85;
1029
- }
1030
-
1031
- .feedtack-btn.active {
1032
- background: var(--ft-primary);
1033
- }
1034
-
1035
- .feedtack-crosshair * {
1036
- cursor: crosshair !important;
1037
- }
1038
-
1039
- .feedtack-pin-marker {
1040
- position: absolute;
1041
- z-index: 2147483641;
1042
- width: 24px;
1043
- height: 24px;
1044
- border-radius: 50% 50% 50% 0;
1045
- transform: translate(-50%, -100%) rotate(-45deg);
1046
- transform-origin: bottom center;
1047
- border: 2px solid rgba(255,255,255,0.8);
1048
- box-shadow: 0 2px 6px rgba(0,0,0,0.3);
1049
- cursor: pointer;
1050
- pointer-events: all;
1051
- }
1052
-
1053
- .feedtack-pin-resolved { opacity: 0.6; }
1054
-
1055
- .feedtack-pin-icon {
1056
- position: absolute;
1057
- inset: 0;
1058
- display: flex;
1059
- align-items: center;
1060
- justify-content: center;
1061
- transform: rotate(45deg);
1062
- font-size: 12px;
1063
- font-weight: 700;
1064
- color: #fff;
1065
- line-height: 1;
1066
- pointer-events: none;
1067
- }
1068
-
1069
- .feedtack-pin-badge {
1070
- position: absolute;
1071
- top: -4px;
1072
- right: -4px;
1073
- width: 10px;
1074
- height: 10px;
1075
- background: var(--ft-badge);
1076
- border-radius: 50%;
1077
- border: 1.5px solid var(--ft-bg);
1078
- }
1079
-
1080
- .feedtack-color-picker {
1081
- display: flex;
1082
- gap: 6px;
1083
- padding: 8px;
1084
- background: var(--ft-bg) !important;
1085
- border-radius: var(--ft-radius);
1086
- box-shadow: 0 2px 8px rgba(0,0,0,0.15);
1087
- position: fixed;
1088
- bottom: 72px;
1089
- right: 24px;
1090
- z-index: 2147483641;
1091
- }
1092
-
1093
- .feedtack-color-swatch {
1094
- width: 20px;
1095
- height: 20px;
1096
- border-radius: 50%;
1097
- border: 2px solid transparent;
1098
- cursor: pointer;
1099
- transition: transform 0.1s;
1100
- }
1101
-
1102
- .feedtack-color-swatch.selected {
1103
- border-color: var(--ft-text);
1104
- transform: scale(1.15);
1105
- }
1106
-
1107
- .feedtack-form {
1108
- position: absolute;
1109
- z-index: 2147483642;
1110
- background: var(--ft-bg) !important;
1111
- border-radius: calc(var(--ft-radius) + 2px);
1112
- box-shadow: 0 4px 20px rgba(0,0,0,0.18);
1113
- padding: 16px;
1114
- width: 280px;
1115
- display: flex;
1116
- flex-direction: column;
1117
- gap: 10px;
1118
- }
1119
-
1120
- .feedtack-form textarea {
1121
- width: 100%;
1122
- border: 1.5px solid var(--ft-border);
1123
- border-radius: var(--ft-radius);
1124
- padding: 8px;
1125
- font-size: 13px;
1126
- resize: vertical;
1127
- min-height: 80px;
1128
- outline: none;
1129
- background: var(--ft-surface);
1130
- color: var(--ft-text);
1131
- }
1132
-
1133
- .feedtack-form textarea:focus {
1134
- border-color: var(--ft-primary);
1135
- }
1136
-
1137
- .feedtack-form textarea.error {
1138
- border-color: var(--ft-error);
1139
- }
1140
-
1141
- .feedtack-error-msg {
1142
- font-size: 12px;
1143
- color: var(--ft-error);
1144
- }
1145
-
1146
- .feedtack-sentiment {
1147
- display: flex;
1148
- gap: 8px;
1149
- }
1150
-
1151
- .feedtack-sentiment button {
1152
- flex: 1;
1153
- padding: 6px 10px;
1154
- border: 1.5px solid var(--ft-border);
1155
- border-radius: var(--ft-radius);
1156
- background: var(--ft-bg);
1157
- color: var(--ft-text);
1158
- font-size: 12px;
1159
- cursor: pointer;
1160
- transition: all 0.1s;
1161
- }
1162
-
1163
- .feedtack-sentiment button.selected {
1164
- border-color: var(--ft-primary);
1165
- background: var(--ft-surface);
1166
- color: var(--ft-primary);
1167
- }
1168
-
1169
- .feedtack-form-actions {
1170
- display: flex;
1171
- gap: 8px;
1172
- justify-content: flex-end;
1173
- }
1174
-
1175
- .feedtack-btn-cancel {
1176
- padding: 6px 12px;
1177
- border: 1.5px solid var(--ft-border);
1178
- border-radius: var(--ft-radius);
1179
- background: var(--ft-bg);
1180
- color: var(--ft-text);
1181
- font-size: 13px;
1182
- cursor: pointer;
1183
- }
1184
-
1185
- .feedtack-btn-submit {
1186
- padding: 6px 12px;
1187
- border: none;
1188
- border-radius: var(--ft-radius);
1189
- background: var(--ft-primary);
1190
- color: #fff;
1191
- font-size: 13px;
1192
- font-weight: 500;
1193
- cursor: pointer;
1194
- }
1195
-
1196
- .feedtack-btn-submit:disabled {
1197
- opacity: 0.5;
1198
- cursor: not-allowed;
1199
- }
1200
-
1201
- .feedtack-thread {
1202
- position: absolute;
1203
- z-index: 2147483642;
1204
- background: var(--ft-bg) !important;
1205
- border-radius: calc(var(--ft-radius) + 2px);
1206
- box-shadow: 0 4px 20px rgba(0,0,0,0.18);
1207
- padding: 16px;
1208
- width: 300px;
1209
- max-height: 400px;
1210
- overflow-y: auto;
1211
- display: flex;
1212
- flex-direction: column;
1213
- gap: 10px;
1214
- }
1215
-
1216
- .feedtack-sr-only {
1217
- position: absolute; width: 1px; height: 1px; padding: 0; margin: -1px;
1218
- overflow: hidden; clip: rect(0,0,0,0); white-space: nowrap; border: 0;
1219
- }
1220
-
1221
- ` + FEEDTACK_MODAL_STYLES;
1222
-
1223
- // src/react/useFeedtackDom.ts
1224
- function useFeedtackDom(theme, disabled) {
1225
- const rootRef = useRef2(null);
1226
- useEffect3(() => {
1227
- if (disabled) return;
1228
- if (document.getElementById("feedtack-styles")) return;
1229
- const style = document.createElement("style");
1230
- style.id = "feedtack-styles";
1231
- style.textContent = FEEDTACK_DEFAULT_TOKENS + FEEDTACK_STYLES;
1232
- document.head.appendChild(style);
1233
- return () => {
1234
- style.remove();
1235
- };
1236
- }, [disabled]);
1237
- useEffect3(() => {
1238
- if (disabled) return;
1239
- const root = document.createElement("div");
1240
- root.id = "feedtack-root";
1241
- document.body.appendChild(root);
1242
- rootRef.current = root;
1243
- return () => {
1244
- root.remove();
1245
- };
1246
- }, [disabled]);
1247
- useEffect3(() => {
1248
- if (disabled) return;
1249
- const root = document.getElementById("feedtack-root");
1250
- if (!root || !theme) return;
1251
- const tokens = themeToCSS(theme);
1252
- for (const [k, v] of Object.entries(tokens)) {
1253
- root.style.setProperty(k, v);
1254
- }
1255
- }, [theme, disabled]);
1256
- return rootRef;
1257
- }
1258
-
1259
- // src/react/useFeedtackFlush.ts
1260
- import { useCallback as useCallback3, useEffect as useEffect4, useRef as useRef3 } from "react";
1261
- var DEFAULT_IDLE_MS = 5 * 60 * 1e3;
1262
- function useFeedtackFlush({
1263
- pathname,
1264
- feedbackItems,
1265
- onFlush,
1266
- flushIdleMs = DEFAULT_IDLE_MS,
1267
- disabled
1268
- }) {
1269
- const flushedRef = useRef3(/* @__PURE__ */ new Set());
1270
- const prevPathnameRef = useRef3(pathname);
1271
- const idleTimerRef = useRef3(null);
1272
- const flush = useCallback3(
1273
- (path, items) => {
1274
- if (!onFlush || flushedRef.current.has(path)) return;
1275
- const pageItems = items.filter((i) => i.payload.page.pathname === path);
1276
- if (pageItems.length === 0) return;
1277
- flushedRef.current.add(path);
1278
- onFlush({ pathname: path, items: pageItems });
1279
- },
1280
- [onFlush]
1281
- );
1282
- useEffect4(() => {
1283
- if (disabled || !onFlush) return;
1284
- const prev = prevPathnameRef.current;
1285
- prevPathnameRef.current = pathname;
1286
- if (prev !== pathname) {
1287
- flush(prev, feedbackItems);
1288
- }
1289
- }, [pathname, feedbackItems, flush, onFlush, disabled]);
1290
- useEffect4(() => {
1291
- if (disabled || !onFlush || flushIdleMs <= 0) return;
1292
- const resetTimer = () => {
1293
- if (idleTimerRef.current) clearTimeout(idleTimerRef.current);
1294
- idleTimerRef.current = setTimeout(() => {
1295
- flush(pathname, feedbackItems);
1296
- }, flushIdleMs);
1297
- };
1298
- const events = ["mousemove", "keydown", "scroll", "touchstart"];
1299
- for (const e of events)
1300
- window.addEventListener(e, resetTimer, { passive: true });
1301
- resetTimer();
1302
- return () => {
1303
- if (idleTimerRef.current) clearTimeout(idleTimerRef.current);
1304
- for (const e of events) window.removeEventListener(e, resetTimer);
1305
- };
1306
- }, [pathname, feedbackItems, flush, onFlush, flushIdleMs, disabled]);
1307
- useEffect4(() => {
1308
- if (disabled || !onFlush) return;
1309
- const handleUnload = () => flush(pathname, feedbackItems);
1310
- window.addEventListener("beforeunload", handleUnload);
1311
- return () => window.removeEventListener("beforeunload", handleUnload);
1312
- }, [pathname, feedbackItems, flush, onFlush, disabled]);
1313
- const clearFlushed = useCallback3((path) => {
1314
- flushedRef.current.delete(path);
1315
- }, []);
1316
- return { clearFlushed };
1317
- }
1318
-
1319
- // src/react/usePinMode.ts
1320
- import { useCallback as useCallback4, useEffect as useEffect5, useState as useState2 } from "react";
1321
- function usePinMode({
1322
- hotkey,
1323
- onDeactivate,
1324
- disabled,
1325
- isModalOpen,
1326
- onHotkey
1327
- }) {
1328
- const [isActive, setIsActive] = useState2(false);
1329
- const [pendingPins, setPendingPins] = useState2([]);
1330
- const [selectedColor, setSelectedColor] = useState2(PIN_PALETTE[0]);
1331
- const [showForm, setShowForm] = useState2(false);
1332
- const activate = useCallback4(() => setIsActive(true), []);
1333
- const deactivate = useCallback4(() => {
1334
- setIsActive(false);
1335
- setPendingPins([]);
1336
- setShowForm(false);
1337
- onDeactivate?.();
1338
- }, [onDeactivate]);
1339
- useEffect5(() => {
1340
- if (isActive) {
1341
- document.documentElement.classList.add("feedtack-crosshair");
1342
- } else {
1343
- document.documentElement.classList.remove("feedtack-crosshair");
1344
- }
1345
- return () => document.documentElement.classList.remove("feedtack-crosshair");
1346
- }, [isActive]);
1347
- useEffect5(() => {
1348
- if (disabled) return;
1349
- const handler = (e) => {
1350
- if (e.key === hotkey.toUpperCase() && e.shiftKey) {
1351
- if (onHotkey) {
1352
- onHotkey();
1353
- } else {
1354
- setIsActive((prev) => !prev);
1355
- }
1356
- }
1357
- if (e.key === "Escape") {
1358
- deactivate();
1359
- }
1360
- if (isActive && !isModalOpen && !showForm && (e.key === "ArrowLeft" || e.key === "ArrowRight")) {
1361
- e.preventDefault();
1362
- setSelectedColor((prev) => {
1363
- const idx = PIN_PALETTE.indexOf(prev);
1364
- const dir = e.key === "ArrowRight" ? 1 : -1;
1365
- return PIN_PALETTE[(idx + dir + PIN_PALETTE.length) % PIN_PALETTE.length];
1366
- });
1367
- }
1368
- };
1369
- window.addEventListener("keydown", handler);
1370
- return () => window.removeEventListener("keydown", handler);
1371
- }, [hotkey, deactivate, isActive, disabled, isModalOpen, showForm, onHotkey]);
1372
- const placePin = useCallback4(
1373
- (coords, target) => {
1374
- if (target.closest("#feedtack-root, .feedtack-form, .feedtack-color-picker"))
1375
- return;
1376
- setPendingPins((prev) => [
1377
- ...prev,
1378
- {
1379
- color: selectedColor,
1380
- ...getPinCoords(coords),
1381
- target: getTargetMeta(target)
1382
- }
1383
- ]);
1384
- setShowForm(true);
1385
- },
1386
- [selectedColor]
1387
- );
1388
- const handlePageClick = useCallback4(
1389
- (e) => {
1390
- if (!isActive) return;
1391
- e.preventDefault();
1392
- e.stopPropagation();
1393
- placePin(e, e.target);
1394
- },
1395
- [isActive, placePin]
1396
- );
1397
- const handleTouchEnd = useCallback4(
1398
- (e) => {
1399
- if (!isActive) return;
1400
- const touch = e.changedTouches[0];
1401
- if (!touch) return;
1402
- const target = document.elementFromPoint(touch.clientX, touch.clientY);
1403
- if (!target) return;
1404
- e.preventDefault();
1405
- placePin(touch, target);
1406
- },
1407
- [isActive, placePin]
1408
- );
1409
- useEffect5(() => {
1410
- if (disabled) return;
1411
- document.addEventListener("click", handlePageClick, true);
1412
- document.addEventListener("touchend", handleTouchEnd, true);
1413
- return () => {
1414
- document.removeEventListener("click", handlePageClick, true);
1415
- document.removeEventListener("touchend", handleTouchEnd, true);
1416
- };
1417
- }, [handlePageClick, handleTouchEnd, disabled]);
1418
- return {
1419
- isActive,
1420
- activate,
1421
- deactivate,
1422
- pendingPins,
1423
- selectedColor,
1424
- setSelectedColor,
1425
- showForm
1426
- };
1427
- }
1428
-
1429
- // src/react/useFeedtackState.ts
856
+ import { useCallback as useCallback3, useEffect as useEffect3, useRef as useRef2, useSyncExternalStore } from "react";
1430
857
  function useFeedtackState({
1431
858
  adapter,
1432
859
  currentUser,
@@ -1438,164 +865,112 @@ function useFeedtackState({
1438
865
  flushIdleMs,
1439
866
  rescopeRoles
1440
867
  }) {
1441
- useFeedtackDom(theme, disabled);
1442
- const [pathname, setPathname] = useState3(
1443
- () => typeof window === "undefined" ? "/" : window.location.pathname
1444
- );
1445
- useEffect6(() => {
1446
- const update = () => setPathname(window.location.pathname);
1447
- const origPush = history.pushState.bind(history);
1448
- const origReplace = history.replaceState.bind(history);
1449
- history.pushState = (...args) => {
1450
- origPush(...args);
1451
- queueMicrotask(update);
1452
- };
1453
- history.replaceState = (...args) => {
1454
- origReplace(...args);
1455
- queueMicrotask(update);
1456
- };
1457
- window.addEventListener("popstate", update);
1458
- return () => {
1459
- window.removeEventListener("popstate", update);
1460
- history.pushState = origPush;
1461
- history.replaceState = origReplace;
1462
- };
1463
- }, []);
1464
- const [comment, setComment] = useState3("");
1465
- const [sentiment, setSentiment] = useState3(null);
1466
- const [commentError, setCommentError] = useState3(false);
1467
- const [submitting, setSubmitting] = useState3(false);
1468
- const [feedbackItems, setFeedbackItems] = useState3([]);
1469
- const [loading, setLoading] = useState3(true);
1470
- const [openThreadId, setOpenThreadId] = useState3(null);
1471
- const [replyBody, setReplyBody] = useState3("");
1472
- const [isModalOpen, setIsModalOpen] = useState3(false);
1473
- const [composeScope, setComposeScope] = useState3("site");
1474
- const [siteFeedback, setSiteFeedback] = useState3([]);
1475
- const [pageFeedback, setPageFeedback] = useState3([]);
1476
- const openModal = useCallback5(() => setIsModalOpen(true), []);
1477
- const closeModal = useCallback5(() => setIsModalOpen(false), []);
1478
- const resetForm = useCallback5(() => {
1479
- setComment("");
1480
- setSentiment(null);
1481
- setCommentError(false);
1482
- }, []);
1483
- const pinMode = usePinMode({
1484
- hotkey,
1485
- disabled,
1486
- isModalOpen: openThreadId !== null || isModalOpen,
1487
- onHotkey: openModal,
1488
- onDeactivate: () => {
1489
- resetForm();
1490
- setOpenThreadId(null);
1491
- }
1492
- });
1493
- const { clearFlushed } = useFeedtackFlush({
1494
- pathname,
1495
- feedbackItems,
1496
- onFlush,
1497
- flushIdleMs,
1498
- disabled
1499
- });
1500
- useEffect6(() => {
1501
- setLoading(true);
1502
- adapter.loadFeedback({ pathname }).then((items) => {
1503
- const elementItems = [];
1504
- const siteItems = [];
1505
- const pageItems = [];
1506
- for (const item of items) {
1507
- if (item.payload.scope === "site") siteItems.push(item);
1508
- else if (item.payload.scope === "page") pageItems.push(item);
1509
- else elementItems.push(item);
1510
- }
1511
- setFeedbackItems(elementItems);
1512
- setSiteFeedback(siteItems);
1513
- setPageFeedback(pageItems);
1514
- }).catch((err) => onError?.(err)).finally(() => setLoading(false));
1515
- }, [adapter, onError, pathname]);
1516
- const getCurrentScope = useCallback5(() => {
1517
- if (pinMode.isActive || pinMode.pendingPins.length > 0) return "element";
1518
- return composeScope;
1519
- }, [pinMode.isActive, pinMode.pendingPins.length, composeScope]);
1520
- const commentRef = () => comment;
1521
- const sentimentRef = () => sentiment;
1522
- const scopeRef = () => getCurrentScope();
1523
- const pinsRef = () => pinMode.pendingPins;
1524
- const replyRef = () => replyBody;
1525
- const pathRef = () => pathname;
1526
- const actions = useFeedtackActions({
1527
- adapter,
1528
- currentUser,
1529
- onError,
1530
- getComment: commentRef,
1531
- getSentiment: sentimentRef,
1532
- getScope: scopeRef,
1533
- getPendingPins: pinsRef,
1534
- getReplyBody: replyRef,
1535
- getPathname: pathRef,
1536
- setCommentError,
1537
- setSubmitting,
1538
- setFeedbackItems,
1539
- setReplyBody,
1540
- setOpenThreadId,
1541
- deactivatePinMode: pinMode.deactivate,
1542
- clearFlushed,
1543
- shouldRescope: rescopeRoles ? (role) => rescopeRoles.includes(role) : void 0,
1544
- hasFlush: !!onFlush
1545
- });
1546
- const handleModalSubmit = useCallback5(async () => {
1547
- if (!comment.trim()) {
1548
- setCommentError(true);
1549
- return;
1550
- }
1551
- await actions.handleSubmit();
1552
- const scope = composeScope;
1553
- setFeedbackItems((prev) => {
1554
- const newItem = prev[prev.length - 1];
1555
- if (newItem && newItem.payload.scope === scope) {
1556
- if (scope === "site") {
1557
- setSiteFeedback((s) => [...s, newItem]);
1558
- } else {
1559
- setPageFeedback((p) => [...p, newItem]);
1560
- }
1561
- return prev.slice(0, -1);
1562
- }
1563
- return prev;
868
+ const engineRef = useRef2(null);
869
+ if (!engineRef.current) {
870
+ engineRef.current = new FeedtackEngine({
871
+ adapter,
872
+ currentUser,
873
+ hotkey,
874
+ theme,
875
+ onError,
876
+ disabled,
877
+ onFlush,
878
+ flushIdleMs,
879
+ rescopeRoles
1564
880
  });
1565
- resetForm();
1566
- }, [actions, composeScope, resetForm, comment]);
1567
- const isArchivedForUser = (item) => item.archives.some((a) => a.archivedBy.id === currentUser.id);
1568
- const hasUnread = (item) => item.replies.length > 0;
1569
- const hasValidPins = (item) => Array.isArray(item.payload?.pins) && item.payload.pins.length > 0;
881
+ }
882
+ const engine = engineRef.current;
883
+ useEffect3(() => {
884
+ engine.mount();
885
+ return () => engine.destroy();
886
+ }, [engine]);
887
+ const subscribe = useCallback3(
888
+ (cb) => engine.subscribe(cb),
889
+ [engine]
890
+ );
891
+ const getSnapshot = useCallback3(() => engine.getState(), [engine]);
892
+ const state = useSyncExternalStore(
893
+ subscribe,
894
+ getSnapshot,
895
+ getSnapshot
896
+ );
897
+ const isArchivedForUser = useCallback3(
898
+ (item) => engine.isArchivedForUser(item),
899
+ [engine]
900
+ );
901
+ const hasUnread = useCallback3(
902
+ (item) => engine.hasUnread(item),
903
+ [engine]
904
+ );
905
+ const hasValidPins = useCallback3(
906
+ (item) => engine.hasValidPins(item),
907
+ [engine]
908
+ );
1570
909
  return {
1571
- ...pinMode,
1572
- isPinModeActive: pinMode.isActive,
1573
- activatePinMode: pinMode.activate,
1574
- deactivatePinMode: pinMode.deactivate,
1575
- comment,
1576
- setComment,
1577
- sentiment,
1578
- setSentiment,
1579
- commentError,
1580
- setCommentError,
1581
- submitting,
1582
- pathname,
1583
- feedbackItems,
1584
- siteFeedback,
1585
- pageFeedback,
1586
- loading,
1587
- openThreadId,
1588
- setOpenThreadId,
1589
- replyBody,
1590
- setReplyBody,
1591
- // Modal state
1592
- isModalOpen,
1593
- openModal,
1594
- closeModal,
1595
- composeScope,
1596
- setComposeScope,
1597
- handleModalSubmit,
1598
- ...actions,
910
+ // Pin mode
911
+ isPinModeActive: state.isPinModeActive,
912
+ isActive: state.isPinModeActive,
913
+ activatePinMode: useCallback3(() => engine.activatePinMode(), [engine]),
914
+ activate: useCallback3(() => engine.activatePinMode(), [engine]),
915
+ deactivatePinMode: useCallback3(() => engine.deactivatePinMode(), [engine]),
916
+ deactivate: useCallback3(() => engine.deactivatePinMode(), [engine]),
917
+ pendingPins: state.pendingPins,
918
+ selectedColor: state.selectedColor,
919
+ setSelectedColor: useCallback3(
920
+ (c) => engine.setSelectedColor(c),
921
+ [engine]
922
+ ),
923
+ showForm: state.showForm,
924
+ // Form
925
+ comment: state.comment,
926
+ setComment: useCallback3((v) => engine.setComment(v), [engine]),
927
+ sentiment: state.sentiment,
928
+ setSentiment: useCallback3(
929
+ (v) => engine.setSentiment(v),
930
+ [engine]
931
+ ),
932
+ commentError: state.commentError,
933
+ setCommentError: useCallback3(
934
+ (v) => engine.setCommentError(v),
935
+ [engine]
936
+ ),
937
+ submitting: state.submitting,
938
+ // Feedback
939
+ feedbackItems: state.feedbackItems,
940
+ siteFeedback: state.siteFeedback,
941
+ pageFeedback: state.pageFeedback,
942
+ loading: state.loading,
943
+ pathname: state.pathname,
944
+ // Thread
945
+ openThreadId: state.openThreadId,
946
+ setOpenThreadId: useCallback3(
947
+ (id) => engine.setOpenThreadId(id),
948
+ [engine]
949
+ ),
950
+ replyBody: state.replyBody,
951
+ setReplyBody: useCallback3((v) => engine.setReplyBody(v), [engine]),
952
+ // Modal
953
+ isModalOpen: state.isModalOpen,
954
+ openModal: useCallback3(() => engine.openModal(), [engine]),
955
+ closeModal: useCallback3(() => engine.closeModal(), [engine]),
956
+ composeScope: state.composeScope,
957
+ setComposeScope: useCallback3(
958
+ (s) => engine.setComposeScope(s),
959
+ [engine]
960
+ ),
961
+ // Actions
962
+ handleSubmit: useCallback3(() => engine.handleSubmit(), [engine]),
963
+ handleModalSubmit: useCallback3(() => engine.handleModalSubmit(), [engine]),
964
+ handleReply: useCallback3((id) => engine.handleReply(id), [engine]),
965
+ handleResolve: useCallback3(
966
+ (id) => engine.handleResolve(id),
967
+ [engine]
968
+ ),
969
+ handleArchive: useCallback3(
970
+ (id) => engine.handleArchive(id),
971
+ [engine]
972
+ ),
973
+ // Derived helpers
1599
974
  isArchivedForUser,
1600
975
  hasUnread,
1601
976
  hasValidPins
@@ -1603,7 +978,7 @@ function useFeedtackState({
1603
978
  }
1604
979
 
1605
980
  // src/react/FeedtackProvider.tsx
1606
- import { jsx as jsx6, jsxs as jsxs6 } from "react/jsx-runtime";
981
+ import { jsx as jsx7, jsxs as jsxs7 } from "react/jsx-runtime";
1607
982
  function FeedtackProvider({
1608
983
  children,
1609
984
  adapter,
@@ -1640,7 +1015,7 @@ function FeedtackProvider({
1640
1015
  state.closeModal();
1641
1016
  state.activatePinMode();
1642
1017
  };
1643
- return /* @__PURE__ */ jsxs6(
1018
+ return /* @__PURE__ */ jsxs7(
1644
1019
  FeedtackContext.Provider,
1645
1020
  {
1646
1021
  value: {
@@ -1661,7 +1036,7 @@ function FeedtackProvider({
1661
1036
  },
1662
1037
  children: [
1663
1038
  children,
1664
- !disabled && showButton && /* @__PURE__ */ jsx6(
1039
+ !disabled && showButton && /* @__PURE__ */ jsx7(
1665
1040
  "button",
1666
1041
  {
1667
1042
  type: "button",
@@ -1676,7 +1051,7 @@ function FeedtackProvider({
1676
1051
  children: "Feedback"
1677
1052
  }
1678
1053
  ),
1679
- state.isPinModeActive && /* @__PURE__ */ jsx6("div", { className: cx("feedtack-color-picker", classes.colorPicker), children: PIN_PALETTE.map((color) => /* @__PURE__ */ jsx6(
1054
+ state.isPinModeActive && /* @__PURE__ */ jsx7("div", { className: cx("feedtack-color-picker", classes.colorPicker), children: PIN_PALETTE.map((color) => /* @__PURE__ */ jsx7(
1680
1055
  "button",
1681
1056
  {
1682
1057
  type: "button",
@@ -1690,7 +1065,7 @@ function FeedtackProvider({
1690
1065
  },
1691
1066
  color
1692
1067
  )) }),
1693
- state.pendingPins.map((pin) => /* @__PURE__ */ jsx6(
1068
+ state.pendingPins.map((pin) => /* @__PURE__ */ jsx7(
1694
1069
  "div",
1695
1070
  {
1696
1071
  className: cx("feedtack-pin-marker", classes.pinMarker),
@@ -1703,7 +1078,7 @@ function FeedtackProvider({
1703
1078
  },
1704
1079
  `${pin.x}-${pin.y}-${pin.color}`
1705
1080
  )),
1706
- state.showForm && /* @__PURE__ */ jsx6(
1081
+ state.showForm && /* @__PURE__ */ jsx7(
1707
1082
  CommentForm,
1708
1083
  {
1709
1084
  comment: state.comment,
@@ -1722,7 +1097,7 @@ function FeedtackProvider({
1722
1097
  onCancel: state.deactivatePinMode
1723
1098
  }
1724
1099
  ),
1725
- !state.loading && /* @__PURE__ */ jsx6(
1100
+ !state.loading && /* @__PURE__ */ jsx7(
1726
1101
  PinOverlay,
1727
1102
  {
1728
1103
  feedbackItems: state.feedbackItems,
@@ -1737,7 +1112,7 @@ function FeedtackProvider({
1737
1112
  pinMarkerClass: classes.pinMarker
1738
1113
  }
1739
1114
  ),
1740
- openItem && /* @__PURE__ */ jsx6(
1115
+ openItem && /* @__PURE__ */ jsx7(
1741
1116
  ThreadPanel,
1742
1117
  {
1743
1118
  item: openItem,
@@ -1754,7 +1129,7 @@ function FeedtackProvider({
1754
1129
  className: classes.thread
1755
1130
  }
1756
1131
  ),
1757
- !disabled && /* @__PURE__ */ jsx6(
1132
+ !disabled && /* @__PURE__ */ jsx7(
1758
1133
  FeedbackModal,
1759
1134
  {
1760
1135
  isOpen: state.isModalOpen,
@@ -1783,18 +1158,350 @@ function FeedtackProvider({
1783
1158
  onOpenThread: state.setOpenThreadId
1784
1159
  }
1785
1160
  ),
1786
- state.loading && /* @__PURE__ */ jsx6("div", { className: "feedtack-loading", children: "Loading feedback\u2026" })
1161
+ state.loading && /* @__PURE__ */ jsx7("div", { className: "feedtack-loading", children: "Loading feedback\u2026" })
1787
1162
  ]
1788
1163
  }
1789
1164
  );
1790
1165
  }
1791
1166
 
1167
+ // src/react/useContentApproval.ts
1168
+ import { useCallback as useCallback4, useEffect as useEffect4, useState as useState3 } from "react";
1169
+ function useContentApproval(adapter, userId, options) {
1170
+ const [fields, setFields] = useState3([]);
1171
+ const getContentForField = useCallback4(
1172
+ (fieldPath, domContent) => {
1173
+ return options?.storedValues?.get(fieldPath) ?? domContent;
1174
+ },
1175
+ [options?.storedValues]
1176
+ );
1177
+ const rescan = useCallback4(async () => {
1178
+ warnIfNotContentAdapter(adapter, "useContentApproval");
1179
+ if (!isContentAdapter(adapter)) {
1180
+ setFields([]);
1181
+ return;
1182
+ }
1183
+ const scanned = scanFields();
1184
+ const storedApprovals = await adapter.loadApprovals();
1185
+ const approvalMap = new Map(
1186
+ storedApprovals.map((s) => [s.fieldPath, s.approval])
1187
+ );
1188
+ const states = await Promise.all(
1189
+ scanned.map(async (f) => {
1190
+ const content = getContentForField(f.fieldPath, f.content);
1191
+ const currentHash = await hashField(content);
1192
+ const approval = approvalMap.get(f.fieldPath) ?? null;
1193
+ return {
1194
+ fieldPath: f.fieldPath,
1195
+ approval,
1196
+ stale: approval === null || approval.hash !== currentHash
1197
+ };
1198
+ })
1199
+ );
1200
+ setFields(states);
1201
+ }, [adapter, getContentForField]);
1202
+ useEffect4(() => {
1203
+ void rescan();
1204
+ }, [rescan]);
1205
+ const approve = useCallback4(
1206
+ async (fieldPath) => {
1207
+ warnIfNotContentAdapter(adapter, "approve");
1208
+ if (!isContentAdapter(adapter)) return;
1209
+ const scanned = scanFields();
1210
+ const field = scanned.find((f) => f.fieldPath === fieldPath);
1211
+ if (!field) return;
1212
+ const content = getContentForField(fieldPath, field.content);
1213
+ const hash = await hashField(content);
1214
+ const existing = fields.find((f) => f.fieldPath === fieldPath);
1215
+ const existingBy = existing?.approval?.by ?? [];
1216
+ const by = existingBy.includes(userId) ? existingBy : [...existingBy, userId];
1217
+ await adapter.approve(fieldPath, {
1218
+ hash,
1219
+ by,
1220
+ at: (/* @__PURE__ */ new Date()).toISOString()
1221
+ });
1222
+ setFields(
1223
+ (prev) => prev.map(
1224
+ (f) => f.fieldPath === fieldPath ? {
1225
+ ...f,
1226
+ approval: { hash, by, at: (/* @__PURE__ */ new Date()).toISOString() },
1227
+ stale: false
1228
+ } : f
1229
+ )
1230
+ );
1231
+ },
1232
+ [adapter, userId, fields, getContentForField]
1233
+ );
1234
+ const revoke = useCallback4(
1235
+ async (fieldPath) => {
1236
+ warnIfNotContentAdapter(adapter, "revokeApproval");
1237
+ if (!isContentAdapter(adapter)) return;
1238
+ await adapter.revokeApproval(fieldPath, userId);
1239
+ setFields(
1240
+ (prev) => prev.map((f) => {
1241
+ if (f.fieldPath !== fieldPath || !f.approval) return f;
1242
+ const by = f.approval.by.filter((id) => id !== userId);
1243
+ const approval = by.length > 0 ? { ...f.approval, by } : null;
1244
+ return { ...f, approval, stale: true };
1245
+ })
1246
+ );
1247
+ },
1248
+ [adapter, userId]
1249
+ );
1250
+ const checkDeploy = useCallback4(async () => {
1251
+ warnIfNotContentAdapter(adapter, "checkDeploy");
1252
+ if (!isContentAdapter(adapter)) {
1253
+ return { approved: false, pending: [] };
1254
+ }
1255
+ const scanned = scanFields();
1256
+ const storedApprovals = await adapter.loadApprovals();
1257
+ const approvalMap = new Map(
1258
+ storedApprovals.map((s) => [s.fieldPath, s.approval])
1259
+ );
1260
+ const pending = [];
1261
+ await Promise.all(
1262
+ scanned.map(async (f) => {
1263
+ const content = getContentForField(f.fieldPath, f.content);
1264
+ const currentHash = await hashField(content);
1265
+ const approval = approvalMap.get(f.fieldPath) ?? null;
1266
+ if (approval === null || approval.hash !== currentHash) {
1267
+ pending.push(f.fieldPath);
1268
+ }
1269
+ })
1270
+ );
1271
+ return { approved: pending.length === 0, pending };
1272
+ }, [adapter, getContentForField]);
1273
+ return { fields, approve, revoke, rescan, checkDeploy };
1274
+ }
1275
+
1276
+ // src/react/useContentEdit.ts
1277
+ import { useCallback as useCallback5, useRef as useRef3, useState as useState4 } from "react";
1278
+
1279
+ // src/react/fieldDom.ts
1280
+ function getFieldValue(el) {
1281
+ if (isFormField(el)) {
1282
+ return el.placeholder || "";
1283
+ }
1284
+ return el.innerText;
1285
+ }
1286
+ function setFieldValue(el, value) {
1287
+ if (isFormField(el)) {
1288
+ ;
1289
+ el.placeholder = value;
1290
+ } else {
1291
+ el.innerText = value;
1292
+ }
1293
+ }
1294
+ function isFormField(el) {
1295
+ const tag = el.tagName.toLowerCase();
1296
+ return tag === "input" || tag === "textarea";
1297
+ }
1298
+ function findFieldElement(fieldPath) {
1299
+ return document.querySelector(
1300
+ `[data-feedtack-field="${fieldPath}"]`
1301
+ );
1302
+ }
1303
+ function buildToolbarProps(focusedField, approvalFields, changes, saving, approval, revert) {
1304
+ const approvalState = focusedField ? approvalFields.find((f) => f.fieldPath === focusedField.fieldPath) ?? null : null;
1305
+ return {
1306
+ focusedField,
1307
+ approvalState,
1308
+ changes,
1309
+ saving,
1310
+ onApprove: approval.approve,
1311
+ onRevoke: approval.revoke,
1312
+ onRevert: revert,
1313
+ onCheckDeploy: approval.checkDeploy
1314
+ };
1315
+ }
1316
+
1317
+ // src/react/useContentEdit.ts
1318
+ function useContentEdit(adapter, userId) {
1319
+ const [active, setActive] = useState4(false);
1320
+ const [changes, setChanges] = useState4([]);
1321
+ const [saving, setSaving] = useState4(null);
1322
+ const [focusedField, setFocusedField] = useState4(
1323
+ null
1324
+ );
1325
+ const storedValuesRef = useRef3(/* @__PURE__ */ new Map());
1326
+ const [storedValuesVersion, setStoredValuesVersion] = useState4(0);
1327
+ const storedValuesMap = storedValuesRef.current;
1328
+ const approval = useContentApproval(adapter, userId, {
1329
+ storedValues: storedValuesMap
1330
+ });
1331
+ const observerRef = useRef3(null);
1332
+ const boundFieldsRef = useRef3(/* @__PURE__ */ new Set());
1333
+ const changesRef = useRef3([]);
1334
+ const updateStoredValue = useCallback5((fieldPath, value) => {
1335
+ storedValuesRef.current.set(fieldPath, value);
1336
+ setStoredValuesVersion((v) => v + 1);
1337
+ }, []);
1338
+ const handleFocus = useCallback5((e) => {
1339
+ const el = e.target;
1340
+ const fieldPath = el.dataset.feedtackField;
1341
+ if (!fieldPath) return;
1342
+ const storedValue = storedValuesRef.current.get(fieldPath) ?? getFieldValue(el);
1343
+ el.dataset.feedtackOriginal = storedValue;
1344
+ setFocusedField({ element: el, fieldPath });
1345
+ }, []);
1346
+ const handleBlur = useCallback5(
1347
+ async (e) => {
1348
+ const el = e.target;
1349
+ const fieldPath = el.dataset.feedtackField;
1350
+ if (!fieldPath) return;
1351
+ setTimeout(() => {
1352
+ const f = document.activeElement;
1353
+ if (!f || f === document.body || f.closest?.("[data-feedtack-edit-ui]"))
1354
+ return;
1355
+ setFocusedField(null);
1356
+ }, 150);
1357
+ const value = getFieldValue(el);
1358
+ const original = el.dataset.feedtackOriginal ?? "";
1359
+ if (value === original) return;
1360
+ if (!isContentEditAdapter(adapter)) return;
1361
+ setSaving(fieldPath);
1362
+ try {
1363
+ await adapter.saveField(fieldPath, value);
1364
+ updateStoredValue(fieldPath, value);
1365
+ const existing = changesRef.current.find(
1366
+ (c) => c.fieldPath === fieldPath
1367
+ );
1368
+ if (existing) {
1369
+ changesRef.current = changesRef.current.map(
1370
+ (c) => c.fieldPath === fieldPath ? { ...c, to: value, savedAt: Date.now() } : c
1371
+ );
1372
+ } else {
1373
+ changesRef.current = [
1374
+ ...changesRef.current,
1375
+ { fieldPath, from: original, to: value, savedAt: Date.now() }
1376
+ ];
1377
+ }
1378
+ setChanges([...changesRef.current]);
1379
+ void approval.rescan();
1380
+ } catch {
1381
+ setFieldValue(el, original);
1382
+ }
1383
+ setTimeout(() => setSaving(null), 1500);
1384
+ },
1385
+ [adapter, approval, updateStoredValue]
1386
+ );
1387
+ const bindField = useCallback5(
1388
+ (el) => {
1389
+ if (boundFieldsRef.current.has(el)) return;
1390
+ if (el.querySelector("[data-feedtack-field]")) return;
1391
+ boundFieldsRef.current.add(el);
1392
+ if (isFormField(el)) {
1393
+ el.style.cursor = "pointer";
1394
+ } else {
1395
+ el.setAttribute("contenteditable", "true");
1396
+ el.setAttribute("spellcheck", "true");
1397
+ }
1398
+ el.addEventListener("focus", handleFocus);
1399
+ el.addEventListener("blur", handleBlur);
1400
+ },
1401
+ [handleFocus, handleBlur]
1402
+ );
1403
+ const unbindField = useCallback5(
1404
+ (el) => {
1405
+ boundFieldsRef.current.delete(el);
1406
+ el.removeAttribute("contenteditable");
1407
+ el.removeAttribute("spellcheck");
1408
+ el.style.cursor = "";
1409
+ delete el.dataset.feedtackOriginal;
1410
+ el.removeEventListener("focus", handleFocus);
1411
+ el.removeEventListener("blur", handleBlur);
1412
+ },
1413
+ [handleFocus, handleBlur]
1414
+ );
1415
+ const setupFields = useCallback5(() => {
1416
+ document.querySelectorAll("[data-feedtack-field]").forEach(bindField);
1417
+ }, [bindField]);
1418
+ const activate = useCallback5(async () => {
1419
+ warnIfNotContentEditAdapter(adapter, "activate");
1420
+ if (!isContentEditAdapter(adapter)) return;
1421
+ document.body.setAttribute("data-feedtack-hydrating", "true");
1422
+ try {
1423
+ const fields = await adapter.loadFields();
1424
+ for (const [fp, value] of Object.entries(fields)) {
1425
+ storedValuesRef.current.set(fp, value);
1426
+ const el = findFieldElement(fp);
1427
+ if (el) setFieldValue(el, value);
1428
+ }
1429
+ setStoredValuesVersion((v) => v + 1);
1430
+ } finally {
1431
+ document.body.removeAttribute("data-feedtack-hydrating");
1432
+ }
1433
+ setupFields();
1434
+ const observer = new MutationObserver(() => setupFields());
1435
+ observer.observe(document.body, { childList: true, subtree: true });
1436
+ observerRef.current = observer;
1437
+ setActive(true);
1438
+ void approval.rescan();
1439
+ }, [adapter, setupFields, approval]);
1440
+ const deactivate = useCallback5(() => {
1441
+ observerRef.current?.disconnect();
1442
+ observerRef.current = null;
1443
+ boundFieldsRef.current.forEach(unbindField);
1444
+ boundFieldsRef.current.clear();
1445
+ setActive(false);
1446
+ setFocusedField(null);
1447
+ }, [unbindField]);
1448
+ const revert = useCallback5(
1449
+ async (fieldPath) => {
1450
+ warnIfNotContentEditAdapter(adapter, "revert");
1451
+ if (!isContentEditAdapter(adapter)) return;
1452
+ const change = changesRef.current.find((c) => c.fieldPath === fieldPath);
1453
+ if (!change) return;
1454
+ setSaving(fieldPath);
1455
+ try {
1456
+ await adapter.saveField(fieldPath, change.from);
1457
+ updateStoredValue(fieldPath, change.from);
1458
+ const el = findFieldElement(fieldPath);
1459
+ if (el) setFieldValue(el, change.from);
1460
+ changesRef.current = changesRef.current.filter(
1461
+ (c) => c.fieldPath !== fieldPath
1462
+ );
1463
+ setChanges([...changesRef.current]);
1464
+ void approval.rescan();
1465
+ } finally {
1466
+ setTimeout(() => setSaving(null), 1500);
1467
+ }
1468
+ },
1469
+ [adapter, approval, updateStoredValue]
1470
+ );
1471
+ const toolbarProps = buildToolbarProps(
1472
+ focusedField,
1473
+ approval.fields,
1474
+ changes,
1475
+ saving,
1476
+ approval,
1477
+ revert
1478
+ );
1479
+ return {
1480
+ active,
1481
+ activate,
1482
+ deactivate,
1483
+ changes,
1484
+ revert,
1485
+ saving,
1486
+ focusedField,
1487
+ toolbarProps,
1488
+ fields: approval.fields,
1489
+ approve: approval.approve,
1490
+ revoke: approval.revoke,
1491
+ rescan: approval.rescan,
1492
+ checkDeploy: approval.checkDeploy
1493
+ };
1494
+ }
1495
+
1792
1496
  // src/react/useFeedtack.ts
1793
1497
  function useFeedtack() {
1794
1498
  return useFeedtackContext();
1795
1499
  }
1796
1500
  export {
1501
+ ContentEditToolbar,
1797
1502
  FeedtackProvider,
1798
1503
  PIN_PALETTE,
1504
+ useContentApproval,
1505
+ useContentEdit,
1799
1506
  useFeedtack
1800
1507
  };