@embeddables/cli 0.15.0 → 0.15.1-beta.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.prompts/custom/build-funnel.md +3 -1
- package/.prompts/custom/carousel.md +159 -0
- package/.prompts/embeddables-cli.md +37 -3
- package/.prompts/short-rule-body.md +2 -2
- package/README.md +52 -0
- package/dist/cli.js +40 -0
- package/dist/cli.js.map +1 -1
- package/dist/commands/build-workbench.d.ts +5 -0
- package/dist/commands/build-workbench.d.ts.map +1 -0
- package/dist/commands/build-workbench.js +117 -0
- package/dist/commands/build-workbench.js.map +1 -0
- package/dist/commands/dangerously-publish.d.ts +53 -0
- package/dist/commands/dangerously-publish.d.ts.map +1 -0
- package/dist/commands/dangerously-publish.js +731 -0
- package/dist/commands/dangerously-publish.js.map +1 -0
- package/dist/commands/dev.d.ts.map +1 -1
- package/dist/commands/dev.js +3 -0
- package/dist/commands/dev.js.map +1 -1
- package/dist/commands/inspect.d.ts.map +1 -1
- package/dist/commands/inspect.js +24 -16
- package/dist/commands/inspect.js.map +1 -1
- package/dist/commands/pull.d.ts.map +1 -1
- package/dist/commands/pull.js +20 -59
- package/dist/commands/pull.js.map +1 -1
- package/dist/commands/save.js +8 -1
- package/dist/commands/save.js.map +1 -1
- package/dist/commands/update-project-files.d.ts.map +1 -1
- package/dist/commands/update-project-files.js +47 -19
- package/dist/commands/update-project-files.js.map +1 -1
- package/dist/compiler/errors.d.ts +13 -0
- package/dist/compiler/errors.d.ts.map +1 -1
- package/dist/compiler/errors.js +16 -0
- package/dist/compiler/errors.js.map +1 -1
- package/dist/compiler/flatten.js +1 -0
- package/dist/compiler/reverse.d.ts.map +1 -1
- package/dist/compiler/reverse.js +9 -7
- package/dist/compiler/reverse.js.map +1 -1
- package/dist/components/primitives/OptionSelector.d.ts +1 -0
- package/dist/components/primitives/OptionSelector.d.ts.map +1 -1
- package/dist/components/primitives/OptionSelector.js.map +1 -1
- package/dist/constants.d.ts +8 -1
- package/dist/constants.d.ts.map +1 -1
- package/dist/constants.js +10 -3
- package/dist/constants.js.map +1 -1
- package/dist/helpers/TEMP helpers file.d.ts +1 -0
- package/dist/helpers/TEMP helpers file.d.ts.map +1 -0
- package/dist/helpers/TEMP helpers file.js +1 -0
- package/dist/helpers/reverseCompileWithRetry.d.ts +60 -0
- package/dist/helpers/reverseCompileWithRetry.d.ts.map +1 -0
- package/dist/helpers/reverseCompileWithRetry.js +96 -0
- package/dist/helpers/reverseCompileWithRetry.js.map +1 -0
- package/dist/proxy/injectWorkbench.d.ts +4 -0
- package/dist/proxy/injectWorkbench.d.ts.map +1 -1
- package/dist/proxy/injectWorkbench.js +5 -1
- package/dist/proxy/injectWorkbench.js.map +1 -1
- package/dist/proxy/server.d.ts +1 -0
- package/dist/proxy/server.d.ts.map +1 -1
- package/dist/proxy/server.js +12 -0
- package/dist/proxy/server.js.map +1 -1
- package/dist/types-builder.d.ts +1 -0
- package/dist/types-builder.d.ts.map +1 -1
- package/dist/workbench/FeedbackPanel.d.ts +43 -0
- package/dist/workbench/FeedbackPanel.d.ts.map +1 -0
- package/dist/workbench/FeedbackPanel.js +1146 -0
- package/dist/workbench/FeedbackPanel.js.map +1 -0
- package/dist/workbench/FieldEditorPanel.d.ts.map +1 -1
- package/dist/workbench/FieldEditorPanel.js +3 -2
- package/dist/workbench/FieldEditorPanel.js.map +1 -1
- package/dist/workbench/InspectorPanel.d.ts.map +1 -1
- package/dist/workbench/InspectorPanel.js +3 -172
- package/dist/workbench/InspectorPanel.js.map +1 -1
- package/dist/workbench/MediaLightbox.d.ts +20 -0
- package/dist/workbench/MediaLightbox.d.ts.map +1 -0
- package/dist/workbench/MediaLightbox.js +76 -0
- package/dist/workbench/MediaLightbox.js.map +1 -0
- package/dist/workbench/PageThumbnailStrip.d.ts +19 -0
- package/dist/workbench/PageThumbnailStrip.d.ts.map +1 -0
- package/dist/workbench/PageThumbnailStrip.js +304 -0
- package/dist/workbench/PageThumbnailStrip.js.map +1 -0
- package/dist/workbench/Toast.d.ts +18 -0
- package/dist/workbench/Toast.d.ts.map +1 -0
- package/dist/workbench/Toast.js +46 -0
- package/dist/workbench/Toast.js.map +1 -0
- package/dist/workbench/UserDataPanel.d.ts +2 -1
- package/dist/workbench/UserDataPanel.d.ts.map +1 -1
- package/dist/workbench/UserDataPanel.js +2 -1
- package/dist/workbench/UserDataPanel.js.map +1 -1
- package/dist/workbench/WorkbenchApp.d.ts +8 -1
- package/dist/workbench/WorkbenchApp.d.ts.map +1 -1
- package/dist/workbench/WorkbenchApp.js +32 -7
- package/dist/workbench/WorkbenchApp.js.map +1 -1
- package/dist/workbench/WorkbenchModalOverlay.d.ts +20 -0
- package/dist/workbench/WorkbenchModalOverlay.d.ts.map +1 -0
- package/dist/workbench/WorkbenchModalOverlay.js +45 -0
- package/dist/workbench/WorkbenchModalOverlay.js.map +1 -0
- package/dist/workbench/cloudflare/public/_headers +4 -0
- package/dist/workbench/cloudflare/public/workbench.css +2148 -0
- package/dist/workbench/cloudflare/public/workbench.js +709 -0
- package/dist/workbench/cloudflare/wrangler.jsonc +9 -0
- package/dist/workbench/cloudflare-worker/README.md +31 -0
- package/dist/workbench/cloudflare-worker/public/workbench.css +1614 -0
- package/dist/workbench/cloudflare-worker/public/workbench.js +77 -0
- package/dist/workbench/cloudflare-worker/worker.js +40 -0
- package/dist/workbench/cloudflare-worker/wrangler.toml +10 -0
- package/dist/workbench/conditionEvaluator.d.ts +6 -0
- package/dist/workbench/conditionEvaluator.d.ts.map +1 -0
- package/dist/workbench/conditionEvaluator.js +111 -0
- package/dist/workbench/conditionEvaluator.js.map +1 -0
- package/dist/workbench/feedbackSessionsStorage.d.ts +24 -0
- package/dist/workbench/feedbackSessionsStorage.d.ts.map +1 -0
- package/dist/workbench/feedbackSessionsStorage.js +80 -0
- package/dist/workbench/feedbackSessionsStorage.js.map +1 -0
- package/dist/workbench/index.d.ts +4 -0
- package/dist/workbench/index.d.ts.map +1 -1
- package/dist/workbench/index.js +42 -2
- package/dist/workbench/index.js.map +1 -1
- package/dist/workbench/pageKeyDisplay.d.ts +9 -0
- package/dist/workbench/pageKeyDisplay.d.ts.map +1 -0
- package/dist/workbench/pageKeyDisplay.js +18 -0
- package/dist/workbench/pageKeyDisplay.js.map +1 -0
- package/dist/workbench/stringUtils.d.ts +6 -0
- package/dist/workbench/stringUtils.d.ts.map +1 -0
- package/dist/workbench/stringUtils.js +24 -0
- package/dist/workbench/stringUtils.js.map +1 -0
- package/dist/workbench/supabase-browser.d.ts +11 -0
- package/dist/workbench/supabase-browser.d.ts.map +1 -0
- package/dist/workbench/supabase-browser.js +18 -0
- package/dist/workbench/supabase-browser.js.map +1 -0
- package/dist/workbench/types.d.ts +29 -0
- package/dist/workbench/types.d.ts.map +1 -0
- package/dist/workbench/types.js +2 -0
- package/dist/workbench/types.js.map +1 -0
- package/dist/workbench/useComponentSelection.d.ts +27 -0
- package/dist/workbench/useComponentSelection.d.ts.map +1 -0
- package/dist/workbench/useComponentSelection.js +203 -0
- package/dist/workbench/useComponentSelection.js.map +1 -0
- package/dist/workbench/workbench.css +2148 -0
- package/dist/workbench/workbench.js +709 -0
- package/dist/workbench/workbenchShadowPortal.d.ts +4 -0
- package/dist/workbench/workbenchShadowPortal.d.ts.map +1 -0
- package/dist/workbench/workbenchShadowPortal.js +18 -0
- package/dist/workbench/workbenchShadowPortal.js.map +1 -0
- package/package.json +6 -1
|
@@ -0,0 +1,1146 @@
|
|
|
1
|
+
import { jsx as _jsx, jsxs as _jsxs, Fragment as _Fragment } from "react/jsx-runtime";
|
|
2
|
+
/**
|
|
3
|
+
* Feedback Panel for collecting and reviewing design feedback on embeddable components.
|
|
4
|
+
* Supports session-based feedback collection, screenshots, and shareable review URLs.
|
|
5
|
+
*/
|
|
6
|
+
import { Fragment, useCallback, useEffect, useMemo, useRef, useState } from 'react';
|
|
7
|
+
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome';
|
|
8
|
+
import { faArrowLeft, faBan, faCamera, faCheck, faChevronRight, faCircleExclamation, faCircleXmark, faCloudArrowUp, faCopy, faLink, faComments, faCrosshairs, faFile, faFileLines, faFolderOpen, faImage, faMessage, faPaperclip, faPenToSquare, faPlus, faSpinner, faTrashCan, faUserGroup, faXmark, } from '@fortawesome/free-solid-svg-icons';
|
|
9
|
+
import html2canvas from 'html2canvas';
|
|
10
|
+
import { getSupabaseBrowserClient } from './supabase-browser.js';
|
|
11
|
+
import { useComponentSelection } from './useComponentSelection.js';
|
|
12
|
+
import { SUPABASE_URL } from '../constants.js';
|
|
13
|
+
import { pascalCaseToTitleCase } from './stringUtils.js';
|
|
14
|
+
import { appendMyFeedbackSession, appendSharedFeedbackSession, readMyFeedbackSessionRefs, readSharedFeedbackSessionRefs, removeMyFeedbackSession, removeSharedFeedbackSession, } from './feedbackSessionsStorage.js';
|
|
15
|
+
import { MediaLightbox } from './MediaLightbox.js';
|
|
16
|
+
import { WorkbenchModalOverlay } from './WorkbenchModalOverlay.js';
|
|
17
|
+
const FEEDBACK_BUCKET = 'feedback_reviews';
|
|
18
|
+
/** Generate shareable URL with feedbackReview and feedbackProject params. */
|
|
19
|
+
function buildShareableUrl(reviewId, projectId) {
|
|
20
|
+
const url = new URL(window.location.href);
|
|
21
|
+
url.searchParams.set('feedbackReview', reviewId);
|
|
22
|
+
url.searchParams.set('feedbackProject', projectId);
|
|
23
|
+
return url.toString();
|
|
24
|
+
}
|
|
25
|
+
function isImageAttachmentData(data) {
|
|
26
|
+
return data.startsWith('data:image');
|
|
27
|
+
}
|
|
28
|
+
/** Runtime-parse untrusted storage JSON into FeedbackComment, or null if shape is invalid. */
|
|
29
|
+
function parseFeedbackComment(raw) {
|
|
30
|
+
if (typeof raw !== 'object' || raw === null)
|
|
31
|
+
return null;
|
|
32
|
+
const c = raw;
|
|
33
|
+
if (typeof c.id !== 'string')
|
|
34
|
+
return null;
|
|
35
|
+
if (typeof c.pageKey !== 'string')
|
|
36
|
+
return null;
|
|
37
|
+
if (typeof c.pageId !== 'string')
|
|
38
|
+
return null;
|
|
39
|
+
if (typeof c.comment !== 'string')
|
|
40
|
+
return null;
|
|
41
|
+
if (!Array.isArray(c.attachments))
|
|
42
|
+
return null;
|
|
43
|
+
const attachments = [];
|
|
44
|
+
for (const rawAtt of c.attachments) {
|
|
45
|
+
if (typeof rawAtt !== 'object' || rawAtt === null)
|
|
46
|
+
return null;
|
|
47
|
+
const a = rawAtt;
|
|
48
|
+
if (a.type !== 'file' && a.type !== 'screenshot')
|
|
49
|
+
return null;
|
|
50
|
+
if (typeof a.data !== 'string')
|
|
51
|
+
return null;
|
|
52
|
+
if (a.filename !== undefined && typeof a.filename !== 'string')
|
|
53
|
+
return null;
|
|
54
|
+
attachments.push(a.filename !== undefined
|
|
55
|
+
? { type: a.type, data: a.data, filename: a.filename }
|
|
56
|
+
: { type: a.type, data: a.data });
|
|
57
|
+
}
|
|
58
|
+
if (typeof c.metadata !== 'object' || c.metadata === null)
|
|
59
|
+
return null;
|
|
60
|
+
const m = c.metadata;
|
|
61
|
+
if (typeof m.timestamp !== 'string')
|
|
62
|
+
return null;
|
|
63
|
+
if (typeof m.viewportWidth !== 'number' || typeof m.viewportHeight !== 'number')
|
|
64
|
+
return null;
|
|
65
|
+
if (m.author !== undefined && typeof m.author !== 'string')
|
|
66
|
+
return null;
|
|
67
|
+
const optionalStringOk = (v) => v === undefined || v === null || typeof v === 'string';
|
|
68
|
+
if (!optionalStringOk(c.componentKey))
|
|
69
|
+
return null;
|
|
70
|
+
if (!optionalStringOk(c.componentId))
|
|
71
|
+
return null;
|
|
72
|
+
if (!optionalStringOk(c.componentType))
|
|
73
|
+
return null;
|
|
74
|
+
if (!optionalStringOk(c.feedbackTag))
|
|
75
|
+
return null;
|
|
76
|
+
if (c.userData !== undefined) {
|
|
77
|
+
if (typeof c.userData !== 'object' || c.userData === null || Array.isArray(c.userData)) {
|
|
78
|
+
return null;
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
const comment = {
|
|
82
|
+
id: c.id,
|
|
83
|
+
pageKey: c.pageKey,
|
|
84
|
+
pageId: c.pageId,
|
|
85
|
+
comment: c.comment,
|
|
86
|
+
attachments,
|
|
87
|
+
metadata: {
|
|
88
|
+
timestamp: m.timestamp,
|
|
89
|
+
viewportWidth: m.viewportWidth,
|
|
90
|
+
viewportHeight: m.viewportHeight,
|
|
91
|
+
...(m.author !== undefined ? { author: m.author } : {}),
|
|
92
|
+
},
|
|
93
|
+
};
|
|
94
|
+
if (c.componentKey != null)
|
|
95
|
+
comment.componentKey = c.componentKey;
|
|
96
|
+
if (c.componentId != null)
|
|
97
|
+
comment.componentId = c.componentId;
|
|
98
|
+
if (c.componentType != null)
|
|
99
|
+
comment.componentType = c.componentType;
|
|
100
|
+
if (c.feedbackTag != null)
|
|
101
|
+
comment.feedbackTag = c.feedbackTag;
|
|
102
|
+
if (c.userData !== undefined)
|
|
103
|
+
comment.userData = c.userData;
|
|
104
|
+
return comment;
|
|
105
|
+
}
|
|
106
|
+
/** Runtime-parse untrusted storage JSON into FeedbackReview, or null if shape is invalid. */
|
|
107
|
+
function parseFeedbackReviewFromJson(raw) {
|
|
108
|
+
if (typeof raw !== 'object' || raw === null)
|
|
109
|
+
return null;
|
|
110
|
+
const o = raw;
|
|
111
|
+
if (typeof o.id !== 'string')
|
|
112
|
+
return null;
|
|
113
|
+
if (typeof o.embeddableId !== 'string')
|
|
114
|
+
return null;
|
|
115
|
+
if (typeof o.baseUrl !== 'string')
|
|
116
|
+
return null;
|
|
117
|
+
if (typeof o.createdAt !== 'string')
|
|
118
|
+
return null;
|
|
119
|
+
if (o.projectId !== undefined && typeof o.projectId !== 'string')
|
|
120
|
+
return null;
|
|
121
|
+
if (o.title !== undefined && typeof o.title !== 'string')
|
|
122
|
+
return null;
|
|
123
|
+
if (!Array.isArray(o.comments))
|
|
124
|
+
return null;
|
|
125
|
+
const comments = [];
|
|
126
|
+
for (const item of o.comments) {
|
|
127
|
+
const parsed = parseFeedbackComment(item);
|
|
128
|
+
if (!parsed)
|
|
129
|
+
return null;
|
|
130
|
+
comments.push(parsed);
|
|
131
|
+
}
|
|
132
|
+
const review = {
|
|
133
|
+
id: o.id,
|
|
134
|
+
embeddableId: o.embeddableId,
|
|
135
|
+
baseUrl: o.baseUrl,
|
|
136
|
+
comments,
|
|
137
|
+
createdAt: o.createdAt,
|
|
138
|
+
};
|
|
139
|
+
if (o.projectId !== undefined)
|
|
140
|
+
review.projectId = o.projectId;
|
|
141
|
+
if (o.title !== undefined)
|
|
142
|
+
review.title = o.title;
|
|
143
|
+
return review;
|
|
144
|
+
}
|
|
145
|
+
/** Fetch review JSON from Supabase storage. Path: feedback_reviews/{projectId}/{reviewId}.json */
|
|
146
|
+
async function fetchReview(reviewId, projectId) {
|
|
147
|
+
try {
|
|
148
|
+
const base = SUPABASE_URL.replace(/\/$/, '');
|
|
149
|
+
const url = `${base}/storage/v1/object/public/${FEEDBACK_BUCKET}/${projectId}/${reviewId}.json`;
|
|
150
|
+
const res = await fetch(url);
|
|
151
|
+
if (!res.ok)
|
|
152
|
+
return null;
|
|
153
|
+
const json = await res.json();
|
|
154
|
+
return parseFeedbackReviewFromJson(json);
|
|
155
|
+
}
|
|
156
|
+
catch {
|
|
157
|
+
return null;
|
|
158
|
+
}
|
|
159
|
+
}
|
|
160
|
+
function readActiveReviewFromUrl() {
|
|
161
|
+
if (typeof window === 'undefined')
|
|
162
|
+
return null;
|
|
163
|
+
const params = new URLSearchParams(window.location.search);
|
|
164
|
+
const r = params.get('feedbackReview');
|
|
165
|
+
const p = params.get('feedbackProject');
|
|
166
|
+
return r && p ? { reviewId: r, projectId: p } : null;
|
|
167
|
+
}
|
|
168
|
+
function feedbackBackDismissKey(embeddableId) {
|
|
169
|
+
return `embeddables:feedback:back:${embeddableId}`;
|
|
170
|
+
}
|
|
171
|
+
function readFeedbackDismissedPair(embeddableId) {
|
|
172
|
+
if (typeof window === 'undefined')
|
|
173
|
+
return null;
|
|
174
|
+
try {
|
|
175
|
+
return window.sessionStorage.getItem(feedbackBackDismissKey(embeddableId));
|
|
176
|
+
}
|
|
177
|
+
catch {
|
|
178
|
+
return null;
|
|
179
|
+
}
|
|
180
|
+
}
|
|
181
|
+
function clearFeedbackDismissed(embeddableId) {
|
|
182
|
+
if (typeof window === 'undefined')
|
|
183
|
+
return;
|
|
184
|
+
try {
|
|
185
|
+
window.sessionStorage.removeItem(feedbackBackDismissKey(embeddableId));
|
|
186
|
+
}
|
|
187
|
+
catch {
|
|
188
|
+
// ignore
|
|
189
|
+
}
|
|
190
|
+
}
|
|
191
|
+
/** URL wins; else Workbench props. Respects session dismiss after Back (stale props). */
|
|
192
|
+
function resolveActiveReview(embeddableId, feedbackReviewId, feedbackProjectId) {
|
|
193
|
+
const fromUrl = readActiveReviewFromUrl();
|
|
194
|
+
if (fromUrl) {
|
|
195
|
+
clearFeedbackDismissed(embeddableId);
|
|
196
|
+
return fromUrl;
|
|
197
|
+
}
|
|
198
|
+
if (feedbackReviewId && feedbackProjectId) {
|
|
199
|
+
const pair = `${feedbackReviewId}:${feedbackProjectId}`;
|
|
200
|
+
if (readFeedbackDismissedPair(embeddableId) === pair)
|
|
201
|
+
return null;
|
|
202
|
+
return { reviewId: feedbackReviewId, projectId: feedbackProjectId };
|
|
203
|
+
}
|
|
204
|
+
return null;
|
|
205
|
+
}
|
|
206
|
+
function stripFeedbackParamsFromUrl() {
|
|
207
|
+
if (typeof window === 'undefined')
|
|
208
|
+
return;
|
|
209
|
+
const url = new URL(window.location.href);
|
|
210
|
+
if (!url.searchParams.has('feedbackReview') && !url.searchParams.has('feedbackProject'))
|
|
211
|
+
return;
|
|
212
|
+
url.searchParams.delete('feedbackReview');
|
|
213
|
+
url.searchParams.delete('feedbackProject');
|
|
214
|
+
window.history.replaceState({}, '', url.toString());
|
|
215
|
+
}
|
|
216
|
+
function formatReviewDate(iso) {
|
|
217
|
+
return new Date(iso).toLocaleString(undefined, {
|
|
218
|
+
dateStyle: 'medium',
|
|
219
|
+
timeStyle: 'short',
|
|
220
|
+
});
|
|
221
|
+
}
|
|
222
|
+
/** Page key as stored (snake_case); use with `font-mono` in the UI. */
|
|
223
|
+
function displayFeedbackPageKey(pageKey) {
|
|
224
|
+
if (!pageKey || pageKey === 'unknown')
|
|
225
|
+
return 'unknown';
|
|
226
|
+
return pageKey;
|
|
227
|
+
}
|
|
228
|
+
/** Stable key for grouping consecutive comments under one page heading. */
|
|
229
|
+
function feedbackPageGroupKey(c) {
|
|
230
|
+
if (c.pageId && c.pageId !== 'unknown')
|
|
231
|
+
return `id:${c.pageId}`;
|
|
232
|
+
return `key:${c.pageKey}`;
|
|
233
|
+
}
|
|
234
|
+
function feedbackPageOrderIndex(flow, c) {
|
|
235
|
+
if (!flow?.pages?.length)
|
|
236
|
+
return 9999;
|
|
237
|
+
const byId = flow.pages.findIndex((p) => p.id === c.pageId);
|
|
238
|
+
if (byId >= 0)
|
|
239
|
+
return byId;
|
|
240
|
+
const byKey = flow.pages.findIndex((p) => p.key === c.pageKey);
|
|
241
|
+
if (byKey >= 0)
|
|
242
|
+
return byKey;
|
|
243
|
+
return 9999;
|
|
244
|
+
}
|
|
245
|
+
/** 1-based index in `flow.pages`, matching `PageNavigator` (`absolutePageIndex + 1`). */
|
|
246
|
+
function feedbackPageOneBasedIndex(flow, c) {
|
|
247
|
+
const idx = feedbackPageOrderIndex(flow, c);
|
|
248
|
+
if (idx >= 9999)
|
|
249
|
+
return null;
|
|
250
|
+
return idx + 1;
|
|
251
|
+
}
|
|
252
|
+
function feedbackComponentSortKey(c) {
|
|
253
|
+
return (c.componentKey ?? c.componentId ?? '').toLowerCase();
|
|
254
|
+
}
|
|
255
|
+
/** Order comments by flow page order, then component key/id, then time. */
|
|
256
|
+
function sortFeedbackCommentsForDisplay(list, flow) {
|
|
257
|
+
return list.sort((a, b) => {
|
|
258
|
+
const pa = feedbackPageOrderIndex(flow, a);
|
|
259
|
+
const pb = feedbackPageOrderIndex(flow, b);
|
|
260
|
+
if (pa !== pb)
|
|
261
|
+
return pa - pb;
|
|
262
|
+
const ca = feedbackComponentSortKey(a);
|
|
263
|
+
const cb = feedbackComponentSortKey(b);
|
|
264
|
+
if (ca !== cb)
|
|
265
|
+
return ca.localeCompare(cb);
|
|
266
|
+
return a.metadata.timestamp.localeCompare(b.metadata.timestamp);
|
|
267
|
+
});
|
|
268
|
+
}
|
|
269
|
+
/** Totals and first-page info for the shared-session welcome modal (same ordering as jump-to-first-comment). */
|
|
270
|
+
function computeSharedSessionWelcomeStats(comments, flow) {
|
|
271
|
+
const totalComments = comments.length;
|
|
272
|
+
if (totalComments === 0) {
|
|
273
|
+
return { totalComments: 0, pageCount: 0, firstPageKey: '', commentsOnFirstPage: 0 };
|
|
274
|
+
}
|
|
275
|
+
const pageKeySet = new Set();
|
|
276
|
+
for (const c of comments) {
|
|
277
|
+
pageKeySet.add(feedbackPageGroupKey(c));
|
|
278
|
+
}
|
|
279
|
+
const pageCount = pageKeySet.size;
|
|
280
|
+
const sorted = sortFeedbackCommentsForDisplay([...comments], flow);
|
|
281
|
+
const first = sorted[0];
|
|
282
|
+
const firstGroup = feedbackPageGroupKey(first);
|
|
283
|
+
const commentsOnFirstPage = comments.filter((c) => feedbackPageGroupKey(c) === firstGroup).length;
|
|
284
|
+
const firstPageKey = displayFeedbackPageKey(first.pageKey);
|
|
285
|
+
return { totalComments, pageCount, firstPageKey, commentsOnFirstPage };
|
|
286
|
+
}
|
|
287
|
+
function formatAttachmentLineForPrompt(a, index) {
|
|
288
|
+
const name = a.filename?.trim() || (a.type === 'screenshot' ? 'screenshot' : 'file');
|
|
289
|
+
const kind = isImageAttachmentData(a.data) ? 'image' : 'non-image';
|
|
290
|
+
return `${index}. ${a.type} (${kind}): ${name} — binary not included in this paste; open the item in workbench feedback to view`;
|
|
291
|
+
}
|
|
292
|
+
/** Single clipboard-ready prompt: all comments with targeting + metadata (images summarized only). */
|
|
293
|
+
function buildFeedbackAiPrompt(opts) {
|
|
294
|
+
const { embeddableId, loadedReview, comments, flow } = opts;
|
|
295
|
+
const sorted = sortFeedbackCommentsForDisplay([...comments], flow);
|
|
296
|
+
const lines = [];
|
|
297
|
+
lines.push('You are assisting with an Embeddables embeddable. Implement ALL feedback items below in the project source (pages, styles, computed fields, actions) so the running embeddable matches the requested changes.');
|
|
298
|
+
lines.push("The User Data properties show the User Data as it was captured when that particular comment was submitted, in case that's relevant to figuring out why the UI looked/behaved as it did.");
|
|
299
|
+
lines.push('When an item lists **feedback_scope_tag** on the target component, that value is one of the component’s Embeddables tags: use it to control how widely to apply the change. Propagate updates to every appropriate part of the project that shares that tag—not only visual styling but also copy, layout, computed fields, actions, and any other change that should stay consistent across the tagged group.');
|
|
300
|
+
lines.push('');
|
|
301
|
+
lines.push('## Context');
|
|
302
|
+
lines.push(`- **Embeddable ID**: \`${embeddableId}\``);
|
|
303
|
+
if (loadedReview?.title)
|
|
304
|
+
lines.push(`- **Review title**: ${loadedReview.title}`);
|
|
305
|
+
if (loadedReview?.createdAt) {
|
|
306
|
+
lines.push(`- **Review created**: ${formatReviewDate(loadedReview.createdAt)}`);
|
|
307
|
+
}
|
|
308
|
+
if (loadedReview?.baseUrl)
|
|
309
|
+
lines.push(`- **Captured from base URL**: ${loadedReview.baseUrl}`);
|
|
310
|
+
if (loadedReview?.projectId)
|
|
311
|
+
lines.push(`- **Project ID**: \`${loadedReview.projectId}\``);
|
|
312
|
+
lines.push('');
|
|
313
|
+
lines.push('## Feedback items (complete every numbered item)');
|
|
314
|
+
sorted.forEach((c, i) => {
|
|
315
|
+
const pageNum = feedbackPageOneBasedIndex(flow, c);
|
|
316
|
+
const heading = pageNum != null
|
|
317
|
+
? `### ${i + 1}. Page ${pageNum} — ${displayFeedbackPageKey(c.pageKey)}`
|
|
318
|
+
: `### ${i + 1}. ${displayFeedbackPageKey(c.pageKey)}`;
|
|
319
|
+
lines.push('');
|
|
320
|
+
lines.push(heading);
|
|
321
|
+
lines.push('');
|
|
322
|
+
lines.push(`- **comment_id**: \`${c.id}\``);
|
|
323
|
+
lines.push('');
|
|
324
|
+
lines.push('**Change requested**');
|
|
325
|
+
lines.push(c.comment.trim() ||
|
|
326
|
+
'(No written description — use component context and screenshots or files attached in workbench feedback.)');
|
|
327
|
+
lines.push('');
|
|
328
|
+
lines.push('**Target**');
|
|
329
|
+
lines.push(`- page_key: \`${displayFeedbackPageKey(c.pageKey)}\``);
|
|
330
|
+
lines.push(`- page_id: \`${c.pageId}\``);
|
|
331
|
+
if (c.componentKey != null || c.componentId) {
|
|
332
|
+
lines.push(`- component_type: \`${c.componentType ?? 'unknown'}\``);
|
|
333
|
+
if (c.componentKey != null)
|
|
334
|
+
lines.push(`- component_key: \`${c.componentKey}\``);
|
|
335
|
+
if (c.componentId)
|
|
336
|
+
lines.push(`- component_id: \`${c.componentId}\``);
|
|
337
|
+
if (c.feedbackTag) {
|
|
338
|
+
lines.push(`- feedback_scope_tag: \`${c.feedbackTag}\``);
|
|
339
|
+
}
|
|
340
|
+
}
|
|
341
|
+
else {
|
|
342
|
+
lines.push('- component: (none — page-level or general note)');
|
|
343
|
+
}
|
|
344
|
+
lines.push('');
|
|
345
|
+
lines.push('**Metadata**');
|
|
346
|
+
if (c.metadata.author)
|
|
347
|
+
lines.push(`- author: ${c.metadata.author}`);
|
|
348
|
+
lines.push(`- timestamp: ${c.metadata.timestamp}`);
|
|
349
|
+
lines.push(`- viewport: ${c.metadata.viewportWidth}×${c.metadata.viewportHeight}`);
|
|
350
|
+
if (c.userData && Object.keys(c.userData).length > 0) {
|
|
351
|
+
lines.push(`- userData: \`${JSON.stringify(c.userData)}\``);
|
|
352
|
+
}
|
|
353
|
+
if (c.attachments?.length) {
|
|
354
|
+
lines.push('');
|
|
355
|
+
lines.push('**Attachments**');
|
|
356
|
+
c.attachments.forEach((a, ai) => {
|
|
357
|
+
lines.push(formatAttachmentLineForPrompt(a, ai + 1));
|
|
358
|
+
});
|
|
359
|
+
}
|
|
360
|
+
});
|
|
361
|
+
lines.push('');
|
|
362
|
+
lines.push('---');
|
|
363
|
+
lines.push('Verify in the workbench preview that every item is addressed.');
|
|
364
|
+
return lines.join('\n');
|
|
365
|
+
}
|
|
366
|
+
/** After `goToPage`, the target node may not exist until the embeddable finishes rendering. */
|
|
367
|
+
function scrollToComponentWithRetry(componentId) {
|
|
368
|
+
let attempts = 0;
|
|
369
|
+
const maxAttempts = 72;
|
|
370
|
+
const step = () => {
|
|
371
|
+
const el = document.querySelector(`.cid-${componentId}`);
|
|
372
|
+
if (el) {
|
|
373
|
+
scrollToComponent(componentId);
|
|
374
|
+
return;
|
|
375
|
+
}
|
|
376
|
+
attempts++;
|
|
377
|
+
if (attempts < maxAttempts)
|
|
378
|
+
requestAnimationFrame(step);
|
|
379
|
+
};
|
|
380
|
+
requestAnimationFrame(step);
|
|
381
|
+
}
|
|
382
|
+
function defaultFeedbackReviewTitle() {
|
|
383
|
+
return `Feedback · ${formatReviewDate(new Date().toISOString())}`;
|
|
384
|
+
}
|
|
385
|
+
// Track last highlighted element so we can clear its outline when switching comments
|
|
386
|
+
let lastHighlightedEl = null;
|
|
387
|
+
/** Scroll to and highlight a component by ID. Clears previous highlight immediately. */
|
|
388
|
+
function scrollToComponent(componentId) {
|
|
389
|
+
if (lastHighlightedEl) {
|
|
390
|
+
lastHighlightedEl.style.outline = '';
|
|
391
|
+
lastHighlightedEl = null;
|
|
392
|
+
}
|
|
393
|
+
const el = document.querySelector(`.cid-${componentId}`);
|
|
394
|
+
if (el) {
|
|
395
|
+
el.scrollIntoView({ behavior: 'smooth', block: 'center' });
|
|
396
|
+
el.style.outline = '2px solid rgba(59, 130, 246, 0.8)';
|
|
397
|
+
lastHighlightedEl = el;
|
|
398
|
+
setTimeout(() => {
|
|
399
|
+
if (lastHighlightedEl === el) {
|
|
400
|
+
el.style.outline = '';
|
|
401
|
+
lastHighlightedEl = null;
|
|
402
|
+
}
|
|
403
|
+
}, 2000);
|
|
404
|
+
}
|
|
405
|
+
}
|
|
406
|
+
export function FeedbackPanel({ embeddableId, feedbackReviewId, feedbackProjectId, projectId: injectedProjectId, }) {
|
|
407
|
+
const [sessionActive, setSessionActive] = useState(false);
|
|
408
|
+
const [comments, setComments] = useState([]);
|
|
409
|
+
const [commentText, setCommentText] = useState('');
|
|
410
|
+
/** '' = scope tag off (not sent); otherwise one of the selected component’s `tags`. Off by default until Add Tag. */
|
|
411
|
+
const [selectedFeedbackTag, setSelectedFeedbackTag] = useState('');
|
|
412
|
+
const [attachments, setAttachments] = useState([]);
|
|
413
|
+
const [includeViewportScreenshot, setIncludeViewportScreenshot] = useState(true);
|
|
414
|
+
const [includeComponentScreenshot, setIncludeComponentScreenshot] = useState(true);
|
|
415
|
+
const [activeReview, setActiveReview] = useState(() => resolveActiveReview(embeddableId, feedbackReviewId, feedbackProjectId));
|
|
416
|
+
const shareLinkLaunchRef = useRef(typeof window !== 'undefined' &&
|
|
417
|
+
new URLSearchParams(window.location.search).has('feedbackReview'));
|
|
418
|
+
/** Full share URL (`feedbackReview` + `feedbackProject`): jump embeddable to first comment's page once. */
|
|
419
|
+
const jumpToFirstShareCommentRef = useRef(typeof window !== 'undefined' &&
|
|
420
|
+
(() => {
|
|
421
|
+
const p = new URLSearchParams(window.location.search);
|
|
422
|
+
return p.has('feedbackReview') && p.has('feedbackProject');
|
|
423
|
+
})());
|
|
424
|
+
/** Same share URL: show a one-time welcome modal with session title + date after the review loads. */
|
|
425
|
+
const sharedLinkWelcomePendingRef = useRef(typeof window !== 'undefined' &&
|
|
426
|
+
(() => {
|
|
427
|
+
const p = new URLSearchParams(window.location.search);
|
|
428
|
+
return p.has('feedbackReview') && p.has('feedbackProject');
|
|
429
|
+
})());
|
|
430
|
+
const [loadingReview, setLoadingReview] = useState(() => resolveActiveReview(embeddableId, feedbackReviewId, feedbackProjectId) != null);
|
|
431
|
+
const [loadedReview, setLoadedReview] = useState(null);
|
|
432
|
+
const [completeError, setCompleteError] = useState(null);
|
|
433
|
+
const [copiedUrl, setCopiedUrl] = useState(false);
|
|
434
|
+
const [copiedPrompt, setCopiedPrompt] = useState(false);
|
|
435
|
+
const [copiedShareUrl, setCopiedShareUrl] = useState(false);
|
|
436
|
+
/** After a successful upload in the current panel; cleared when starting/discarding a session or opening another review. */
|
|
437
|
+
const [savedToBucketPair, setSavedToBucketPair] = useState(null);
|
|
438
|
+
/** After upload: stay in-panel with comments but hide inspector/form and block re-upload. */
|
|
439
|
+
const [sessionUploadComplete, setSessionUploadComplete] = useState(false);
|
|
440
|
+
const [isUploading, setIsUploading] = useState(false);
|
|
441
|
+
const [loadError, setLoadError] = useState(null);
|
|
442
|
+
const [submitError, setSubmitError] = useState(null);
|
|
443
|
+
const [isSubmittingComment, setIsSubmittingComment] = useState(false);
|
|
444
|
+
const isSubmittingCommentRef = useRef(false);
|
|
445
|
+
const [mySessionRows, setMySessionRows] = useState([]);
|
|
446
|
+
const [sharedSessionRows, setSharedSessionRows] = useState([]);
|
|
447
|
+
const [storedSessionsNonce, setStoredSessionsNonce] = useState(0);
|
|
448
|
+
const [mediaLightbox, setMediaLightbox] = useState(null);
|
|
449
|
+
const [confirmDialog, setConfirmDialog] = useState(null);
|
|
450
|
+
const [reviewTitleModalOpen, setReviewTitleModalOpen] = useState(false);
|
|
451
|
+
const [reviewTitleInput, setReviewTitleInput] = useState('');
|
|
452
|
+
const [reviewTitleError, setReviewTitleError] = useState(null);
|
|
453
|
+
const reviewTitleInputRef = useRef(null);
|
|
454
|
+
const [sharedSessionWelcomeOpen, setSharedSessionWelcomeOpen] = useState(false);
|
|
455
|
+
const sharedSessionWelcomeDismissRef = useRef(null);
|
|
456
|
+
const { state, selectedComponent, selectedComponentId, error, startInspecting, stopInspecting, clearSelection, inspectAnother, } = useComponentSelection(embeddableId);
|
|
457
|
+
useEffect(() => {
|
|
458
|
+
setSelectedFeedbackTag('');
|
|
459
|
+
}, [selectedComponentId]);
|
|
460
|
+
const descriptionRef = useRef(null);
|
|
461
|
+
const savvy = window.Savvy;
|
|
462
|
+
const isReviewMode = !!(activeReview?.reviewId && activeReview?.projectId);
|
|
463
|
+
/** Inspector + form share the row with comments; review-only / post-upload view uses full width for the list. */
|
|
464
|
+
const feedbackSplitLayout = sessionActive && !isReviewMode && !sessionUploadComplete;
|
|
465
|
+
const displayedComments = useMemo(() => {
|
|
466
|
+
const flow = window.Savvy?.getFlowJson?.(embeddableId);
|
|
467
|
+
return sortFeedbackCommentsForDisplay([...comments], flow);
|
|
468
|
+
}, [comments, embeddableId]);
|
|
469
|
+
const feedbackFlowJson = useMemo(() => window.Savvy?.getFlowJson?.(embeddableId), [embeddableId, comments]);
|
|
470
|
+
const sharedSessionWelcomeStats = useMemo(() => {
|
|
471
|
+
const flow = window.Savvy?.getFlowJson?.(embeddableId);
|
|
472
|
+
return computeSharedSessionWelcomeStats(comments, flow);
|
|
473
|
+
}, [comments, embeddableId]);
|
|
474
|
+
const shareableBucketPair = useMemo(() => {
|
|
475
|
+
if (activeReview?.reviewId && activeReview?.projectId) {
|
|
476
|
+
return { reviewId: activeReview.reviewId, projectId: activeReview.projectId };
|
|
477
|
+
}
|
|
478
|
+
return savedToBucketPair;
|
|
479
|
+
}, [activeReview, savedToBucketPair]);
|
|
480
|
+
// When switching embeddable / workbench props, re-resolve (URL overrides props)
|
|
481
|
+
useEffect(() => {
|
|
482
|
+
shareLinkLaunchRef.current =
|
|
483
|
+
typeof window !== 'undefined' &&
|
|
484
|
+
new URLSearchParams(window.location.search).has('feedbackReview');
|
|
485
|
+
const fromUrl = readActiveReviewFromUrl();
|
|
486
|
+
if (fromUrl) {
|
|
487
|
+
clearFeedbackDismissed(embeddableId);
|
|
488
|
+
setActiveReview(fromUrl);
|
|
489
|
+
return;
|
|
490
|
+
}
|
|
491
|
+
setActiveReview(resolveActiveReview(embeddableId, feedbackReviewId, feedbackProjectId));
|
|
492
|
+
}, [embeddableId, feedbackReviewId, feedbackProjectId]);
|
|
493
|
+
useEffect(() => {
|
|
494
|
+
setSavedToBucketPair(null);
|
|
495
|
+
setSessionUploadComplete(false);
|
|
496
|
+
}, [embeddableId]);
|
|
497
|
+
// After selecting a component, move focus to the description field
|
|
498
|
+
useEffect(() => {
|
|
499
|
+
if (!sessionActive || isReviewMode || sessionUploadComplete)
|
|
500
|
+
return;
|
|
501
|
+
if (state === 'selected' && selectedComponentId) {
|
|
502
|
+
descriptionRef.current?.focus();
|
|
503
|
+
}
|
|
504
|
+
}, [sessionActive, isReviewMode, sessionUploadComplete, state, selectedComponentId]);
|
|
505
|
+
useEffect(() => {
|
|
506
|
+
if (reviewTitleModalOpen) {
|
|
507
|
+
requestAnimationFrame(() => reviewTitleInputRef.current?.focus());
|
|
508
|
+
}
|
|
509
|
+
}, [reviewTitleModalOpen]);
|
|
510
|
+
useEffect(() => {
|
|
511
|
+
if (sharedSessionWelcomeOpen) {
|
|
512
|
+
requestAnimationFrame(() => sharedSessionWelcomeDismissRef.current?.focus());
|
|
513
|
+
}
|
|
514
|
+
}, [sharedSessionWelcomeOpen]);
|
|
515
|
+
// Load review JSON when a review is selected (URL or in-panel)
|
|
516
|
+
useEffect(() => {
|
|
517
|
+
if (!activeReview?.reviewId || !activeReview?.projectId) {
|
|
518
|
+
setLoadingReview(false);
|
|
519
|
+
setLoadedReview(null);
|
|
520
|
+
setComments([]);
|
|
521
|
+
setLoadError(null);
|
|
522
|
+
return;
|
|
523
|
+
}
|
|
524
|
+
let cancelled = false;
|
|
525
|
+
setLoadError(null);
|
|
526
|
+
setLoadingReview(true);
|
|
527
|
+
fetchReview(activeReview.reviewId, activeReview.projectId)
|
|
528
|
+
.then((review) => {
|
|
529
|
+
if (!cancelled && review) {
|
|
530
|
+
setLoadedReview(review);
|
|
531
|
+
setComments(review.comments);
|
|
532
|
+
}
|
|
533
|
+
else if (!cancelled && !review) {
|
|
534
|
+
setLoadError('Feedback review not found or could not be loaded.');
|
|
535
|
+
}
|
|
536
|
+
})
|
|
537
|
+
.finally(() => {
|
|
538
|
+
if (!cancelled)
|
|
539
|
+
setLoadingReview(false);
|
|
540
|
+
});
|
|
541
|
+
return () => {
|
|
542
|
+
cancelled = true;
|
|
543
|
+
};
|
|
544
|
+
}, [activeReview?.reviewId, activeReview?.projectId]);
|
|
545
|
+
// Remember share-link opens (initial URL with feedbackReview) for Shared With Me
|
|
546
|
+
useEffect(() => {
|
|
547
|
+
if (!activeReview || !loadedReview)
|
|
548
|
+
return;
|
|
549
|
+
if (!shareLinkLaunchRef.current)
|
|
550
|
+
return;
|
|
551
|
+
appendSharedFeedbackSession({
|
|
552
|
+
reviewId: activeReview.reviewId,
|
|
553
|
+
projectId: activeReview.projectId,
|
|
554
|
+
embeddableId: loadedReview.embeddableId,
|
|
555
|
+
});
|
|
556
|
+
shareLinkLaunchRef.current = false;
|
|
557
|
+
}, [activeReview, loadedReview]);
|
|
558
|
+
// Shared feedback URL: move the preview to the first comment's page (same order as the comment list).
|
|
559
|
+
useEffect(() => {
|
|
560
|
+
if (!jumpToFirstShareCommentRef.current || !loadedReview || comments.length === 0) {
|
|
561
|
+
if (loadedReview && comments.length === 0)
|
|
562
|
+
jumpToFirstShareCommentRef.current = false;
|
|
563
|
+
return;
|
|
564
|
+
}
|
|
565
|
+
if (loadedReview.embeddableId !== embeddableId) {
|
|
566
|
+
jumpToFirstShareCommentRef.current = false;
|
|
567
|
+
return;
|
|
568
|
+
}
|
|
569
|
+
const flow = window.Savvy?.getFlowJson?.(embeddableId);
|
|
570
|
+
const sorted = sortFeedbackCommentsForDisplay([...comments], flow);
|
|
571
|
+
const first = sorted[0];
|
|
572
|
+
if (!first?.pageId || first.pageId === 'unknown') {
|
|
573
|
+
jumpToFirstShareCommentRef.current = false;
|
|
574
|
+
return;
|
|
575
|
+
}
|
|
576
|
+
let cancelled = false;
|
|
577
|
+
let attempts = 0;
|
|
578
|
+
const tryNav = () => {
|
|
579
|
+
if (cancelled || !jumpToFirstShareCommentRef.current)
|
|
580
|
+
return;
|
|
581
|
+
const s = window.Savvy;
|
|
582
|
+
if (!s?.goToPage) {
|
|
583
|
+
attempts++;
|
|
584
|
+
if (attempts < 40)
|
|
585
|
+
setTimeout(tryNav, 100);
|
|
586
|
+
else
|
|
587
|
+
jumpToFirstShareCommentRef.current = false;
|
|
588
|
+
return;
|
|
589
|
+
}
|
|
590
|
+
const already = s.getUserData?.(embeddableId)?.current_page_id;
|
|
591
|
+
if (already === first.pageId) {
|
|
592
|
+
jumpToFirstShareCommentRef.current = false;
|
|
593
|
+
return;
|
|
594
|
+
}
|
|
595
|
+
s.goToPage(embeddableId, first.pageId);
|
|
596
|
+
jumpToFirstShareCommentRef.current = false;
|
|
597
|
+
};
|
|
598
|
+
tryNav();
|
|
599
|
+
return () => {
|
|
600
|
+
cancelled = true;
|
|
601
|
+
};
|
|
602
|
+
}, [loadedReview, comments, embeddableId]);
|
|
603
|
+
// Full share URL: show a one-time modal explaining the session + title + recorded date.
|
|
604
|
+
useEffect(() => {
|
|
605
|
+
if (!sharedLinkWelcomePendingRef.current || !loadedReview)
|
|
606
|
+
return;
|
|
607
|
+
if (loadedReview.embeddableId !== embeddableId) {
|
|
608
|
+
sharedLinkWelcomePendingRef.current = false;
|
|
609
|
+
return;
|
|
610
|
+
}
|
|
611
|
+
setSharedSessionWelcomeOpen(true);
|
|
612
|
+
sharedLinkWelcomePendingRef.current = false;
|
|
613
|
+
}, [loadedReview, embeddableId]);
|
|
614
|
+
const closeSharedSessionWelcome = useCallback(() => {
|
|
615
|
+
setSharedSessionWelcomeOpen(false);
|
|
616
|
+
}, []);
|
|
617
|
+
// Load saved / shared session refs from localStorage and hydrate from the storage bucket
|
|
618
|
+
useEffect(() => {
|
|
619
|
+
if (isReviewMode)
|
|
620
|
+
return;
|
|
621
|
+
let cancelled = false;
|
|
622
|
+
const myRefs = readMyFeedbackSessionRefs().filter((r) => r.embeddableId === embeddableId);
|
|
623
|
+
const sharedRefs = readSharedFeedbackSessionRefs().filter((r) => r.embeddableId === embeddableId);
|
|
624
|
+
setMySessionRows(myRefs.map((ref) => ({ ref, review: null, status: 'loading' })));
|
|
625
|
+
setSharedSessionRows(sharedRefs.map((ref) => ({ ref, review: null, status: 'loading' })));
|
|
626
|
+
void (async () => {
|
|
627
|
+
const loadRows = async (refs) => Promise.all(refs.map(async (ref) => {
|
|
628
|
+
const review = await fetchReview(ref.reviewId, ref.projectId);
|
|
629
|
+
return {
|
|
630
|
+
ref,
|
|
631
|
+
review,
|
|
632
|
+
status: review ? 'ok' : 'error',
|
|
633
|
+
};
|
|
634
|
+
}));
|
|
635
|
+
const [myLoaded, sharedLoaded] = await Promise.all([loadRows(myRefs), loadRows(sharedRefs)]);
|
|
636
|
+
if (!cancelled) {
|
|
637
|
+
setMySessionRows(myLoaded);
|
|
638
|
+
setSharedSessionRows(sharedLoaded);
|
|
639
|
+
}
|
|
640
|
+
})();
|
|
641
|
+
return () => {
|
|
642
|
+
cancelled = true;
|
|
643
|
+
};
|
|
644
|
+
}, [isReviewMode, embeddableId, storedSessionsNonce]);
|
|
645
|
+
const handleStartSession = useCallback(() => {
|
|
646
|
+
setSavedToBucketPair(null);
|
|
647
|
+
setSessionUploadComplete(false);
|
|
648
|
+
setSessionActive(true);
|
|
649
|
+
setComments([]);
|
|
650
|
+
startInspecting();
|
|
651
|
+
}, [startInspecting]);
|
|
652
|
+
/** Capture screenshots at submit time based on toggles. */
|
|
653
|
+
const captureScreenshots = useCallback(async () => {
|
|
654
|
+
const screenshots = [];
|
|
655
|
+
if (includeViewportScreenshot) {
|
|
656
|
+
try {
|
|
657
|
+
const canvas = await html2canvas(document.body, {
|
|
658
|
+
useCORS: true,
|
|
659
|
+
allowTaint: true,
|
|
660
|
+
logging: false,
|
|
661
|
+
// Exclude workbench panel from viewport screenshot
|
|
662
|
+
ignoreElements: (el) => el.id === '__embeddables_workbench_root' ||
|
|
663
|
+
!!el.closest('#__embeddables_workbench_root'),
|
|
664
|
+
});
|
|
665
|
+
screenshots.push({
|
|
666
|
+
type: 'screenshot',
|
|
667
|
+
data: canvas.toDataURL('image/png'),
|
|
668
|
+
filename: 'viewport.png',
|
|
669
|
+
});
|
|
670
|
+
}
|
|
671
|
+
catch (err) {
|
|
672
|
+
console.error('[Feedback] Viewport screenshot failed:', err);
|
|
673
|
+
}
|
|
674
|
+
}
|
|
675
|
+
if (includeComponentScreenshot && selectedComponentId) {
|
|
676
|
+
const el = document.querySelector(`.cid-${selectedComponentId}`);
|
|
677
|
+
if (el) {
|
|
678
|
+
try {
|
|
679
|
+
const canvas = await html2canvas(el, {
|
|
680
|
+
useCORS: true,
|
|
681
|
+
allowTaint: true,
|
|
682
|
+
logging: false,
|
|
683
|
+
});
|
|
684
|
+
screenshots.push({
|
|
685
|
+
type: 'screenshot',
|
|
686
|
+
data: canvas.toDataURL('image/png'),
|
|
687
|
+
filename: `component-${selectedComponentId}.png`,
|
|
688
|
+
});
|
|
689
|
+
}
|
|
690
|
+
catch (err) {
|
|
691
|
+
console.error('[Feedback] Component screenshot failed:', err);
|
|
692
|
+
}
|
|
693
|
+
}
|
|
694
|
+
}
|
|
695
|
+
return screenshots;
|
|
696
|
+
}, [includeViewportScreenshot, includeComponentScreenshot, selectedComponentId]);
|
|
697
|
+
const handleFileAttach = useCallback((e) => {
|
|
698
|
+
const files = e.target.files;
|
|
699
|
+
if (!files?.length)
|
|
700
|
+
return;
|
|
701
|
+
const file = files[0];
|
|
702
|
+
const reader = new FileReader();
|
|
703
|
+
reader.onload = () => {
|
|
704
|
+
const data = reader.result;
|
|
705
|
+
setAttachments((prev) => [...prev, { type: 'file', data, filename: file.name }]);
|
|
706
|
+
};
|
|
707
|
+
reader.readAsDataURL(file);
|
|
708
|
+
e.target.value = '';
|
|
709
|
+
}, []);
|
|
710
|
+
const removeAttachment = useCallback((index) => {
|
|
711
|
+
setAttachments((prev) => prev.filter((_, i) => i !== index));
|
|
712
|
+
}, []);
|
|
713
|
+
const handleSubmitComment = useCallback(async () => {
|
|
714
|
+
if (sessionUploadComplete)
|
|
715
|
+
return;
|
|
716
|
+
const blocked = !commentText.trim() &&
|
|
717
|
+
attachments.length === 0 &&
|
|
718
|
+
!includeViewportScreenshot &&
|
|
719
|
+
!(includeComponentScreenshot && !!selectedComponentId);
|
|
720
|
+
if (blocked || isSubmittingCommentRef.current)
|
|
721
|
+
return;
|
|
722
|
+
isSubmittingCommentRef.current = true;
|
|
723
|
+
setIsSubmittingComment(true);
|
|
724
|
+
try {
|
|
725
|
+
const screenshotAttachments = await captureScreenshots();
|
|
726
|
+
const allAttachments = [...attachments, ...screenshotAttachments];
|
|
727
|
+
if (!commentText.trim() && allAttachments.length === 0)
|
|
728
|
+
return;
|
|
729
|
+
if (!savvy?.getUserData || !savvy?.getFlowJson) {
|
|
730
|
+
setSubmitError('Embeddable runtime is not ready. Please wait for the page to load.');
|
|
731
|
+
return;
|
|
732
|
+
}
|
|
733
|
+
setSubmitError(null);
|
|
734
|
+
const userData = savvy.getUserData(embeddableId);
|
|
735
|
+
const flowJson = savvy.getFlowJson(embeddableId);
|
|
736
|
+
const pageKey = userData?.current_page_key ?? 'unknown';
|
|
737
|
+
const pageId = userData?.current_page_id ?? 'unknown';
|
|
738
|
+
const tag = selectedComponentId &&
|
|
739
|
+
selectedComponent?.tags &&
|
|
740
|
+
selectedComponent.tags.length > 0 &&
|
|
741
|
+
selectedFeedbackTag.trim()
|
|
742
|
+
? selectedFeedbackTag.trim()
|
|
743
|
+
: undefined;
|
|
744
|
+
const comment = {
|
|
745
|
+
id: crypto.randomUUID(),
|
|
746
|
+
pageKey,
|
|
747
|
+
pageId,
|
|
748
|
+
componentKey: selectedComponent?.key,
|
|
749
|
+
componentId: selectedComponentId ?? undefined,
|
|
750
|
+
componentType: selectedComponent?.type,
|
|
751
|
+
feedbackTag: tag,
|
|
752
|
+
comment: commentText.trim(),
|
|
753
|
+
attachments: allAttachments,
|
|
754
|
+
userData: userData,
|
|
755
|
+
metadata: {
|
|
756
|
+
timestamp: new Date().toISOString(),
|
|
757
|
+
viewportWidth: window.innerWidth,
|
|
758
|
+
viewportHeight: window.innerHeight,
|
|
759
|
+
},
|
|
760
|
+
};
|
|
761
|
+
setComments((prev) => [...prev, comment]);
|
|
762
|
+
setCommentText('');
|
|
763
|
+
setAttachments([]);
|
|
764
|
+
setIncludeViewportScreenshot(true);
|
|
765
|
+
setIncludeComponentScreenshot(true);
|
|
766
|
+
clearSelection();
|
|
767
|
+
startInspecting();
|
|
768
|
+
}
|
|
769
|
+
finally {
|
|
770
|
+
isSubmittingCommentRef.current = false;
|
|
771
|
+
setIsSubmittingComment(false);
|
|
772
|
+
}
|
|
773
|
+
}, [
|
|
774
|
+
commentText,
|
|
775
|
+
attachments,
|
|
776
|
+
selectedComponent,
|
|
777
|
+
selectedComponentId,
|
|
778
|
+
embeddableId,
|
|
779
|
+
savvy,
|
|
780
|
+
clearSelection,
|
|
781
|
+
startInspecting,
|
|
782
|
+
captureScreenshots,
|
|
783
|
+
includeViewportScreenshot,
|
|
784
|
+
includeComponentScreenshot,
|
|
785
|
+
selectedFeedbackTag,
|
|
786
|
+
sessionUploadComplete,
|
|
787
|
+
]);
|
|
788
|
+
const performCompleteReviewUpload = useCallback(async (title) => {
|
|
789
|
+
if (comments.length === 0)
|
|
790
|
+
return;
|
|
791
|
+
if (!savvy?.getFlowJson)
|
|
792
|
+
return;
|
|
793
|
+
setCompleteError(null);
|
|
794
|
+
setIsUploading(true);
|
|
795
|
+
const flowJson = savvy.getFlowJson(embeddableId);
|
|
796
|
+
// Prefer injected projectId (from embeddables.json in dev), then flow, else fallback
|
|
797
|
+
const projectId = injectedProjectId ?? flowJson?.project_id ?? '_unknown';
|
|
798
|
+
const reviewId = crypto.randomUUID();
|
|
799
|
+
const baseUrl = window.location.href.split('?')[0];
|
|
800
|
+
const review = {
|
|
801
|
+
id: reviewId,
|
|
802
|
+
embeddableId,
|
|
803
|
+
projectId,
|
|
804
|
+
baseUrl,
|
|
805
|
+
comments,
|
|
806
|
+
createdAt: new Date().toISOString(),
|
|
807
|
+
title,
|
|
808
|
+
};
|
|
809
|
+
try {
|
|
810
|
+
const supabase = getSupabaseBrowserClient();
|
|
811
|
+
// Store in project_id folder: feedback_reviews/{projectId}/{reviewId}.json
|
|
812
|
+
const storagePath = `${projectId}/${reviewId}.json`;
|
|
813
|
+
const { error: uploadError } = await supabase.storage
|
|
814
|
+
.from(FEEDBACK_BUCKET)
|
|
815
|
+
.upload(storagePath, JSON.stringify(review), {
|
|
816
|
+
contentType: 'application/json',
|
|
817
|
+
upsert: false,
|
|
818
|
+
});
|
|
819
|
+
if (uploadError) {
|
|
820
|
+
setCompleteError(uploadError.message);
|
|
821
|
+
return;
|
|
822
|
+
}
|
|
823
|
+
appendMyFeedbackSession({ reviewId, projectId, embeddableId });
|
|
824
|
+
setStoredSessionsNonce((n) => n + 1);
|
|
825
|
+
setSavedToBucketPair({ reviewId, projectId });
|
|
826
|
+
setSessionUploadComplete(true);
|
|
827
|
+
clearSelection();
|
|
828
|
+
stopInspecting();
|
|
829
|
+
const shareUrl = buildShareableUrl(reviewId, projectId);
|
|
830
|
+
try {
|
|
831
|
+
await navigator.clipboard.writeText(shareUrl);
|
|
832
|
+
setCopiedUrl(true);
|
|
833
|
+
setTimeout(() => setCopiedUrl(false), 3000);
|
|
834
|
+
}
|
|
835
|
+
catch {
|
|
836
|
+
setCompleteError(`Review saved! Copy this URL: ${shareUrl}`);
|
|
837
|
+
}
|
|
838
|
+
}
|
|
839
|
+
catch (err) {
|
|
840
|
+
setCompleteError(err instanceof Error ? err.message : String(err));
|
|
841
|
+
}
|
|
842
|
+
finally {
|
|
843
|
+
setIsUploading(false);
|
|
844
|
+
}
|
|
845
|
+
}, [comments, embeddableId, savvy, injectedProjectId, clearSelection, stopInspecting]);
|
|
846
|
+
const openCompleteReviewModal = useCallback(() => {
|
|
847
|
+
if (sessionUploadComplete)
|
|
848
|
+
return;
|
|
849
|
+
if (comments.length === 0)
|
|
850
|
+
return;
|
|
851
|
+
if (!savvy?.getFlowJson)
|
|
852
|
+
return;
|
|
853
|
+
setReviewTitleError(null);
|
|
854
|
+
setReviewTitleInput(defaultFeedbackReviewTitle());
|
|
855
|
+
setReviewTitleModalOpen(true);
|
|
856
|
+
}, [sessionUploadComplete, comments.length, savvy, embeddableId]);
|
|
857
|
+
const closeReviewTitleModal = useCallback(() => {
|
|
858
|
+
setReviewTitleModalOpen(false);
|
|
859
|
+
setReviewTitleError(null);
|
|
860
|
+
}, []);
|
|
861
|
+
const submitReviewTitleModal = useCallback(() => {
|
|
862
|
+
const trimmed = reviewTitleInput.trim();
|
|
863
|
+
if (!trimmed) {
|
|
864
|
+
setReviewTitleError('Please enter a title for this feedback session.');
|
|
865
|
+
return;
|
|
866
|
+
}
|
|
867
|
+
setReviewTitleModalOpen(false);
|
|
868
|
+
setReviewTitleError(null);
|
|
869
|
+
void performCompleteReviewUpload(trimmed);
|
|
870
|
+
}, [reviewTitleInput, performCompleteReviewUpload]);
|
|
871
|
+
const handleCopyAiPrompt = useCallback(async () => {
|
|
872
|
+
if (comments.length === 0)
|
|
873
|
+
return;
|
|
874
|
+
setSubmitError(null);
|
|
875
|
+
const text = buildFeedbackAiPrompt({
|
|
876
|
+
embeddableId,
|
|
877
|
+
loadedReview,
|
|
878
|
+
comments,
|
|
879
|
+
flow: feedbackFlowJson,
|
|
880
|
+
});
|
|
881
|
+
try {
|
|
882
|
+
await navigator.clipboard.writeText(text);
|
|
883
|
+
setCopiedPrompt(true);
|
|
884
|
+
setTimeout(() => setCopiedPrompt(false), 2500);
|
|
885
|
+
}
|
|
886
|
+
catch {
|
|
887
|
+
setSubmitError('Could not copy prompt to clipboard.');
|
|
888
|
+
}
|
|
889
|
+
}, [comments, embeddableId, loadedReview, feedbackFlowJson]);
|
|
890
|
+
const handleCopyShareableUrl = useCallback(async () => {
|
|
891
|
+
if (!shareableBucketPair)
|
|
892
|
+
return;
|
|
893
|
+
setSubmitError(null);
|
|
894
|
+
const shareUrl = buildShareableUrl(shareableBucketPair.reviewId, shareableBucketPair.projectId);
|
|
895
|
+
try {
|
|
896
|
+
await navigator.clipboard.writeText(shareUrl);
|
|
897
|
+
setCopiedShareUrl(true);
|
|
898
|
+
setTimeout(() => setCopiedShareUrl(false), 2500);
|
|
899
|
+
}
|
|
900
|
+
catch {
|
|
901
|
+
setSubmitError('Could not copy share link to clipboard.');
|
|
902
|
+
}
|
|
903
|
+
}, [shareableBucketPair]);
|
|
904
|
+
const handleJumpToComment = useCallback((c) => {
|
|
905
|
+
const w = window;
|
|
906
|
+
const s = w.Savvy;
|
|
907
|
+
const userData = s?.getUserData?.(embeddableId);
|
|
908
|
+
const currentPageId = userData?.current_page_id ?? '';
|
|
909
|
+
const targetPageId = c.pageId && c.pageId !== 'unknown' ? c.pageId : '';
|
|
910
|
+
const needsPageChange = Boolean(targetPageId && targetPageId !== currentPageId);
|
|
911
|
+
if (needsPageChange && s?.goToPage) {
|
|
912
|
+
s.goToPage(embeddableId, targetPageId);
|
|
913
|
+
if (c.componentId)
|
|
914
|
+
scrollToComponentWithRetry(c.componentId);
|
|
915
|
+
return;
|
|
916
|
+
}
|
|
917
|
+
if (c.componentId) {
|
|
918
|
+
scrollToComponent(c.componentId);
|
|
919
|
+
}
|
|
920
|
+
}, [embeddableId]);
|
|
921
|
+
const openFeedbackAttachmentLightbox = useCallback((comment, attachmentIndex) => {
|
|
922
|
+
const imageEntries = comment.attachments
|
|
923
|
+
.map((a, i) => ({ attachment: a, index: i }))
|
|
924
|
+
.filter(({ attachment }) => isImageAttachmentData(attachment.data));
|
|
925
|
+
const pos = imageEntries.findIndex(({ index }) => index === attachmentIndex);
|
|
926
|
+
if (pos < 0)
|
|
927
|
+
return;
|
|
928
|
+
setMediaLightbox({
|
|
929
|
+
items: imageEntries.map(({ attachment }) => ({
|
|
930
|
+
src: attachment.data,
|
|
931
|
+
alt: attachment.filename ?? 'Attachment',
|
|
932
|
+
caption: attachment.filename,
|
|
933
|
+
})),
|
|
934
|
+
initialIndex: pos,
|
|
935
|
+
});
|
|
936
|
+
}, []);
|
|
937
|
+
const handleDiscardSession = useCallback(() => {
|
|
938
|
+
const finishDiscard = () => {
|
|
939
|
+
setSavedToBucketPair(null);
|
|
940
|
+
setSessionUploadComplete(false);
|
|
941
|
+
setSessionActive(false);
|
|
942
|
+
setComments([]);
|
|
943
|
+
setCommentText('');
|
|
944
|
+
setAttachments([]);
|
|
945
|
+
setSelectedFeedbackTag('');
|
|
946
|
+
setSubmitError(null);
|
|
947
|
+
clearSelection();
|
|
948
|
+
stopInspecting();
|
|
949
|
+
};
|
|
950
|
+
if (sessionUploadComplete) {
|
|
951
|
+
finishDiscard();
|
|
952
|
+
return;
|
|
953
|
+
}
|
|
954
|
+
if (comments.length > 0) {
|
|
955
|
+
setConfirmDialog({
|
|
956
|
+
title: 'Discard session?',
|
|
957
|
+
message: 'All comments in this session will be removed. Nothing is saved to the cloud until you complete a review.',
|
|
958
|
+
confirmLabel: 'Discard',
|
|
959
|
+
variant: 'danger',
|
|
960
|
+
onConfirm: () => {
|
|
961
|
+
setConfirmDialog(null);
|
|
962
|
+
finishDiscard();
|
|
963
|
+
},
|
|
964
|
+
});
|
|
965
|
+
return;
|
|
966
|
+
}
|
|
967
|
+
finishDiscard();
|
|
968
|
+
}, [comments.length, sessionUploadComplete, clearSelection, stopInspecting]);
|
|
969
|
+
const openReviewInPanel = useCallback((reviewId, projectId) => {
|
|
970
|
+
setLoadError(null);
|
|
971
|
+
shareLinkLaunchRef.current = false;
|
|
972
|
+
clearFeedbackDismissed(embeddableId);
|
|
973
|
+
setSavedToBucketPair(null);
|
|
974
|
+
setActiveReview({ reviewId, projectId });
|
|
975
|
+
}, [embeddableId]);
|
|
976
|
+
const handleRemoveMySessionFromList = useCallback((reviewId, projectId) => {
|
|
977
|
+
setConfirmDialog({
|
|
978
|
+
title: 'Remove from list?',
|
|
979
|
+
message: 'Remove this session from your list? The review file in cloud storage is not deleted.',
|
|
980
|
+
confirmLabel: 'Remove',
|
|
981
|
+
variant: 'danger',
|
|
982
|
+
onConfirm: () => {
|
|
983
|
+
setConfirmDialog(null);
|
|
984
|
+
removeMyFeedbackSession(reviewId, projectId);
|
|
985
|
+
setStoredSessionsNonce((n) => n + 1);
|
|
986
|
+
},
|
|
987
|
+
});
|
|
988
|
+
}, []);
|
|
989
|
+
const handleRemoveSharedSessionFromList = useCallback((reviewId, projectId) => {
|
|
990
|
+
setConfirmDialog({
|
|
991
|
+
title: 'Remove from list?',
|
|
992
|
+
message: 'Remove this session from your shared list? The review file in cloud storage is not deleted.',
|
|
993
|
+
confirmLabel: 'Remove',
|
|
994
|
+
variant: 'danger',
|
|
995
|
+
onConfirm: () => {
|
|
996
|
+
setConfirmDialog(null);
|
|
997
|
+
removeSharedFeedbackSession(reviewId, projectId);
|
|
998
|
+
setStoredSessionsNonce((n) => n + 1);
|
|
999
|
+
},
|
|
1000
|
+
});
|
|
1001
|
+
}, []);
|
|
1002
|
+
const handleBackFromReview = useCallback(() => {
|
|
1003
|
+
const dirty = loadedReview != null && JSON.stringify(comments) !== JSON.stringify(loadedReview.comments);
|
|
1004
|
+
const proceedBack = () => {
|
|
1005
|
+
if (activeReview) {
|
|
1006
|
+
try {
|
|
1007
|
+
window.sessionStorage.setItem(feedbackBackDismissKey(embeddableId), `${activeReview.reviewId}:${activeReview.projectId}`);
|
|
1008
|
+
}
|
|
1009
|
+
catch {
|
|
1010
|
+
// ignore
|
|
1011
|
+
}
|
|
1012
|
+
}
|
|
1013
|
+
setActiveReview(null);
|
|
1014
|
+
setLoadedReview(null);
|
|
1015
|
+
setComments([]);
|
|
1016
|
+
setLoadError(null);
|
|
1017
|
+
setSavedToBucketPair(null);
|
|
1018
|
+
stripFeedbackParamsFromUrl();
|
|
1019
|
+
};
|
|
1020
|
+
if (dirty) {
|
|
1021
|
+
setConfirmDialog({
|
|
1022
|
+
title: 'Leave review?',
|
|
1023
|
+
message: 'You have unsaved changes. Leave this review?',
|
|
1024
|
+
confirmLabel: 'Leave',
|
|
1025
|
+
variant: 'danger',
|
|
1026
|
+
onConfirm: () => {
|
|
1027
|
+
setConfirmDialog(null);
|
|
1028
|
+
proceedBack();
|
|
1029
|
+
},
|
|
1030
|
+
});
|
|
1031
|
+
return;
|
|
1032
|
+
}
|
|
1033
|
+
proceedBack();
|
|
1034
|
+
}, [loadedReview, comments, activeReview, embeddableId]);
|
|
1035
|
+
const isAddCommentDisabled = isSubmittingComment ||
|
|
1036
|
+
(!commentText.trim() &&
|
|
1037
|
+
attachments.length === 0 &&
|
|
1038
|
+
!includeViewportScreenshot &&
|
|
1039
|
+
!(includeComponentScreenshot && !!selectedComponentId));
|
|
1040
|
+
const handleDescriptionKeyDown = useCallback((e) => {
|
|
1041
|
+
if (e.key !== 'Enter' || e.shiftKey)
|
|
1042
|
+
return;
|
|
1043
|
+
if (e.nativeEvent.isComposing)
|
|
1044
|
+
return;
|
|
1045
|
+
e.preventDefault();
|
|
1046
|
+
if (isAddCommentDisabled)
|
|
1047
|
+
return;
|
|
1048
|
+
void handleSubmitComment();
|
|
1049
|
+
}, [handleSubmitComment, isAddCommentDisabled]);
|
|
1050
|
+
const handleDescriptionPaste = useCallback((e) => {
|
|
1051
|
+
if (isSubmittingComment)
|
|
1052
|
+
return;
|
|
1053
|
+
const items = e.clipboardData?.items;
|
|
1054
|
+
if (!items?.length)
|
|
1055
|
+
return;
|
|
1056
|
+
const imageFiles = [];
|
|
1057
|
+
for (let i = 0; i < items.length; i++) {
|
|
1058
|
+
const item = items[i];
|
|
1059
|
+
if (item.kind === 'file' && item.type.startsWith('image/')) {
|
|
1060
|
+
const f = item.getAsFile();
|
|
1061
|
+
if (f)
|
|
1062
|
+
imageFiles.push(f);
|
|
1063
|
+
}
|
|
1064
|
+
}
|
|
1065
|
+
if (imageFiles.length === 0)
|
|
1066
|
+
return;
|
|
1067
|
+
e.preventDefault();
|
|
1068
|
+
const readAsDataUrl = (file) => new Promise((resolve, reject) => {
|
|
1069
|
+
const reader = new FileReader();
|
|
1070
|
+
reader.onload = () => resolve(reader.result);
|
|
1071
|
+
reader.onerror = () => reject(reader.error);
|
|
1072
|
+
reader.readAsDataURL(file);
|
|
1073
|
+
});
|
|
1074
|
+
const extForMime = (mime) => {
|
|
1075
|
+
if (mime === 'image/jpeg')
|
|
1076
|
+
return 'jpg';
|
|
1077
|
+
if (mime === 'image/png')
|
|
1078
|
+
return 'png';
|
|
1079
|
+
if (mime === 'image/gif')
|
|
1080
|
+
return 'gif';
|
|
1081
|
+
if (mime === 'image/webp')
|
|
1082
|
+
return 'webp';
|
|
1083
|
+
if (mime === 'image/svg+xml')
|
|
1084
|
+
return 'svg';
|
|
1085
|
+
return 'png';
|
|
1086
|
+
};
|
|
1087
|
+
void (async () => {
|
|
1088
|
+
const next = [];
|
|
1089
|
+
for (let i = 0; i < imageFiles.length; i++) {
|
|
1090
|
+
const file = imageFiles[i];
|
|
1091
|
+
const data = await readAsDataUrl(file);
|
|
1092
|
+
const ext = extForMime(file.type);
|
|
1093
|
+
const filename = imageFiles.length > 1 ? `pasted-image-${i + 1}.${ext}` : `pasted-image.${ext}`;
|
|
1094
|
+
next.push({ type: 'file', data, filename });
|
|
1095
|
+
}
|
|
1096
|
+
setAttachments((prev) => [...prev, ...next]);
|
|
1097
|
+
})();
|
|
1098
|
+
}, [isSubmittingComment]);
|
|
1099
|
+
if (loadingReview) {
|
|
1100
|
+
return (_jsxs("div", { className: "flex h-full flex-col items-center justify-center gap-4", children: [_jsxs("div", { className: "flex items-center gap-2 text-sm text-slate-300", children: [_jsx(FontAwesomeIcon, { icon: faSpinner, spin: true, className: "h-4 w-4 text-sky-400" }), "Loading feedback review\u2026"] }), _jsxs("button", { type: "button", onClick: handleBackFromReview, className: "inline-flex cursor-pointer items-center gap-1.5 rounded-lg bg-white/5 px-3 py-1.5 text-xs font-semibold text-slate-200 ring-1 ring-inset ring-white/15 transition-colors hover:bg-white/10", children: [_jsx(FontAwesomeIcon, { icon: faArrowLeft, className: "h-3 w-3" }), "Back"] })] }));
|
|
1101
|
+
}
|
|
1102
|
+
if (loadError && isReviewMode) {
|
|
1103
|
+
return (_jsxs("div", { className: "flex h-full flex-col items-center justify-center gap-3", children: [_jsxs("div", { className: "flex max-w-md items-start gap-2 rounded-xl bg-rose-500/10 px-4 py-3 text-sm text-rose-200 ring-1 ring-inset ring-rose-500/20", children: [_jsx(FontAwesomeIcon, { icon: faCircleExclamation, className: "mt-0.5 h-4 w-4 shrink-0 text-rose-300" }), _jsx("span", { children: loadError })] }), _jsxs("button", { type: "button", onClick: handleBackFromReview, className: "inline-flex cursor-pointer items-center gap-1.5 rounded-lg bg-white/5 px-3 py-1.5 text-xs font-semibold text-slate-200 ring-1 ring-inset ring-white/15 transition-colors hover:bg-white/10", children: [_jsx(FontAwesomeIcon, { icon: faArrowLeft, className: "h-3 w-3" }), "Back"] })] }));
|
|
1104
|
+
}
|
|
1105
|
+
return (_jsxs("div", { className: "mx-auto flex h-full min-h-0 w-full max-w-6xl flex-col gap-3", children: [_jsxs("div", { className: "flex flex-wrap items-center justify-between gap-2", children: [_jsxs("div", { className: "flex flex-wrap items-center gap-2", children: [(sessionActive || isReviewMode) && (_jsxs("button", { type: "button", onClick: isReviewMode ? handleBackFromReview : handleDiscardSession, disabled: sessionActive && !isReviewMode && isUploading, className: "inline-flex cursor-pointer items-center gap-1.5 rounded-lg bg-white/5 px-2.5 py-1.5 text-xs font-semibold text-slate-200 ring-1 ring-inset ring-white/15 transition-colors hover:bg-white/10 disabled:cursor-not-allowed disabled:opacity-50", children: [_jsx(FontAwesomeIcon, { icon: faArrowLeft, className: "h-3 w-3" }), "Back"] })), _jsxs("div", { className: "inline-flex items-center gap-1.5 text-xs font-semibold tracking-wide text-slate-100", children: [_jsx(FontAwesomeIcon, { icon: faComments, className: "h-3.5 w-3.5 text-sky-400/90" }), isReviewMode ? 'Feedback Review' : 'Feedback'] })] }), _jsxs("div", { className: "flex flex-wrap items-center justify-end gap-2", children: [(sessionActive || isReviewMode) && comments.length > 0 && (_jsx("button", { type: "button", onClick: () => void handleCopyAiPrompt(), className: "inline-flex cursor-pointer items-center gap-1.5 rounded-lg bg-white/5 px-2.5 py-1.5 text-xs font-semibold text-slate-200 ring-1 ring-inset ring-white/15 transition-colors hover:bg-white/10", children: copiedPrompt ? (_jsxs(_Fragment, { children: [_jsx(FontAwesomeIcon, { icon: faCheck, className: "h-3.5 w-3.5 text-emerald-400" }), "Prompt copied"] })) : (_jsxs(_Fragment, { children: [_jsx(FontAwesomeIcon, { icon: faCopy, className: "h-3.5 w-3.5 opacity-80" }), "Copy prompt for AI"] })) })), shareableBucketPair && (_jsx("button", { type: "button", onClick: () => void handleCopyShareableUrl(), className: "inline-flex cursor-pointer items-center gap-1.5 rounded-lg bg-white/5 px-2.5 py-1.5 text-xs font-semibold text-slate-200 ring-1 ring-inset ring-white/15 transition-colors hover:bg-white/10", children: copiedShareUrl ? (_jsxs(_Fragment, { children: [_jsx(FontAwesomeIcon, { icon: faCheck, className: "h-3.5 w-3.5 text-emerald-400" }), "Link copied"] })) : (_jsxs(_Fragment, { children: [_jsx(FontAwesomeIcon, { icon: faLink, className: "h-3.5 w-3.5 opacity-80" }), "Copy shareable URL"] })) })), !isReviewMode && (_jsx(_Fragment, { children: !sessionActive ? (_jsxs("button", { type: "button", onClick: handleStartSession, className: "inline-flex cursor-pointer items-center gap-1.5 rounded-lg bg-emerald-600 px-2.5 py-1.5 text-xs font-semibold text-white ring-1 ring-inset ring-emerald-500 transition-colors hover:bg-emerald-500", children: [_jsx(FontAwesomeIcon, { icon: faPlus, className: "h-3.5 w-3.5" }), "Start feedback session"] })) : (comments.length > 0 &&
|
|
1106
|
+
(sessionUploadComplete ? (_jsxs("span", { className: "inline-flex items-center gap-1.5 rounded-lg bg-emerald-500/15 px-2.5 py-1.5 text-xs font-semibold text-emerald-200 ring-1 ring-inset ring-emerald-500/30", children: [_jsx(FontAwesomeIcon, { icon: faCheck, className: "h-3.5 w-3.5 text-emerald-400" }), "Review saved"] })) : (_jsx("button", { type: "button", onClick: openCompleteReviewModal, disabled: isUploading, className: "inline-flex cursor-pointer items-center gap-1.5 rounded-lg bg-sky-600 px-2.5 py-1.5 text-xs font-semibold text-white ring-1 ring-inset ring-sky-500 transition-colors hover:bg-sky-500 disabled:opacity-50 disabled:cursor-not-allowed", children: isUploading ? (_jsxs(_Fragment, { children: [_jsx(FontAwesomeIcon, { icon: faSpinner, spin: true, className: "h-3.5 w-3.5" }), "Uploading\u2026"] })) : copiedUrl ? (_jsxs(_Fragment, { children: [_jsx(FontAwesomeIcon, { icon: faCheck, className: "h-3.5 w-3.5" }), "Copied!"] })) : (_jsxs(_Fragment, { children: [_jsx(FontAwesomeIcon, { icon: faCloudArrowUp, className: "h-3.5 w-3.5" }), "Complete feedback review"] })) })))) }))] })] }), isReviewMode && loadedReview?.title && (_jsx("div", { className: "-mt-1 text-[11px] font-medium text-slate-300", children: loadedReview.title })), sessionUploadComplete && !isReviewMode && (_jsx("div", { className: "-mt-1 text-[11px] font-medium text-emerald-300/90", children: "Review saved \u2014 view only" })), (completeError || submitError) && (_jsxs("div", { className: "flex items-start gap-2 rounded-xl bg-rose-500/10 px-3 py-2 text-xs text-rose-200 ring-1 ring-inset ring-rose-500/20", children: [_jsx(FontAwesomeIcon, { icon: faCircleExclamation, className: "mt-0.5 h-3.5 w-3.5 shrink-0 text-rose-300" }), _jsx("span", { children: completeError ?? submitError })] })), !sessionActive && !isReviewMode && (_jsxs("div", { className: "flex min-h-0 flex-1 flex-col gap-4 overflow-auto", children: [_jsxs("section", { className: "min-w-0", children: [_jsxs("h2", { className: "mb-2 inline-flex items-center gap-1.5 text-[11px] font-semibold uppercase tracking-wider text-sky-400", children: [_jsx(FontAwesomeIcon, { icon: faFolderOpen, className: "h-3 w-3" }), "My Feedback Sessions"] }), _jsx("div", { className: "flex flex-col gap-1.5", children: mySessionRows.length === 0 ? (_jsx("div", { className: "text-[11px] text-slate-300 italic", children: "No saved sessions yet." })) : (mySessionRows.map((row) => (_jsxs("div", { className: "flex min-w-0 items-stretch gap-1 rounded-lg bg-white/5 ring-1 ring-inset ring-white/10", children: [_jsxs("button", { type: "button", disabled: row.status !== 'ok', onClick: () => openReviewInPanel(row.ref.reviewId, row.ref.projectId), className: "flex min-w-0 flex-1 flex-wrap items-center justify-between gap-2 rounded-l-lg px-2.5 py-2 text-left transition-colors hover:bg-white/10 disabled:cursor-not-allowed disabled:opacity-50 disabled:hover:bg-transparent cursor-pointer", children: [_jsx("div", { className: "min-w-0 flex-1 text-[11px]", children: row.status === 'loading' ? (_jsx("span", { className: "text-slate-400", children: "Loading\u2026" })) : row.status === 'error' ? (_jsx("span", { className: "text-rose-300", children: "Could not load this review from storage." })) : (_jsxs(_Fragment, { children: [_jsx("div", { className: "font-medium text-slate-100", children: row.review.title || formatReviewDate(row.review.createdAt) }), _jsxs("div", { className: "text-slate-300", children: [row.review.title
|
|
1107
|
+
? `${formatReviewDate(row.review.createdAt)} · `
|
|
1108
|
+
: '', row.review.comments.length, " comment", row.review.comments.length === 1 ? '' : 's', row.ref.projectId ? ` · ${row.ref.projectId}` : ''] })] })) }), row.status === 'ok' && (_jsxs("span", { className: "inline-flex shrink-0 items-center gap-0.5 text-[10px] font-semibold text-sky-400", children: ["Open", _jsx(FontAwesomeIcon, { icon: faChevronRight, className: "h-2.5 w-2.5" })] }))] }), _jsxs("button", { type: "button", onClick: () => handleRemoveMySessionFromList(row.ref.reviewId, row.ref.projectId), className: "inline-flex shrink-0 cursor-pointer items-center justify-center gap-1 self-stretch rounded-r-lg border-l border-white/10 px-2.5 py-2 text-[10px] font-semibold text-rose-300 transition-colors hover:bg-rose-500/10 hover:text-rose-200", "aria-label": "Remove from my list", children: [_jsx(FontAwesomeIcon, { icon: faTrashCan, className: "h-3 w-3" }), _jsx("span", { className: "hidden sm:inline", children: "Delete" })] })] }, `${row.ref.reviewId}-${row.ref.projectId}`)))) })] }), _jsxs("section", { className: "min-w-0", children: [_jsxs("h2", { className: "mb-2 inline-flex items-center gap-1.5 text-[11px] font-semibold uppercase tracking-wider text-purple-300", children: [_jsx(FontAwesomeIcon, { icon: faUserGroup, className: "h-3 w-3" }), "Feedback Sessions Shared With Me"] }), _jsx("div", { className: "flex flex-col gap-1.5", children: sharedSessionRows.length === 0 ? (_jsx("div", { className: "text-[11px] text-slate-300 italic", children: "Open a share link to save it here for later." })) : (sharedSessionRows.map((row) => (_jsxs("div", { className: "flex min-w-0 items-stretch gap-1 rounded-lg bg-white/5 ring-1 ring-inset ring-white/10", children: [_jsxs("button", { type: "button", disabled: row.status !== 'ok', onClick: () => openReviewInPanel(row.ref.reviewId, row.ref.projectId), className: "flex min-w-0 flex-1 flex-wrap items-center justify-between gap-2 rounded-l-lg px-2.5 py-2 text-left transition-colors hover:bg-white/10 disabled:cursor-not-allowed disabled:opacity-50 disabled:hover:bg-transparent cursor-pointer", children: [_jsx("div", { className: "min-w-0 flex-1 text-[11px]", children: row.status === 'loading' ? (_jsx("span", { className: "text-slate-400", children: "Loading\u2026" })) : row.status === 'error' ? (_jsx("span", { className: "text-rose-300", children: "Could not load this review from storage." })) : (_jsxs(_Fragment, { children: [_jsx("div", { className: "font-medium text-slate-100", children: row.review.title || formatReviewDate(row.review.createdAt) }), _jsxs("div", { className: "text-slate-300", children: [row.review.title
|
|
1109
|
+
? `${formatReviewDate(row.review.createdAt)} · `
|
|
1110
|
+
: '', row.review.comments.length, " comment", row.review.comments.length === 1 ? '' : 's', row.ref.projectId ? ` · ${row.ref.projectId}` : ''] })] })) }), row.status === 'ok' && (_jsxs("span", { className: "inline-flex shrink-0 items-center gap-0.5 text-[10px] font-semibold text-sky-400", children: ["Open", _jsx(FontAwesomeIcon, { icon: faChevronRight, className: "h-2.5 w-2.5" })] }))] }), _jsxs("button", { type: "button", onClick: () => handleRemoveSharedSessionFromList(row.ref.reviewId, row.ref.projectId), className: "inline-flex shrink-0 cursor-pointer items-center justify-center gap-1 self-stretch rounded-r-lg border-l border-white/10 px-2.5 py-2 text-[10px] font-semibold text-amber-300 transition-colors hover:bg-amber-500/10 hover:text-amber-200", "aria-label": "Remove from shared list", children: [_jsx(FontAwesomeIcon, { icon: faXmark, className: "h-3 w-3" }), _jsx("span", { className: "hidden sm:inline", children: "Remove" })] })] }, `${row.ref.reviewId}-${row.ref.projectId}-shared`)))) })] })] })), (sessionActive || isReviewMode) && (_jsxs("div", { className: `grid min-h-0 flex-1 gap-4 overflow-hidden ${feedbackSplitLayout ? 'grid-cols-2' : 'grid-cols-1'}`, children: [feedbackSplitLayout && (_jsxs("div", { className: "flex min-h-0 min-w-0 flex-col gap-3 overflow-auto", children: [!isReviewMode && (_jsxs("div", { className: "rounded-xl bg-white/5 p-3 ring-1 ring-inset ring-white/10", children: [_jsxs("div", { className: "mb-2 inline-flex items-center gap-1.5 text-[11px] font-semibold uppercase tracking-wider text-slate-300", children: [_jsx(FontAwesomeIcon, { icon: faCrosshairs, className: "h-3 w-3 text-sky-400/90" }), "Select element"] }), _jsxs("div", { className: "flex flex-wrap items-center gap-2", children: [state === 'idle' && (_jsxs("button", { type: "button", onClick: startInspecting, className: "inline-flex cursor-pointer items-center gap-1.5 rounded-lg bg-sky-600 px-2.5 py-1.5 text-xs font-semibold text-white ring-1 ring-inset ring-sky-500 transition-colors hover:bg-sky-500", children: [_jsx(FontAwesomeIcon, { icon: faCrosshairs, className: "h-3.5 w-3.5" }), "Select element"] })), state === 'inspecting' && (_jsxs("button", { type: "button", onClick: stopInspecting, className: "inline-flex cursor-pointer items-center gap-1.5 rounded-lg bg-rose-600 px-2.5 py-1.5 text-xs font-semibold text-white ring-1 ring-inset ring-rose-500 transition-colors hover:bg-rose-500", children: [_jsx(FontAwesomeIcon, { icon: faBan, className: "h-3.5 w-3.5" }), "Stop selecting"] })), state === 'selected' && selectedComponent && (_jsxs(_Fragment, { children: [_jsxs("span", { className: "text-sm text-slate-300", children: [_jsxs("span", { className: "mr-2 font-medium text-sky-400", children: [pascalCaseToTitleCase(selectedComponent.type), ":"] }), _jsx("span", { className: "font-mono text-white", children: selectedComponent.key })] }), _jsxs("button", { type: "button", onClick: inspectAnother, className: "inline-flex cursor-pointer items-center gap-1 rounded-lg bg-sky-600 px-2.5 py-1.5 text-xs font-semibold text-white transition-colors hover:bg-sky-500", children: [_jsx(FontAwesomeIcon, { icon: faPenToSquare, className: "h-3 w-3" }), "Change"] }), _jsxs("button", { type: "button", onClick: clearSelection, className: "inline-flex cursor-pointer items-center gap-1 rounded-lg bg-slate-700 px-2.5 py-1.5 text-xs font-semibold text-slate-200 transition-colors hover:bg-slate-600", children: [_jsx(FontAwesomeIcon, { icon: faCircleXmark, className: "h-3 w-3" }), "Clear"] }), selectedComponent.tags && selectedComponent.tags.length > 0 && (_jsx(_Fragment, { children: selectedFeedbackTag ? (_jsxs("button", { type: "button", onClick: () => setSelectedFeedbackTag(''), disabled: isSubmittingComment, className: "inline-flex cursor-pointer items-center gap-1 rounded-lg bg-slate-700 px-2.5 py-1.5 text-xs font-semibold text-slate-200 ring-1 ring-inset ring-white/10 transition-colors hover:bg-slate-600 disabled:cursor-not-allowed disabled:opacity-50", children: [_jsx(FontAwesomeIcon, { icon: faXmark, className: "h-3 w-3" }), "Remove tag"] })) : (_jsxs("button", { type: "button", onClick: () => setSelectedFeedbackTag(selectedComponent.tags[0]), disabled: isSubmittingComment, className: "inline-flex cursor-pointer items-center gap-1 rounded-lg bg-slate-700 px-2.5 py-1.5 text-xs font-semibold text-slate-200 ring-1 ring-inset ring-white/10 transition-colors hover:bg-slate-600 disabled:cursor-not-allowed disabled:opacity-50", children: [_jsx(FontAwesomeIcon, { icon: faPlus, className: "h-3 w-3" }), "Add Tag"] })) }))] }))] }), state === 'selected' &&
|
|
1111
|
+
selectedComponent &&
|
|
1112
|
+
selectedComponent.tags &&
|
|
1113
|
+
selectedComponent.tags.length > 0 &&
|
|
1114
|
+
selectedFeedbackTag && (_jsxs("div", { className: "mt-2 flex min-w-0 max-w-md flex-col gap-1", children: [_jsx("label", { htmlFor: "embeddables-feedback-scope-tag", className: "text-[10px] font-semibold uppercase tracking-wide text-slate-400", children: "Scope tag" }), _jsx("select", { id: "embeddables-feedback-scope-tag", value: selectedFeedbackTag, onChange: (e) => setSelectedFeedbackTag(e.target.value), disabled: isSubmittingComment, className: "w-full cursor-pointer rounded-lg bg-slate-900/80 px-2.5 py-1.5 text-xs text-slate-100 ring-1 ring-inset ring-slate-600 focus:outline-none focus:ring-2 focus:ring-sky-500/60 disabled:cursor-not-allowed disabled:opacity-50", children: selectedComponent.tags.map((t, ti) => (_jsx("option", { value: t, children: t }, `${t}-${ti}`))) })] })), error && _jsx("div", { className: "mt-2 text-xs text-rose-300", children: error })] })), sessionActive && !isReviewMode && !sessionUploadComplete && (_jsxs("div", { className: "flex flex-col gap-2 rounded-xl bg-white/5 p-3 ring-1 ring-inset ring-white/10", children: [_jsxs("div", { className: "inline-flex items-center gap-1.5 text-[11px] font-semibold uppercase tracking-wider text-slate-300", children: [_jsx(FontAwesomeIcon, { icon: faMessage, className: "h-3 w-3 text-slate-400" }), "Describe the change you want"] }), _jsx("textarea", { ref: descriptionRef, value: commentText, onChange: (e) => setCommentText(e.target.value), onPaste: handleDescriptionPaste, onKeyDown: handleDescriptionKeyDown, disabled: isSubmittingComment, placeholder: `e.g. Make this button larger, change the color to blue...\n\nOr paste an image from the clipboard.`, className: "min-h-[80px] w-full rounded-lg bg-slate-900/60 px-3 py-2 text-xs text-slate-100 ring-1 ring-inset ring-slate-600 placeholder:text-slate-500 focus:outline-none focus:ring-2 focus:ring-sky-500/60 disabled:cursor-not-allowed disabled:opacity-60", rows: 3 }), _jsxs("div", { className: "flex flex-wrap items-center gap-x-3 gap-y-2", children: [_jsxs("span", { className: "inline-flex items-center gap-1.5 text-xs font-medium text-slate-300", children: [_jsx(FontAwesomeIcon, { icon: faImage, className: "h-3 w-3 text-slate-500" }), "Include screenshots:"] }), _jsxs("label", { className: "flex cursor-pointer items-center gap-1.5 rounded-lg px-2 py-1 text-xs font-medium text-slate-200 transition-colors hover:bg-slate-700/50", children: [_jsx("input", { type: "checkbox", checked: includeViewportScreenshot, disabled: isSubmittingComment, onChange: (e) => setIncludeViewportScreenshot(e.target.checked), className: "rounded border-slate-600 bg-slate-800 text-sky-500 focus:ring-sky-500 disabled:opacity-50" }), "Page"] }), _jsxs("label", { className: `flex items-center gap-1.5 rounded-lg px-2 py-1 text-xs font-medium transition-colors hover:bg-slate-700/50 ${selectedComponentId
|
|
1115
|
+
? 'cursor-pointer text-slate-200'
|
|
1116
|
+
: 'cursor-not-allowed text-slate-500'}`, children: [_jsx("input", { type: "checkbox", checked: includeComponentScreenshot, disabled: !selectedComponentId || isSubmittingComment, onChange: (e) => setIncludeComponentScreenshot(e.target.checked), className: "rounded border-slate-600 bg-slate-800 text-sky-500 focus:ring-sky-500 disabled:opacity-50" }), "Component"] }), _jsxs("label", { className: `inline-flex items-center gap-1.5 rounded-lg bg-slate-700 px-2.5 py-1.5 text-xs font-medium text-slate-200 ${isSubmittingComment
|
|
1117
|
+
? 'cursor-not-allowed opacity-50'
|
|
1118
|
+
: 'cursor-pointer transition-colors hover:bg-slate-600'}`, children: [_jsx(FontAwesomeIcon, { icon: faPaperclip, className: "h-3 w-3 opacity-80" }), "Attach file", _jsx("input", { type: "file", accept: "image/*,.pdf", className: "hidden", disabled: isSubmittingComment, onChange: handleFileAttach })] })] }), attachments.length > 0 && (_jsx("div", { className: "flex flex-wrap gap-2", children: attachments.map((a, i) => (_jsxs("div", { className: "flex items-center gap-1 rounded bg-slate-800 px-2 py-1 text-[10px]", children: [_jsx(FontAwesomeIcon, { icon: a.type === 'screenshot' ? faCamera : faPaperclip, className: "h-3 w-3 text-slate-400" }), a.filename ?? 'file', _jsx("button", { type: "button", onClick: () => removeAttachment(i), disabled: isSubmittingComment, className: "cursor-pointer p-0.5 text-rose-400 transition-colors hover:text-rose-300 disabled:pointer-events-none disabled:opacity-50", "aria-label": "Remove attachment", children: _jsx(FontAwesomeIcon, { icon: faXmark, className: "h-3 w-3" }) })] }, i))) })), _jsxs("div", { className: "flex flex-col items-start gap-0.5 self-start", children: [_jsx("button", { type: "button", onClick: handleSubmitComment, disabled: isAddCommentDisabled, className: "inline-flex cursor-pointer items-center gap-1.5 rounded-lg bg-emerald-600 px-3 py-1.5 text-xs font-semibold text-white ring-1 ring-inset ring-emerald-500 transition-colors hover:bg-emerald-500 disabled:cursor-not-allowed disabled:opacity-50", children: isSubmittingComment ? (_jsxs(_Fragment, { children: [_jsx(FontAwesomeIcon, { icon: faSpinner, spin: true, className: "h-3.5 w-3.5" }), "Adding\u2026"] })) : (_jsxs(_Fragment, { children: [_jsx(FontAwesomeIcon, { icon: faPlus, className: "h-3.5 w-3.5" }), "Add comment"] })) }), !isSubmittingComment && (_jsx("span", { className: "pl-0.5 text-[9px] font-semibold uppercase tracking-wide text-slate-500", children: "Press enter" }))] })] }))] })), _jsxs("div", { className: "flex min-h-0 min-w-0 flex-col pr-2", children: [_jsxs("div", { className: "mb-2 inline-flex items-center gap-1.5 text-[11px] font-semibold uppercase tracking-wider text-slate-300", children: [_jsx(FontAwesomeIcon, { icon: faComments, className: "h-3 w-3 text-slate-500" }), "Comments (", comments.length, ")"] }), _jsx("div", { className: "min-h-0 flex-1 overflow-auto rounded-xl bg-slate-950/60 p-3 ring-1 ring-inset ring-white/10", children: comments.length === 0 ? (_jsx("div", { className: "text-[11px] text-slate-300 italic", children: "No comments yet." })) : (_jsx("div", { className: "flex flex-col gap-2", children: displayedComments.map((c, index) => {
|
|
1119
|
+
const showPageHeading = index === 0 ||
|
|
1120
|
+
feedbackPageGroupKey(displayedComments[index - 1]) !==
|
|
1121
|
+
feedbackPageGroupKey(c);
|
|
1122
|
+
const pageNum = feedbackPageOneBasedIndex(feedbackFlowJson, c);
|
|
1123
|
+
return (_jsxs(Fragment, { children: [showPageHeading && (_jsxs("div", { className: `flex min-w-0 flex-wrap items-center gap-1 font-mono text-[10px] font-semibold ${index > 0 ? 'mt-3 border-t border-white/10 pt-3' : ''}`, children: [pageNum != null ? (_jsxs("span", { className: "inline-flex shrink-0 items-center gap-1 text-slate-400", children: [_jsx(FontAwesomeIcon, { icon: faFileLines, className: "h-2.5 w-2.5 shrink-0" }), pageNum, "."] })) : null, _jsx("span", { className: "min-w-0 truncate text-slate-300", children: displayFeedbackPageKey(c.pageKey) })] })), _jsxs("div", { className: "relative flex gap-2 rounded-lg bg-white/5 p-2 text-xs text-slate-200 ring-1 ring-inset ring-white/10 transition-colors hover:bg-white/15 has-[.feedback-comment-thumb:hover]:bg-white/5! has-[.feedback-comment-thumb:focus-visible]:bg-white/5!", children: [_jsx("button", { type: "button", "aria-label": `Jump to comment: ${(c.comment || '(No description)').slice(0, 120)}`, onClick: () => handleJumpToComment(c), className: "absolute inset-0 z-0 cursor-pointer rounded-lg border-0 bg-transparent p-0 text-left focus:outline-none focus-visible:ring-2 focus-visible:ring-inset focus-visible:ring-sky-500/80" }), _jsxs("div", { className: "pointer-events-none relative z-1 min-w-0 flex-1 text-left text-inherit", children: [_jsx("div", { className: "font-medium text-slate-100 line-clamp-2", children: c.comment || '(No description)' }), _jsxs("div", { className: "mt-1 space-y-0.5 text-[10px] text-slate-400", children: [_jsxs("div", { children: [_jsx("span", { className: "font-medium text-slate-500", children: "Page" }), ' ', _jsx("span", { className: "font-mono text-slate-300", children: displayFeedbackPageKey(c.pageKey) })] }), (c.componentKey != null || c.componentId) && (_jsx("div", { children: c.componentKey != null ? (_jsxs(_Fragment, { children: [_jsxs("span", { className: "mr-2 font-medium text-sky-400", children: [pascalCaseToTitleCase(c.componentType ?? 'Component'), ":"] }), _jsx("span", { className: "font-mono text-white", children: c.componentKey })] })) : (_jsx("span", { className: "font-mono text-white", children: c.componentId })) })), c.feedbackTag ? (_jsxs("div", { children: [_jsx("span", { className: "font-medium text-slate-500", children: "Scope tag" }), ' ', _jsx("span", { className: "font-mono text-amber-200/90", children: c.feedbackTag })] })) : null] })] }), c.attachments && c.attachments.length > 0 && (_jsx("div", { className: "relative z-1 flex shrink-0 flex-row flex-wrap items-start justify-end gap-1.5 self-start", children: c.attachments.map((a, i) => isImageAttachmentData(a.data) ? (_jsx("button", { type: "button", onClick: (e) => {
|
|
1124
|
+
e.stopPropagation();
|
|
1125
|
+
openFeedbackAttachmentLightbox(c, i);
|
|
1126
|
+
}, className: "feedback-comment-thumb relative z-10 h-14 max-w-32 shrink-0 cursor-pointer overflow-hidden rounded border-0 p-0 ring-1 ring-inset ring-white/10 transition-opacity duration-150 hover:opacity-70 focus:outline-none focus-visible:ring-2 focus-visible:ring-sky-500/80", title: a.filename ? `View ${a.filename}` : 'View image', children: _jsx("img", { src: a.data, alt: a.filename ?? 'Attachment', className: "h-full w-full object-contain" }) }, i)) : (_jsx("div", { className: "pointer-events-none flex h-14 w-14 max-w-32 shrink-0 items-center justify-center rounded border border-white/10 bg-slate-800 text-slate-300", title: a.filename ?? 'File', children: _jsx(FontAwesomeIcon, { icon: a.type === 'screenshot' ? faCamera : faFile, className: "h-6 w-6" }) }, i))) }))] })] }, c.id));
|
|
1127
|
+
}) })) })] })] })), confirmDialog && (_jsx("div", { className: "fixed inset-0 z-50 flex items-center justify-center bg-black/50", onClick: () => setConfirmDialog(null), onKeyDown: (e) => {
|
|
1128
|
+
if (e.key === 'Escape')
|
|
1129
|
+
setConfirmDialog(null);
|
|
1130
|
+
}, children: _jsxs("div", { className: "w-full max-w-sm rounded-xl bg-slate-800 p-5 shadow-xl ring-1 ring-inset ring-white/10", onClick: (e) => e.stopPropagation(), children: [_jsx("h3", { className: "text-sm font-semibold text-slate-100", children: confirmDialog.title }), _jsx("p", { className: "mt-1 text-[11px] text-slate-400", children: confirmDialog.message }), _jsxs("div", { className: "mt-4 flex items-center justify-end gap-2", children: [_jsx("button", { type: "button", onClick: () => setConfirmDialog(null), className: "cursor-pointer rounded-lg bg-slate-700 px-3 py-1.5 text-xs font-semibold text-slate-300 ring-1 ring-inset ring-slate-600 hover:bg-slate-600 hover:text-white", children: "Cancel" }), _jsx("button", { type: "button", onClick: () => confirmDialog.onConfirm(), className: confirmDialog.variant === 'danger'
|
|
1131
|
+
? 'cursor-pointer rounded-lg bg-rose-600 px-3 py-1.5 text-xs font-semibold text-white ring-1 ring-inset ring-rose-500 hover:bg-rose-500'
|
|
1132
|
+
: 'cursor-pointer rounded-lg bg-sky-600 px-3 py-1.5 text-xs font-semibold text-white ring-1 ring-inset ring-sky-500 hover:bg-sky-500', children: confirmDialog.confirmLabel })] })] }) })), _jsx(WorkbenchModalOverlay, { open: sharedSessionWelcomeOpen && !!loadedReview, onClose: closeSharedSessionWelcome, portalToWorkbenchShadow: true, "aria-labelledby": "shared-session-welcome-title", children: loadedReview && (_jsxs("div", { className: "w-full max-w-lg rounded-xl bg-slate-800 p-7 shadow-xl ring-1 ring-inset ring-white/10 sm:max-w-xl sm:p-8", onClick: (e) => e.stopPropagation(), children: [_jsxs("div", { className: "flex items-start gap-4 sm:gap-5", children: [_jsx("div", { className: "grid h-11 w-11 shrink-0 place-items-center rounded-lg bg-purple-500/15 text-purple-300 ring-1 ring-inset ring-purple-400/25", children: _jsx(FontAwesomeIcon, { icon: faUserGroup, className: "h-5 w-5" }) }), _jsxs("div", { className: "min-w-0 flex-1 space-y-5", children: [_jsxs("div", { children: [_jsx("h3", { id: "shared-session-welcome-title", className: "text-sm font-semibold leading-snug text-slate-100 sm:text-base", children: "You're viewing a shared feedback session" }), _jsx("p", { className: "mt-3 max-w-prose text-[12px] leading-relaxed text-slate-400 sm:text-sm", children: "You've opened a link shared from some feedback that someone else left on this Embeddable." }), sharedSessionWelcomeStats.totalComments > 0 && (_jsxs(_Fragment, { children: [_jsxs("p", { className: "mt-3 max-w-prose text-[12px] leading-relaxed text-slate-400 sm:text-sm", children: ["This session has", ' ', _jsx("span", { className: "font-semibold text-slate-200", children: sharedSessionWelcomeStats.totalComments }), ' ', sharedSessionWelcomeStats.totalComments === 1 ? 'comment' : 'comments', ' ', "across", ' ', _jsx("span", { className: "font-semibold text-slate-200", children: sharedSessionWelcomeStats.pageCount }), ' ', sharedSessionWelcomeStats.pageCount === 1 ? 'page' : 'pages', "."] }), _jsxs("p", { className: "mt-2 max-w-prose text-[12px] leading-relaxed text-slate-400 sm:text-sm", children: ["We've started you on the first page with feedback \u2014", ' ', _jsx("code", { className: "rounded bg-slate-950/80 px-1.5 py-0.5 font-mono text-[11px] text-sky-200 sm:text-xs", children: sharedSessionWelcomeStats.firstPageKey }), ' ', "\u2014 which has", ' ', _jsx("span", { className: "font-semibold text-slate-200", children: sharedSessionWelcomeStats.commentsOnFirstPage }), ' ', sharedSessionWelcomeStats.commentsOnFirstPage === 1 ? 'comment' : 'comments', "."] })] }))] }), _jsxs("div", { className: "rounded-lg bg-slate-900/80 px-4 py-4 ring-1 ring-inset ring-white/10 sm:px-5 sm:py-5", children: [_jsx("div", { className: "text-[11px] font-medium uppercase tracking-wide text-slate-500", children: "Feedback Session" }), _jsx("div", { className: "mt-2 text-sm font-semibold text-slate-100 sm:text-base", children: loadedReview.title?.trim() ? loadedReview.title.trim() : 'Untitled session' }), _jsx("div", { className: "mt-2 text-[11px] text-slate-400 sm:text-xs", children: formatReviewDate(loadedReview.createdAt) })] })] })] }), _jsx("div", { className: "mt-8 flex justify-end sm:mt-9", children: _jsx("button", { ref: sharedSessionWelcomeDismissRef, type: "button", onClick: closeSharedSessionWelcome, className: "min-w-32 cursor-pointer rounded-lg bg-sky-600 px-6 py-2.5 text-sm font-semibold text-white ring-1 ring-inset ring-sky-500 transition-colors hover:bg-sky-500 sm:px-7 sm:py-3", children: "Got it" }) })] })) }), reviewTitleModalOpen && (_jsx("div", { className: "fixed inset-0 z-50 flex items-center justify-center bg-black/50", onClick: closeReviewTitleModal, onKeyDown: (e) => {
|
|
1133
|
+
if (e.key === 'Escape')
|
|
1134
|
+
closeReviewTitleModal();
|
|
1135
|
+
}, children: _jsxs("div", { className: "w-full max-w-sm rounded-xl bg-slate-800 p-5 shadow-xl ring-1 ring-inset ring-white/10", onClick: (e) => e.stopPropagation(), children: [_jsx("h3", { className: "text-sm font-semibold text-slate-100", children: "Complete feedback review" }), _jsx("p", { className: "mt-1 text-[11px] text-slate-400", children: "Give this feedback session a title. It will be stored with the uploaded review." }), _jsx("input", { ref: reviewTitleInputRef, type: "text", value: reviewTitleInput, onChange: (e) => {
|
|
1136
|
+
setReviewTitleInput(e.target.value);
|
|
1137
|
+
if (reviewTitleError)
|
|
1138
|
+
setReviewTitleError(null);
|
|
1139
|
+
}, onKeyDown: (e) => {
|
|
1140
|
+
if (e.key === 'Enter')
|
|
1141
|
+
submitReviewTitleModal();
|
|
1142
|
+
if (e.key === 'Escape')
|
|
1143
|
+
closeReviewTitleModal();
|
|
1144
|
+
}, placeholder: "Review title\u2026", className: "mt-3 w-full rounded-lg bg-slate-900 px-3 py-2 text-xs text-slate-100 ring-1 ring-inset ring-slate-600 placeholder:text-slate-500 focus:outline-none focus:ring-2 focus:ring-sky-500/60" }), reviewTitleError && (_jsx("div", { className: "mt-2 text-xs text-rose-300", children: reviewTitleError })), _jsxs("div", { className: "mt-4 flex items-center justify-end gap-2", children: [_jsx("button", { type: "button", onClick: closeReviewTitleModal, className: "cursor-pointer rounded-lg bg-slate-700 px-3 py-1.5 text-xs font-semibold text-slate-300 ring-1 ring-inset ring-slate-600 hover:bg-slate-600 hover:text-white", children: "Cancel" }), _jsx("button", { type: "button", onClick: submitReviewTitleModal, className: "cursor-pointer rounded-lg bg-sky-600 px-3 py-1.5 text-xs font-semibold text-white ring-1 ring-inset ring-sky-500 hover:bg-sky-500", children: "Complete review" })] })] }) })), _jsx(MediaLightbox, { open: mediaLightbox != null, onClose: () => setMediaLightbox(null), items: mediaLightbox?.items ?? [], initialIndex: mediaLightbox?.initialIndex ?? 0 })] }));
|
|
1145
|
+
}
|
|
1146
|
+
//# sourceMappingURL=FeedbackPanel.js.map
|