@toolr/ui-design 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +63 -0
- package/components/content/info-panel-primitives.tsx +297 -0
- package/components/diagrams/diagram-utils.tsx +908 -0
- package/components/hooks/use-click-outside.ts +27 -0
- package/components/hooks/use-dropdown-max-height.ts +20 -0
- package/components/hooks/use-navigation-history.ts +94 -0
- package/components/lib/ai-tools.tsx +44 -0
- package/components/lib/cn.ts +6 -0
- package/components/lib/form-colors.ts +32 -0
- package/components/lib/theme-engine.ts +97 -0
- package/components/lib/toolr-brand.tsx +31 -0
- package/components/sections/ai-tools-paths/index.ts +37 -0
- package/components/sections/ai-tools-paths/tools-paths-panel.tsx +212 -0
- package/components/sections/ai-tools-paths/types.ts +111 -0
- package/components/sections/ai-tools-paths/use-tools-paths.ts +159 -0
- package/components/sections/captured-issues/captured-issues-panel.tsx +214 -0
- package/components/sections/captured-issues/index.ts +38 -0
- package/components/sections/captured-issues/types.ts +113 -0
- package/components/sections/captured-issues/use-captured-issues.ts +111 -0
- package/components/sections/golden-snapshots/file-diff-viewer.tsx +420 -0
- package/components/sections/golden-snapshots/golden-sync-panel.tsx +223 -0
- package/components/sections/golden-snapshots/index.ts +145 -0
- package/components/sections/golden-snapshots/snapshot-manager.tsx +200 -0
- package/components/sections/golden-snapshots/status-overview.tsx +305 -0
- package/components/sections/golden-snapshots/types.ts +288 -0
- package/components/sections/golden-snapshots/use-golden-sync.ts +477 -0
- package/components/sections/golden-snapshots/version-manager.tsx +186 -0
- package/components/sections/prompt-editor/file-type-tabbed-prompt-editor.tsx +210 -0
- package/components/sections/prompt-editor/index.ts +121 -0
- package/components/sections/prompt-editor/simulator-prompt-editor.tsx +276 -0
- package/components/sections/prompt-editor/tabbed-prompt-editor.tsx +514 -0
- package/components/sections/prompt-editor/types.ts +101 -0
- package/components/sections/prompt-editor/use-prompt-editor.ts +131 -0
- package/components/sections/report-bug/error-logger.ts +392 -0
- package/components/sections/report-bug/index.ts +59 -0
- package/components/sections/report-bug/issue-reporter-api.ts +83 -0
- package/components/sections/report-bug/report-bug-form.tsx +282 -0
- package/components/sections/report-bug/screenshot-uploader.tsx +228 -0
- package/components/sections/report-bug/use-report-bug.ts +170 -0
- package/components/sections/snapshot-browser/index.ts +53 -0
- package/components/sections/snapshot-browser/snapshot-browser-panel.tsx +147 -0
- package/components/sections/snapshot-browser/snapshot-tree.tsx +451 -0
- package/components/sections/snapshot-browser/types.ts +106 -0
- package/components/sections/snapshot-browser/use-snapshot-browser.ts +125 -0
- package/components/sections/snippets-editor/index.ts +31 -0
- package/components/sections/snippets-editor/snippets-editor.tsx +381 -0
- package/components/sections/snippets-editor/types.ts +48 -0
- package/components/sections/snippets-editor/use-snippets-editor.ts +217 -0
- package/components/ui/action-dialog.tsx +309 -0
- package/components/ui/ai-action-button.tsx +137 -0
- package/components/ui/ai-execution-action-buttons.tsx +106 -0
- package/components/ui/badge.tsx +67 -0
- package/components/ui/bottom-panel-header.tsx +240 -0
- package/components/ui/breadcrumb.tsx +168 -0
- package/components/ui/checkbox.tsx +102 -0
- package/components/ui/collapsible-section.tsx +100 -0
- package/components/ui/confirm-badge.tsx +71 -0
- package/components/ui/detail-section.tsx +67 -0
- package/components/ui/detail-view-wrapper.tsx +55 -0
- package/components/ui/editor-placeholder-card.tsx +197 -0
- package/components/ui/editor-toolbar.tsx +123 -0
- package/components/ui/execution-details-panel.tsx +93 -0
- package/components/ui/extension-list-card.tsx +105 -0
- package/components/ui/file-structure-section.tsx +373 -0
- package/components/ui/file-tree.tsx +171 -0
- package/components/ui/files-panel.tsx +251 -0
- package/components/ui/filter-dropdown.tsx +173 -0
- package/components/ui/form-actions.tsx +127 -0
- package/components/ui/frontmatter-form-header.tsx +80 -0
- package/components/ui/icon-button.tsx +388 -0
- package/components/ui/input.tsx +211 -0
- package/components/ui/label.tsx +159 -0
- package/components/ui/layout-tab-bar.tsx +289 -0
- package/components/ui/modal.tsx +194 -0
- package/components/ui/nav-card.tsx +81 -0
- package/components/ui/navigation-bar.tsx +285 -0
- package/components/ui/number-input.tsx +165 -0
- package/components/ui/registry-browser.tsx +261 -0
- package/components/ui/registry-card.tsx +710 -0
- package/components/ui/registry-detail.tsx +224 -0
- package/components/ui/resizable-textarea.tsx +290 -0
- package/components/ui/scope-badge.tsx +67 -0
- package/components/ui/segmented-toggle.tsx +133 -0
- package/components/ui/select.tsx +172 -0
- package/components/ui/selection-grid.tsx +313 -0
- package/components/ui/setting-row.tsx +97 -0
- package/components/ui/snapshot-card.tsx +107 -0
- package/components/ui/snippets-panel.tsx +161 -0
- package/components/ui/sort-dropdown.tsx +109 -0
- package/components/ui/status-card.tsx +96 -0
- package/components/ui/tab-bar.tsx +340 -0
- package/components/ui/toggle.tsx +142 -0
- package/components/ui/tooltip.tsx +326 -0
- package/dist/content.d.ts +110 -0
- package/dist/content.js +195 -0
- package/dist/diagrams.d.ts +371 -0
- package/dist/diagrams.js +702 -0
- package/dist/index.d.ts +2714 -0
- package/dist/index.js +11220 -0
- package/dist/preset.d.ts +24 -0
- package/dist/preset.js +17 -0
- package/dist/tokens/tokens/primitives.css +45 -0
- package/dist/tokens/tokens/semantic.css +46 -0
- package/dist/tokens/tokens/theme.css +11 -0
- package/dist/tokens/tokens/tokens.json +65 -0
- package/index.ts +123 -0
- package/package.json +63 -0
- package/tailwind-preset.ts +22 -0
- package/tokens/primitives.css +45 -0
- package/tokens/semantic.css +46 -0
- package/tokens/theme.css +11 -0
- package/tokens/tokens.json +65 -0
|
@@ -0,0 +1,282 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* ReportBugForm — Self-contained issue reporting form
|
|
3
|
+
*
|
|
4
|
+
* Part of: Sections > Report a Bug
|
|
5
|
+
*
|
|
6
|
+
* This is the main "drop in and it works" component. Provide configuration
|
|
7
|
+
* (worker URL, system info function, Linear team) and it handles the full
|
|
8
|
+
* submission flow including screenshots, error logs, and status feedback.
|
|
9
|
+
*
|
|
10
|
+
* Usage:
|
|
11
|
+
* <ReportBugForm
|
|
12
|
+
* workerUrl="https://your-worker.workers.dev"
|
|
13
|
+
* getSystemInfo={getSystemInfo}
|
|
14
|
+
* linearTeamId="your-team-uuid"
|
|
15
|
+
* onSuccess={(result) => showToast(`Created ${result.issueId}`)}
|
|
16
|
+
* onError={(msg) => showToast(msg, 'error')}
|
|
17
|
+
* />
|
|
18
|
+
*
|
|
19
|
+
* AI agent notes:
|
|
20
|
+
* - This component manages all form state internally via useReportBug hook
|
|
21
|
+
* - It renders: issue type pills, title/description inputs, email, screenshot
|
|
22
|
+
* uploader, optional "include logs" checkbox, and submit/cancel buttons
|
|
23
|
+
* - Error data (errorReport, errorCount, warnCount) is optional — when provided,
|
|
24
|
+
* a checkbox lets the user include captured logs with their submission
|
|
25
|
+
* - The submitFn prop overrides the default API call for testing/mocking
|
|
26
|
+
* - Consuming apps handle success/error feedback via callbacks (modals, toasts, etc.)
|
|
27
|
+
* - Uses ui-design components (Input, ResizableTextarea, Checkbox) for consistency
|
|
28
|
+
*/
|
|
29
|
+
|
|
30
|
+
import { Loader2, Send } from 'lucide-react'
|
|
31
|
+
import { cn } from '../../lib/cn.ts'
|
|
32
|
+
import { Input } from '../../ui/input.tsx'
|
|
33
|
+
import { ResizableTextarea } from '../../ui/resizable-textarea.tsx'
|
|
34
|
+
import { Checkbox } from '../../ui/checkbox.tsx'
|
|
35
|
+
import { ScreenshotUploader } from './screenshot-uploader.tsx'
|
|
36
|
+
import { useReportBug, type UseReportBugOptions } from './use-report-bug.ts'
|
|
37
|
+
import { ISSUE_TYPES, type IssueType, type IssueReport, type IssueReportResult, type SystemInfo } from './issue-reporter-api.ts'
|
|
38
|
+
import type { TrackedError } from './error-logger.ts'
|
|
39
|
+
|
|
40
|
+
export interface ReportBugFormProps {
|
|
41
|
+
/** URL of the issue reporter worker endpoint */
|
|
42
|
+
workerUrl: string
|
|
43
|
+
/** Function that returns system info. Each app provides its own implementation. */
|
|
44
|
+
getSystemInfo: () => Promise<SystemInfo>
|
|
45
|
+
/** Linear team ID — routes issues to the correct team */
|
|
46
|
+
linearTeamId?: string
|
|
47
|
+
/** Linear project ID — associates issues with a project */
|
|
48
|
+
linearProjectId?: string
|
|
49
|
+
/** Pre-formatted error report to optionally include (from error logger) */
|
|
50
|
+
errorReport?: string
|
|
51
|
+
/** Error fingerprint for duplicate detection */
|
|
52
|
+
errorFingerprint?: string
|
|
53
|
+
/** Number of captured errors (shown in checkbox label) */
|
|
54
|
+
errorCount?: number
|
|
55
|
+
/** Number of captured warnings */
|
|
56
|
+
warnCount?: number
|
|
57
|
+
/** Captured error entries to display when "include logs" is checked */
|
|
58
|
+
capturedErrors?: TrackedError[]
|
|
59
|
+
/** Called after successful submission */
|
|
60
|
+
onSuccess?: (result: IssueReportResult) => void
|
|
61
|
+
/** Called on submission failure */
|
|
62
|
+
onError?: (message: string) => void
|
|
63
|
+
/** Called when user clicks cancel (hidden if not provided) */
|
|
64
|
+
onCancel?: () => void
|
|
65
|
+
/** Called when error logs are included in a successful submission */
|
|
66
|
+
onErrorsSubmitted?: () => void
|
|
67
|
+
/** Override default API submission (for testing/mocking) */
|
|
68
|
+
submitFn?: (report: IssueReport) => Promise<IssueReportResult>
|
|
69
|
+
className?: string
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
export function ReportBugForm({
|
|
73
|
+
workerUrl,
|
|
74
|
+
getSystemInfo,
|
|
75
|
+
linearTeamId,
|
|
76
|
+
linearProjectId,
|
|
77
|
+
errorReport,
|
|
78
|
+
errorFingerprint,
|
|
79
|
+
errorCount,
|
|
80
|
+
warnCount,
|
|
81
|
+
capturedErrors,
|
|
82
|
+
onSuccess,
|
|
83
|
+
onError,
|
|
84
|
+
onCancel,
|
|
85
|
+
onErrorsSubmitted,
|
|
86
|
+
submitFn,
|
|
87
|
+
className,
|
|
88
|
+
}: ReportBugFormProps) {
|
|
89
|
+
const hookOptions: UseReportBugOptions = {
|
|
90
|
+
workerUrl,
|
|
91
|
+
getSystemInfo,
|
|
92
|
+
linearTeamId,
|
|
93
|
+
linearProjectId,
|
|
94
|
+
errorReport,
|
|
95
|
+
errorFingerprint,
|
|
96
|
+
onSuccess,
|
|
97
|
+
onError,
|
|
98
|
+
onErrorsSubmitted,
|
|
99
|
+
submitFn,
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
const {
|
|
103
|
+
issueType,
|
|
104
|
+
setIssueType,
|
|
105
|
+
title,
|
|
106
|
+
setTitle,
|
|
107
|
+
description,
|
|
108
|
+
setDescription,
|
|
109
|
+
email,
|
|
110
|
+
setEmail,
|
|
111
|
+
screenshots,
|
|
112
|
+
setScreenshots,
|
|
113
|
+
includeLogs,
|
|
114
|
+
setIncludeLogs,
|
|
115
|
+
isSubmitting,
|
|
116
|
+
submissionStatus,
|
|
117
|
+
emailHasError,
|
|
118
|
+
canSubmit,
|
|
119
|
+
handleSubmit,
|
|
120
|
+
} = useReportBug(hookOptions)
|
|
121
|
+
|
|
122
|
+
const hasErrors = (errorCount ?? 0) > 0 || (warnCount ?? 0) > 0
|
|
123
|
+
|
|
124
|
+
return (
|
|
125
|
+
<div className={cn('rounded-lg border border-neutral-700 bg-neutral-800', className)}>
|
|
126
|
+
<div className="border-b border-neutral-700 px-4 py-3">
|
|
127
|
+
<h3 className="text-sm font-medium text-neutral-200">Report a Bug</h3>
|
|
128
|
+
</div>
|
|
129
|
+
|
|
130
|
+
<div className="space-y-4 p-4">
|
|
131
|
+
{/* Issue type pills */}
|
|
132
|
+
<div>
|
|
133
|
+
<label className="mb-2 block text-xs text-neutral-400">Issue Type</label>
|
|
134
|
+
<div className="flex flex-wrap gap-2">
|
|
135
|
+
{ISSUE_TYPES.map((type) => (
|
|
136
|
+
<button
|
|
137
|
+
key={type.value}
|
|
138
|
+
type="button"
|
|
139
|
+
onClick={() => setIssueType(type.value as IssueType)}
|
|
140
|
+
className={cn(
|
|
141
|
+
'px-3 py-1.5 text-xs font-medium rounded-md transition-all',
|
|
142
|
+
issueType === type.value
|
|
143
|
+
? 'bg-blue-600 text-white'
|
|
144
|
+
: 'bg-neutral-700 text-neutral-400 hover:bg-neutral-600 hover:text-neutral-200',
|
|
145
|
+
)}
|
|
146
|
+
>
|
|
147
|
+
{type.label}
|
|
148
|
+
</button>
|
|
149
|
+
))}
|
|
150
|
+
</div>
|
|
151
|
+
</div>
|
|
152
|
+
|
|
153
|
+
{/* Title */}
|
|
154
|
+
<div>
|
|
155
|
+
<label className="mb-1.5 block text-xs text-neutral-400">
|
|
156
|
+
Title <span className="text-red-400">*</span>
|
|
157
|
+
</label>
|
|
158
|
+
<Input
|
|
159
|
+
value={title}
|
|
160
|
+
onChange={setTitle}
|
|
161
|
+
placeholder="Brief summary of the issue"
|
|
162
|
+
size="md"
|
|
163
|
+
/>
|
|
164
|
+
</div>
|
|
165
|
+
|
|
166
|
+
{/* Description */}
|
|
167
|
+
<div>
|
|
168
|
+
<label className="mb-1.5 block text-xs text-neutral-400">
|
|
169
|
+
Description <span className="text-red-400">*</span>
|
|
170
|
+
</label>
|
|
171
|
+
<ResizableTextarea
|
|
172
|
+
value={description}
|
|
173
|
+
onChange={(e) => setDescription(e.target.value)}
|
|
174
|
+
placeholder="Describe the issue in detail. Include steps to reproduce if applicable."
|
|
175
|
+
rows={6}
|
|
176
|
+
className="w-full px-3 py-1.5 bg-neutral-800 border border-neutral-700 rounded-lg text-sm text-neutral-200 placeholder-neutral-500 focus:outline-none focus:border-blue-500 transition-colors resize-none min-h-[120px]"
|
|
177
|
+
/>
|
|
178
|
+
</div>
|
|
179
|
+
|
|
180
|
+
{/* Email */}
|
|
181
|
+
<div>
|
|
182
|
+
<label className="mb-1.5 block text-xs text-neutral-400">Email (optional)</label>
|
|
183
|
+
<Input
|
|
184
|
+
type="text"
|
|
185
|
+
value={email}
|
|
186
|
+
onChange={setEmail}
|
|
187
|
+
placeholder="Your email for follow-up"
|
|
188
|
+
error={emailHasError ? 'Please enter a valid email address' : undefined}
|
|
189
|
+
size="md"
|
|
190
|
+
/>
|
|
191
|
+
</div>
|
|
192
|
+
|
|
193
|
+
{/* Screenshots */}
|
|
194
|
+
<div>
|
|
195
|
+
<label className="mb-2 block text-xs text-neutral-400">
|
|
196
|
+
Screenshots (optional, max 20MB total)
|
|
197
|
+
</label>
|
|
198
|
+
<ScreenshotUploader
|
|
199
|
+
screenshots={screenshots}
|
|
200
|
+
onChange={setScreenshots}
|
|
201
|
+
disabled={isSubmitting}
|
|
202
|
+
/>
|
|
203
|
+
</div>
|
|
204
|
+
|
|
205
|
+
{/* Include logs checkbox */}
|
|
206
|
+
{hasErrors && errorReport && (
|
|
207
|
+
<div className="space-y-2">
|
|
208
|
+
<div className="flex items-center gap-2.5">
|
|
209
|
+
<Checkbox checked={includeLogs} onChange={setIncludeLogs} />
|
|
210
|
+
<span
|
|
211
|
+
className="text-sm text-neutral-400 cursor-pointer select-none"
|
|
212
|
+
onClick={() => setIncludeLogs(!includeLogs)}
|
|
213
|
+
>
|
|
214
|
+
Include error logs
|
|
215
|
+
{errorCount ? (
|
|
216
|
+
<span className="text-orange-400 ml-1">
|
|
217
|
+
({errorCount} error{errorCount !== 1 ? 's' : ''} captured)
|
|
218
|
+
</span>
|
|
219
|
+
) : null}
|
|
220
|
+
</span>
|
|
221
|
+
</div>
|
|
222
|
+
|
|
223
|
+
{/* Captured errors list */}
|
|
224
|
+
{includeLogs && capturedErrors && capturedErrors.length > 0 && (
|
|
225
|
+
<div className="rounded-md border border-neutral-700 bg-neutral-900/50">
|
|
226
|
+
<div className="px-3 py-2 border-b border-neutral-700">
|
|
227
|
+
<span className="text-xs font-medium text-neutral-400">Captured Errors</span>
|
|
228
|
+
</div>
|
|
229
|
+
<div className="max-h-[200px] overflow-y-auto divide-y divide-neutral-800">
|
|
230
|
+
{capturedErrors.map((error) => (
|
|
231
|
+
<div key={error.fingerprint} className="px-3 py-2 text-xs">
|
|
232
|
+
<div className="flex items-start gap-2">
|
|
233
|
+
{error.count > 1 && (
|
|
234
|
+
<span className="shrink-0 text-orange-400 font-mono font-medium">
|
|
235
|
+
×{error.count}
|
|
236
|
+
</span>
|
|
237
|
+
)}
|
|
238
|
+
<span className="text-neutral-300 break-all font-mono leading-relaxed">
|
|
239
|
+
{error.firstMessage}
|
|
240
|
+
</span>
|
|
241
|
+
</div>
|
|
242
|
+
</div>
|
|
243
|
+
))}
|
|
244
|
+
</div>
|
|
245
|
+
</div>
|
|
246
|
+
)}
|
|
247
|
+
</div>
|
|
248
|
+
)}
|
|
249
|
+
</div>
|
|
250
|
+
|
|
251
|
+
{/* Footer */}
|
|
252
|
+
<div className="flex items-center justify-end gap-2 border-t border-neutral-700 px-4 py-3">
|
|
253
|
+
{isSubmitting && submissionStatus && (
|
|
254
|
+
<span className="text-xs text-neutral-400 mr-auto">{submissionStatus}</span>
|
|
255
|
+
)}
|
|
256
|
+
{onCancel && (
|
|
257
|
+
<button
|
|
258
|
+
type="button"
|
|
259
|
+
onClick={onCancel}
|
|
260
|
+
disabled={isSubmitting}
|
|
261
|
+
className="rounded-md border border-neutral-700 bg-transparent px-3 py-1.5 text-sm text-neutral-400 transition-colors hover:bg-neutral-700 hover:text-neutral-200 disabled:opacity-50"
|
|
262
|
+
>
|
|
263
|
+
Cancel
|
|
264
|
+
</button>
|
|
265
|
+
)}
|
|
266
|
+
<button
|
|
267
|
+
type="button"
|
|
268
|
+
onClick={handleSubmit}
|
|
269
|
+
disabled={!canSubmit}
|
|
270
|
+
className="flex items-center gap-1.5 rounded-md bg-blue-600 px-3 py-1.5 text-sm text-white transition-colors hover:bg-blue-500 disabled:cursor-not-allowed disabled:opacity-50"
|
|
271
|
+
>
|
|
272
|
+
{isSubmitting ? (
|
|
273
|
+
<Loader2 className="w-3.5 h-3.5 animate-spin" />
|
|
274
|
+
) : (
|
|
275
|
+
<Send className="w-3.5 h-3.5" />
|
|
276
|
+
)}
|
|
277
|
+
Submit
|
|
278
|
+
</button>
|
|
279
|
+
</div>
|
|
280
|
+
</div>
|
|
281
|
+
)
|
|
282
|
+
}
|
|
@@ -0,0 +1,228 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* ScreenshotUploader — Drag & drop multi-image upload
|
|
3
|
+
*
|
|
4
|
+
* Part of: Sections > Report a Bug
|
|
5
|
+
* Also usable standalone for any image upload scenario.
|
|
6
|
+
*
|
|
7
|
+
* Features:
|
|
8
|
+
* - Drag & drop or click to select images
|
|
9
|
+
* - Base64 encoding for API submission
|
|
10
|
+
* - Preview thumbnails in a 3-column grid
|
|
11
|
+
* - Total size limit enforcement (default 20MB)
|
|
12
|
+
* - Individual screenshot removal
|
|
13
|
+
*
|
|
14
|
+
* AI agent notes:
|
|
15
|
+
* - Fully controlled component: parent owns the screenshots array
|
|
16
|
+
* - Screenshot.data is base64 without the data URL prefix
|
|
17
|
+
* - The Screenshot type (with id, size) is for UI management;
|
|
18
|
+
* ScreenshotAttachment (without id, size) is what gets sent to the API
|
|
19
|
+
* - Used by ReportBugForm but exported independently
|
|
20
|
+
*/
|
|
21
|
+
|
|
22
|
+
import { useCallback, useRef, useState } from 'react'
|
|
23
|
+
import { ImagePlus, X, AlertCircle } from 'lucide-react'
|
|
24
|
+
import { cn } from '../../lib/cn.ts'
|
|
25
|
+
|
|
26
|
+
export interface Screenshot {
|
|
27
|
+
id: string
|
|
28
|
+
filename: string
|
|
29
|
+
data: string
|
|
30
|
+
contentType: string
|
|
31
|
+
size: number
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
export interface ScreenshotUploaderProps {
|
|
35
|
+
screenshots: Screenshot[]
|
|
36
|
+
onChange: (screenshots: Screenshot[]) => void
|
|
37
|
+
maxTotalSize?: number
|
|
38
|
+
disabled?: boolean
|
|
39
|
+
className?: string
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
const DEFAULT_MAX_SIZE = 20 * 1024 * 1024
|
|
43
|
+
|
|
44
|
+
function formatFileSize(bytes: number): string {
|
|
45
|
+
if (bytes < 1024) return `${bytes} B`
|
|
46
|
+
if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`
|
|
47
|
+
return `${(bytes / (1024 * 1024)).toFixed(1)} MB`
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
export function ScreenshotUploader({
|
|
51
|
+
screenshots,
|
|
52
|
+
onChange,
|
|
53
|
+
maxTotalSize = DEFAULT_MAX_SIZE,
|
|
54
|
+
disabled = false,
|
|
55
|
+
className,
|
|
56
|
+
}: ScreenshotUploaderProps) {
|
|
57
|
+
const fileInputRef = useRef<HTMLInputElement>(null)
|
|
58
|
+
const [isDragging, setIsDragging] = useState(false)
|
|
59
|
+
const [error, setError] = useState<string | null>(null)
|
|
60
|
+
|
|
61
|
+
const totalSize = screenshots.reduce((sum, s) => sum + s.size, 0)
|
|
62
|
+
const remainingSize = maxTotalSize - totalSize
|
|
63
|
+
|
|
64
|
+
const processFiles = useCallback(
|
|
65
|
+
async (files: FileList | File[]) => {
|
|
66
|
+
setError(null)
|
|
67
|
+
const imageFiles = Array.from(files).filter((f) => f.type.startsWith('image/'))
|
|
68
|
+
if (imageFiles.length === 0) {
|
|
69
|
+
setError('Please select image files only')
|
|
70
|
+
return
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
const newFilesSize = imageFiles.reduce((sum, f) => sum + f.size, 0)
|
|
74
|
+
if (totalSize + newFilesSize > maxTotalSize) {
|
|
75
|
+
setError(`Total size would exceed ${formatFileSize(maxTotalSize)} limit`)
|
|
76
|
+
return
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
const newScreenshots: Screenshot[] = await Promise.all(
|
|
80
|
+
imageFiles.map(
|
|
81
|
+
(file) =>
|
|
82
|
+
new Promise<Screenshot>((resolve, reject) => {
|
|
83
|
+
const reader = new FileReader()
|
|
84
|
+
reader.onload = () => {
|
|
85
|
+
const base64 = (reader.result as string).split(',')[1]
|
|
86
|
+
resolve({
|
|
87
|
+
id: Math.random().toString(36).substring(2, 9),
|
|
88
|
+
filename: file.name,
|
|
89
|
+
data: base64,
|
|
90
|
+
contentType: file.type,
|
|
91
|
+
size: file.size,
|
|
92
|
+
})
|
|
93
|
+
}
|
|
94
|
+
reader.onerror = reject
|
|
95
|
+
reader.readAsDataURL(file)
|
|
96
|
+
}),
|
|
97
|
+
),
|
|
98
|
+
)
|
|
99
|
+
|
|
100
|
+
onChange([...screenshots, ...newScreenshots])
|
|
101
|
+
},
|
|
102
|
+
[screenshots, onChange, totalSize, maxTotalSize],
|
|
103
|
+
)
|
|
104
|
+
|
|
105
|
+
const handleFileSelect = useCallback(
|
|
106
|
+
(e: React.ChangeEvent<HTMLInputElement>) => {
|
|
107
|
+
if (e.target.files && e.target.files.length > 0) processFiles(e.target.files)
|
|
108
|
+
e.target.value = ''
|
|
109
|
+
},
|
|
110
|
+
[processFiles],
|
|
111
|
+
)
|
|
112
|
+
|
|
113
|
+
const handleDragOver = useCallback((e: React.DragEvent) => {
|
|
114
|
+
e.preventDefault()
|
|
115
|
+
e.stopPropagation()
|
|
116
|
+
setIsDragging(true)
|
|
117
|
+
}, [])
|
|
118
|
+
|
|
119
|
+
const handleDragLeave = useCallback((e: React.DragEvent) => {
|
|
120
|
+
e.preventDefault()
|
|
121
|
+
e.stopPropagation()
|
|
122
|
+
setIsDragging(false)
|
|
123
|
+
}, [])
|
|
124
|
+
|
|
125
|
+
const handleDrop = useCallback(
|
|
126
|
+
(e: React.DragEvent) => {
|
|
127
|
+
e.preventDefault()
|
|
128
|
+
e.stopPropagation()
|
|
129
|
+
setIsDragging(false)
|
|
130
|
+
if (e.dataTransfer.files && e.dataTransfer.files.length > 0) processFiles(e.dataTransfer.files)
|
|
131
|
+
},
|
|
132
|
+
[processFiles],
|
|
133
|
+
)
|
|
134
|
+
|
|
135
|
+
const removeScreenshot = useCallback(
|
|
136
|
+
(id: string) => {
|
|
137
|
+
onChange(screenshots.filter((s) => s.id !== id))
|
|
138
|
+
setError(null)
|
|
139
|
+
},
|
|
140
|
+
[screenshots, onChange],
|
|
141
|
+
)
|
|
142
|
+
|
|
143
|
+
return (
|
|
144
|
+
<div className={cn('space-y-3', className)}>
|
|
145
|
+
<div
|
|
146
|
+
onClick={() => !disabled && fileInputRef.current?.click()}
|
|
147
|
+
onDragOver={handleDragOver}
|
|
148
|
+
onDragLeave={handleDragLeave}
|
|
149
|
+
onDrop={handleDrop}
|
|
150
|
+
className={cn(
|
|
151
|
+
'relative flex flex-col items-center justify-center gap-2 p-4 border border-dashed rounded-lg transition-all cursor-pointer',
|
|
152
|
+
isDragging
|
|
153
|
+
? 'border-blue-500 bg-blue-500/10'
|
|
154
|
+
: disabled
|
|
155
|
+
? 'border-neutral-700 bg-neutral-900/50 cursor-not-allowed opacity-50'
|
|
156
|
+
: 'border-neutral-600 hover:border-neutral-500 hover:bg-neutral-800/30',
|
|
157
|
+
)}
|
|
158
|
+
>
|
|
159
|
+
<ImagePlus className={cn('w-6 h-6', isDragging ? 'text-blue-400' : 'text-neutral-500')} />
|
|
160
|
+
<div className="text-center">
|
|
161
|
+
<p className="text-sm text-neutral-400">
|
|
162
|
+
{isDragging ? 'Drop images here' : 'Click or drag images to attach'}
|
|
163
|
+
</p>
|
|
164
|
+
<p className="text-xs text-neutral-500 mt-1">
|
|
165
|
+
{formatFileSize(remainingSize)} remaining of {formatFileSize(maxTotalSize)}
|
|
166
|
+
</p>
|
|
167
|
+
</div>
|
|
168
|
+
<input
|
|
169
|
+
ref={fileInputRef}
|
|
170
|
+
type="file"
|
|
171
|
+
accept="image/*"
|
|
172
|
+
multiple
|
|
173
|
+
onChange={handleFileSelect}
|
|
174
|
+
className="hidden"
|
|
175
|
+
disabled={disabled}
|
|
176
|
+
/>
|
|
177
|
+
</div>
|
|
178
|
+
|
|
179
|
+
{error && (
|
|
180
|
+
<div className="flex items-center gap-2 text-sm text-red-400">
|
|
181
|
+
<AlertCircle className="w-4 h-4 flex-shrink-0" />
|
|
182
|
+
<span>{error}</span>
|
|
183
|
+
</div>
|
|
184
|
+
)}
|
|
185
|
+
|
|
186
|
+
{screenshots.length > 0 && (
|
|
187
|
+
<div className="grid grid-cols-3 gap-2">
|
|
188
|
+
{screenshots.map((s) => (
|
|
189
|
+
<div
|
|
190
|
+
key={s.id}
|
|
191
|
+
className="relative group aspect-video bg-neutral-900 border border-neutral-700 rounded-lg overflow-hidden"
|
|
192
|
+
>
|
|
193
|
+
<img
|
|
194
|
+
src={`data:${s.contentType};base64,${s.data}`}
|
|
195
|
+
alt={s.filename}
|
|
196
|
+
className="w-full h-full object-cover"
|
|
197
|
+
/>
|
|
198
|
+
<div className="absolute inset-0 bg-black/60 opacity-0 group-hover:opacity-100 transition-opacity flex flex-col justify-between p-2">
|
|
199
|
+
<button
|
|
200
|
+
type="button"
|
|
201
|
+
onClick={(e) => {
|
|
202
|
+
e.stopPropagation()
|
|
203
|
+
removeScreenshot(s.id)
|
|
204
|
+
}}
|
|
205
|
+
disabled={disabled}
|
|
206
|
+
className="self-end w-5 h-5 flex items-center justify-center rounded bg-red-500/80 hover:bg-red-500 text-white transition-colors"
|
|
207
|
+
>
|
|
208
|
+
<X className="w-3 h-3" />
|
|
209
|
+
</button>
|
|
210
|
+
<span className="text-xs text-white truncate">{s.filename}</span>
|
|
211
|
+
</div>
|
|
212
|
+
<div className="absolute bottom-1 right-1 px-1.5 py-0.5 bg-black/70 rounded text-xs text-neutral-400">
|
|
213
|
+
{formatFileSize(s.size)}
|
|
214
|
+
</div>
|
|
215
|
+
</div>
|
|
216
|
+
))}
|
|
217
|
+
</div>
|
|
218
|
+
)}
|
|
219
|
+
|
|
220
|
+
{screenshots.length > 0 && (
|
|
221
|
+
<div className="flex items-center justify-between text-xs text-neutral-500">
|
|
222
|
+
<span>{screenshots.length} image{screenshots.length !== 1 ? 's' : ''} attached</span>
|
|
223
|
+
<span>Total: {formatFileSize(totalSize)}</span>
|
|
224
|
+
</div>
|
|
225
|
+
)}
|
|
226
|
+
</div>
|
|
227
|
+
)
|
|
228
|
+
}
|
|
@@ -0,0 +1,170 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* useReportBug — Form state and submission hook
|
|
3
|
+
*
|
|
4
|
+
* Part of: Sections > Report a Bug
|
|
5
|
+
*
|
|
6
|
+
* Manages all form state (issue type, title, description, email, screenshots,
|
|
7
|
+
* include-logs toggle) and the async submission flow with status updates.
|
|
8
|
+
*
|
|
9
|
+
* AI agent notes:
|
|
10
|
+
* - This hook is used internally by ReportBugForm but can also be used
|
|
11
|
+
* standalone for custom form UIs
|
|
12
|
+
* - Uses callbacks (onSuccess/onError) instead of alert modals for flexibility
|
|
13
|
+
* - Error data (errorReport, errorFingerprint) comes as options, not from
|
|
14
|
+
* an imported error logger — keeps the hook decoupled
|
|
15
|
+
* - The hook resets the form on successful submission
|
|
16
|
+
* - submitFn can override the default API call (useful for testing/mocking)
|
|
17
|
+
*/
|
|
18
|
+
|
|
19
|
+
import { useState, useCallback } from 'react'
|
|
20
|
+
import {
|
|
21
|
+
submitIssueReport,
|
|
22
|
+
type IssueType,
|
|
23
|
+
type IssueReport,
|
|
24
|
+
type IssueReportResult,
|
|
25
|
+
type SystemInfo,
|
|
26
|
+
type ScreenshotAttachment,
|
|
27
|
+
} from './issue-reporter-api.ts'
|
|
28
|
+
import type { Screenshot } from './screenshot-uploader.tsx'
|
|
29
|
+
|
|
30
|
+
export interface UseReportBugOptions {
|
|
31
|
+
/** Issue reporter worker endpoint URL */
|
|
32
|
+
workerUrl: string
|
|
33
|
+
/** Returns system info (OS, version, etc.) — each app provides its own */
|
|
34
|
+
getSystemInfo: () => Promise<SystemInfo>
|
|
35
|
+
/** Linear team to route issues to */
|
|
36
|
+
linearTeamId?: string
|
|
37
|
+
/** Linear project to associate issues with */
|
|
38
|
+
linearProjectId?: string
|
|
39
|
+
/** Pre-formatted error report string (from error logger or custom source) */
|
|
40
|
+
errorReport?: string
|
|
41
|
+
/** Combined error fingerprint for duplicate detection */
|
|
42
|
+
errorFingerprint?: string
|
|
43
|
+
/** Called after successful submission */
|
|
44
|
+
onSuccess?: (result: IssueReportResult) => void
|
|
45
|
+
/** Called on submission failure */
|
|
46
|
+
onError?: (message: string) => void
|
|
47
|
+
/** Called when error logs are included in a successful submission */
|
|
48
|
+
onErrorsSubmitted?: () => void
|
|
49
|
+
/** Override default API submission (useful for testing/mocking) */
|
|
50
|
+
submitFn?: (report: IssueReport) => Promise<IssueReportResult>
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
export interface UseReportBugReturn {
|
|
54
|
+
issueType: IssueType
|
|
55
|
+
setIssueType: (type: IssueType) => void
|
|
56
|
+
title: string
|
|
57
|
+
setTitle: (title: string) => void
|
|
58
|
+
description: string
|
|
59
|
+
setDescription: (desc: string) => void
|
|
60
|
+
email: string
|
|
61
|
+
setEmail: (email: string) => void
|
|
62
|
+
screenshots: Screenshot[]
|
|
63
|
+
setScreenshots: (screenshots: Screenshot[]) => void
|
|
64
|
+
includeLogs: boolean
|
|
65
|
+
setIncludeLogs: (include: boolean) => void
|
|
66
|
+
isSubmitting: boolean
|
|
67
|
+
submissionStatus: string
|
|
68
|
+
emailHasError: boolean
|
|
69
|
+
canSubmit: boolean
|
|
70
|
+
handleSubmit: () => Promise<void>
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
const isValidEmail = (email: string) => {
|
|
74
|
+
if (!email.trim()) return true
|
|
75
|
+
return /^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(email)
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
export function useReportBug(options: UseReportBugOptions): UseReportBugReturn {
|
|
79
|
+
const [issueType, setIssueType] = useState<IssueType>('bug')
|
|
80
|
+
const [title, setTitle] = useState('')
|
|
81
|
+
const [description, setDescription] = useState('')
|
|
82
|
+
const [email, setEmail] = useState('')
|
|
83
|
+
const [screenshots, setScreenshots] = useState<Screenshot[]>([])
|
|
84
|
+
const [includeLogs, setIncludeLogs] = useState(true)
|
|
85
|
+
const [isSubmitting, setIsSubmitting] = useState(false)
|
|
86
|
+
const [submissionStatus, setSubmissionStatus] = useState('')
|
|
87
|
+
|
|
88
|
+
const emailHasError = email.trim() !== '' && !isValidEmail(email)
|
|
89
|
+
const canSubmit = !isSubmitting && !!title.trim() && !!description.trim() && !emailHasError
|
|
90
|
+
|
|
91
|
+
const handleSubmit = useCallback(async () => {
|
|
92
|
+
if (!title.trim() || !description.trim()) return
|
|
93
|
+
|
|
94
|
+
setIsSubmitting(true)
|
|
95
|
+
try {
|
|
96
|
+
setSubmissionStatus('Gathering system info...')
|
|
97
|
+
const systemInfo = await options.getSystemInfo()
|
|
98
|
+
|
|
99
|
+
const screenshotAttachments: ScreenshotAttachment[] = screenshots.map((s: Screenshot) => ({
|
|
100
|
+
filename: s.filename,
|
|
101
|
+
data: s.data,
|
|
102
|
+
contentType: s.contentType,
|
|
103
|
+
}))
|
|
104
|
+
|
|
105
|
+
if (screenshotAttachments.length > 0) {
|
|
106
|
+
setSubmissionStatus(
|
|
107
|
+
`Uploading ${screenshotAttachments.length} screenshot${screenshotAttachments.length > 1 ? 's' : ''}...`,
|
|
108
|
+
)
|
|
109
|
+
} else {
|
|
110
|
+
setSubmissionStatus('Submitting issue...')
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
const report: IssueReport = {
|
|
114
|
+
title: title.trim(),
|
|
115
|
+
description: description.trim(),
|
|
116
|
+
type: issueType,
|
|
117
|
+
systemInfo,
|
|
118
|
+
userEmail: email.trim() || undefined,
|
|
119
|
+
stackTrace: includeLogs ? options.errorReport : undefined,
|
|
120
|
+
errorFingerprint: includeLogs ? options.errorFingerprint : undefined,
|
|
121
|
+
screenshots: screenshotAttachments.length > 0 ? screenshotAttachments : undefined,
|
|
122
|
+
linearTeamId: options.linearTeamId,
|
|
123
|
+
linearProjectId: options.linearProjectId,
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
const submitFn = options.submitFn ?? ((r: IssueReport) => submitIssueReport(options.workerUrl, r))
|
|
127
|
+
const result = await submitFn(report)
|
|
128
|
+
|
|
129
|
+
if (result.success) {
|
|
130
|
+
if (includeLogs && options.errorReport) {
|
|
131
|
+
options.onErrorsSubmitted?.()
|
|
132
|
+
}
|
|
133
|
+
options.onSuccess?.(result)
|
|
134
|
+
setIssueType('bug')
|
|
135
|
+
setTitle('')
|
|
136
|
+
setDescription('')
|
|
137
|
+
setEmail('')
|
|
138
|
+
setScreenshots([])
|
|
139
|
+
setSubmissionStatus('')
|
|
140
|
+
} else {
|
|
141
|
+
options.onError?.(result.message)
|
|
142
|
+
}
|
|
143
|
+
} catch (error) {
|
|
144
|
+
options.onError?.(`An error occurred: ${error}`)
|
|
145
|
+
} finally {
|
|
146
|
+
setIsSubmitting(false)
|
|
147
|
+
setSubmissionStatus('')
|
|
148
|
+
}
|
|
149
|
+
}, [title, description, issueType, email, screenshots, includeLogs, options])
|
|
150
|
+
|
|
151
|
+
return {
|
|
152
|
+
issueType,
|
|
153
|
+
setIssueType,
|
|
154
|
+
title,
|
|
155
|
+
setTitle,
|
|
156
|
+
description,
|
|
157
|
+
setDescription,
|
|
158
|
+
email,
|
|
159
|
+
setEmail,
|
|
160
|
+
screenshots,
|
|
161
|
+
setScreenshots,
|
|
162
|
+
includeLogs,
|
|
163
|
+
setIncludeLogs,
|
|
164
|
+
isSubmitting,
|
|
165
|
+
submissionStatus,
|
|
166
|
+
emailHasError,
|
|
167
|
+
canSubmit,
|
|
168
|
+
handleSubmit,
|
|
169
|
+
}
|
|
170
|
+
}
|