@tonytangdev/pin-point 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/index.mjs ADDED
@@ -0,0 +1,914 @@
1
+ // src/FeedbackOverlay.tsx
2
+ import { useCallback as useCallback3, useEffect as useEffect3, useRef as useRef3, useState as useState5 } from "react";
3
+
4
+ // src/components/AdminKeyModal.tsx
5
+ import { useState } from "react";
6
+ import { jsx, jsxs } from "react/jsx-runtime";
7
+ function AdminKeyModal({ onValidate, onSuccess, onClose }) {
8
+ const [value, setValue] = useState("");
9
+ const [error, setError] = useState(null);
10
+ const [busy, setBusy] = useState(false);
11
+ const handleSubmit = async () => {
12
+ setBusy(true);
13
+ setError(null);
14
+ try {
15
+ const ok = await onValidate(value);
16
+ if (ok) {
17
+ onSuccess(value);
18
+ } else {
19
+ setError("Invalid admin key");
20
+ }
21
+ } catch {
22
+ setError("Could not reach server");
23
+ } finally {
24
+ setBusy(false);
25
+ }
26
+ };
27
+ return (
28
+ // biome-ignore lint/a11y/useKeyWithClickEvents: backdrop click dismissal, Cancel button and Escape are primary dismissal paths
29
+ // biome-ignore lint/a11y/noStaticElementInteractions: backdrop is a visual scrim, inner dialog has role
30
+ /* @__PURE__ */ jsx("div", { className: "pp-modal-backdrop", onClick: onClose, children: /* @__PURE__ */ jsxs(
31
+ "div",
32
+ {
33
+ className: "pp-modal",
34
+ onClick: (e) => e.stopPropagation(),
35
+ role: "dialog",
36
+ "aria-modal": "true",
37
+ children: [
38
+ /* @__PURE__ */ jsx("h3", { children: "Enter admin key" }),
39
+ /* @__PURE__ */ jsx(
40
+ "input",
41
+ {
42
+ type: "password",
43
+ placeholder: "Admin key",
44
+ value,
45
+ onChange: (e) => setValue(e.target.value),
46
+ disabled: busy
47
+ }
48
+ ),
49
+ error && /* @__PURE__ */ jsx("div", { className: "pp-modal-error", children: error }),
50
+ /* @__PURE__ */ jsxs("div", { className: "pp-modal-actions", children: [
51
+ /* @__PURE__ */ jsx("button", { type: "button", onClick: onClose, disabled: busy, children: "Cancel" }),
52
+ /* @__PURE__ */ jsx(
53
+ "button",
54
+ {
55
+ type: "button",
56
+ onClick: handleSubmit,
57
+ disabled: busy || !value,
58
+ children: "Save"
59
+ }
60
+ )
61
+ ] })
62
+ ]
63
+ }
64
+ ) })
65
+ );
66
+ }
67
+
68
+ // src/components/ClickInterceptLayer.tsx
69
+ import { useCallback, useRef } from "react";
70
+ import { jsx as jsx2 } from "react/jsx-runtime";
71
+ function ClickInterceptLayer({ onClick }) {
72
+ const lastClickTime = useRef(0);
73
+ const handleClick = useCallback(
74
+ (e) => {
75
+ const now = Date.now();
76
+ if (now - lastClickTime.current < 300) {
77
+ return;
78
+ }
79
+ lastClickTime.current = now;
80
+ onClick(e.clientX, e.clientY);
81
+ },
82
+ [onClick]
83
+ );
84
+ return (
85
+ // biome-ignore lint/a11y/noStaticElementInteractions: click-only overlay for pin placement
86
+ // biome-ignore lint/a11y/useKeyWithClickEvents: click-only overlay for pin placement
87
+ /* @__PURE__ */ jsx2("div", { className: "pp-intercept", onClick: handleClick })
88
+ );
89
+ }
90
+
91
+ // src/components/CommentPopover.tsx
92
+ import { useEffect, useLayoutEffect, useRef as useRef2, useState as useState2 } from "react";
93
+ import { Fragment, jsx as jsx3, jsxs as jsxs2 } from "react/jsx-runtime";
94
+ var POPOVER_OFFSET = 14;
95
+ var VIEWPORT_MARGIN = 8;
96
+ function CommentPopover(props) {
97
+ const { mode, top, left } = props;
98
+ const popoverRef = useRef2(null);
99
+ const [placement, setPlacement] = useState2({
100
+ x: "right",
101
+ y: "bottom"
102
+ });
103
+ const [size, setSize] = useState2(
104
+ null
105
+ );
106
+ useLayoutEffect(() => {
107
+ const el = popoverRef.current;
108
+ if (!el) return;
109
+ const rect = el.getBoundingClientRect();
110
+ const w = rect.width;
111
+ const h = rect.height;
112
+ const pinViewX = left - window.scrollX;
113
+ const pinViewY = top - window.scrollY;
114
+ const fitsRight = pinViewX - POPOVER_OFFSET + w + VIEWPORT_MARGIN <= window.innerWidth;
115
+ const fitsLeft = pinViewX + POPOVER_OFFSET - w - VIEWPORT_MARGIN >= 0;
116
+ const fitsBottom = pinViewY + POPOVER_OFFSET + h + VIEWPORT_MARGIN <= window.innerHeight;
117
+ const fitsTop = pinViewY - POPOVER_OFFSET - h - VIEWPORT_MARGIN >= 0;
118
+ setPlacement({
119
+ x: fitsRight ? "right" : fitsLeft ? "left" : "right",
120
+ y: fitsBottom ? "bottom" : fitsTop ? "top" : "bottom"
121
+ });
122
+ setSize({ width: w, height: h });
123
+ }, [top, left]);
124
+ const style = size ? {
125
+ top: `${placement.y === "bottom" ? top + POPOVER_OFFSET : top - POPOVER_OFFSET - size.height}px`,
126
+ left: `${placement.x === "right" ? left - POPOVER_OFFSET : left + POPOVER_OFFSET - size.width}px`
127
+ } : {
128
+ top: `${top + POPOVER_OFFSET}px`,
129
+ left: `${left - POPOVER_OFFSET}px`,
130
+ visibility: "hidden"
131
+ };
132
+ return (
133
+ // biome-ignore lint/a11y/useKeyWithClickEvents: stopPropagation only, not interactive
134
+ /* @__PURE__ */ jsxs2(
135
+ "div",
136
+ {
137
+ ref: popoverRef,
138
+ className: "pp-popover",
139
+ "data-placement-x": placement.x,
140
+ "data-placement-y": placement.y,
141
+ style,
142
+ onClick: (e) => e.stopPropagation(),
143
+ role: "dialog",
144
+ children: [
145
+ /* @__PURE__ */ jsx3("div", { className: "pp-popover-arrow" }),
146
+ mode === "read" ? /* @__PURE__ */ jsx3(
147
+ ReadContent,
148
+ {
149
+ content: props.content,
150
+ createdAt: props.createdAt,
151
+ viewportWidth: props.viewportWidth,
152
+ onDelete: props.onDelete,
153
+ onUpdate: props.onUpdate
154
+ }
155
+ ) : /* @__PURE__ */ jsx3(CreateContent, { onSubmit: props.onSubmit, onCancel: props.onCancel })
156
+ ]
157
+ }
158
+ )
159
+ );
160
+ }
161
+ function ReadContent({
162
+ content,
163
+ createdAt,
164
+ viewportWidth,
165
+ onDelete,
166
+ onUpdate
167
+ }) {
168
+ const [isConfirmingDelete, setIsConfirmingDelete] = useState2(false);
169
+ const [isEditing, setIsEditing] = useState2(false);
170
+ const [editContent, setEditContent] = useState2(content);
171
+ const [saving, setSaving] = useState2(false);
172
+ const [deleting, setDeleting] = useState2(false);
173
+ const [deleteError, setDeleteError] = useState2(false);
174
+ const [editError, setEditError] = useState2(false);
175
+ useEffect(() => setEditContent(content), [content]);
176
+ const date = new Date(createdAt).toLocaleDateString(void 0, {
177
+ month: "short",
178
+ day: "numeric",
179
+ year: "numeric"
180
+ });
181
+ if (isEditing) {
182
+ return /* @__PURE__ */ jsxs2(Fragment, { children: [
183
+ /* @__PURE__ */ jsx3(
184
+ "textarea",
185
+ {
186
+ className: "pp-popover-textarea",
187
+ value: editContent,
188
+ onChange: (e) => setEditContent(e.target.value)
189
+ }
190
+ ),
191
+ /* @__PURE__ */ jsxs2("div", { className: "pp-popover-actions", children: [
192
+ /* @__PURE__ */ jsx3(
193
+ "button",
194
+ {
195
+ type: "button",
196
+ className: "pp-btn pp-btn--cancel",
197
+ onClick: () => {
198
+ setIsEditing(false);
199
+ setEditContent(content);
200
+ setEditError(false);
201
+ },
202
+ children: "Cancel"
203
+ }
204
+ ),
205
+ /* @__PURE__ */ jsx3(
206
+ "button",
207
+ {
208
+ type: "button",
209
+ className: "pp-btn pp-btn--submit",
210
+ disabled: editContent.trim().length === 0 || saving,
211
+ onClick: async () => {
212
+ setSaving(true);
213
+ setEditError(false);
214
+ try {
215
+ await onUpdate?.(editContent);
216
+ setIsEditing(false);
217
+ } catch {
218
+ setEditError(true);
219
+ } finally {
220
+ setSaving(false);
221
+ }
222
+ },
223
+ children: "Save"
224
+ }
225
+ )
226
+ ] }),
227
+ editError && /* @__PURE__ */ jsx3("div", { className: "pp-popover-error", children: "Couldn't save. Try again." })
228
+ ] });
229
+ }
230
+ if (isConfirmingDelete) {
231
+ return /* @__PURE__ */ jsxs2("div", { className: "pp-delete-confirm", children: [
232
+ /* @__PURE__ */ jsx3("div", { className: "pp-delete-confirm-icon", children: /* @__PURE__ */ jsxs2(
233
+ "svg",
234
+ {
235
+ "aria-hidden": "true",
236
+ width: "20",
237
+ height: "20",
238
+ viewBox: "0 0 24 24",
239
+ fill: "none",
240
+ stroke: "currentColor",
241
+ strokeWidth: "2",
242
+ strokeLinecap: "round",
243
+ strokeLinejoin: "round",
244
+ children: [
245
+ /* @__PURE__ */ jsx3("circle", { cx: "12", cy: "12", r: "10" }),
246
+ /* @__PURE__ */ jsx3("line", { x1: "12", y1: "8", x2: "12", y2: "12" }),
247
+ /* @__PURE__ */ jsx3("line", { x1: "12", y1: "16", x2: "12.01", y2: "16" })
248
+ ]
249
+ }
250
+ ) }),
251
+ /* @__PURE__ */ jsx3("p", { className: "pp-delete-confirm-text", children: "Delete this comment?" }),
252
+ deleteError && /* @__PURE__ */ jsx3("div", { className: "pp-popover-error", children: "Couldn't delete. Try again." }),
253
+ /* @__PURE__ */ jsxs2("div", { className: "pp-delete-confirm-actions", children: [
254
+ /* @__PURE__ */ jsx3(
255
+ "button",
256
+ {
257
+ type: "button",
258
+ className: "pp-btn pp-btn--cancel",
259
+ disabled: deleting,
260
+ onClick: () => {
261
+ setIsConfirmingDelete(false);
262
+ setDeleteError(false);
263
+ },
264
+ children: "Cancel"
265
+ }
266
+ ),
267
+ /* @__PURE__ */ jsx3(
268
+ "button",
269
+ {
270
+ type: "button",
271
+ className: "pp-btn pp-btn--danger",
272
+ disabled: deleting,
273
+ onClick: async () => {
274
+ setDeleting(true);
275
+ setDeleteError(false);
276
+ try {
277
+ await onDelete?.();
278
+ } catch {
279
+ setDeleteError(true);
280
+ } finally {
281
+ setDeleting(false);
282
+ }
283
+ },
284
+ children: deleting ? "Deleting..." : "Delete"
285
+ }
286
+ )
287
+ ] })
288
+ ] });
289
+ }
290
+ return /* @__PURE__ */ jsxs2(Fragment, { children: [
291
+ /* @__PURE__ */ jsx3("div", { className: "pp-popover-content", children: content }),
292
+ /* @__PURE__ */ jsxs2("div", { className: "pp-popover-footer", children: [
293
+ /* @__PURE__ */ jsxs2("div", { className: "pp-popover-meta", children: [
294
+ date,
295
+ " \xB7 ",
296
+ viewportWidth,
297
+ "px viewport"
298
+ ] }),
299
+ (onDelete || onUpdate) && /* @__PURE__ */ jsxs2("div", { className: "pp-popover-actions-row", children: [
300
+ onUpdate && /* @__PURE__ */ jsx3(
301
+ "button",
302
+ {
303
+ type: "button",
304
+ className: "pp-action-btn",
305
+ "aria-label": "Edit",
306
+ onClick: () => setIsEditing(true),
307
+ children: /* @__PURE__ */ jsxs2(
308
+ "svg",
309
+ {
310
+ "aria-hidden": "true",
311
+ width: "13",
312
+ height: "13",
313
+ viewBox: "0 0 24 24",
314
+ fill: "none",
315
+ stroke: "currentColor",
316
+ strokeWidth: "2",
317
+ strokeLinecap: "round",
318
+ strokeLinejoin: "round",
319
+ children: [
320
+ /* @__PURE__ */ jsx3("path", { d: "M17 3a2.83 2.83 0 1 1 4 4L7.5 20.5 2 22l1.5-5.5Z" }),
321
+ /* @__PURE__ */ jsx3("path", { d: "m15 5 4 4" })
322
+ ]
323
+ }
324
+ )
325
+ }
326
+ ),
327
+ onDelete && /* @__PURE__ */ jsx3(
328
+ "button",
329
+ {
330
+ type: "button",
331
+ className: "pp-action-btn pp-action-btn--danger",
332
+ "aria-label": "Delete",
333
+ onClick: () => setIsConfirmingDelete(true),
334
+ children: /* @__PURE__ */ jsxs2(
335
+ "svg",
336
+ {
337
+ "aria-hidden": "true",
338
+ width: "13",
339
+ height: "13",
340
+ viewBox: "0 0 24 24",
341
+ fill: "none",
342
+ stroke: "currentColor",
343
+ strokeWidth: "2",
344
+ strokeLinecap: "round",
345
+ strokeLinejoin: "round",
346
+ children: [
347
+ /* @__PURE__ */ jsx3("path", { d: "M3 6h18" }),
348
+ /* @__PURE__ */ jsx3("path", { d: "M19 6v14c0 1-1 2-2 2H7c-1 0-2-1-2-2V6" }),
349
+ /* @__PURE__ */ jsx3("path", { d: "M8 6V4c0-1 1-2 2-2h4c1 0 2 1 2 2v2" })
350
+ ]
351
+ }
352
+ )
353
+ }
354
+ )
355
+ ] })
356
+ ] })
357
+ ] });
358
+ }
359
+ function CreateContent({
360
+ onSubmit,
361
+ onCancel
362
+ }) {
363
+ const [text, setText] = useState2("");
364
+ const [error, setError] = useState2(null);
365
+ const [submitting, setSubmitting] = useState2(false);
366
+ const handleSubmit = async () => {
367
+ setError(null);
368
+ setSubmitting(true);
369
+ try {
370
+ await onSubmit(text);
371
+ } catch {
372
+ setError("Couldn't save. Try again.");
373
+ } finally {
374
+ setSubmitting(false);
375
+ }
376
+ };
377
+ return /* @__PURE__ */ jsxs2(Fragment, { children: [
378
+ /* @__PURE__ */ jsx3(
379
+ "textarea",
380
+ {
381
+ className: "pp-popover-textarea",
382
+ placeholder: "Leave your feedback...",
383
+ value: text,
384
+ onChange: (e) => setText(e.target.value)
385
+ }
386
+ ),
387
+ /* @__PURE__ */ jsxs2("div", { className: "pp-popover-actions", children: [
388
+ /* @__PURE__ */ jsx3(
389
+ "button",
390
+ {
391
+ type: "button",
392
+ className: "pp-btn pp-btn--cancel",
393
+ onClick: onCancel,
394
+ children: "Cancel"
395
+ }
396
+ ),
397
+ /* @__PURE__ */ jsx3(
398
+ "button",
399
+ {
400
+ type: "button",
401
+ className: "pp-btn pp-btn--submit",
402
+ onClick: handleSubmit,
403
+ disabled: text.trim().length === 0 || submitting,
404
+ children: "Submit"
405
+ }
406
+ )
407
+ ] }),
408
+ error && /* @__PURE__ */ jsx3("div", { className: "pp-popover-error", children: error })
409
+ ] });
410
+ }
411
+
412
+ // src/components/FeedbackToolbar.tsx
413
+ import { jsx as jsx4, jsxs as jsxs3 } from "react/jsx-runtime";
414
+ var CommentIcon = () => /* @__PURE__ */ jsx4(
415
+ "svg",
416
+ {
417
+ "aria-hidden": "true",
418
+ width: "16",
419
+ height: "16",
420
+ viewBox: "0 0 24 24",
421
+ fill: "none",
422
+ stroke: "currentColor",
423
+ strokeWidth: "2",
424
+ strokeLinecap: "round",
425
+ strokeLinejoin: "round",
426
+ children: /* @__PURE__ */ jsx4("path", { d: "M21 15a2 2 0 0 1-2 2H7l-4 4V5a2 2 0 0 1 2-2h14a2 2 0 0 1 2 2z" })
427
+ }
428
+ );
429
+ var KeyIcon = () => /* @__PURE__ */ jsxs3(
430
+ "svg",
431
+ {
432
+ "aria-hidden": "true",
433
+ width: "16",
434
+ height: "16",
435
+ viewBox: "0 0 24 24",
436
+ fill: "none",
437
+ stroke: "currentColor",
438
+ strokeWidth: "2",
439
+ strokeLinecap: "round",
440
+ strokeLinejoin: "round",
441
+ children: [
442
+ /* @__PURE__ */ jsx4("path", { d: "m21 2-9.6 9.6" }),
443
+ /* @__PURE__ */ jsx4("circle", { cx: "7.5", cy: "15.5", r: "5.5" }),
444
+ /* @__PURE__ */ jsx4("path", { d: "m15.5 7.5 3 3" })
445
+ ]
446
+ }
447
+ );
448
+ function FeedbackToolbar({
449
+ auth,
450
+ commentCount,
451
+ pinModeActive,
452
+ onPinModeToggle,
453
+ onAdminKeyOpen,
454
+ shareButton,
455
+ error
456
+ }) {
457
+ const commentDisabled = auth.role === "anonymous";
458
+ const commentTitle = commentDisabled ? "Sign in with a share link to leave feedback" : pinModeActive ? "Exit pin mode" : "Leave feedback";
459
+ const showAdminKey = auth.role !== "tokenHolder";
460
+ return /* @__PURE__ */ jsxs3("div", { className: "pp-toolbar", children: [
461
+ /* @__PURE__ */ jsx4("div", { className: "pp-toolbar-dot" }),
462
+ /* @__PURE__ */ jsx4(
463
+ "button",
464
+ {
465
+ type: "button",
466
+ className: "pp-toolbar-btn",
467
+ onClick: onPinModeToggle,
468
+ disabled: commentDisabled,
469
+ "aria-pressed": pinModeActive,
470
+ "aria-label": "Leave feedback",
471
+ title: commentTitle,
472
+ children: /* @__PURE__ */ jsx4(CommentIcon, {})
473
+ }
474
+ ),
475
+ showAdminKey && /* @__PURE__ */ jsx4(
476
+ "button",
477
+ {
478
+ type: "button",
479
+ className: "pp-toolbar-btn",
480
+ onClick: onAdminKeyOpen,
481
+ "aria-label": "Enter admin key",
482
+ title: "Enter admin key",
483
+ children: /* @__PURE__ */ jsx4(KeyIcon, {})
484
+ }
485
+ ),
486
+ shareButton,
487
+ error ? /* @__PURE__ */ jsx4("span", { className: "pp-toolbar-error", children: error }) : /* @__PURE__ */ jsxs3("span", { className: "pp-toolbar-badge", children: [
488
+ commentCount,
489
+ " ",
490
+ commentCount === 1 ? "comment" : "comments"
491
+ ] })
492
+ ] });
493
+ }
494
+
495
+ // src/components/PinMarker.tsx
496
+ import { jsx as jsx5 } from "react/jsx-runtime";
497
+ function PinMarker({ number, top, left, onClick }) {
498
+ return /* @__PURE__ */ jsx5(
499
+ "button",
500
+ {
501
+ type: "button",
502
+ className: "pp-pin",
503
+ style: { top: `${top}px`, left: `${left}px` },
504
+ onClick: (e) => {
505
+ e.stopPropagation();
506
+ onClick();
507
+ },
508
+ children: number
509
+ }
510
+ );
511
+ }
512
+
513
+ // src/components/ShareLinkButton.tsx
514
+ import { useState as useState3 } from "react";
515
+ import { jsx as jsx6, jsxs as jsxs4 } from "react/jsx-runtime";
516
+ var ShareIcon = () => /* @__PURE__ */ jsxs4(
517
+ "svg",
518
+ {
519
+ "aria-hidden": "true",
520
+ width: "14",
521
+ height: "14",
522
+ viewBox: "0 0 24 24",
523
+ fill: "none",
524
+ stroke: "currentColor",
525
+ strokeWidth: "2.2",
526
+ strokeLinecap: "round",
527
+ strokeLinejoin: "round",
528
+ children: [
529
+ /* @__PURE__ */ jsx6("path", { d: "M4 12v8a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2v-8" }),
530
+ /* @__PURE__ */ jsx6("polyline", { points: "16 6 12 2 8 6" }),
531
+ /* @__PURE__ */ jsx6("line", { x1: "12", y1: "2", x2: "12", y2: "15" })
532
+ ]
533
+ }
534
+ );
535
+ var CheckIcon = () => /* @__PURE__ */ jsx6(
536
+ "svg",
537
+ {
538
+ "aria-hidden": "true",
539
+ width: "14",
540
+ height: "14",
541
+ viewBox: "0 0 24 24",
542
+ fill: "none",
543
+ stroke: "currentColor",
544
+ strokeWidth: "2.5",
545
+ strokeLinecap: "round",
546
+ strokeLinejoin: "round",
547
+ children: /* @__PURE__ */ jsx6("polyline", { points: "20 6 9 17 4 12" })
548
+ }
549
+ );
550
+ function ShareLinkButton({ onCreate }) {
551
+ const [busy, setBusy] = useState3(false);
552
+ const [copied, setCopied] = useState3(false);
553
+ const handleClick = async () => {
554
+ setBusy(true);
555
+ try {
556
+ const { tokenId } = await onCreate();
557
+ const url = new URL(window.location.href);
558
+ url.searchParams.set("pin-token", tokenId);
559
+ await navigator.clipboard.writeText(url.toString());
560
+ setCopied(true);
561
+ setTimeout(() => setCopied(false), 2e3);
562
+ } finally {
563
+ setBusy(false);
564
+ }
565
+ };
566
+ return /* @__PURE__ */ jsx6("div", { className: "pp-share-wrapper", children: /* @__PURE__ */ jsxs4(
567
+ "button",
568
+ {
569
+ type: "button",
570
+ className: "pp-share-btn",
571
+ onClick: handleClick,
572
+ disabled: busy,
573
+ "data-state": copied ? "copied" : busy ? "busy" : "idle",
574
+ children: [
575
+ copied ? /* @__PURE__ */ jsx6(CheckIcon, {}) : /* @__PURE__ */ jsx6(ShareIcon, {}),
576
+ /* @__PURE__ */ jsx6("span", { children: copied ? "Link copied" : "Share for feedback" })
577
+ ]
578
+ }
579
+ ) });
580
+ }
581
+
582
+ // src/hooks/useAuth.ts
583
+ import { useCallback as useCallback2, useEffect as useEffect2, useState as useState4 } from "react";
584
+ var ADMIN_KEY_STORAGE = "pin-admin-key";
585
+ var TOKEN_QUERY_PARAM = "pin-token";
586
+ var resolveAuth = () => {
587
+ if (typeof window === "undefined") return { role: "anonymous" };
588
+ const params = new URLSearchParams(window.location.search);
589
+ const token = params.get(TOKEN_QUERY_PARAM);
590
+ if (token) return { role: "tokenHolder", token };
591
+ const adminKey = localStorage.getItem(ADMIN_KEY_STORAGE);
592
+ if (adminKey) return { role: "admin", secret: adminKey };
593
+ return { role: "anonymous" };
594
+ };
595
+ var computeHeaders = (auth) => {
596
+ switch (auth.role) {
597
+ case "tokenHolder":
598
+ return { "X-Pin-Token": auth.token };
599
+ case "admin":
600
+ return { "X-Pin-Admin": auth.secret };
601
+ case "anonymous":
602
+ return {};
603
+ }
604
+ };
605
+ var useAuth = () => {
606
+ const [auth, setAuth] = useState4(resolveAuth);
607
+ useEffect2(() => {
608
+ const onPopState = () => setAuth(resolveAuth());
609
+ window.addEventListener("popstate", onPopState);
610
+ return () => window.removeEventListener("popstate", onPopState);
611
+ }, []);
612
+ const setAdminKey = useCallback2((secret) => {
613
+ localStorage.setItem(ADMIN_KEY_STORAGE, secret);
614
+ setAuth(resolveAuth());
615
+ }, []);
616
+ const clearAdminKey = useCallback2(() => {
617
+ localStorage.removeItem(ADMIN_KEY_STORAGE);
618
+ setAuth(resolveAuth());
619
+ }, []);
620
+ return {
621
+ auth,
622
+ authHeaders: computeHeaders(auth),
623
+ setAdminKey,
624
+ clearAdminKey
625
+ };
626
+ };
627
+
628
+ // src/utils/resolveAnchor.ts
629
+ var DATA_ATTR_PRIORITY = ["data-testid", "data-cy"];
630
+ function findAnchorElement(element) {
631
+ if (element.id) {
632
+ return { el: element, selector: `#${CSS.escape(element.id)}` };
633
+ }
634
+ let current = element.parentElement;
635
+ while (current && current !== document.body) {
636
+ if (current.id) {
637
+ return { el: current, selector: `#${CSS.escape(current.id)}` };
638
+ }
639
+ current = current.parentElement;
640
+ }
641
+ current = element;
642
+ while (current && current !== document.body) {
643
+ for (const attr of DATA_ATTR_PRIORITY) {
644
+ const value = current.getAttribute(attr);
645
+ if (value) {
646
+ return { el: current, selector: `[${attr}="${CSS.escape(value)}"]` };
647
+ }
648
+ }
649
+ for (const attr of current.getAttributeNames()) {
650
+ if (attr.startsWith("data-") && !DATA_ATTR_PRIORITY.includes(attr)) {
651
+ const value = current.getAttribute(attr);
652
+ return {
653
+ el: current,
654
+ selector: `[${attr}="${CSS.escape(value ?? "")}"]`
655
+ };
656
+ }
657
+ }
658
+ current = current.parentElement;
659
+ }
660
+ return { el: element, selector: buildStructuralSelector(element) };
661
+ }
662
+ function buildStructuralSelector(element) {
663
+ const parts = [];
664
+ let current = element;
665
+ let depth = 0;
666
+ while (current && current !== document.body && depth < 3) {
667
+ const tag = current.tagName.toLowerCase();
668
+ const parent = current.parentElement;
669
+ if (parent) {
670
+ const siblings = Array.from(parent.children).filter(
671
+ (c) => c.tagName === current?.tagName
672
+ );
673
+ if (siblings.length > 1) {
674
+ const index = siblings.indexOf(current) + 1;
675
+ parts.unshift(`${tag}:nth-of-type(${index})`);
676
+ } else {
677
+ parts.unshift(tag);
678
+ }
679
+ } else {
680
+ parts.unshift(tag);
681
+ }
682
+ current = parent;
683
+ depth++;
684
+ }
685
+ return parts.join(" > ");
686
+ }
687
+ function computePercentages(anchorEl, clickX, clickY) {
688
+ const rect = anchorEl.getBoundingClientRect();
689
+ const width = rect.width || 1;
690
+ const height = rect.height || 1;
691
+ const xPercent = (clickX - rect.left) / width * 100;
692
+ const yPercent = (clickY - rect.top) / height * 100;
693
+ return { xPercent, yPercent };
694
+ }
695
+ function resolveAnchor(element, clickX, clickY) {
696
+ const { el, selector } = findAnchorElement(element);
697
+ const { xPercent, yPercent } = computePercentages(el, clickX, clickY);
698
+ return { selector, xPercent, yPercent };
699
+ }
700
+
701
+ // src/utils/restorePosition.ts
702
+ function restorePosition(anchor) {
703
+ if (!anchor.selector) {
704
+ return null;
705
+ }
706
+ let el;
707
+ try {
708
+ el = document.querySelector(anchor.selector);
709
+ } catch {
710
+ return null;
711
+ }
712
+ if (!el) {
713
+ return null;
714
+ }
715
+ const rect = el.getBoundingClientRect();
716
+ const scrollX = window.scrollX;
717
+ const scrollY = window.scrollY;
718
+ const left = rect.left + scrollX + rect.width * anchor.xPercent / 100;
719
+ const top = rect.top + scrollY + rect.height * anchor.yPercent / 100;
720
+ return { top, left };
721
+ }
722
+
723
+ // src/FeedbackOverlay.tsx
724
+ import { Fragment as Fragment2, jsx as jsx7, jsxs as jsxs5 } from "react/jsx-runtime";
725
+ function FeedbackOverlay({
726
+ onCommentCreate,
727
+ onCommentsFetch,
728
+ onCommentDelete,
729
+ onCommentUpdate,
730
+ onAdminValidate,
731
+ onShareLinkCreate,
732
+ children
733
+ }) {
734
+ const { auth, authHeaders, setAdminKey } = useAuth();
735
+ const [comments, setComments] = useState5([]);
736
+ const [pendingPin, setPendingPin] = useState5(null);
737
+ const [expandedPinId, setExpandedPinId] = useState5(null);
738
+ const [fetchError, setFetchError] = useState5(null);
739
+ const [pinMode, setPinMode] = useState5(false);
740
+ const [adminModalOpen, setAdminModalOpen] = useState5(false);
741
+ const hasFetched = useRef3(false);
742
+ useEffect3(() => {
743
+ if (hasFetched.current) return;
744
+ hasFetched.current = true;
745
+ onCommentsFetch(authHeaders).then((data) => setComments([...data])).catch(() => setFetchError("Couldn't load comments."));
746
+ }, [onCommentsFetch, authHeaders]);
747
+ const handleClick = useCallback3((clientX, clientY) => {
748
+ setExpandedPinId(null);
749
+ const interceptLayer = document.querySelector(
750
+ ".pp-intercept"
751
+ );
752
+ if (interceptLayer) interceptLayer.style.pointerEvents = "none";
753
+ const elementBelow = document.elementFromPoint(clientX, clientY);
754
+ if (interceptLayer) interceptLayer.style.pointerEvents = "";
755
+ if (!elementBelow) return;
756
+ const anchor = resolveAnchor(elementBelow, clientX, clientY);
757
+ setPendingPin({
758
+ x: clientX + window.scrollX,
759
+ y: clientY + window.scrollY,
760
+ anchor
761
+ });
762
+ }, []);
763
+ const handleSubmit = useCallback3(
764
+ async (content) => {
765
+ if (!pendingPin) return;
766
+ const comment = {
767
+ id: crypto.randomUUID(),
768
+ url: window.location.pathname,
769
+ content,
770
+ anchor: pendingPin.anchor,
771
+ viewport: { width: window.innerWidth },
772
+ createdAt: (/* @__PURE__ */ new Date()).toISOString()
773
+ };
774
+ await onCommentCreate(comment, authHeaders);
775
+ setComments((prev) => [...prev, comment]);
776
+ setPendingPin(null);
777
+ setPinMode(false);
778
+ },
779
+ [pendingPin, onCommentCreate, authHeaders]
780
+ );
781
+ const handleCancel = useCallback3(() => {
782
+ setPendingPin(null);
783
+ }, []);
784
+ const handleDelete = useCallback3(
785
+ async (id) => {
786
+ if (!onCommentDelete) return;
787
+ await onCommentDelete(id, authHeaders);
788
+ setComments((prev) => prev.filter((c) => c.id !== id));
789
+ setExpandedPinId(null);
790
+ },
791
+ [onCommentDelete, authHeaders]
792
+ );
793
+ const handleUpdate = useCallback3(
794
+ async (id, content) => {
795
+ if (!onCommentUpdate) return;
796
+ const updated = await onCommentUpdate(id, content, authHeaders);
797
+ setComments((prev) => prev.map((c) => c.id === id ? updated : c));
798
+ },
799
+ [onCommentUpdate, authHeaders]
800
+ );
801
+ const handlePinModeToggle = useCallback3(() => {
802
+ setPinMode((prev) => {
803
+ const next = !prev;
804
+ if (!next) setPendingPin(null);
805
+ return next;
806
+ });
807
+ }, []);
808
+ const handleAdminValidate = useCallback3(
809
+ async (secret) => {
810
+ if (!onAdminValidate) return false;
811
+ return onAdminValidate(secret);
812
+ },
813
+ [onAdminValidate]
814
+ );
815
+ const handleAdminSuccess = useCallback3(
816
+ (secret) => {
817
+ setAdminKey(secret);
818
+ setAdminModalOpen(false);
819
+ },
820
+ [setAdminKey]
821
+ );
822
+ const handleShareCreate = useCallback3(
823
+ async (label, ttl) => {
824
+ if (!onShareLinkCreate) throw new Error("onShareLinkCreate not provided");
825
+ return onShareLinkCreate(label, ttl, authHeaders);
826
+ },
827
+ [onShareLinkCreate, authHeaders]
828
+ );
829
+ return /* @__PURE__ */ jsxs5("div", { "data-pin-point": "", children: [
830
+ children,
831
+ pinMode && /* @__PURE__ */ jsx7(ClickInterceptLayer, { onClick: handleClick }),
832
+ comments.map((comment, index) => {
833
+ const pos = restorePosition(comment.anchor);
834
+ if (!pos) return null;
835
+ return /* @__PURE__ */ jsx7(
836
+ PinMarker,
837
+ {
838
+ number: index + 1,
839
+ top: pos.top,
840
+ left: pos.left,
841
+ onClick: () => setExpandedPinId(expandedPinId === comment.id ? null : comment.id)
842
+ },
843
+ comment.id
844
+ );
845
+ }),
846
+ expandedPinId && (() => {
847
+ const comment = comments.find((c) => c.id === expandedPinId);
848
+ if (!comment) return null;
849
+ const pos = restorePosition(comment.anchor);
850
+ if (!pos) return null;
851
+ return /* @__PURE__ */ jsx7(
852
+ CommentPopover,
853
+ {
854
+ mode: "read",
855
+ content: comment.content,
856
+ createdAt: comment.createdAt,
857
+ viewportWidth: comment.viewport.width,
858
+ top: pos.top,
859
+ left: pos.left,
860
+ onDelete: onCommentDelete ? async () => handleDelete(comment.id) : void 0,
861
+ onUpdate: onCommentUpdate ? async (content) => {
862
+ await handleUpdate(comment.id, content);
863
+ } : void 0
864
+ }
865
+ );
866
+ })(),
867
+ pendingPin && /* @__PURE__ */ jsxs5(Fragment2, { children: [
868
+ /* @__PURE__ */ jsx7(
869
+ PinMarker,
870
+ {
871
+ number: comments.length + 1,
872
+ top: pendingPin.y,
873
+ left: pendingPin.x,
874
+ onClick: () => {
875
+ }
876
+ }
877
+ ),
878
+ /* @__PURE__ */ jsx7(
879
+ CommentPopover,
880
+ {
881
+ mode: "create",
882
+ top: pendingPin.y,
883
+ left: pendingPin.x,
884
+ onSubmit: handleSubmit,
885
+ onCancel: handleCancel
886
+ }
887
+ )
888
+ ] }),
889
+ /* @__PURE__ */ jsx7(
890
+ FeedbackToolbar,
891
+ {
892
+ auth,
893
+ commentCount: comments.length,
894
+ pinModeActive: pinMode,
895
+ onPinModeToggle: handlePinModeToggle,
896
+ onAdminKeyOpen: () => setAdminModalOpen(true),
897
+ shareButton: auth.role === "admin" && onShareLinkCreate ? /* @__PURE__ */ jsx7(ShareLinkButton, { onCreate: handleShareCreate }) : void 0,
898
+ error: fetchError ?? void 0
899
+ }
900
+ ),
901
+ adminModalOpen && /* @__PURE__ */ jsx7(
902
+ AdminKeyModal,
903
+ {
904
+ onValidate: handleAdminValidate,
905
+ onSuccess: handleAdminSuccess,
906
+ onClose: () => setAdminModalOpen(false)
907
+ }
908
+ )
909
+ ] });
910
+ }
911
+ export {
912
+ FeedbackOverlay
913
+ };
914
+ //# sourceMappingURL=index.mjs.map