@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.
Files changed (112) hide show
  1. package/README.md +63 -0
  2. package/components/content/info-panel-primitives.tsx +297 -0
  3. package/components/diagrams/diagram-utils.tsx +908 -0
  4. package/components/hooks/use-click-outside.ts +27 -0
  5. package/components/hooks/use-dropdown-max-height.ts +20 -0
  6. package/components/hooks/use-navigation-history.ts +94 -0
  7. package/components/lib/ai-tools.tsx +44 -0
  8. package/components/lib/cn.ts +6 -0
  9. package/components/lib/form-colors.ts +32 -0
  10. package/components/lib/theme-engine.ts +97 -0
  11. package/components/lib/toolr-brand.tsx +31 -0
  12. package/components/sections/ai-tools-paths/index.ts +37 -0
  13. package/components/sections/ai-tools-paths/tools-paths-panel.tsx +212 -0
  14. package/components/sections/ai-tools-paths/types.ts +111 -0
  15. package/components/sections/ai-tools-paths/use-tools-paths.ts +159 -0
  16. package/components/sections/captured-issues/captured-issues-panel.tsx +214 -0
  17. package/components/sections/captured-issues/index.ts +38 -0
  18. package/components/sections/captured-issues/types.ts +113 -0
  19. package/components/sections/captured-issues/use-captured-issues.ts +111 -0
  20. package/components/sections/golden-snapshots/file-diff-viewer.tsx +420 -0
  21. package/components/sections/golden-snapshots/golden-sync-panel.tsx +223 -0
  22. package/components/sections/golden-snapshots/index.ts +145 -0
  23. package/components/sections/golden-snapshots/snapshot-manager.tsx +200 -0
  24. package/components/sections/golden-snapshots/status-overview.tsx +305 -0
  25. package/components/sections/golden-snapshots/types.ts +288 -0
  26. package/components/sections/golden-snapshots/use-golden-sync.ts +477 -0
  27. package/components/sections/golden-snapshots/version-manager.tsx +186 -0
  28. package/components/sections/prompt-editor/file-type-tabbed-prompt-editor.tsx +210 -0
  29. package/components/sections/prompt-editor/index.ts +121 -0
  30. package/components/sections/prompt-editor/simulator-prompt-editor.tsx +276 -0
  31. package/components/sections/prompt-editor/tabbed-prompt-editor.tsx +514 -0
  32. package/components/sections/prompt-editor/types.ts +101 -0
  33. package/components/sections/prompt-editor/use-prompt-editor.ts +131 -0
  34. package/components/sections/report-bug/error-logger.ts +392 -0
  35. package/components/sections/report-bug/index.ts +59 -0
  36. package/components/sections/report-bug/issue-reporter-api.ts +83 -0
  37. package/components/sections/report-bug/report-bug-form.tsx +282 -0
  38. package/components/sections/report-bug/screenshot-uploader.tsx +228 -0
  39. package/components/sections/report-bug/use-report-bug.ts +170 -0
  40. package/components/sections/snapshot-browser/index.ts +53 -0
  41. package/components/sections/snapshot-browser/snapshot-browser-panel.tsx +147 -0
  42. package/components/sections/snapshot-browser/snapshot-tree.tsx +451 -0
  43. package/components/sections/snapshot-browser/types.ts +106 -0
  44. package/components/sections/snapshot-browser/use-snapshot-browser.ts +125 -0
  45. package/components/sections/snippets-editor/index.ts +31 -0
  46. package/components/sections/snippets-editor/snippets-editor.tsx +381 -0
  47. package/components/sections/snippets-editor/types.ts +48 -0
  48. package/components/sections/snippets-editor/use-snippets-editor.ts +217 -0
  49. package/components/ui/action-dialog.tsx +309 -0
  50. package/components/ui/ai-action-button.tsx +137 -0
  51. package/components/ui/ai-execution-action-buttons.tsx +106 -0
  52. package/components/ui/badge.tsx +67 -0
  53. package/components/ui/bottom-panel-header.tsx +240 -0
  54. package/components/ui/breadcrumb.tsx +168 -0
  55. package/components/ui/checkbox.tsx +102 -0
  56. package/components/ui/collapsible-section.tsx +100 -0
  57. package/components/ui/confirm-badge.tsx +71 -0
  58. package/components/ui/detail-section.tsx +67 -0
  59. package/components/ui/detail-view-wrapper.tsx +55 -0
  60. package/components/ui/editor-placeholder-card.tsx +197 -0
  61. package/components/ui/editor-toolbar.tsx +123 -0
  62. package/components/ui/execution-details-panel.tsx +93 -0
  63. package/components/ui/extension-list-card.tsx +105 -0
  64. package/components/ui/file-structure-section.tsx +373 -0
  65. package/components/ui/file-tree.tsx +171 -0
  66. package/components/ui/files-panel.tsx +251 -0
  67. package/components/ui/filter-dropdown.tsx +173 -0
  68. package/components/ui/form-actions.tsx +127 -0
  69. package/components/ui/frontmatter-form-header.tsx +80 -0
  70. package/components/ui/icon-button.tsx +388 -0
  71. package/components/ui/input.tsx +211 -0
  72. package/components/ui/label.tsx +159 -0
  73. package/components/ui/layout-tab-bar.tsx +289 -0
  74. package/components/ui/modal.tsx +194 -0
  75. package/components/ui/nav-card.tsx +81 -0
  76. package/components/ui/navigation-bar.tsx +285 -0
  77. package/components/ui/number-input.tsx +165 -0
  78. package/components/ui/registry-browser.tsx +261 -0
  79. package/components/ui/registry-card.tsx +710 -0
  80. package/components/ui/registry-detail.tsx +224 -0
  81. package/components/ui/resizable-textarea.tsx +290 -0
  82. package/components/ui/scope-badge.tsx +67 -0
  83. package/components/ui/segmented-toggle.tsx +133 -0
  84. package/components/ui/select.tsx +172 -0
  85. package/components/ui/selection-grid.tsx +313 -0
  86. package/components/ui/setting-row.tsx +97 -0
  87. package/components/ui/snapshot-card.tsx +107 -0
  88. package/components/ui/snippets-panel.tsx +161 -0
  89. package/components/ui/sort-dropdown.tsx +109 -0
  90. package/components/ui/status-card.tsx +96 -0
  91. package/components/ui/tab-bar.tsx +340 -0
  92. package/components/ui/toggle.tsx +142 -0
  93. package/components/ui/tooltip.tsx +326 -0
  94. package/dist/content.d.ts +110 -0
  95. package/dist/content.js +195 -0
  96. package/dist/diagrams.d.ts +371 -0
  97. package/dist/diagrams.js +702 -0
  98. package/dist/index.d.ts +2714 -0
  99. package/dist/index.js +11220 -0
  100. package/dist/preset.d.ts +24 -0
  101. package/dist/preset.js +17 -0
  102. package/dist/tokens/tokens/primitives.css +45 -0
  103. package/dist/tokens/tokens/semantic.css +46 -0
  104. package/dist/tokens/tokens/theme.css +11 -0
  105. package/dist/tokens/tokens/tokens.json +65 -0
  106. package/index.ts +123 -0
  107. package/package.json +63 -0
  108. package/tailwind-preset.ts +22 -0
  109. package/tokens/primitives.css +45 -0
  110. package/tokens/semantic.css +46 -0
  111. package/tokens/theme.css +11 -0
  112. 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
+ }