spaps-issue-reporting-react 0.4.0 → 0.4.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/CHANGELOG.md +12 -0
- package/README.md +26 -1
- package/dist/index.d.mts +9 -2
- package/dist/index.d.ts +9 -2
- package/dist/index.js +373 -12
- package/dist/index.mjs +376 -13
- package/package.json +2 -2
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,17 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
|
+
## [Unreleased]
|
|
4
|
+
|
|
5
|
+
- Added optional screenshot attachment UI that uploads private PNG/JPEG/WebP evidence through `issueReporting.uploadAttachment`, validates size/count locally, and submits only backend attachment IDs.
|
|
6
|
+
|
|
7
|
+
## [0.4.0] - 2026-05-11
|
|
8
|
+
|
|
9
|
+
- Maintenance: align the React issue-reporting package version after release automation.
|
|
10
|
+
|
|
11
|
+
## [0.3.0] - 2026-05-11
|
|
12
|
+
|
|
13
|
+
- Maintenance: align the React issue-reporting package version after release automation.
|
|
14
|
+
|
|
3
15
|
## [0.2.0] - 2026-05-09
|
|
4
16
|
|
|
5
17
|
- Added voice input support through SPAPS-minted ElevenLabs realtime tokens.
|
package/README.md
CHANGED
|
@@ -18,6 +18,7 @@ This package targets `Node.js >=18` and React 18+. Voice input uses the package'
|
|
|
18
18
|
| --- | --- |
|
|
19
19
|
| A visible issue-report entry point | Floating button with open/recent state |
|
|
20
20
|
| A guided reporting flow | Page-first create flow, optional section picking, create/edit/reply modal |
|
|
21
|
+
| Screenshot evidence | Optional picker that uploads private PNG/JPEG/WebP attachments through the host client |
|
|
21
22
|
| A lightweight integration contract | Any client that exposes `issueReporting.*` methods |
|
|
22
23
|
| App-level control | Eligibility, reporter identity, scope, page policy, and copy stay in your app |
|
|
23
24
|
|
|
@@ -71,6 +72,7 @@ export function AppShell() {
|
|
|
71
72
|
|
|
72
73
|
- A client with `issueReporting.getStatus`, `list`, `get`, `create`, `update`, and `reply`.
|
|
73
74
|
- A client with `issueReporting.createVoiceToken` when `inputModes` includes `voice`.
|
|
75
|
+
- A client with `issueReporting.uploadAttachment(file, { filename })` when you want screenshot uploads.
|
|
74
76
|
- Any auth and token refresh behavior needed by that client.
|
|
75
77
|
- Eligibility rules such as feature flags, account state, or role checks.
|
|
76
78
|
- The current principal ID and optional role hint passed into the provider.
|
|
@@ -78,6 +80,7 @@ export function AppShell() {
|
|
|
78
80
|
supports `mine` only; use `allowTenantScope` only with a client that implements tenant reads.
|
|
79
81
|
- Whether a page defaults to `general_page`, `surface_required`, or `surface_preferred` reporting.
|
|
80
82
|
- Whether the app allows `["text"]`, `["voice"]`, or `["text", "voice"]` report input.
|
|
83
|
+
- User-facing screenshot sensitivity warnings for surfaces that may capture private data.
|
|
81
84
|
- Styling integration if your build strips package utility classes.
|
|
82
85
|
- Any app-specific copy overrides.
|
|
83
86
|
- Origin registration on the owning SPAPS application if the browser calls SPAPS directly with a publishable key.
|
|
@@ -127,6 +130,24 @@ The SPAPS server also needs `ELEVENLABS_API_KEY`. The browser calls your normal
|
|
|
127
130
|
|
|
128
131
|
When text and voice are both enabled, the modal keeps the textarea available and lets the user append a committed transcript into the editable note.
|
|
129
132
|
|
|
133
|
+
## Screenshot Attachments
|
|
134
|
+
|
|
135
|
+
Screenshot attachments are opt-in through the client contract. If the client passed to `IssueReportingProvider` exposes `issueReporting.uploadAttachment`, the modal renders a screenshot picker. If the method is absent, the picker stays hidden and text/voice reporting works unchanged.
|
|
136
|
+
|
|
137
|
+
```tsx
|
|
138
|
+
const client = {
|
|
139
|
+
issueReporting: {
|
|
140
|
+
...spaps.issueReporting,
|
|
141
|
+
uploadAttachment: (file: Blob, options?: { filename?: string }) =>
|
|
142
|
+
spaps.issueReporting.uploadAttachment(file, options),
|
|
143
|
+
},
|
|
144
|
+
};
|
|
145
|
+
```
|
|
146
|
+
|
|
147
|
+
The picker accepts PNG, JPEG, and WebP files, enforces the 10 MiB per-file limit and 5-screenshot report limit, previews pending files, and preserves existing attachments while editing. On submit, it uploads pending files first and sends only attachment IDs through `attachment_ids`, `add_attachment_ids`, or `remove_attachment_ids`.
|
|
148
|
+
|
|
149
|
+
This package does not store screenshots, generate public URLs, or redact image content. SPAPS stores screenshots through its shared hosted-asset boundary as private objects, and access URLs are minted by the backend after authorization. Host apps should warn users before upload when a screenshot might include PHI, credentials, payment data, or other sensitive content.
|
|
150
|
+
|
|
130
151
|
## Exported Surface
|
|
131
152
|
|
|
132
153
|
| Export | Purpose |
|
|
@@ -192,6 +213,10 @@ Pass `inputModes={["voice"]}` or `inputModes={["text", "voice"]}` to the provide
|
|
|
192
213
|
|
|
193
214
|
Confirm that the SPAPS server has `ELEVENLABS_API_KEY` set and that the current user is authenticated and eligible for issue reporting.
|
|
194
215
|
|
|
216
|
+
### Screenshot controls do not appear
|
|
217
|
+
|
|
218
|
+
Expose `issueReporting.uploadAttachment` on the client passed to `IssueReportingProvider`. The package intentionally hides the picker when the upload helper is absent.
|
|
219
|
+
|
|
195
220
|
## Limitations
|
|
196
221
|
|
|
197
222
|
- This package assumes React Query is already part of the host app.
|
|
@@ -231,7 +256,7 @@ No. This package is UI only. If your browser app calls SPAPS directly with a pub
|
|
|
231
256
|
## Metadata
|
|
232
257
|
|
|
233
258
|
- `package_name`: `spaps-issue-reporting-react`
|
|
234
|
-
- `latest_version`: `0.
|
|
259
|
+
- `latest_version`: `0.4.0`
|
|
235
260
|
- `minimum_runtime`: `Node.js >=18.0.0`
|
|
236
261
|
- `api_base_url`: `https://api.sweetpotato.dev`
|
|
237
262
|
|
package/dist/index.d.mts
CHANGED
|
@@ -2,8 +2,8 @@ import * as react_jsx_runtime from 'react/jsx-runtime';
|
|
|
2
2
|
import * as React from 'react';
|
|
3
3
|
import React__default, { ReactNode } from 'react';
|
|
4
4
|
import * as spaps_types from 'spaps-types';
|
|
5
|
-
import { IssueReportScope, IssueReportStatusResult, IssueReportStatus, IssueReportListResult, IssueReport, CreateIssueReportRequest, UpdateIssueReportRequest, ReplyIssueReportRequest, IssueReportingVoiceTokenResult, IssueReportingInputMode, IssueReportingVoiceProvider } from 'spaps-types';
|
|
6
|
-
export { CreateIssueReportRequest, IssueReport, IssueReportListResult, IssueReportScope, IssueReportStatus, IssueReportStatusResult, ReplyIssueReportRequest, UpdateIssueReportRequest } from 'spaps-types';
|
|
5
|
+
import { IssueReportScope, IssueReportStatusResult, IssueReportStatus, IssueReportListResult, IssueReport, CreateIssueReportRequest, UpdateIssueReportRequest, ReplyIssueReportRequest, IssueReportingVoiceTokenResult, IssueReportAttachmentOut, IssueReportingInputMode, IssueReportingVoiceProvider } from 'spaps-types';
|
|
6
|
+
export { CreateIssueReportRequest, IssueReport, IssueReportAttachmentOut, IssueReportListResult, IssueReportScope, IssueReportStatus, IssueReportStatusResult, ReplyIssueReportRequest, UpdateIssueReportRequest } from 'spaps-types';
|
|
7
7
|
import * as _tanstack_query_core from '@tanstack/query-core';
|
|
8
8
|
import * as _tanstack_react_query from '@tanstack/react-query';
|
|
9
9
|
|
|
@@ -36,6 +36,9 @@ interface IssueReportingClient {
|
|
|
36
36
|
update: (issueReportId: string, payload: UpdateIssueReportRequest) => Promise<IssueReport>;
|
|
37
37
|
reply: (issueReportId: string, payload: ReplyIssueReportRequest) => Promise<IssueReport>;
|
|
38
38
|
createVoiceToken?: () => Promise<IssueReportingVoiceTokenResult>;
|
|
39
|
+
uploadAttachment?: (file: Blob, options?: {
|
|
40
|
+
filename?: string;
|
|
41
|
+
}) => Promise<IssueReportAttachmentOut>;
|
|
39
42
|
};
|
|
40
43
|
}
|
|
41
44
|
interface ReportableTargetDescriptor {
|
|
@@ -373,15 +376,19 @@ declare function useIssueReportingMutations(): {
|
|
|
373
376
|
target: ResolvedTarget;
|
|
374
377
|
note: string;
|
|
375
378
|
reporter_role_hint?: string;
|
|
379
|
+
attachment_ids?: string[];
|
|
376
380
|
}, unknown>;
|
|
377
381
|
updateMutation: _tanstack_react_query.UseMutationResult<IssueReport, Error, {
|
|
378
382
|
issueReportId: string;
|
|
379
383
|
note: string;
|
|
384
|
+
add_attachment_ids?: string[];
|
|
385
|
+
remove_attachment_ids?: string[];
|
|
380
386
|
}, unknown>;
|
|
381
387
|
replyMutation: _tanstack_react_query.UseMutationResult<IssueReport, Error, {
|
|
382
388
|
issueReportId: string;
|
|
383
389
|
note: string;
|
|
384
390
|
reporterRoleHint?: string;
|
|
391
|
+
attachment_ids?: string[];
|
|
385
392
|
}, unknown>;
|
|
386
393
|
};
|
|
387
394
|
declare function IssueReportingProvider({ client, isEligible, reporterRoleHint, principalId, getPageUrl, defaultScope, allowTenantScope, defaultCreateMode, inputModes, defaultInputMode, voice, copy, children, }: IssueReportingProviderProps): react_jsx_runtime.JSX.Element;
|
package/dist/index.d.ts
CHANGED
|
@@ -2,8 +2,8 @@ import * as react_jsx_runtime from 'react/jsx-runtime';
|
|
|
2
2
|
import * as React from 'react';
|
|
3
3
|
import React__default, { ReactNode } from 'react';
|
|
4
4
|
import * as spaps_types from 'spaps-types';
|
|
5
|
-
import { IssueReportScope, IssueReportStatusResult, IssueReportStatus, IssueReportListResult, IssueReport, CreateIssueReportRequest, UpdateIssueReportRequest, ReplyIssueReportRequest, IssueReportingVoiceTokenResult, IssueReportingInputMode, IssueReportingVoiceProvider } from 'spaps-types';
|
|
6
|
-
export { CreateIssueReportRequest, IssueReport, IssueReportListResult, IssueReportScope, IssueReportStatus, IssueReportStatusResult, ReplyIssueReportRequest, UpdateIssueReportRequest } from 'spaps-types';
|
|
5
|
+
import { IssueReportScope, IssueReportStatusResult, IssueReportStatus, IssueReportListResult, IssueReport, CreateIssueReportRequest, UpdateIssueReportRequest, ReplyIssueReportRequest, IssueReportingVoiceTokenResult, IssueReportAttachmentOut, IssueReportingInputMode, IssueReportingVoiceProvider } from 'spaps-types';
|
|
6
|
+
export { CreateIssueReportRequest, IssueReport, IssueReportAttachmentOut, IssueReportListResult, IssueReportScope, IssueReportStatus, IssueReportStatusResult, ReplyIssueReportRequest, UpdateIssueReportRequest } from 'spaps-types';
|
|
7
7
|
import * as _tanstack_query_core from '@tanstack/query-core';
|
|
8
8
|
import * as _tanstack_react_query from '@tanstack/react-query';
|
|
9
9
|
|
|
@@ -36,6 +36,9 @@ interface IssueReportingClient {
|
|
|
36
36
|
update: (issueReportId: string, payload: UpdateIssueReportRequest) => Promise<IssueReport>;
|
|
37
37
|
reply: (issueReportId: string, payload: ReplyIssueReportRequest) => Promise<IssueReport>;
|
|
38
38
|
createVoiceToken?: () => Promise<IssueReportingVoiceTokenResult>;
|
|
39
|
+
uploadAttachment?: (file: Blob, options?: {
|
|
40
|
+
filename?: string;
|
|
41
|
+
}) => Promise<IssueReportAttachmentOut>;
|
|
39
42
|
};
|
|
40
43
|
}
|
|
41
44
|
interface ReportableTargetDescriptor {
|
|
@@ -373,15 +376,19 @@ declare function useIssueReportingMutations(): {
|
|
|
373
376
|
target: ResolvedTarget;
|
|
374
377
|
note: string;
|
|
375
378
|
reporter_role_hint?: string;
|
|
379
|
+
attachment_ids?: string[];
|
|
376
380
|
}, unknown>;
|
|
377
381
|
updateMutation: _tanstack_react_query.UseMutationResult<IssueReport, Error, {
|
|
378
382
|
issueReportId: string;
|
|
379
383
|
note: string;
|
|
384
|
+
add_attachment_ids?: string[];
|
|
385
|
+
remove_attachment_ids?: string[];
|
|
380
386
|
}, unknown>;
|
|
381
387
|
replyMutation: _tanstack_react_query.UseMutationResult<IssueReport, Error, {
|
|
382
388
|
issueReportId: string;
|
|
383
389
|
note: string;
|
|
384
390
|
reporterRoleHint?: string;
|
|
391
|
+
attachment_ids?: string[];
|
|
385
392
|
}, unknown>;
|
|
386
393
|
};
|
|
387
394
|
declare function IssueReportingProvider({ client, isEligible, reporterRoleHint, principalId, getPageUrl, defaultScope, allowTenantScope, defaultCreateMode, inputModes, defaultInputMode, voice, copy, children, }: IssueReportingProviderProps): react_jsx_runtime.JSX.Element;
|
package/dist/index.js
CHANGED
|
@@ -79,6 +79,13 @@ var import_jsx_runtime = require("react/jsx-runtime");
|
|
|
79
79
|
var LIST_LIMIT = 200;
|
|
80
80
|
var NOTE_MIN_LENGTH = 10;
|
|
81
81
|
var NOTE_MAX_LENGTH = 2e3;
|
|
82
|
+
var ATTACHMENT_MAX_COUNT = 5;
|
|
83
|
+
var ATTACHMENT_MAX_BYTES = 10 * 1024 * 1024;
|
|
84
|
+
var ATTACHMENT_ALLOWED_MIMES = /* @__PURE__ */ new Set([
|
|
85
|
+
"image/png",
|
|
86
|
+
"image/jpeg",
|
|
87
|
+
"image/webp"
|
|
88
|
+
]);
|
|
82
89
|
var DEFAULT_INPUT_MODES = ["text"];
|
|
83
90
|
var DEFAULT_VOICE_PROVIDER = "elevenlabs_scribe_realtime";
|
|
84
91
|
var DEFAULT_VOICE_MODEL_ID = "scribe_v2_realtime";
|
|
@@ -421,18 +428,26 @@ function useIssueReportingMutations() {
|
|
|
421
428
|
const updateMutation = (0, import_react_query.useMutation)({
|
|
422
429
|
mutationFn: ({
|
|
423
430
|
issueReportId,
|
|
424
|
-
note
|
|
425
|
-
|
|
431
|
+
note,
|
|
432
|
+
add_attachment_ids,
|
|
433
|
+
remove_attachment_ids
|
|
434
|
+
}) => client.issueReporting.update(issueReportId, {
|
|
435
|
+
note,
|
|
436
|
+
add_attachment_ids,
|
|
437
|
+
remove_attachment_ids
|
|
438
|
+
}),
|
|
426
439
|
onSuccess: invalidateAll
|
|
427
440
|
});
|
|
428
441
|
const replyMutation = (0, import_react_query.useMutation)({
|
|
429
442
|
mutationFn: ({
|
|
430
443
|
issueReportId,
|
|
431
444
|
note,
|
|
432
|
-
reporterRoleHint
|
|
445
|
+
reporterRoleHint,
|
|
446
|
+
attachment_ids
|
|
433
447
|
}) => client.issueReporting.reply(issueReportId, {
|
|
434
448
|
note,
|
|
435
|
-
reporter_role_hint: reporterRoleHint
|
|
449
|
+
reporter_role_hint: reporterRoleHint,
|
|
450
|
+
attachment_ids
|
|
436
451
|
}),
|
|
437
452
|
onSuccess: invalidateAll
|
|
438
453
|
});
|
|
@@ -988,6 +1003,7 @@ function IssueReportModalBody({
|
|
|
988
1003
|
error,
|
|
989
1004
|
canUseVoice,
|
|
990
1005
|
canUseText,
|
|
1006
|
+
canUseAttachments,
|
|
991
1007
|
effectiveInputMode,
|
|
992
1008
|
note,
|
|
993
1009
|
normalizedNote,
|
|
@@ -1001,6 +1017,11 @@ function IssueReportModalBody({
|
|
|
1001
1017
|
voiceError,
|
|
1002
1018
|
scribeError,
|
|
1003
1019
|
submitError,
|
|
1020
|
+
existingAttachments,
|
|
1021
|
+
removedExistingIds,
|
|
1022
|
+
pendingFiles,
|
|
1023
|
+
uploadProgress,
|
|
1024
|
+
attachmentValidationErrors,
|
|
1004
1025
|
onRetryHydration,
|
|
1005
1026
|
onClose,
|
|
1006
1027
|
onSelectText,
|
|
@@ -1009,7 +1030,10 @@ function IssueReportModalBody({
|
|
|
1009
1030
|
onStopVoiceInput,
|
|
1010
1031
|
onAppendTranscript,
|
|
1011
1032
|
onNoteChange,
|
|
1012
|
-
onSubmit
|
|
1033
|
+
onSubmit,
|
|
1034
|
+
onAddFiles,
|
|
1035
|
+
onRemoveExistingAttachment,
|
|
1036
|
+
onRemovePendingAttachment
|
|
1013
1037
|
}) {
|
|
1014
1038
|
if (isHydrating) {
|
|
1015
1039
|
return /* @__PURE__ */ (0, import_jsx_runtime2.jsxs)("div", { className: "mt-5 flex items-center gap-2 rounded-2xl border border-slate-200 bg-slate-50 px-4 py-4 text-sm text-slate-600", children: [
|
|
@@ -1079,6 +1103,20 @@ function IssueReportModalBody({
|
|
|
1079
1103
|
onSubmit
|
|
1080
1104
|
}
|
|
1081
1105
|
),
|
|
1106
|
+
canUseAttachments && /* @__PURE__ */ (0, import_jsx_runtime2.jsx)(
|
|
1107
|
+
AttachmentPicker,
|
|
1108
|
+
{
|
|
1109
|
+
existingAttachments,
|
|
1110
|
+
removedExistingIds,
|
|
1111
|
+
pendingFiles,
|
|
1112
|
+
uploadProgress,
|
|
1113
|
+
validationErrors: attachmentValidationErrors,
|
|
1114
|
+
disabled: isSubmitting,
|
|
1115
|
+
onAddFiles,
|
|
1116
|
+
onRemoveExisting: onRemoveExistingAttachment,
|
|
1117
|
+
onRemovePending: onRemovePendingAttachment
|
|
1118
|
+
}
|
|
1119
|
+
),
|
|
1082
1120
|
submitError ? /* @__PURE__ */ (0, import_jsx_runtime2.jsx)("div", { className: "mt-4 rounded-2xl border border-rose-200 bg-rose-50 px-4 py-3 text-sm text-rose-700", children: submitError }) : null,
|
|
1083
1121
|
/* @__PURE__ */ (0, import_jsx_runtime2.jsxs)("div", { className: "mt-6 flex justify-end gap-3", children: [
|
|
1084
1122
|
/* @__PURE__ */ (0, import_jsx_runtime2.jsx)(
|
|
@@ -1202,6 +1240,262 @@ function useIssueReportVoiceCapture({
|
|
|
1202
1240
|
appendTranscript
|
|
1203
1241
|
};
|
|
1204
1242
|
}
|
|
1243
|
+
var INITIAL_UPLOAD_PROGRESS = {
|
|
1244
|
+
phase: "idle",
|
|
1245
|
+
uploaded: 0,
|
|
1246
|
+
total: 0
|
|
1247
|
+
};
|
|
1248
|
+
var pendingFileCounter = 0;
|
|
1249
|
+
function nextPendingId() {
|
|
1250
|
+
pendingFileCounter += 1;
|
|
1251
|
+
return `pending-${pendingFileCounter}`;
|
|
1252
|
+
}
|
|
1253
|
+
function formatFileSize(bytes) {
|
|
1254
|
+
if (bytes < 1024) return `${bytes} B`;
|
|
1255
|
+
if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`;
|
|
1256
|
+
return `${(bytes / (1024 * 1024)).toFixed(1)} MB`;
|
|
1257
|
+
}
|
|
1258
|
+
function validateAttachmentFile(file) {
|
|
1259
|
+
if (!ATTACHMENT_ALLOWED_MIMES.has(file.type)) {
|
|
1260
|
+
return `"${file.name}" is not a supported image type (PNG, JPEG, or WebP).`;
|
|
1261
|
+
}
|
|
1262
|
+
if (file.size > ATTACHMENT_MAX_BYTES) {
|
|
1263
|
+
return `"${file.name}" exceeds the 10 MB limit.`;
|
|
1264
|
+
}
|
|
1265
|
+
return null;
|
|
1266
|
+
}
|
|
1267
|
+
function AttachmentPicker({
|
|
1268
|
+
existingAttachments,
|
|
1269
|
+
removedExistingIds,
|
|
1270
|
+
pendingFiles,
|
|
1271
|
+
uploadProgress,
|
|
1272
|
+
validationErrors,
|
|
1273
|
+
disabled,
|
|
1274
|
+
onAddFiles,
|
|
1275
|
+
onRemoveExisting,
|
|
1276
|
+
onRemovePending
|
|
1277
|
+
}) {
|
|
1278
|
+
const fileInputRef = (0, import_react5.useRef)(null);
|
|
1279
|
+
const retainedExisting = existingAttachments.filter(
|
|
1280
|
+
(a) => !removedExistingIds.has(a.id)
|
|
1281
|
+
);
|
|
1282
|
+
const totalCount = retainedExisting.length + pendingFiles.length;
|
|
1283
|
+
const canAdd = totalCount < ATTACHMENT_MAX_COUNT && !disabled;
|
|
1284
|
+
return /* @__PURE__ */ (0, import_jsx_runtime2.jsxs)("div", { className: "mt-4 space-y-3", children: [
|
|
1285
|
+
/* @__PURE__ */ (0, import_jsx_runtime2.jsxs)("div", { className: "flex items-center justify-between", children: [
|
|
1286
|
+
/* @__PURE__ */ (0, import_jsx_runtime2.jsxs)("div", { className: "flex items-center gap-1.5 text-xs font-medium uppercase tracking-wide text-slate-500", children: [
|
|
1287
|
+
/* @__PURE__ */ (0, import_jsx_runtime2.jsx)(import_react4.Image, { className: "h-3.5 w-3.5" }),
|
|
1288
|
+
"Screenshots (",
|
|
1289
|
+
totalCount,
|
|
1290
|
+
"/",
|
|
1291
|
+
ATTACHMENT_MAX_COUNT,
|
|
1292
|
+
")"
|
|
1293
|
+
] }),
|
|
1294
|
+
/* @__PURE__ */ (0, import_jsx_runtime2.jsx)(
|
|
1295
|
+
"input",
|
|
1296
|
+
{
|
|
1297
|
+
ref: fileInputRef,
|
|
1298
|
+
type: "file",
|
|
1299
|
+
accept: "image/png,image/jpeg,image/webp",
|
|
1300
|
+
multiple: true,
|
|
1301
|
+
className: "hidden",
|
|
1302
|
+
onChange: (e) => {
|
|
1303
|
+
if (e.target.files && e.target.files.length > 0) {
|
|
1304
|
+
onAddFiles(e.target.files);
|
|
1305
|
+
}
|
|
1306
|
+
e.target.value = "";
|
|
1307
|
+
},
|
|
1308
|
+
disabled: !canAdd,
|
|
1309
|
+
"aria-label": "Select screenshot files"
|
|
1310
|
+
}
|
|
1311
|
+
),
|
|
1312
|
+
/* @__PURE__ */ (0, import_jsx_runtime2.jsx)(
|
|
1313
|
+
"button",
|
|
1314
|
+
{
|
|
1315
|
+
type: "button",
|
|
1316
|
+
className: cn(
|
|
1317
|
+
"rounded-full border px-3 py-1 text-xs font-medium transition",
|
|
1318
|
+
canAdd ? "border-slate-300 text-slate-700 hover:bg-slate-50" : "cursor-not-allowed border-slate-200 text-slate-400"
|
|
1319
|
+
),
|
|
1320
|
+
onClick: () => fileInputRef.current?.click(),
|
|
1321
|
+
disabled: !canAdd,
|
|
1322
|
+
"aria-label": "Add screenshots",
|
|
1323
|
+
children: "Add"
|
|
1324
|
+
}
|
|
1325
|
+
)
|
|
1326
|
+
] }),
|
|
1327
|
+
validationErrors.length > 0 && /* @__PURE__ */ (0, import_jsx_runtime2.jsx)("div", { className: "space-y-1", children: validationErrors.map((err, i) => /* @__PURE__ */ (0, import_jsx_runtime2.jsx)(
|
|
1328
|
+
"div",
|
|
1329
|
+
{
|
|
1330
|
+
className: "rounded-xl border border-rose-200 bg-rose-50 px-3 py-2 text-xs text-rose-700",
|
|
1331
|
+
role: "alert",
|
|
1332
|
+
children: err
|
|
1333
|
+
},
|
|
1334
|
+
i
|
|
1335
|
+
)) }),
|
|
1336
|
+
uploadProgress.phase === "uploading" && /* @__PURE__ */ (0, import_jsx_runtime2.jsxs)("div", { className: "flex items-center gap-2 rounded-xl border border-slate-200 bg-slate-50 px-3 py-2 text-xs text-slate-600", children: [
|
|
1337
|
+
/* @__PURE__ */ (0, import_jsx_runtime2.jsx)(import_react4.Spinner, { className: "h-3.5 w-3.5 animate-spin" }),
|
|
1338
|
+
"Uploading ",
|
|
1339
|
+
uploadProgress.uploaded,
|
|
1340
|
+
" of ",
|
|
1341
|
+
uploadProgress.total,
|
|
1342
|
+
"..."
|
|
1343
|
+
] }),
|
|
1344
|
+
uploadProgress.phase === "error" && uploadProgress.error && /* @__PURE__ */ (0, import_jsx_runtime2.jsx)(
|
|
1345
|
+
"div",
|
|
1346
|
+
{
|
|
1347
|
+
className: "rounded-xl border border-rose-200 bg-rose-50 px-3 py-2 text-xs text-rose-700",
|
|
1348
|
+
role: "alert",
|
|
1349
|
+
children: uploadProgress.error
|
|
1350
|
+
}
|
|
1351
|
+
),
|
|
1352
|
+
(retainedExisting.length > 0 || pendingFiles.length > 0) && /* @__PURE__ */ (0, import_jsx_runtime2.jsxs)("div", { className: "flex flex-wrap gap-2", children: [
|
|
1353
|
+
retainedExisting.map((att) => /* @__PURE__ */ (0, import_jsx_runtime2.jsxs)(
|
|
1354
|
+
"div",
|
|
1355
|
+
{
|
|
1356
|
+
className: "group relative flex items-center gap-2 rounded-xl border border-slate-200 bg-white px-3 py-2",
|
|
1357
|
+
children: [
|
|
1358
|
+
/* @__PURE__ */ (0, import_jsx_runtime2.jsx)(import_react4.Image, { className: "h-4 w-4 flex-shrink-0 text-slate-400" }),
|
|
1359
|
+
/* @__PURE__ */ (0, import_jsx_runtime2.jsxs)("div", { className: "min-w-0", children: [
|
|
1360
|
+
/* @__PURE__ */ (0, import_jsx_runtime2.jsx)("div", { className: "max-w-[140px] truncate text-xs font-medium text-slate-700", children: att.original_filename }),
|
|
1361
|
+
/* @__PURE__ */ (0, import_jsx_runtime2.jsx)("div", { className: "text-xs text-slate-400", children: formatFileSize(att.byte_size) })
|
|
1362
|
+
] }),
|
|
1363
|
+
/* @__PURE__ */ (0, import_jsx_runtime2.jsx)(
|
|
1364
|
+
"button",
|
|
1365
|
+
{
|
|
1366
|
+
type: "button",
|
|
1367
|
+
className: "ml-1 rounded-full p-0.5 text-slate-400 transition hover:bg-slate-100 hover:text-slate-700",
|
|
1368
|
+
onClick: () => onRemoveExisting(att.id),
|
|
1369
|
+
disabled,
|
|
1370
|
+
"aria-label": `Remove ${att.original_filename}`,
|
|
1371
|
+
children: /* @__PURE__ */ (0, import_jsx_runtime2.jsx)(import_react4.Trash, { className: "h-3.5 w-3.5" })
|
|
1372
|
+
}
|
|
1373
|
+
)
|
|
1374
|
+
]
|
|
1375
|
+
},
|
|
1376
|
+
att.id
|
|
1377
|
+
)),
|
|
1378
|
+
pendingFiles.map((pf) => /* @__PURE__ */ (0, import_jsx_runtime2.jsxs)(
|
|
1379
|
+
"div",
|
|
1380
|
+
{
|
|
1381
|
+
className: "group relative flex items-center gap-2 rounded-xl border border-slate-200 bg-white px-3 py-2",
|
|
1382
|
+
children: [
|
|
1383
|
+
/* @__PURE__ */ (0, import_jsx_runtime2.jsx)(
|
|
1384
|
+
"img",
|
|
1385
|
+
{
|
|
1386
|
+
src: pf.previewUrl,
|
|
1387
|
+
alt: pf.file.name,
|
|
1388
|
+
className: "h-8 w-8 flex-shrink-0 rounded object-cover"
|
|
1389
|
+
}
|
|
1390
|
+
),
|
|
1391
|
+
/* @__PURE__ */ (0, import_jsx_runtime2.jsxs)("div", { className: "min-w-0", children: [
|
|
1392
|
+
/* @__PURE__ */ (0, import_jsx_runtime2.jsx)("div", { className: "max-w-[140px] truncate text-xs font-medium text-slate-700", children: pf.file.name }),
|
|
1393
|
+
/* @__PURE__ */ (0, import_jsx_runtime2.jsx)("div", { className: "text-xs text-slate-400", children: formatFileSize(pf.file.size) })
|
|
1394
|
+
] }),
|
|
1395
|
+
/* @__PURE__ */ (0, import_jsx_runtime2.jsx)(
|
|
1396
|
+
"button",
|
|
1397
|
+
{
|
|
1398
|
+
type: "button",
|
|
1399
|
+
className: "ml-1 rounded-full p-0.5 text-slate-400 transition hover:bg-slate-100 hover:text-slate-700",
|
|
1400
|
+
onClick: () => onRemovePending(pf.clientId),
|
|
1401
|
+
disabled,
|
|
1402
|
+
"aria-label": `Remove ${pf.file.name}`,
|
|
1403
|
+
children: /* @__PURE__ */ (0, import_jsx_runtime2.jsx)(import_react4.Trash, { className: "h-3.5 w-3.5" })
|
|
1404
|
+
}
|
|
1405
|
+
)
|
|
1406
|
+
]
|
|
1407
|
+
},
|
|
1408
|
+
pf.clientId
|
|
1409
|
+
))
|
|
1410
|
+
] })
|
|
1411
|
+
] });
|
|
1412
|
+
}
|
|
1413
|
+
function useAttachmentState(existingAttachments) {
|
|
1414
|
+
const [pendingFiles, setPendingFiles] = (0, import_react5.useState)([]);
|
|
1415
|
+
const [removedExistingIds, setRemovedExistingIds] = (0, import_react5.useState)(
|
|
1416
|
+
/* @__PURE__ */ new Set()
|
|
1417
|
+
);
|
|
1418
|
+
const [validationErrors, setValidationErrors] = (0, import_react5.useState)([]);
|
|
1419
|
+
const [uploadProgress, setUploadProgress] = (0, import_react5.useState)(INITIAL_UPLOAD_PROGRESS);
|
|
1420
|
+
const retainedExistingCount = existingAttachments.filter(
|
|
1421
|
+
(a) => !removedExistingIds.has(a.id)
|
|
1422
|
+
).length;
|
|
1423
|
+
const reset = (0, import_react5.useCallback)(() => {
|
|
1424
|
+
for (const pf of pendingFiles) {
|
|
1425
|
+
URL.revokeObjectURL(pf.previewUrl);
|
|
1426
|
+
}
|
|
1427
|
+
setPendingFiles([]);
|
|
1428
|
+
setRemovedExistingIds(/* @__PURE__ */ new Set());
|
|
1429
|
+
setValidationErrors([]);
|
|
1430
|
+
setUploadProgress(INITIAL_UPLOAD_PROGRESS);
|
|
1431
|
+
}, [pendingFiles]);
|
|
1432
|
+
const addFiles = (0, import_react5.useCallback)(
|
|
1433
|
+
(fileList) => {
|
|
1434
|
+
const currentTotal = retainedExistingCount + pendingFiles.length;
|
|
1435
|
+
const errors = [];
|
|
1436
|
+
const accepted = [];
|
|
1437
|
+
let count = currentTotal;
|
|
1438
|
+
for (let i = 0; i < fileList.length; i++) {
|
|
1439
|
+
const file = fileList[i];
|
|
1440
|
+
if (count >= ATTACHMENT_MAX_COUNT) {
|
|
1441
|
+
errors.push(
|
|
1442
|
+
`"${file.name}" was not added; maximum ${ATTACHMENT_MAX_COUNT} screenshots reached.`
|
|
1443
|
+
);
|
|
1444
|
+
continue;
|
|
1445
|
+
}
|
|
1446
|
+
const err = validateAttachmentFile(file);
|
|
1447
|
+
if (err) {
|
|
1448
|
+
errors.push(err);
|
|
1449
|
+
continue;
|
|
1450
|
+
}
|
|
1451
|
+
accepted.push({
|
|
1452
|
+
clientId: nextPendingId(),
|
|
1453
|
+
file,
|
|
1454
|
+
previewUrl: URL.createObjectURL(file)
|
|
1455
|
+
});
|
|
1456
|
+
count += 1;
|
|
1457
|
+
}
|
|
1458
|
+
if (accepted.length > 0) {
|
|
1459
|
+
setPendingFiles((prev) => [...prev, ...accepted]);
|
|
1460
|
+
}
|
|
1461
|
+
setValidationErrors(errors);
|
|
1462
|
+
setUploadProgress(INITIAL_UPLOAD_PROGRESS);
|
|
1463
|
+
},
|
|
1464
|
+
[pendingFiles.length, retainedExistingCount]
|
|
1465
|
+
);
|
|
1466
|
+
const removeExisting = (0, import_react5.useCallback)((id) => {
|
|
1467
|
+
setRemovedExistingIds((prev) => /* @__PURE__ */ new Set([...prev, id]));
|
|
1468
|
+
}, []);
|
|
1469
|
+
const removePending = (0, import_react5.useCallback)((clientId) => {
|
|
1470
|
+
setPendingFiles((prev) => {
|
|
1471
|
+
const removed = prev.find((pf) => pf.clientId === clientId);
|
|
1472
|
+
if (removed) {
|
|
1473
|
+
URL.revokeObjectURL(removed.previewUrl);
|
|
1474
|
+
}
|
|
1475
|
+
return prev.filter((pf) => pf.clientId !== clientId);
|
|
1476
|
+
});
|
|
1477
|
+
setUploadProgress(INITIAL_UPLOAD_PROGRESS);
|
|
1478
|
+
}, []);
|
|
1479
|
+
const markPendingUploaded = (0, import_react5.useCallback)((clientId, attachmentId) => {
|
|
1480
|
+
setPendingFiles(
|
|
1481
|
+
(prev) => prev.map(
|
|
1482
|
+
(pf) => pf.clientId === clientId ? { ...pf, uploadedAttachmentId: attachmentId } : pf
|
|
1483
|
+
)
|
|
1484
|
+
);
|
|
1485
|
+
}, []);
|
|
1486
|
+
return {
|
|
1487
|
+
pendingFiles,
|
|
1488
|
+
removedExistingIds,
|
|
1489
|
+
validationErrors,
|
|
1490
|
+
uploadProgress,
|
|
1491
|
+
setUploadProgress,
|
|
1492
|
+
addFiles,
|
|
1493
|
+
removeExisting,
|
|
1494
|
+
removePending,
|
|
1495
|
+
markPendingUploaded,
|
|
1496
|
+
reset
|
|
1497
|
+
};
|
|
1498
|
+
}
|
|
1205
1499
|
function IssueReportModeBanner() {
|
|
1206
1500
|
const { copy } = useIssueReporting();
|
|
1207
1501
|
const reportMode = useReportMode();
|
|
@@ -1502,7 +1796,13 @@ function IssueReportModal() {
|
|
|
1502
1796
|
const { isOpen, mode, issue, target, isHydrating, error } = modalState;
|
|
1503
1797
|
const canUseVoice = mode === "create" && inputModes.includes("voice");
|
|
1504
1798
|
const canUseText = mode !== "create" || inputModes.includes("text");
|
|
1505
|
-
const
|
|
1799
|
+
const canUseAttachments = Boolean(client.issueReporting.uploadAttachment);
|
|
1800
|
+
const existingAttachments = (0, import_react5.useMemo)(
|
|
1801
|
+
() => mode === "edit" && issue ? issue.attachments ?? [] : [],
|
|
1802
|
+
[issue, mode]
|
|
1803
|
+
);
|
|
1804
|
+
const attachmentState = useAttachmentState(existingAttachments);
|
|
1805
|
+
const isSubmitting = createMutation.isPending || updateMutation.isPending || replyMutation.isPending || attachmentState.uploadProgress.phase === "uploading";
|
|
1506
1806
|
const {
|
|
1507
1807
|
inputMode,
|
|
1508
1808
|
setInputMode,
|
|
@@ -1526,6 +1826,7 @@ function IssueReportModal() {
|
|
|
1526
1826
|
(0, import_react5.useEffect)(() => {
|
|
1527
1827
|
if (!isOpen) {
|
|
1528
1828
|
resetVoiceCapture();
|
|
1829
|
+
attachmentState.reset();
|
|
1529
1830
|
setNote("");
|
|
1530
1831
|
setSubmitError(null);
|
|
1531
1832
|
return;
|
|
@@ -1536,6 +1837,7 @@ function IssueReportModal() {
|
|
|
1536
1837
|
setNote("");
|
|
1537
1838
|
}
|
|
1538
1839
|
resetVoiceCapture();
|
|
1840
|
+
attachmentState.reset();
|
|
1539
1841
|
setSubmitError(null);
|
|
1540
1842
|
}, [isOpen, issue, mode, resetVoiceCapture]);
|
|
1541
1843
|
const effectiveInputMode = mode !== "create" ? "text" : canUseVoice && inputMode === "voice" ? "voice" : "text";
|
|
@@ -1544,6 +1846,7 @@ function IssueReportModal() {
|
|
|
1544
1846
|
const title = mode === "create" ? `${copy.createTitlePrefix}: ${target?.component_label ?? ""}` : mode === "edit" ? `${copy.editTitlePrefix}: ${target?.component_label ?? ""}` : `${copy.replyTitlePrefix}: ${target?.component_label ?? ""}`;
|
|
1545
1847
|
const handleCloseModal = () => {
|
|
1546
1848
|
resetVoiceCapture();
|
|
1849
|
+
attachmentState.reset();
|
|
1547
1850
|
closeModal();
|
|
1548
1851
|
};
|
|
1549
1852
|
const handleStartVoiceInput = async () => {
|
|
@@ -1555,6 +1858,38 @@ function IssueReportModal() {
|
|
|
1555
1858
|
const handleAppendTranscript = () => {
|
|
1556
1859
|
appendTranscript(setNote);
|
|
1557
1860
|
};
|
|
1861
|
+
const uploadPendingFiles = async () => {
|
|
1862
|
+
const upload = client.issueReporting.uploadAttachment;
|
|
1863
|
+
if (!upload || attachmentState.pendingFiles.length === 0) {
|
|
1864
|
+
return [];
|
|
1865
|
+
}
|
|
1866
|
+
const files = attachmentState.pendingFiles;
|
|
1867
|
+
attachmentState.setUploadProgress({
|
|
1868
|
+
phase: "uploading",
|
|
1869
|
+
uploaded: 0,
|
|
1870
|
+
total: files.length
|
|
1871
|
+
});
|
|
1872
|
+
const ids = [];
|
|
1873
|
+
for (let i = 0; i < files.length; i++) {
|
|
1874
|
+
const pf = files[i];
|
|
1875
|
+
const attachmentId = pf.uploadedAttachmentId ?? (await upload(pf.file, { filename: pf.file.name })).id;
|
|
1876
|
+
if (!pf.uploadedAttachmentId) {
|
|
1877
|
+
attachmentState.markPendingUploaded(pf.clientId, attachmentId);
|
|
1878
|
+
}
|
|
1879
|
+
ids.push(attachmentId);
|
|
1880
|
+
attachmentState.setUploadProgress({
|
|
1881
|
+
phase: "uploading",
|
|
1882
|
+
uploaded: i + 1,
|
|
1883
|
+
total: files.length
|
|
1884
|
+
});
|
|
1885
|
+
}
|
|
1886
|
+
attachmentState.setUploadProgress({
|
|
1887
|
+
phase: "done",
|
|
1888
|
+
uploaded: files.length,
|
|
1889
|
+
total: files.length
|
|
1890
|
+
});
|
|
1891
|
+
return ids;
|
|
1892
|
+
};
|
|
1558
1893
|
const handleSubmit = async () => {
|
|
1559
1894
|
if (!target || !isValid || isSubmitting) {
|
|
1560
1895
|
return;
|
|
@@ -1562,6 +1897,10 @@ function IssueReportModal() {
|
|
|
1562
1897
|
setSubmitError(null);
|
|
1563
1898
|
try {
|
|
1564
1899
|
const noteForSubmit = normalizedNote;
|
|
1900
|
+
let newAttachmentIds = [];
|
|
1901
|
+
if (canUseAttachments && attachmentState.pendingFiles.length > 0) {
|
|
1902
|
+
newAttachmentIds = await uploadPendingFiles();
|
|
1903
|
+
}
|
|
1565
1904
|
const effectiveVoiceMetadata = effectiveInputMode === "voice" ? voiceSubmitMetadata ?? {
|
|
1566
1905
|
provider: voice.provider,
|
|
1567
1906
|
token: "",
|
|
@@ -1586,24 +1925,37 @@ function IssueReportModal() {
|
|
|
1586
1925
|
}
|
|
1587
1926
|
} : target,
|
|
1588
1927
|
note: noteForSubmit,
|
|
1589
|
-
reporter_role_hint: reporterRoleHint
|
|
1928
|
+
reporter_role_hint: reporterRoleHint,
|
|
1929
|
+
attachment_ids: newAttachmentIds.length > 0 ? newAttachmentIds : void 0
|
|
1590
1930
|
});
|
|
1591
1931
|
} else if (mode === "edit" && issue) {
|
|
1932
|
+
const removedIds = [...attachmentState.removedExistingIds];
|
|
1592
1933
|
await updateMutation.mutateAsync({
|
|
1593
1934
|
issueReportId: issue.id,
|
|
1594
|
-
note: noteForSubmit
|
|
1935
|
+
note: noteForSubmit,
|
|
1936
|
+
add_attachment_ids: newAttachmentIds.length > 0 ? newAttachmentIds : void 0,
|
|
1937
|
+
remove_attachment_ids: removedIds.length > 0 ? removedIds : void 0
|
|
1595
1938
|
});
|
|
1596
1939
|
} else if (mode === "reply" && issue) {
|
|
1597
1940
|
await replyMutation.mutateAsync({
|
|
1598
1941
|
issueReportId: issue.id,
|
|
1599
1942
|
note: noteForSubmit,
|
|
1600
|
-
reporterRoleHint
|
|
1943
|
+
reporterRoleHint,
|
|
1944
|
+
attachment_ids: newAttachmentIds.length > 0 ? newAttachmentIds : void 0
|
|
1601
1945
|
});
|
|
1602
1946
|
}
|
|
1603
1947
|
resetVoiceCapture();
|
|
1948
|
+
attachmentState.reset();
|
|
1604
1949
|
closeModal();
|
|
1605
1950
|
} catch (submissionError) {
|
|
1606
|
-
|
|
1951
|
+
const message = resolveErrorMessage(
|
|
1952
|
+
submissionError,
|
|
1953
|
+
"Failed to submit issue report"
|
|
1954
|
+
);
|
|
1955
|
+
attachmentState.setUploadProgress(
|
|
1956
|
+
(current) => current.phase === "uploading" ? { ...current, phase: "error", error: message } : current
|
|
1957
|
+
);
|
|
1958
|
+
setSubmitError(message);
|
|
1607
1959
|
}
|
|
1608
1960
|
};
|
|
1609
1961
|
return /* @__PURE__ */ (0, import_jsx_runtime2.jsx)(Dialog.Root, { open: isOpen, onOpenChange: (open) => !open && handleCloseModal(), children: /* @__PURE__ */ (0, import_jsx_runtime2.jsxs)(Dialog.Portal, { children: [
|
|
@@ -1612,7 +1964,7 @@ function IssueReportModal() {
|
|
|
1612
1964
|
Dialog.Content,
|
|
1613
1965
|
{
|
|
1614
1966
|
className: cn(
|
|
1615
|
-
"fixed left-1/2 top-1/2 max-w-xl -translate-x-1/2 -translate-y-1/2 border border-slate-200 bg-white p-6 focus:outline-none",
|
|
1967
|
+
"fixed left-1/2 top-1/2 max-h-[90vh] max-w-xl -translate-x-1/2 -translate-y-1/2 overflow-y-auto border border-slate-200 bg-white p-6 focus:outline-none",
|
|
1616
1968
|
Z_MODAL_CONTENT,
|
|
1617
1969
|
MODAL_WIDTH,
|
|
1618
1970
|
MODAL_RADIUS,
|
|
@@ -1638,6 +1990,7 @@ function IssueReportModal() {
|
|
|
1638
1990
|
error,
|
|
1639
1991
|
canUseVoice,
|
|
1640
1992
|
canUseText,
|
|
1993
|
+
canUseAttachments,
|
|
1641
1994
|
effectiveInputMode,
|
|
1642
1995
|
note,
|
|
1643
1996
|
normalizedNote,
|
|
@@ -1651,6 +2004,11 @@ function IssueReportModal() {
|
|
|
1651
2004
|
voiceError,
|
|
1652
2005
|
scribeError,
|
|
1653
2006
|
submitError,
|
|
2007
|
+
existingAttachments,
|
|
2008
|
+
removedExistingIds: attachmentState.removedExistingIds,
|
|
2009
|
+
pendingFiles: attachmentState.pendingFiles,
|
|
2010
|
+
uploadProgress: attachmentState.uploadProgress,
|
|
2011
|
+
attachmentValidationErrors: attachmentState.validationErrors,
|
|
1654
2012
|
onRetryHydration: () => void retryModalHydration(),
|
|
1655
2013
|
onClose: handleCloseModal,
|
|
1656
2014
|
onSelectText: () => setInputMode("text"),
|
|
@@ -1659,7 +2017,10 @@ function IssueReportModal() {
|
|
|
1659
2017
|
onStopVoiceInput: handleStopVoiceInput,
|
|
1660
2018
|
onAppendTranscript: handleAppendTranscript,
|
|
1661
2019
|
onNoteChange: setNote,
|
|
1662
|
-
onSubmit: () => void handleSubmit()
|
|
2020
|
+
onSubmit: () => void handleSubmit(),
|
|
2021
|
+
onAddFiles: attachmentState.addFiles,
|
|
2022
|
+
onRemoveExistingAttachment: attachmentState.removeExisting,
|
|
2023
|
+
onRemovePendingAttachment: attachmentState.removePending
|
|
1663
2024
|
}
|
|
1664
2025
|
),
|
|
1665
2026
|
/* @__PURE__ */ (0, import_jsx_runtime2.jsx)(Dialog.Close, { asChild: true, children: /* @__PURE__ */ (0, import_jsx_runtime2.jsx)(
|
package/dist/index.mjs
CHANGED
|
@@ -6,13 +6,15 @@ import {
|
|
|
6
6
|
BugBeetle,
|
|
7
7
|
CheckCircle,
|
|
8
8
|
Circle,
|
|
9
|
+
Image,
|
|
9
10
|
Microphone,
|
|
10
11
|
Spinner,
|
|
11
12
|
TextT,
|
|
13
|
+
Trash,
|
|
12
14
|
X
|
|
13
15
|
} from "@phosphor-icons/react";
|
|
14
16
|
import { formatDistanceToNow } from "date-fns";
|
|
15
|
-
import React2, { useCallback as useCallback2, useEffect as useEffect2, useMemo as useMemo2, useState as useState2 } from "react";
|
|
17
|
+
import React2, { useCallback as useCallback2, useEffect as useEffect2, useMemo as useMemo2, useRef as useRef2, useState as useState2 } from "react";
|
|
16
18
|
|
|
17
19
|
// src/provider.tsx
|
|
18
20
|
import {
|
|
@@ -44,6 +46,13 @@ import { jsx } from "react/jsx-runtime";
|
|
|
44
46
|
var LIST_LIMIT = 200;
|
|
45
47
|
var NOTE_MIN_LENGTH = 10;
|
|
46
48
|
var NOTE_MAX_LENGTH = 2e3;
|
|
49
|
+
var ATTACHMENT_MAX_COUNT = 5;
|
|
50
|
+
var ATTACHMENT_MAX_BYTES = 10 * 1024 * 1024;
|
|
51
|
+
var ATTACHMENT_ALLOWED_MIMES = /* @__PURE__ */ new Set([
|
|
52
|
+
"image/png",
|
|
53
|
+
"image/jpeg",
|
|
54
|
+
"image/webp"
|
|
55
|
+
]);
|
|
47
56
|
var DEFAULT_INPUT_MODES = ["text"];
|
|
48
57
|
var DEFAULT_VOICE_PROVIDER = "elevenlabs_scribe_realtime";
|
|
49
58
|
var DEFAULT_VOICE_MODEL_ID = "scribe_v2_realtime";
|
|
@@ -386,18 +395,26 @@ function useIssueReportingMutations() {
|
|
|
386
395
|
const updateMutation = useMutation({
|
|
387
396
|
mutationFn: ({
|
|
388
397
|
issueReportId,
|
|
389
|
-
note
|
|
390
|
-
|
|
398
|
+
note,
|
|
399
|
+
add_attachment_ids,
|
|
400
|
+
remove_attachment_ids
|
|
401
|
+
}) => client.issueReporting.update(issueReportId, {
|
|
402
|
+
note,
|
|
403
|
+
add_attachment_ids,
|
|
404
|
+
remove_attachment_ids
|
|
405
|
+
}),
|
|
391
406
|
onSuccess: invalidateAll
|
|
392
407
|
});
|
|
393
408
|
const replyMutation = useMutation({
|
|
394
409
|
mutationFn: ({
|
|
395
410
|
issueReportId,
|
|
396
411
|
note,
|
|
397
|
-
reporterRoleHint
|
|
412
|
+
reporterRoleHint,
|
|
413
|
+
attachment_ids
|
|
398
414
|
}) => client.issueReporting.reply(issueReportId, {
|
|
399
415
|
note,
|
|
400
|
-
reporter_role_hint: reporterRoleHint
|
|
416
|
+
reporter_role_hint: reporterRoleHint,
|
|
417
|
+
attachment_ids
|
|
401
418
|
}),
|
|
402
419
|
onSuccess: invalidateAll
|
|
403
420
|
});
|
|
@@ -953,6 +970,7 @@ function IssueReportModalBody({
|
|
|
953
970
|
error,
|
|
954
971
|
canUseVoice,
|
|
955
972
|
canUseText,
|
|
973
|
+
canUseAttachments,
|
|
956
974
|
effectiveInputMode,
|
|
957
975
|
note,
|
|
958
976
|
normalizedNote,
|
|
@@ -966,6 +984,11 @@ function IssueReportModalBody({
|
|
|
966
984
|
voiceError,
|
|
967
985
|
scribeError,
|
|
968
986
|
submitError,
|
|
987
|
+
existingAttachments,
|
|
988
|
+
removedExistingIds,
|
|
989
|
+
pendingFiles,
|
|
990
|
+
uploadProgress,
|
|
991
|
+
attachmentValidationErrors,
|
|
969
992
|
onRetryHydration,
|
|
970
993
|
onClose,
|
|
971
994
|
onSelectText,
|
|
@@ -974,7 +997,10 @@ function IssueReportModalBody({
|
|
|
974
997
|
onStopVoiceInput,
|
|
975
998
|
onAppendTranscript,
|
|
976
999
|
onNoteChange,
|
|
977
|
-
onSubmit
|
|
1000
|
+
onSubmit,
|
|
1001
|
+
onAddFiles,
|
|
1002
|
+
onRemoveExistingAttachment,
|
|
1003
|
+
onRemovePendingAttachment
|
|
978
1004
|
}) {
|
|
979
1005
|
if (isHydrating) {
|
|
980
1006
|
return /* @__PURE__ */ jsxs("div", { className: "mt-5 flex items-center gap-2 rounded-2xl border border-slate-200 bg-slate-50 px-4 py-4 text-sm text-slate-600", children: [
|
|
@@ -1044,6 +1070,20 @@ function IssueReportModalBody({
|
|
|
1044
1070
|
onSubmit
|
|
1045
1071
|
}
|
|
1046
1072
|
),
|
|
1073
|
+
canUseAttachments && /* @__PURE__ */ jsx2(
|
|
1074
|
+
AttachmentPicker,
|
|
1075
|
+
{
|
|
1076
|
+
existingAttachments,
|
|
1077
|
+
removedExistingIds,
|
|
1078
|
+
pendingFiles,
|
|
1079
|
+
uploadProgress,
|
|
1080
|
+
validationErrors: attachmentValidationErrors,
|
|
1081
|
+
disabled: isSubmitting,
|
|
1082
|
+
onAddFiles,
|
|
1083
|
+
onRemoveExisting: onRemoveExistingAttachment,
|
|
1084
|
+
onRemovePending: onRemovePendingAttachment
|
|
1085
|
+
}
|
|
1086
|
+
),
|
|
1047
1087
|
submitError ? /* @__PURE__ */ jsx2("div", { className: "mt-4 rounded-2xl border border-rose-200 bg-rose-50 px-4 py-3 text-sm text-rose-700", children: submitError }) : null,
|
|
1048
1088
|
/* @__PURE__ */ jsxs("div", { className: "mt-6 flex justify-end gap-3", children: [
|
|
1049
1089
|
/* @__PURE__ */ jsx2(
|
|
@@ -1167,6 +1207,262 @@ function useIssueReportVoiceCapture({
|
|
|
1167
1207
|
appendTranscript
|
|
1168
1208
|
};
|
|
1169
1209
|
}
|
|
1210
|
+
var INITIAL_UPLOAD_PROGRESS = {
|
|
1211
|
+
phase: "idle",
|
|
1212
|
+
uploaded: 0,
|
|
1213
|
+
total: 0
|
|
1214
|
+
};
|
|
1215
|
+
var pendingFileCounter = 0;
|
|
1216
|
+
function nextPendingId() {
|
|
1217
|
+
pendingFileCounter += 1;
|
|
1218
|
+
return `pending-${pendingFileCounter}`;
|
|
1219
|
+
}
|
|
1220
|
+
function formatFileSize(bytes) {
|
|
1221
|
+
if (bytes < 1024) return `${bytes} B`;
|
|
1222
|
+
if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`;
|
|
1223
|
+
return `${(bytes / (1024 * 1024)).toFixed(1)} MB`;
|
|
1224
|
+
}
|
|
1225
|
+
function validateAttachmentFile(file) {
|
|
1226
|
+
if (!ATTACHMENT_ALLOWED_MIMES.has(file.type)) {
|
|
1227
|
+
return `"${file.name}" is not a supported image type (PNG, JPEG, or WebP).`;
|
|
1228
|
+
}
|
|
1229
|
+
if (file.size > ATTACHMENT_MAX_BYTES) {
|
|
1230
|
+
return `"${file.name}" exceeds the 10 MB limit.`;
|
|
1231
|
+
}
|
|
1232
|
+
return null;
|
|
1233
|
+
}
|
|
1234
|
+
function AttachmentPicker({
|
|
1235
|
+
existingAttachments,
|
|
1236
|
+
removedExistingIds,
|
|
1237
|
+
pendingFiles,
|
|
1238
|
+
uploadProgress,
|
|
1239
|
+
validationErrors,
|
|
1240
|
+
disabled,
|
|
1241
|
+
onAddFiles,
|
|
1242
|
+
onRemoveExisting,
|
|
1243
|
+
onRemovePending
|
|
1244
|
+
}) {
|
|
1245
|
+
const fileInputRef = useRef2(null);
|
|
1246
|
+
const retainedExisting = existingAttachments.filter(
|
|
1247
|
+
(a) => !removedExistingIds.has(a.id)
|
|
1248
|
+
);
|
|
1249
|
+
const totalCount = retainedExisting.length + pendingFiles.length;
|
|
1250
|
+
const canAdd = totalCount < ATTACHMENT_MAX_COUNT && !disabled;
|
|
1251
|
+
return /* @__PURE__ */ jsxs("div", { className: "mt-4 space-y-3", children: [
|
|
1252
|
+
/* @__PURE__ */ jsxs("div", { className: "flex items-center justify-between", children: [
|
|
1253
|
+
/* @__PURE__ */ jsxs("div", { className: "flex items-center gap-1.5 text-xs font-medium uppercase tracking-wide text-slate-500", children: [
|
|
1254
|
+
/* @__PURE__ */ jsx2(Image, { className: "h-3.5 w-3.5" }),
|
|
1255
|
+
"Screenshots (",
|
|
1256
|
+
totalCount,
|
|
1257
|
+
"/",
|
|
1258
|
+
ATTACHMENT_MAX_COUNT,
|
|
1259
|
+
")"
|
|
1260
|
+
] }),
|
|
1261
|
+
/* @__PURE__ */ jsx2(
|
|
1262
|
+
"input",
|
|
1263
|
+
{
|
|
1264
|
+
ref: fileInputRef,
|
|
1265
|
+
type: "file",
|
|
1266
|
+
accept: "image/png,image/jpeg,image/webp",
|
|
1267
|
+
multiple: true,
|
|
1268
|
+
className: "hidden",
|
|
1269
|
+
onChange: (e) => {
|
|
1270
|
+
if (e.target.files && e.target.files.length > 0) {
|
|
1271
|
+
onAddFiles(e.target.files);
|
|
1272
|
+
}
|
|
1273
|
+
e.target.value = "";
|
|
1274
|
+
},
|
|
1275
|
+
disabled: !canAdd,
|
|
1276
|
+
"aria-label": "Select screenshot files"
|
|
1277
|
+
}
|
|
1278
|
+
),
|
|
1279
|
+
/* @__PURE__ */ jsx2(
|
|
1280
|
+
"button",
|
|
1281
|
+
{
|
|
1282
|
+
type: "button",
|
|
1283
|
+
className: cn(
|
|
1284
|
+
"rounded-full border px-3 py-1 text-xs font-medium transition",
|
|
1285
|
+
canAdd ? "border-slate-300 text-slate-700 hover:bg-slate-50" : "cursor-not-allowed border-slate-200 text-slate-400"
|
|
1286
|
+
),
|
|
1287
|
+
onClick: () => fileInputRef.current?.click(),
|
|
1288
|
+
disabled: !canAdd,
|
|
1289
|
+
"aria-label": "Add screenshots",
|
|
1290
|
+
children: "Add"
|
|
1291
|
+
}
|
|
1292
|
+
)
|
|
1293
|
+
] }),
|
|
1294
|
+
validationErrors.length > 0 && /* @__PURE__ */ jsx2("div", { className: "space-y-1", children: validationErrors.map((err, i) => /* @__PURE__ */ jsx2(
|
|
1295
|
+
"div",
|
|
1296
|
+
{
|
|
1297
|
+
className: "rounded-xl border border-rose-200 bg-rose-50 px-3 py-2 text-xs text-rose-700",
|
|
1298
|
+
role: "alert",
|
|
1299
|
+
children: err
|
|
1300
|
+
},
|
|
1301
|
+
i
|
|
1302
|
+
)) }),
|
|
1303
|
+
uploadProgress.phase === "uploading" && /* @__PURE__ */ jsxs("div", { className: "flex items-center gap-2 rounded-xl border border-slate-200 bg-slate-50 px-3 py-2 text-xs text-slate-600", children: [
|
|
1304
|
+
/* @__PURE__ */ jsx2(Spinner, { className: "h-3.5 w-3.5 animate-spin" }),
|
|
1305
|
+
"Uploading ",
|
|
1306
|
+
uploadProgress.uploaded,
|
|
1307
|
+
" of ",
|
|
1308
|
+
uploadProgress.total,
|
|
1309
|
+
"..."
|
|
1310
|
+
] }),
|
|
1311
|
+
uploadProgress.phase === "error" && uploadProgress.error && /* @__PURE__ */ jsx2(
|
|
1312
|
+
"div",
|
|
1313
|
+
{
|
|
1314
|
+
className: "rounded-xl border border-rose-200 bg-rose-50 px-3 py-2 text-xs text-rose-700",
|
|
1315
|
+
role: "alert",
|
|
1316
|
+
children: uploadProgress.error
|
|
1317
|
+
}
|
|
1318
|
+
),
|
|
1319
|
+
(retainedExisting.length > 0 || pendingFiles.length > 0) && /* @__PURE__ */ jsxs("div", { className: "flex flex-wrap gap-2", children: [
|
|
1320
|
+
retainedExisting.map((att) => /* @__PURE__ */ jsxs(
|
|
1321
|
+
"div",
|
|
1322
|
+
{
|
|
1323
|
+
className: "group relative flex items-center gap-2 rounded-xl border border-slate-200 bg-white px-3 py-2",
|
|
1324
|
+
children: [
|
|
1325
|
+
/* @__PURE__ */ jsx2(Image, { className: "h-4 w-4 flex-shrink-0 text-slate-400" }),
|
|
1326
|
+
/* @__PURE__ */ jsxs("div", { className: "min-w-0", children: [
|
|
1327
|
+
/* @__PURE__ */ jsx2("div", { className: "max-w-[140px] truncate text-xs font-medium text-slate-700", children: att.original_filename }),
|
|
1328
|
+
/* @__PURE__ */ jsx2("div", { className: "text-xs text-slate-400", children: formatFileSize(att.byte_size) })
|
|
1329
|
+
] }),
|
|
1330
|
+
/* @__PURE__ */ jsx2(
|
|
1331
|
+
"button",
|
|
1332
|
+
{
|
|
1333
|
+
type: "button",
|
|
1334
|
+
className: "ml-1 rounded-full p-0.5 text-slate-400 transition hover:bg-slate-100 hover:text-slate-700",
|
|
1335
|
+
onClick: () => onRemoveExisting(att.id),
|
|
1336
|
+
disabled,
|
|
1337
|
+
"aria-label": `Remove ${att.original_filename}`,
|
|
1338
|
+
children: /* @__PURE__ */ jsx2(Trash, { className: "h-3.5 w-3.5" })
|
|
1339
|
+
}
|
|
1340
|
+
)
|
|
1341
|
+
]
|
|
1342
|
+
},
|
|
1343
|
+
att.id
|
|
1344
|
+
)),
|
|
1345
|
+
pendingFiles.map((pf) => /* @__PURE__ */ jsxs(
|
|
1346
|
+
"div",
|
|
1347
|
+
{
|
|
1348
|
+
className: "group relative flex items-center gap-2 rounded-xl border border-slate-200 bg-white px-3 py-2",
|
|
1349
|
+
children: [
|
|
1350
|
+
/* @__PURE__ */ jsx2(
|
|
1351
|
+
"img",
|
|
1352
|
+
{
|
|
1353
|
+
src: pf.previewUrl,
|
|
1354
|
+
alt: pf.file.name,
|
|
1355
|
+
className: "h-8 w-8 flex-shrink-0 rounded object-cover"
|
|
1356
|
+
}
|
|
1357
|
+
),
|
|
1358
|
+
/* @__PURE__ */ jsxs("div", { className: "min-w-0", children: [
|
|
1359
|
+
/* @__PURE__ */ jsx2("div", { className: "max-w-[140px] truncate text-xs font-medium text-slate-700", children: pf.file.name }),
|
|
1360
|
+
/* @__PURE__ */ jsx2("div", { className: "text-xs text-slate-400", children: formatFileSize(pf.file.size) })
|
|
1361
|
+
] }),
|
|
1362
|
+
/* @__PURE__ */ jsx2(
|
|
1363
|
+
"button",
|
|
1364
|
+
{
|
|
1365
|
+
type: "button",
|
|
1366
|
+
className: "ml-1 rounded-full p-0.5 text-slate-400 transition hover:bg-slate-100 hover:text-slate-700",
|
|
1367
|
+
onClick: () => onRemovePending(pf.clientId),
|
|
1368
|
+
disabled,
|
|
1369
|
+
"aria-label": `Remove ${pf.file.name}`,
|
|
1370
|
+
children: /* @__PURE__ */ jsx2(Trash, { className: "h-3.5 w-3.5" })
|
|
1371
|
+
}
|
|
1372
|
+
)
|
|
1373
|
+
]
|
|
1374
|
+
},
|
|
1375
|
+
pf.clientId
|
|
1376
|
+
))
|
|
1377
|
+
] })
|
|
1378
|
+
] });
|
|
1379
|
+
}
|
|
1380
|
+
function useAttachmentState(existingAttachments) {
|
|
1381
|
+
const [pendingFiles, setPendingFiles] = useState2([]);
|
|
1382
|
+
const [removedExistingIds, setRemovedExistingIds] = useState2(
|
|
1383
|
+
/* @__PURE__ */ new Set()
|
|
1384
|
+
);
|
|
1385
|
+
const [validationErrors, setValidationErrors] = useState2([]);
|
|
1386
|
+
const [uploadProgress, setUploadProgress] = useState2(INITIAL_UPLOAD_PROGRESS);
|
|
1387
|
+
const retainedExistingCount = existingAttachments.filter(
|
|
1388
|
+
(a) => !removedExistingIds.has(a.id)
|
|
1389
|
+
).length;
|
|
1390
|
+
const reset = useCallback2(() => {
|
|
1391
|
+
for (const pf of pendingFiles) {
|
|
1392
|
+
URL.revokeObjectURL(pf.previewUrl);
|
|
1393
|
+
}
|
|
1394
|
+
setPendingFiles([]);
|
|
1395
|
+
setRemovedExistingIds(/* @__PURE__ */ new Set());
|
|
1396
|
+
setValidationErrors([]);
|
|
1397
|
+
setUploadProgress(INITIAL_UPLOAD_PROGRESS);
|
|
1398
|
+
}, [pendingFiles]);
|
|
1399
|
+
const addFiles = useCallback2(
|
|
1400
|
+
(fileList) => {
|
|
1401
|
+
const currentTotal = retainedExistingCount + pendingFiles.length;
|
|
1402
|
+
const errors = [];
|
|
1403
|
+
const accepted = [];
|
|
1404
|
+
let count = currentTotal;
|
|
1405
|
+
for (let i = 0; i < fileList.length; i++) {
|
|
1406
|
+
const file = fileList[i];
|
|
1407
|
+
if (count >= ATTACHMENT_MAX_COUNT) {
|
|
1408
|
+
errors.push(
|
|
1409
|
+
`"${file.name}" was not added; maximum ${ATTACHMENT_MAX_COUNT} screenshots reached.`
|
|
1410
|
+
);
|
|
1411
|
+
continue;
|
|
1412
|
+
}
|
|
1413
|
+
const err = validateAttachmentFile(file);
|
|
1414
|
+
if (err) {
|
|
1415
|
+
errors.push(err);
|
|
1416
|
+
continue;
|
|
1417
|
+
}
|
|
1418
|
+
accepted.push({
|
|
1419
|
+
clientId: nextPendingId(),
|
|
1420
|
+
file,
|
|
1421
|
+
previewUrl: URL.createObjectURL(file)
|
|
1422
|
+
});
|
|
1423
|
+
count += 1;
|
|
1424
|
+
}
|
|
1425
|
+
if (accepted.length > 0) {
|
|
1426
|
+
setPendingFiles((prev) => [...prev, ...accepted]);
|
|
1427
|
+
}
|
|
1428
|
+
setValidationErrors(errors);
|
|
1429
|
+
setUploadProgress(INITIAL_UPLOAD_PROGRESS);
|
|
1430
|
+
},
|
|
1431
|
+
[pendingFiles.length, retainedExistingCount]
|
|
1432
|
+
);
|
|
1433
|
+
const removeExisting = useCallback2((id) => {
|
|
1434
|
+
setRemovedExistingIds((prev) => /* @__PURE__ */ new Set([...prev, id]));
|
|
1435
|
+
}, []);
|
|
1436
|
+
const removePending = useCallback2((clientId) => {
|
|
1437
|
+
setPendingFiles((prev) => {
|
|
1438
|
+
const removed = prev.find((pf) => pf.clientId === clientId);
|
|
1439
|
+
if (removed) {
|
|
1440
|
+
URL.revokeObjectURL(removed.previewUrl);
|
|
1441
|
+
}
|
|
1442
|
+
return prev.filter((pf) => pf.clientId !== clientId);
|
|
1443
|
+
});
|
|
1444
|
+
setUploadProgress(INITIAL_UPLOAD_PROGRESS);
|
|
1445
|
+
}, []);
|
|
1446
|
+
const markPendingUploaded = useCallback2((clientId, attachmentId) => {
|
|
1447
|
+
setPendingFiles(
|
|
1448
|
+
(prev) => prev.map(
|
|
1449
|
+
(pf) => pf.clientId === clientId ? { ...pf, uploadedAttachmentId: attachmentId } : pf
|
|
1450
|
+
)
|
|
1451
|
+
);
|
|
1452
|
+
}, []);
|
|
1453
|
+
return {
|
|
1454
|
+
pendingFiles,
|
|
1455
|
+
removedExistingIds,
|
|
1456
|
+
validationErrors,
|
|
1457
|
+
uploadProgress,
|
|
1458
|
+
setUploadProgress,
|
|
1459
|
+
addFiles,
|
|
1460
|
+
removeExisting,
|
|
1461
|
+
removePending,
|
|
1462
|
+
markPendingUploaded,
|
|
1463
|
+
reset
|
|
1464
|
+
};
|
|
1465
|
+
}
|
|
1170
1466
|
function IssueReportModeBanner() {
|
|
1171
1467
|
const { copy } = useIssueReporting();
|
|
1172
1468
|
const reportMode = useReportMode();
|
|
@@ -1467,7 +1763,13 @@ function IssueReportModal() {
|
|
|
1467
1763
|
const { isOpen, mode, issue, target, isHydrating, error } = modalState;
|
|
1468
1764
|
const canUseVoice = mode === "create" && inputModes.includes("voice");
|
|
1469
1765
|
const canUseText = mode !== "create" || inputModes.includes("text");
|
|
1470
|
-
const
|
|
1766
|
+
const canUseAttachments = Boolean(client.issueReporting.uploadAttachment);
|
|
1767
|
+
const existingAttachments = useMemo2(
|
|
1768
|
+
() => mode === "edit" && issue ? issue.attachments ?? [] : [],
|
|
1769
|
+
[issue, mode]
|
|
1770
|
+
);
|
|
1771
|
+
const attachmentState = useAttachmentState(existingAttachments);
|
|
1772
|
+
const isSubmitting = createMutation.isPending || updateMutation.isPending || replyMutation.isPending || attachmentState.uploadProgress.phase === "uploading";
|
|
1471
1773
|
const {
|
|
1472
1774
|
inputMode,
|
|
1473
1775
|
setInputMode,
|
|
@@ -1491,6 +1793,7 @@ function IssueReportModal() {
|
|
|
1491
1793
|
useEffect2(() => {
|
|
1492
1794
|
if (!isOpen) {
|
|
1493
1795
|
resetVoiceCapture();
|
|
1796
|
+
attachmentState.reset();
|
|
1494
1797
|
setNote("");
|
|
1495
1798
|
setSubmitError(null);
|
|
1496
1799
|
return;
|
|
@@ -1501,6 +1804,7 @@ function IssueReportModal() {
|
|
|
1501
1804
|
setNote("");
|
|
1502
1805
|
}
|
|
1503
1806
|
resetVoiceCapture();
|
|
1807
|
+
attachmentState.reset();
|
|
1504
1808
|
setSubmitError(null);
|
|
1505
1809
|
}, [isOpen, issue, mode, resetVoiceCapture]);
|
|
1506
1810
|
const effectiveInputMode = mode !== "create" ? "text" : canUseVoice && inputMode === "voice" ? "voice" : "text";
|
|
@@ -1509,6 +1813,7 @@ function IssueReportModal() {
|
|
|
1509
1813
|
const title = mode === "create" ? `${copy.createTitlePrefix}: ${target?.component_label ?? ""}` : mode === "edit" ? `${copy.editTitlePrefix}: ${target?.component_label ?? ""}` : `${copy.replyTitlePrefix}: ${target?.component_label ?? ""}`;
|
|
1510
1814
|
const handleCloseModal = () => {
|
|
1511
1815
|
resetVoiceCapture();
|
|
1816
|
+
attachmentState.reset();
|
|
1512
1817
|
closeModal();
|
|
1513
1818
|
};
|
|
1514
1819
|
const handleStartVoiceInput = async () => {
|
|
@@ -1520,6 +1825,38 @@ function IssueReportModal() {
|
|
|
1520
1825
|
const handleAppendTranscript = () => {
|
|
1521
1826
|
appendTranscript(setNote);
|
|
1522
1827
|
};
|
|
1828
|
+
const uploadPendingFiles = async () => {
|
|
1829
|
+
const upload = client.issueReporting.uploadAttachment;
|
|
1830
|
+
if (!upload || attachmentState.pendingFiles.length === 0) {
|
|
1831
|
+
return [];
|
|
1832
|
+
}
|
|
1833
|
+
const files = attachmentState.pendingFiles;
|
|
1834
|
+
attachmentState.setUploadProgress({
|
|
1835
|
+
phase: "uploading",
|
|
1836
|
+
uploaded: 0,
|
|
1837
|
+
total: files.length
|
|
1838
|
+
});
|
|
1839
|
+
const ids = [];
|
|
1840
|
+
for (let i = 0; i < files.length; i++) {
|
|
1841
|
+
const pf = files[i];
|
|
1842
|
+
const attachmentId = pf.uploadedAttachmentId ?? (await upload(pf.file, { filename: pf.file.name })).id;
|
|
1843
|
+
if (!pf.uploadedAttachmentId) {
|
|
1844
|
+
attachmentState.markPendingUploaded(pf.clientId, attachmentId);
|
|
1845
|
+
}
|
|
1846
|
+
ids.push(attachmentId);
|
|
1847
|
+
attachmentState.setUploadProgress({
|
|
1848
|
+
phase: "uploading",
|
|
1849
|
+
uploaded: i + 1,
|
|
1850
|
+
total: files.length
|
|
1851
|
+
});
|
|
1852
|
+
}
|
|
1853
|
+
attachmentState.setUploadProgress({
|
|
1854
|
+
phase: "done",
|
|
1855
|
+
uploaded: files.length,
|
|
1856
|
+
total: files.length
|
|
1857
|
+
});
|
|
1858
|
+
return ids;
|
|
1859
|
+
};
|
|
1523
1860
|
const handleSubmit = async () => {
|
|
1524
1861
|
if (!target || !isValid || isSubmitting) {
|
|
1525
1862
|
return;
|
|
@@ -1527,6 +1864,10 @@ function IssueReportModal() {
|
|
|
1527
1864
|
setSubmitError(null);
|
|
1528
1865
|
try {
|
|
1529
1866
|
const noteForSubmit = normalizedNote;
|
|
1867
|
+
let newAttachmentIds = [];
|
|
1868
|
+
if (canUseAttachments && attachmentState.pendingFiles.length > 0) {
|
|
1869
|
+
newAttachmentIds = await uploadPendingFiles();
|
|
1870
|
+
}
|
|
1530
1871
|
const effectiveVoiceMetadata = effectiveInputMode === "voice" ? voiceSubmitMetadata ?? {
|
|
1531
1872
|
provider: voice.provider,
|
|
1532
1873
|
token: "",
|
|
@@ -1551,24 +1892,37 @@ function IssueReportModal() {
|
|
|
1551
1892
|
}
|
|
1552
1893
|
} : target,
|
|
1553
1894
|
note: noteForSubmit,
|
|
1554
|
-
reporter_role_hint: reporterRoleHint
|
|
1895
|
+
reporter_role_hint: reporterRoleHint,
|
|
1896
|
+
attachment_ids: newAttachmentIds.length > 0 ? newAttachmentIds : void 0
|
|
1555
1897
|
});
|
|
1556
1898
|
} else if (mode === "edit" && issue) {
|
|
1899
|
+
const removedIds = [...attachmentState.removedExistingIds];
|
|
1557
1900
|
await updateMutation.mutateAsync({
|
|
1558
1901
|
issueReportId: issue.id,
|
|
1559
|
-
note: noteForSubmit
|
|
1902
|
+
note: noteForSubmit,
|
|
1903
|
+
add_attachment_ids: newAttachmentIds.length > 0 ? newAttachmentIds : void 0,
|
|
1904
|
+
remove_attachment_ids: removedIds.length > 0 ? removedIds : void 0
|
|
1560
1905
|
});
|
|
1561
1906
|
} else if (mode === "reply" && issue) {
|
|
1562
1907
|
await replyMutation.mutateAsync({
|
|
1563
1908
|
issueReportId: issue.id,
|
|
1564
1909
|
note: noteForSubmit,
|
|
1565
|
-
reporterRoleHint
|
|
1910
|
+
reporterRoleHint,
|
|
1911
|
+
attachment_ids: newAttachmentIds.length > 0 ? newAttachmentIds : void 0
|
|
1566
1912
|
});
|
|
1567
1913
|
}
|
|
1568
1914
|
resetVoiceCapture();
|
|
1915
|
+
attachmentState.reset();
|
|
1569
1916
|
closeModal();
|
|
1570
1917
|
} catch (submissionError) {
|
|
1571
|
-
|
|
1918
|
+
const message = resolveErrorMessage(
|
|
1919
|
+
submissionError,
|
|
1920
|
+
"Failed to submit issue report"
|
|
1921
|
+
);
|
|
1922
|
+
attachmentState.setUploadProgress(
|
|
1923
|
+
(current) => current.phase === "uploading" ? { ...current, phase: "error", error: message } : current
|
|
1924
|
+
);
|
|
1925
|
+
setSubmitError(message);
|
|
1572
1926
|
}
|
|
1573
1927
|
};
|
|
1574
1928
|
return /* @__PURE__ */ jsx2(Dialog.Root, { open: isOpen, onOpenChange: (open) => !open && handleCloseModal(), children: /* @__PURE__ */ jsxs(Dialog.Portal, { children: [
|
|
@@ -1577,7 +1931,7 @@ function IssueReportModal() {
|
|
|
1577
1931
|
Dialog.Content,
|
|
1578
1932
|
{
|
|
1579
1933
|
className: cn(
|
|
1580
|
-
"fixed left-1/2 top-1/2 max-w-xl -translate-x-1/2 -translate-y-1/2 border border-slate-200 bg-white p-6 focus:outline-none",
|
|
1934
|
+
"fixed left-1/2 top-1/2 max-h-[90vh] max-w-xl -translate-x-1/2 -translate-y-1/2 overflow-y-auto border border-slate-200 bg-white p-6 focus:outline-none",
|
|
1581
1935
|
Z_MODAL_CONTENT,
|
|
1582
1936
|
MODAL_WIDTH,
|
|
1583
1937
|
MODAL_RADIUS,
|
|
@@ -1603,6 +1957,7 @@ function IssueReportModal() {
|
|
|
1603
1957
|
error,
|
|
1604
1958
|
canUseVoice,
|
|
1605
1959
|
canUseText,
|
|
1960
|
+
canUseAttachments,
|
|
1606
1961
|
effectiveInputMode,
|
|
1607
1962
|
note,
|
|
1608
1963
|
normalizedNote,
|
|
@@ -1616,6 +1971,11 @@ function IssueReportModal() {
|
|
|
1616
1971
|
voiceError,
|
|
1617
1972
|
scribeError,
|
|
1618
1973
|
submitError,
|
|
1974
|
+
existingAttachments,
|
|
1975
|
+
removedExistingIds: attachmentState.removedExistingIds,
|
|
1976
|
+
pendingFiles: attachmentState.pendingFiles,
|
|
1977
|
+
uploadProgress: attachmentState.uploadProgress,
|
|
1978
|
+
attachmentValidationErrors: attachmentState.validationErrors,
|
|
1619
1979
|
onRetryHydration: () => void retryModalHydration(),
|
|
1620
1980
|
onClose: handleCloseModal,
|
|
1621
1981
|
onSelectText: () => setInputMode("text"),
|
|
@@ -1624,7 +1984,10 @@ function IssueReportModal() {
|
|
|
1624
1984
|
onStopVoiceInput: handleStopVoiceInput,
|
|
1625
1985
|
onAppendTranscript: handleAppendTranscript,
|
|
1626
1986
|
onNoteChange: setNote,
|
|
1627
|
-
onSubmit: () => void handleSubmit()
|
|
1987
|
+
onSubmit: () => void handleSubmit(),
|
|
1988
|
+
onAddFiles: attachmentState.addFiles,
|
|
1989
|
+
onRemoveExistingAttachment: attachmentState.removeExisting,
|
|
1990
|
+
onRemovePendingAttachment: attachmentState.removePending
|
|
1628
1991
|
}
|
|
1629
1992
|
),
|
|
1630
1993
|
/* @__PURE__ */ jsx2(Dialog.Close, { asChild: true, children: /* @__PURE__ */ jsx2(
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "spaps-issue-reporting-react",
|
|
3
|
-
"version": "0.4.
|
|
3
|
+
"version": "0.4.1",
|
|
4
4
|
"description": "Shared React issue-reporting UI for Sweet Potato platform consumers",
|
|
5
5
|
"main": "dist/index.js",
|
|
6
6
|
"types": "dist/index.d.ts",
|
|
@@ -40,7 +40,7 @@
|
|
|
40
40
|
"@radix-ui/react-dialog": "^1.1.15",
|
|
41
41
|
"@radix-ui/react-popover": "^1.1.15",
|
|
42
42
|
"date-fns": "^4.1.0",
|
|
43
|
-
"spaps-types": "^1.
|
|
43
|
+
"spaps-types": "^1.4.0"
|
|
44
44
|
},
|
|
45
45
|
"peerDependencies": {
|
|
46
46
|
"@tanstack/react-query": ">=5.0.0",
|