@terrafirmagreg-team/discussions 1.2.1 → 1.2.3
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/package.json +4 -5
- package/src/components/comments-view.tsx +32 -32
- package/src/components/comments.tsx +8 -8
- package/src/components/feedback/block.tsx +366 -366
- package/src/components/feedback/page.tsx +181 -181
- package/src/hooks/comments/use-menu-item.ts +29 -29
- package/src/hooks/comments/use-session.ts +21 -21
- package/src/lib/comments/server.ts +204 -204
- package/src/lib/feedback.ts +74 -74
- package/src/locales/en_us.json +0 -13
- package/src/locales/ru_ru.json +0 -13
- package/src/locales.ts +0 -6
|
@@ -1,366 +1,366 @@
|
|
|
1
|
-
'use client';
|
|
2
|
-
import { cn } from '@terrafirmagreg-team/ui/lib/utils/cn';
|
|
3
|
-
import { injectHighlightStyle } from '@terrafirmagreg-team/ui/lib/utils/highlight-style';
|
|
4
|
-
import { autoUpdate, flip, offset, shift, useFloating } from '@floating-ui/react-dom';
|
|
5
|
-
import { buttonVariants } from '@terrafirmagreg-team/ui/components/ui/button';
|
|
6
|
-
import { CornerDownRightIcon } from 'lucide-react';
|
|
7
|
-
import {
|
|
8
|
-
type CSSProperties,
|
|
9
|
-
type HTMLAttributes,
|
|
10
|
-
type ReactNode,
|
|
11
|
-
type SyntheticEvent,
|
|
12
|
-
useEffect,
|
|
13
|
-
useEffectEvent,
|
|
14
|
-
useRef,
|
|
15
|
-
useState,
|
|
16
|
-
useTransition,
|
|
17
|
-
} from 'react';
|
|
18
|
-
import {
|
|
19
|
-
actionResponse,
|
|
20
|
-
blockFeedback,
|
|
21
|
-
type ActionResponse,
|
|
22
|
-
type BlockFeedback,
|
|
23
|
-
} from '../../lib/feedback-schema';
|
|
24
|
-
import { z } from 'zod/mini';
|
|
25
|
-
import { usePathname } from 'next/navigation';
|
|
26
|
-
import { useTranslations } from '@terrafirmagreg-team/translate/hooks/use-translations';
|
|
27
|
-
import { useSubmissionStorage } from '../../hooks/feedback/use-submission-storage';
|
|
28
|
-
|
|
29
|
-
const blockFeedbackResult = z.extend(blockFeedback, {
|
|
30
|
-
response: actionResponse,
|
|
31
|
-
});
|
|
32
|
-
|
|
33
|
-
export interface FeedbackTextProps {
|
|
34
|
-
onSendAction: (feedback: BlockFeedback) => Promise<ActionResponse>;
|
|
35
|
-
children?: ReactNode;
|
|
36
|
-
}
|
|
37
|
-
|
|
38
|
-
export function FeedbackText({ onSendAction, children }: FeedbackTextProps) {
|
|
39
|
-
const [popup, _setPopup] = useState<{
|
|
40
|
-
mode: 'tooltip' | 'expanded';
|
|
41
|
-
blockId: string;
|
|
42
|
-
selection: string;
|
|
43
|
-
range: Range;
|
|
44
|
-
} | null>(null);
|
|
45
|
-
|
|
46
|
-
const containerRef = useRef<HTMLDivElement>(null);
|
|
47
|
-
const { refs, floatingStyles } = useFloating({
|
|
48
|
-
open: popup !== null,
|
|
49
|
-
placement: 'bottom',
|
|
50
|
-
middleware: [offset(6), flip(), shift({ padding: 8 })],
|
|
51
|
-
whileElementsMounted: autoUpdate,
|
|
52
|
-
});
|
|
53
|
-
|
|
54
|
-
function expandPopup() {
|
|
55
|
-
if (popup?.mode !== 'tooltip') return;
|
|
56
|
-
|
|
57
|
-
const highlight = new Highlight(popup.range);
|
|
58
|
-
CSS.highlights.set('fd-feedback-text', highlight);
|
|
59
|
-
injectHighlightStyle(
|
|
60
|
-
'fd-feedback-text',
|
|
61
|
-
'background-color:var(--color-fd-primary);color:var(--color-fd-primary-foreground)',
|
|
62
|
-
);
|
|
63
|
-
|
|
64
|
-
_setPopup({ ...popup, mode: 'expanded' });
|
|
65
|
-
}
|
|
66
|
-
|
|
67
|
-
function closePopup() {
|
|
68
|
-
if (popup?.mode === 'expanded') {
|
|
69
|
-
CSS.highlights.delete('fd-feedback-text');
|
|
70
|
-
}
|
|
71
|
-
|
|
72
|
-
_setPopup(null);
|
|
73
|
-
}
|
|
74
|
-
|
|
75
|
-
const updateSelectionPopover = useEffectEvent(() => {
|
|
76
|
-
if (popup && popup.mode === 'expanded') return;
|
|
77
|
-
|
|
78
|
-
const container = containerRef.current;
|
|
79
|
-
const selection = window.getSelection();
|
|
80
|
-
|
|
81
|
-
if (!container || !selection || selection.isCollapsed || selection.rangeCount === 0) {
|
|
82
|
-
closePopup();
|
|
83
|
-
return;
|
|
84
|
-
}
|
|
85
|
-
|
|
86
|
-
const range = selection.getRangeAt(0).cloneRange();
|
|
87
|
-
if (!container.contains(range.commonAncestorContainer)) {
|
|
88
|
-
closePopup();
|
|
89
|
-
return;
|
|
90
|
-
}
|
|
91
|
-
|
|
92
|
-
const selectionText = selection.toString().trim();
|
|
93
|
-
if (selectionText.length === 0 || selectionText.includes('\n')) {
|
|
94
|
-
closePopup();
|
|
95
|
-
return;
|
|
96
|
-
}
|
|
97
|
-
|
|
98
|
-
const element =
|
|
99
|
-
range.startContainer instanceof Element
|
|
100
|
-
? range.startContainer
|
|
101
|
-
: range.startContainer.parentElement;
|
|
102
|
-
const blockId = element?.closest('[data-block="feedback"]')?.id;
|
|
103
|
-
if (!blockId) {
|
|
104
|
-
closePopup();
|
|
105
|
-
return;
|
|
106
|
-
}
|
|
107
|
-
|
|
108
|
-
refs.setReference({
|
|
109
|
-
getBoundingClientRect() {
|
|
110
|
-
return range.getBoundingClientRect();
|
|
111
|
-
},
|
|
112
|
-
contextElement: container,
|
|
113
|
-
});
|
|
114
|
-
|
|
115
|
-
_setPopup({ mode: 'tooltip', range, selection: selectionText, blockId });
|
|
116
|
-
});
|
|
117
|
-
|
|
118
|
-
const closeOnEscape = useEffectEvent((event: KeyboardEvent) => {
|
|
119
|
-
if (popup === null) return;
|
|
120
|
-
if (event.key === 'Escape') closePopup();
|
|
121
|
-
});
|
|
122
|
-
|
|
123
|
-
const closeOnPointerDown = useEffectEvent((event: PointerEvent) => {
|
|
124
|
-
const target = event.target;
|
|
125
|
-
if (popup === null || !(target instanceof Node)) return;
|
|
126
|
-
|
|
127
|
-
if (
|
|
128
|
-
refs.floating.current?.contains(target) ||
|
|
129
|
-
(popup.mode === 'tooltip' && containerRef.current?.contains(target))
|
|
130
|
-
) {
|
|
131
|
-
return;
|
|
132
|
-
}
|
|
133
|
-
|
|
134
|
-
closePopup();
|
|
135
|
-
});
|
|
136
|
-
|
|
137
|
-
useEffect(() => {
|
|
138
|
-
let frame: number | null = null;
|
|
139
|
-
|
|
140
|
-
function scheduleSelectionPopover() {
|
|
141
|
-
if (frame !== null) window.cancelAnimationFrame(frame);
|
|
142
|
-
|
|
143
|
-
frame = window.requestAnimationFrame(() => {
|
|
144
|
-
frame = null;
|
|
145
|
-
updateSelectionPopover();
|
|
146
|
-
});
|
|
147
|
-
}
|
|
148
|
-
|
|
149
|
-
document.addEventListener('selectionchange', scheduleSelectionPopover);
|
|
150
|
-
document.addEventListener('keydown', closeOnEscape);
|
|
151
|
-
document.addEventListener('pointerdown', closeOnPointerDown);
|
|
152
|
-
|
|
153
|
-
return () => {
|
|
154
|
-
document.removeEventListener('keydown', closeOnEscape);
|
|
155
|
-
document.removeEventListener('pointerdown', closeOnPointerDown);
|
|
156
|
-
document.removeEventListener('selectionchange', scheduleSelectionPopover);
|
|
157
|
-
if (frame !== null) window.cancelAnimationFrame(frame);
|
|
158
|
-
};
|
|
159
|
-
}, []);
|
|
160
|
-
|
|
161
|
-
return (
|
|
162
|
-
<>
|
|
163
|
-
<div
|
|
164
|
-
ref={containerRef}
|
|
165
|
-
className="prose-no-margin"
|
|
166
|
-
>
|
|
167
|
-
{children}
|
|
168
|
-
</div>
|
|
169
|
-
|
|
170
|
-
{popup && (
|
|
171
|
-
<FeedbackTextPopup
|
|
172
|
-
popup={popup}
|
|
173
|
-
refs={refs}
|
|
174
|
-
floatingStyles={floatingStyles}
|
|
175
|
-
expandPopup={expandPopup}
|
|
176
|
-
closePopup={closePopup}
|
|
177
|
-
onSendAction={onSendAction}
|
|
178
|
-
/>
|
|
179
|
-
)}
|
|
180
|
-
</>
|
|
181
|
-
);
|
|
182
|
-
}
|
|
183
|
-
|
|
184
|
-
function FeedbackTextPopup({
|
|
185
|
-
popup,
|
|
186
|
-
refs,
|
|
187
|
-
floatingStyles,
|
|
188
|
-
expandPopup,
|
|
189
|
-
closePopup,
|
|
190
|
-
onSendAction,
|
|
191
|
-
}: {
|
|
192
|
-
popup: {
|
|
193
|
-
mode: 'tooltip' | 'expanded';
|
|
194
|
-
blockId: string;
|
|
195
|
-
selection: string;
|
|
196
|
-
range: Range;
|
|
197
|
-
};
|
|
198
|
-
refs: ReturnType<typeof useFloating>['refs'];
|
|
199
|
-
floatingStyles: CSSProperties;
|
|
200
|
-
expandPopup: () => void;
|
|
201
|
-
closePopup: () => void;
|
|
202
|
-
onSendAction: (feedback: BlockFeedback) => Promise<ActionResponse>;
|
|
203
|
-
}) {
|
|
204
|
-
const t = useTranslations();
|
|
205
|
-
return (
|
|
206
|
-
<div
|
|
207
|
-
ref={(el) => refs.setFloating(el)}
|
|
208
|
-
className={cn(
|
|
209
|
-
'not-prose z-40 text-sm bg-fd-popover text-fd-popover-foreground border overflow-hidden shadow-lg rounded-xl w-30 h-9.5 box-content transition-[width,height]',
|
|
210
|
-
popup.mode === 'expanded' ? 'w-[300px] h-32 max-w-[98vw]' : 'select-none',
|
|
211
|
-
)}
|
|
212
|
-
style={floatingStyles}
|
|
213
|
-
>
|
|
214
|
-
{popup.mode === 'tooltip' ? (
|
|
215
|
-
<div className="w-30 h-9.5 p-1">
|
|
216
|
-
<button
|
|
217
|
-
className={cn(
|
|
218
|
-
buttonVariants({ variant: 'ghost', size: 'sm' }),
|
|
219
|
-
'size-full gap-1.5',
|
|
220
|
-
)}
|
|
221
|
-
onClick={expandPopup}
|
|
222
|
-
>
|
|
223
|
-
<CornerDownRightIcon className="size-4 text-fd-muted-foreground" />
|
|
224
|
-
{t('Feedback', { note: 'ui.feedback.tooltip' })}
|
|
225
|
-
</button>
|
|
226
|
-
</div>
|
|
227
|
-
) : (
|
|
228
|
-
<FeedbackTextForm
|
|
229
|
-
blockId={popup.blockId}
|
|
230
|
-
selection={popup.selection}
|
|
231
|
-
onSendAction={onSendAction}
|
|
232
|
-
onClose={closePopup}
|
|
233
|
-
container={{ className: 'p-2 w-[300px] h-32 max-w-[98vw] animate-fd-fade-in' }}
|
|
234
|
-
/>
|
|
235
|
-
)}
|
|
236
|
-
</div>
|
|
237
|
-
);
|
|
238
|
-
}
|
|
239
|
-
|
|
240
|
-
function FeedbackTextForm({
|
|
241
|
-
blockId,
|
|
242
|
-
selection,
|
|
243
|
-
onSendAction,
|
|
244
|
-
onClose,
|
|
245
|
-
container,
|
|
246
|
-
}: {
|
|
247
|
-
container: HTMLAttributes<HTMLElement>;
|
|
248
|
-
blockId: string;
|
|
249
|
-
selection: string;
|
|
250
|
-
onSendAction: (feedback: BlockFeedback) => Promise<ActionResponse>;
|
|
251
|
-
onClose: () => void;
|
|
252
|
-
}) {
|
|
253
|
-
const t = useTranslations();
|
|
254
|
-
const pathname = usePathname();
|
|
255
|
-
const { previous, setPrevious } = useSubmissionStorage(`${pathname}-${blockId}`, (v) => {
|
|
256
|
-
const result = blockFeedbackResult.safeParse(v);
|
|
257
|
-
if (result.success) return result.data;
|
|
258
|
-
return null;
|
|
259
|
-
});
|
|
260
|
-
const [message, setMessage] = useState('');
|
|
261
|
-
const [isPending, startTransition] = useTransition();
|
|
262
|
-
|
|
263
|
-
function submit(e?: SyntheticEvent) {
|
|
264
|
-
startTransition(async () => {
|
|
265
|
-
const feedback: BlockFeedback = {
|
|
266
|
-
blockId,
|
|
267
|
-
blockBody: selection,
|
|
268
|
-
url: location.href,
|
|
269
|
-
message,
|
|
270
|
-
};
|
|
271
|
-
|
|
272
|
-
const response = await onSendAction(feedback);
|
|
273
|
-
setPrevious({
|
|
274
|
-
response,
|
|
275
|
-
...feedback,
|
|
276
|
-
});
|
|
277
|
-
setMessage('');
|
|
278
|
-
});
|
|
279
|
-
|
|
280
|
-
e?.preventDefault();
|
|
281
|
-
}
|
|
282
|
-
|
|
283
|
-
if (previous)
|
|
284
|
-
return (
|
|
285
|
-
<div
|
|
286
|
-
{...container}
|
|
287
|
-
className={cn(
|
|
288
|
-
'flex flex-col items-center justify-center gap-2 text-fd-muted-foreground text-center',
|
|
289
|
-
container.className,
|
|
290
|
-
)}
|
|
291
|
-
>
|
|
292
|
-
<p>{t('Thank you for your feedback!', { note: 'ui.feedback.thanks' })}</p>
|
|
293
|
-
<div className="flex flex-row items-center gap-2">
|
|
294
|
-
<a
|
|
295
|
-
href={previous.response?.githubUrl}
|
|
296
|
-
rel="noreferrer noopener"
|
|
297
|
-
target="_blank"
|
|
298
|
-
className={cn(
|
|
299
|
-
buttonVariants({
|
|
300
|
-
variant: 'default',
|
|
301
|
-
}),
|
|
302
|
-
'text-xs',
|
|
303
|
-
)}
|
|
304
|
-
>
|
|
305
|
-
{t('View on GitHub', { note: 'ui.feedback.view_on_github' })}
|
|
306
|
-
</a>
|
|
307
|
-
|
|
308
|
-
<button
|
|
309
|
-
className={cn(
|
|
310
|
-
buttonVariants({
|
|
311
|
-
variant: 'secondary',
|
|
312
|
-
}),
|
|
313
|
-
'text-xs',
|
|
314
|
-
)}
|
|
315
|
-
onClick={() => {
|
|
316
|
-
setPrevious(null);
|
|
317
|
-
}}
|
|
318
|
-
>
|
|
319
|
-
{t('Submit Again', { note: 'ui.feedback.submit_again' })}
|
|
320
|
-
</button>
|
|
321
|
-
</div>
|
|
322
|
-
</div>
|
|
323
|
-
);
|
|
324
|
-
|
|
325
|
-
return (
|
|
326
|
-
<form
|
|
327
|
-
{...container}
|
|
328
|
-
className={cn('flex flex-col gap-2', container.className)}
|
|
329
|
-
onSubmit={submit}
|
|
330
|
-
>
|
|
331
|
-
<textarea
|
|
332
|
-
autoFocus
|
|
333
|
-
required
|
|
334
|
-
value={message}
|
|
335
|
-
onChange={(e) => setMessage(e.target.value)}
|
|
336
|
-
className="border rounded-lg bg-fd-secondary text-fd-secondary-foreground p-3 resize-none focus-visible:outline-none placeholder:text-fd-muted-foreground"
|
|
337
|
-
placeholder={t('Leave your feedback…', { note: 'ui.feedback.placeholder' })}
|
|
338
|
-
onKeyDown={(e) => {
|
|
339
|
-
if (e.nativeEvent.isComposing || e.keyCode === 229) return;
|
|
340
|
-
if (!e.shiftKey && e.key === 'Enter') {
|
|
341
|
-
submit(e);
|
|
342
|
-
}
|
|
343
|
-
}}
|
|
344
|
-
/>
|
|
345
|
-
<div className="grid grid-cols-2 gap-2 mt-auto">
|
|
346
|
-
<button
|
|
347
|
-
type="submit"
|
|
348
|
-
className={cn(buttonVariants({ variant: 'default', size: 'sm' }), 'gap-1.5')}
|
|
349
|
-
disabled={isPending}
|
|
350
|
-
>
|
|
351
|
-
<CornerDownRightIcon className="size-4" />
|
|
352
|
-
{t('Submit', { note: 'ui.feedback.submit' })}
|
|
353
|
-
</button>
|
|
354
|
-
<button
|
|
355
|
-
type="button"
|
|
356
|
-
className={cn(buttonVariants({ variant: 'secondary', size: 'sm' }), 'gap-1.5')}
|
|
357
|
-
disabled={isPending}
|
|
358
|
-
onClick={onClose}
|
|
359
|
-
>
|
|
360
|
-
{t('Close', { note: 'ui.feedback.close' })}
|
|
361
|
-
</button>
|
|
362
|
-
</div>
|
|
363
|
-
</form>
|
|
364
|
-
);
|
|
365
|
-
}
|
|
366
|
-
|
|
1
|
+
'use client';
|
|
2
|
+
import { cn } from '@terrafirmagreg-team/ui/lib/utils/cn';
|
|
3
|
+
import { injectHighlightStyle } from '@terrafirmagreg-team/ui/lib/utils/highlight-style';
|
|
4
|
+
import { autoUpdate, flip, offset, shift, useFloating } from '@floating-ui/react-dom';
|
|
5
|
+
import { buttonVariants } from '@terrafirmagreg-team/ui/components/ui/button';
|
|
6
|
+
import { CornerDownRightIcon } from 'lucide-react';
|
|
7
|
+
import {
|
|
8
|
+
type CSSProperties,
|
|
9
|
+
type HTMLAttributes,
|
|
10
|
+
type ReactNode,
|
|
11
|
+
type SyntheticEvent,
|
|
12
|
+
useEffect,
|
|
13
|
+
useEffectEvent,
|
|
14
|
+
useRef,
|
|
15
|
+
useState,
|
|
16
|
+
useTransition,
|
|
17
|
+
} from 'react';
|
|
18
|
+
import {
|
|
19
|
+
actionResponse,
|
|
20
|
+
blockFeedback,
|
|
21
|
+
type ActionResponse,
|
|
22
|
+
type BlockFeedback,
|
|
23
|
+
} from '../../lib/feedback-schema';
|
|
24
|
+
import { z } from 'zod/mini';
|
|
25
|
+
import { usePathname } from 'next/navigation';
|
|
26
|
+
import { useTranslations } from '@terrafirmagreg-team/translate/hooks/use-translations';
|
|
27
|
+
import { useSubmissionStorage } from '../../hooks/feedback/use-submission-storage';
|
|
28
|
+
|
|
29
|
+
const blockFeedbackResult = z.extend(blockFeedback, {
|
|
30
|
+
response: actionResponse,
|
|
31
|
+
});
|
|
32
|
+
|
|
33
|
+
export interface FeedbackTextProps {
|
|
34
|
+
onSendAction: (feedback: BlockFeedback) => Promise<ActionResponse>;
|
|
35
|
+
children?: ReactNode;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
export function FeedbackText({ onSendAction, children }: FeedbackTextProps) {
|
|
39
|
+
const [popup, _setPopup] = useState<{
|
|
40
|
+
mode: 'tooltip' | 'expanded';
|
|
41
|
+
blockId: string;
|
|
42
|
+
selection: string;
|
|
43
|
+
range: Range;
|
|
44
|
+
} | null>(null);
|
|
45
|
+
|
|
46
|
+
const containerRef = useRef<HTMLDivElement>(null);
|
|
47
|
+
const { refs, floatingStyles } = useFloating({
|
|
48
|
+
open: popup !== null,
|
|
49
|
+
placement: 'bottom',
|
|
50
|
+
middleware: [offset(6), flip(), shift({ padding: 8 })],
|
|
51
|
+
whileElementsMounted: autoUpdate,
|
|
52
|
+
});
|
|
53
|
+
|
|
54
|
+
function expandPopup() {
|
|
55
|
+
if (popup?.mode !== 'tooltip') return;
|
|
56
|
+
|
|
57
|
+
const highlight = new Highlight(popup.range);
|
|
58
|
+
CSS.highlights.set('fd-feedback-text', highlight);
|
|
59
|
+
injectHighlightStyle(
|
|
60
|
+
'fd-feedback-text',
|
|
61
|
+
'background-color:var(--color-fd-primary);color:var(--color-fd-primary-foreground)',
|
|
62
|
+
);
|
|
63
|
+
|
|
64
|
+
_setPopup({ ...popup, mode: 'expanded' });
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
function closePopup() {
|
|
68
|
+
if (popup?.mode === 'expanded') {
|
|
69
|
+
CSS.highlights.delete('fd-feedback-text');
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
_setPopup(null);
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
const updateSelectionPopover = useEffectEvent(() => {
|
|
76
|
+
if (popup && popup.mode === 'expanded') return;
|
|
77
|
+
|
|
78
|
+
const container = containerRef.current;
|
|
79
|
+
const selection = window.getSelection();
|
|
80
|
+
|
|
81
|
+
if (!container || !selection || selection.isCollapsed || selection.rangeCount === 0) {
|
|
82
|
+
closePopup();
|
|
83
|
+
return;
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
const range = selection.getRangeAt(0).cloneRange();
|
|
87
|
+
if (!container.contains(range.commonAncestorContainer)) {
|
|
88
|
+
closePopup();
|
|
89
|
+
return;
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
const selectionText = selection.toString().trim();
|
|
93
|
+
if (selectionText.length === 0 || selectionText.includes('\n')) {
|
|
94
|
+
closePopup();
|
|
95
|
+
return;
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
const element =
|
|
99
|
+
range.startContainer instanceof Element
|
|
100
|
+
? range.startContainer
|
|
101
|
+
: range.startContainer.parentElement;
|
|
102
|
+
const blockId = element?.closest('[data-block="feedback"]')?.id;
|
|
103
|
+
if (!blockId) {
|
|
104
|
+
closePopup();
|
|
105
|
+
return;
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
refs.setReference({
|
|
109
|
+
getBoundingClientRect() {
|
|
110
|
+
return range.getBoundingClientRect();
|
|
111
|
+
},
|
|
112
|
+
contextElement: container,
|
|
113
|
+
});
|
|
114
|
+
|
|
115
|
+
_setPopup({ mode: 'tooltip', range, selection: selectionText, blockId });
|
|
116
|
+
});
|
|
117
|
+
|
|
118
|
+
const closeOnEscape = useEffectEvent((event: KeyboardEvent) => {
|
|
119
|
+
if (popup === null) return;
|
|
120
|
+
if (event.key === 'Escape') closePopup();
|
|
121
|
+
});
|
|
122
|
+
|
|
123
|
+
const closeOnPointerDown = useEffectEvent((event: PointerEvent) => {
|
|
124
|
+
const target = event.target;
|
|
125
|
+
if (popup === null || !(target instanceof Node)) return;
|
|
126
|
+
|
|
127
|
+
if (
|
|
128
|
+
refs.floating.current?.contains(target) ||
|
|
129
|
+
(popup.mode === 'tooltip' && containerRef.current?.contains(target))
|
|
130
|
+
) {
|
|
131
|
+
return;
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
closePopup();
|
|
135
|
+
});
|
|
136
|
+
|
|
137
|
+
useEffect(() => {
|
|
138
|
+
let frame: number | null = null;
|
|
139
|
+
|
|
140
|
+
function scheduleSelectionPopover() {
|
|
141
|
+
if (frame !== null) window.cancelAnimationFrame(frame);
|
|
142
|
+
|
|
143
|
+
frame = window.requestAnimationFrame(() => {
|
|
144
|
+
frame = null;
|
|
145
|
+
updateSelectionPopover();
|
|
146
|
+
});
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
document.addEventListener('selectionchange', scheduleSelectionPopover);
|
|
150
|
+
document.addEventListener('keydown', closeOnEscape);
|
|
151
|
+
document.addEventListener('pointerdown', closeOnPointerDown);
|
|
152
|
+
|
|
153
|
+
return () => {
|
|
154
|
+
document.removeEventListener('keydown', closeOnEscape);
|
|
155
|
+
document.removeEventListener('pointerdown', closeOnPointerDown);
|
|
156
|
+
document.removeEventListener('selectionchange', scheduleSelectionPopover);
|
|
157
|
+
if (frame !== null) window.cancelAnimationFrame(frame);
|
|
158
|
+
};
|
|
159
|
+
}, []);
|
|
160
|
+
|
|
161
|
+
return (
|
|
162
|
+
<>
|
|
163
|
+
<div
|
|
164
|
+
ref={containerRef}
|
|
165
|
+
className="prose-no-margin"
|
|
166
|
+
>
|
|
167
|
+
{children}
|
|
168
|
+
</div>
|
|
169
|
+
|
|
170
|
+
{popup && (
|
|
171
|
+
<FeedbackTextPopup
|
|
172
|
+
popup={popup}
|
|
173
|
+
refs={refs}
|
|
174
|
+
floatingStyles={floatingStyles}
|
|
175
|
+
expandPopup={expandPopup}
|
|
176
|
+
closePopup={closePopup}
|
|
177
|
+
onSendAction={onSendAction}
|
|
178
|
+
/>
|
|
179
|
+
)}
|
|
180
|
+
</>
|
|
181
|
+
);
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
function FeedbackTextPopup({
|
|
185
|
+
popup,
|
|
186
|
+
refs,
|
|
187
|
+
floatingStyles,
|
|
188
|
+
expandPopup,
|
|
189
|
+
closePopup,
|
|
190
|
+
onSendAction,
|
|
191
|
+
}: {
|
|
192
|
+
popup: {
|
|
193
|
+
mode: 'tooltip' | 'expanded';
|
|
194
|
+
blockId: string;
|
|
195
|
+
selection: string;
|
|
196
|
+
range: Range;
|
|
197
|
+
};
|
|
198
|
+
refs: ReturnType<typeof useFloating>['refs'];
|
|
199
|
+
floatingStyles: CSSProperties;
|
|
200
|
+
expandPopup: () => void;
|
|
201
|
+
closePopup: () => void;
|
|
202
|
+
onSendAction: (feedback: BlockFeedback) => Promise<ActionResponse>;
|
|
203
|
+
}) {
|
|
204
|
+
const t = useTranslations();
|
|
205
|
+
return (
|
|
206
|
+
<div
|
|
207
|
+
ref={(el) => refs.setFloating(el)}
|
|
208
|
+
className={cn(
|
|
209
|
+
'not-prose z-40 text-sm bg-fd-popover text-fd-popover-foreground border overflow-hidden shadow-lg rounded-xl w-30 h-9.5 box-content transition-[width,height]',
|
|
210
|
+
popup.mode === 'expanded' ? 'w-[300px] h-32 max-w-[98vw]' : 'select-none',
|
|
211
|
+
)}
|
|
212
|
+
style={floatingStyles}
|
|
213
|
+
>
|
|
214
|
+
{popup.mode === 'tooltip' ? (
|
|
215
|
+
<div className="w-30 h-9.5 p-1">
|
|
216
|
+
<button
|
|
217
|
+
className={cn(
|
|
218
|
+
buttonVariants({ variant: 'ghost', size: 'sm' }),
|
|
219
|
+
'size-full gap-1.5',
|
|
220
|
+
)}
|
|
221
|
+
onClick={expandPopup}
|
|
222
|
+
>
|
|
223
|
+
<CornerDownRightIcon className="size-4 text-fd-muted-foreground" />
|
|
224
|
+
{t('Feedback', { note: 'ui.feedback.tooltip' })}
|
|
225
|
+
</button>
|
|
226
|
+
</div>
|
|
227
|
+
) : (
|
|
228
|
+
<FeedbackTextForm
|
|
229
|
+
blockId={popup.blockId}
|
|
230
|
+
selection={popup.selection}
|
|
231
|
+
onSendAction={onSendAction}
|
|
232
|
+
onClose={closePopup}
|
|
233
|
+
container={{ className: 'p-2 w-[300px] h-32 max-w-[98vw] animate-fd-fade-in' }}
|
|
234
|
+
/>
|
|
235
|
+
)}
|
|
236
|
+
</div>
|
|
237
|
+
);
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
function FeedbackTextForm({
|
|
241
|
+
blockId,
|
|
242
|
+
selection,
|
|
243
|
+
onSendAction,
|
|
244
|
+
onClose,
|
|
245
|
+
container,
|
|
246
|
+
}: {
|
|
247
|
+
container: HTMLAttributes<HTMLElement>;
|
|
248
|
+
blockId: string;
|
|
249
|
+
selection: string;
|
|
250
|
+
onSendAction: (feedback: BlockFeedback) => Promise<ActionResponse>;
|
|
251
|
+
onClose: () => void;
|
|
252
|
+
}) {
|
|
253
|
+
const t = useTranslations();
|
|
254
|
+
const pathname = usePathname();
|
|
255
|
+
const { previous, setPrevious } = useSubmissionStorage(`${pathname}-${blockId}`, (v) => {
|
|
256
|
+
const result = blockFeedbackResult.safeParse(v);
|
|
257
|
+
if (result.success) return result.data;
|
|
258
|
+
return null;
|
|
259
|
+
});
|
|
260
|
+
const [message, setMessage] = useState('');
|
|
261
|
+
const [isPending, startTransition] = useTransition();
|
|
262
|
+
|
|
263
|
+
function submit(e?: SyntheticEvent) {
|
|
264
|
+
startTransition(async () => {
|
|
265
|
+
const feedback: BlockFeedback = {
|
|
266
|
+
blockId,
|
|
267
|
+
blockBody: selection,
|
|
268
|
+
url: location.href,
|
|
269
|
+
message,
|
|
270
|
+
};
|
|
271
|
+
|
|
272
|
+
const response = await onSendAction(feedback);
|
|
273
|
+
setPrevious({
|
|
274
|
+
response,
|
|
275
|
+
...feedback,
|
|
276
|
+
});
|
|
277
|
+
setMessage('');
|
|
278
|
+
});
|
|
279
|
+
|
|
280
|
+
e?.preventDefault();
|
|
281
|
+
}
|
|
282
|
+
|
|
283
|
+
if (previous)
|
|
284
|
+
return (
|
|
285
|
+
<div
|
|
286
|
+
{...container}
|
|
287
|
+
className={cn(
|
|
288
|
+
'flex flex-col items-center justify-center gap-2 text-fd-muted-foreground text-center',
|
|
289
|
+
container.className,
|
|
290
|
+
)}
|
|
291
|
+
>
|
|
292
|
+
<p>{t('Thank you for your feedback!', { note: 'ui.feedback.thanks' })}</p>
|
|
293
|
+
<div className="flex flex-row items-center gap-2">
|
|
294
|
+
<a
|
|
295
|
+
href={previous.response?.githubUrl}
|
|
296
|
+
rel="noreferrer noopener"
|
|
297
|
+
target="_blank"
|
|
298
|
+
className={cn(
|
|
299
|
+
buttonVariants({
|
|
300
|
+
variant: 'default',
|
|
301
|
+
}),
|
|
302
|
+
'text-xs',
|
|
303
|
+
)}
|
|
304
|
+
>
|
|
305
|
+
{t('View on GitHub', { note: 'ui.feedback.view_on_github' })}
|
|
306
|
+
</a>
|
|
307
|
+
|
|
308
|
+
<button
|
|
309
|
+
className={cn(
|
|
310
|
+
buttonVariants({
|
|
311
|
+
variant: 'secondary',
|
|
312
|
+
}),
|
|
313
|
+
'text-xs',
|
|
314
|
+
)}
|
|
315
|
+
onClick={() => {
|
|
316
|
+
setPrevious(null);
|
|
317
|
+
}}
|
|
318
|
+
>
|
|
319
|
+
{t('Submit Again', { note: 'ui.feedback.submit_again' })}
|
|
320
|
+
</button>
|
|
321
|
+
</div>
|
|
322
|
+
</div>
|
|
323
|
+
);
|
|
324
|
+
|
|
325
|
+
return (
|
|
326
|
+
<form
|
|
327
|
+
{...container}
|
|
328
|
+
className={cn('flex flex-col gap-2', container.className)}
|
|
329
|
+
onSubmit={submit}
|
|
330
|
+
>
|
|
331
|
+
<textarea
|
|
332
|
+
autoFocus
|
|
333
|
+
required
|
|
334
|
+
value={message}
|
|
335
|
+
onChange={(e) => setMessage(e.target.value)}
|
|
336
|
+
className="border rounded-lg bg-fd-secondary text-fd-secondary-foreground p-3 resize-none focus-visible:outline-none placeholder:text-fd-muted-foreground"
|
|
337
|
+
placeholder={t('Leave your feedback…', { note: 'ui.feedback.placeholder' })}
|
|
338
|
+
onKeyDown={(e) => {
|
|
339
|
+
if (e.nativeEvent.isComposing || e.keyCode === 229) return;
|
|
340
|
+
if (!e.shiftKey && e.key === 'Enter') {
|
|
341
|
+
submit(e);
|
|
342
|
+
}
|
|
343
|
+
}}
|
|
344
|
+
/>
|
|
345
|
+
<div className="grid grid-cols-2 gap-2 mt-auto">
|
|
346
|
+
<button
|
|
347
|
+
type="submit"
|
|
348
|
+
className={cn(buttonVariants({ variant: 'default', size: 'sm' }), 'gap-1.5')}
|
|
349
|
+
disabled={isPending}
|
|
350
|
+
>
|
|
351
|
+
<CornerDownRightIcon className="size-4" />
|
|
352
|
+
{t('Submit', { note: 'ui.feedback.submit' })}
|
|
353
|
+
</button>
|
|
354
|
+
<button
|
|
355
|
+
type="button"
|
|
356
|
+
className={cn(buttonVariants({ variant: 'secondary', size: 'sm' }), 'gap-1.5')}
|
|
357
|
+
disabled={isPending}
|
|
358
|
+
onClick={onClose}
|
|
359
|
+
>
|
|
360
|
+
{t('Close', { note: 'ui.feedback.close' })}
|
|
361
|
+
</button>
|
|
362
|
+
</div>
|
|
363
|
+
</form>
|
|
364
|
+
);
|
|
365
|
+
}
|
|
366
|
+
|