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 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.2.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
- }) => client.issueReporting.update(issueReportId, { note }),
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 isSubmitting = createMutation.isPending || updateMutation.isPending || replyMutation.isPending;
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
- setSubmitError(resolveErrorMessage(submissionError, "Failed to submit issue report"));
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
- }) => client.issueReporting.update(issueReportId, { note }),
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 isSubmitting = createMutation.isPending || updateMutation.isPending || replyMutation.isPending;
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
- setSubmitError(resolveErrorMessage(submissionError, "Failed to submit issue report"));
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.0",
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.3.0"
43
+ "spaps-types": "^1.4.0"
44
44
  },
45
45
  "peerDependencies": {
46
46
  "@tanstack/react-query": ">=5.0.0",