@ttt-productions/file-input 0.11.15 → 0.11.17

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (67) hide show
  1. package/dist/index.d.ts +4 -4
  2. package/dist/index.d.ts.map +1 -1
  3. package/dist/index.js +4 -4
  4. package/dist/index.js.map +1 -1
  5. package/dist/lib/image-utils.d.ts +3 -4
  6. package/dist/lib/image-utils.d.ts.map +1 -1
  7. package/dist/lib/image-utils.js +11 -12
  8. package/dist/lib/image-utils.js.map +1 -1
  9. package/dist/lib/infer-content-type.d.ts +1 -24
  10. package/dist/lib/infer-content-type.d.ts.map +1 -1
  11. package/dist/lib/infer-content-type.js +31 -85
  12. package/dist/lib/infer-content-type.js.map +1 -1
  13. package/dist/lib/read-media-meta.d.ts +1 -1
  14. package/dist/lib/read-media-meta.d.ts.map +1 -1
  15. package/dist/lib/read-media-meta.js +20 -13
  16. package/dist/lib/read-media-meta.js.map +1 -1
  17. package/dist/lib/validate-media-duration.d.ts.map +1 -1
  18. package/dist/lib/validate-media-duration.js +5 -10
  19. package/dist/lib/validate-media-duration.js.map +1 -1
  20. package/dist/lib/validate-upload-kind.d.ts +0 -34
  21. package/dist/lib/validate-upload-kind.d.ts.map +1 -1
  22. package/dist/lib/validate-upload-kind.js +0 -17
  23. package/dist/lib/validate-upload-kind.js.map +1 -1
  24. package/dist/progress-bar.d.ts +2 -0
  25. package/dist/progress-bar.d.ts.map +1 -0
  26. package/dist/progress-bar.js +2 -0
  27. package/dist/progress-bar.js.map +1 -0
  28. package/dist/react/components/audio-input.d.ts +2 -2
  29. package/dist/react/components/audio-input.js +2 -2
  30. package/dist/react/components/audio-input.js.map +1 -1
  31. package/dist/react/components/file-input.d.ts +1 -1
  32. package/dist/react/components/file-input.d.ts.map +1 -1
  33. package/dist/react/components/file-input.js +59 -44
  34. package/dist/react/components/file-input.js.map +1 -1
  35. package/dist/react/components/image-cropper-modal.d.ts +3 -3
  36. package/dist/react/components/image-cropper-modal.d.ts.map +1 -1
  37. package/dist/react/components/image-cropper-modal.js +6 -8
  38. package/dist/react/components/image-cropper-modal.js.map +1 -1
  39. package/dist/react/components/image-input.d.ts +2 -2
  40. package/dist/react/components/image-input.js +2 -2
  41. package/dist/react/components/image-input.js.map +1 -1
  42. package/dist/react/components/media-constraints-hint.d.ts +1 -1
  43. package/dist/react/components/media-constraints-hint.d.ts.map +1 -1
  44. package/dist/react/components/media-constraints-hint.js +26 -25
  45. package/dist/react/components/media-constraints-hint.js.map +1 -1
  46. package/dist/react/components/media-input.d.ts +2 -2
  47. package/dist/react/components/media-input.d.ts.map +1 -1
  48. package/dist/react/components/media-input.js +111 -158
  49. package/dist/react/components/media-input.js.map +1 -1
  50. package/dist/react/components/photo-capture-modal.d.ts +1 -1
  51. package/dist/react/components/photo-capture-modal.d.ts.map +1 -1
  52. package/dist/react/components/photo-capture-modal.js +16 -20
  53. package/dist/react/components/photo-capture-modal.js.map +1 -1
  54. package/dist/react/components/record-dialog.d.ts +3 -7
  55. package/dist/react/components/record-dialog.d.ts.map +1 -1
  56. package/dist/react/components/record-dialog.js +60 -138
  57. package/dist/react/components/record-dialog.js.map +1 -1
  58. package/dist/react/components/video-input.d.ts +2 -2
  59. package/dist/react/components/video-input.js +2 -2
  60. package/dist/react/components/video-input.js.map +1 -1
  61. package/dist/react/index.d.ts +8 -8
  62. package/dist/react/index.js +8 -8
  63. package/dist/react/index.js.map +1 -1
  64. package/dist/types.d.ts +3 -59
  65. package/dist/types.d.ts.map +1 -1
  66. package/dist/types.js.map +1 -1
  67. package/package.json +4 -4
@@ -1,22 +1,20 @@
1
- "use client";
1
+ 'use client';
2
2
  import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
3
- import { forwardRef, useCallback, useEffect, useId, useImperativeHandle, useMemo, useRef, useState } from "react";
4
- import { getSimplifiedMediaType } from "@ttt-productions/media-schemas";
5
- import { Info, Camera, Mic, Video, Upload, X, Plus } from "lucide-react";
6
- import { Alert, AlertDescription, Button, Card, DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuTrigger, Input, Progress, Spinner, } from "@ttt-productions/ui-core/react";
7
- import { cn } from "@ttt-productions/ui-core";
8
- import { MediaPreview } from "@ttt-productions/media-viewer/react";
9
- import { DEFAULT_PROGRESS_BAR_MIN_BYTES } from "../../index.js";
10
- import { RecordDialog } from "./record-dialog.js";
11
- import { ensureFileWithContentType } from "../../lib/infer-content-type.js";
12
- import { NEUTRAL_CONTENT_TYPE, projectAcceptTokens } from "@ttt-productions/media-schemas";
13
- import { ImageCropperModal } from "./image-cropper-modal.js";
14
- import { MediaConstraintsHint } from "./media-constraints-hint.js";
15
- import { PhotoCaptureModal } from "./photo-capture-modal.js";
16
- import { readMediaMeta } from "../../lib/read-media-meta.js";
17
- import { validateMediaDuration } from "../../lib/validate-media-duration.js";
18
- /** Built-in advisory shown on EVERY MediaInput's Info affordance (overridable via `helpNotes`,
19
- * which appends). Generic + app-agnostic: uploaded files are not a backup/storage service. */
3
+ import { forwardRef, useCallback, useEffect, useId, useImperativeHandle, useMemo, useRef, useState, } from 'react';
4
+ import { getSimplifiedMediaType } from '@ttt-productions/media-schemas';
5
+ import { Info, Camera, Mic, Video, Upload, X, Plus } from 'lucide-react';
6
+ import { Alert, AlertDescription, Button, Card, DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuTrigger, Input, Progress, Spinner, } from '@ttt-productions/ui-core/react';
7
+ import { cn } from '@ttt-productions/ui-core';
8
+ import { MediaPreview } from '@ttt-productions/media-viewer/react';
9
+ import { DEFAULT_PROGRESS_BAR_MIN_BYTES } from '../../progress-bar.js';
10
+ import { RecordDialog } from './record-dialog.js';
11
+ import { ensureFileWithContentType } from '../../lib/infer-content-type.js';
12
+ import { NEUTRAL_CONTENT_TYPE, projectAcceptTokens } from '@ttt-productions/media-schemas';
13
+ import { ImageCropperModal } from './image-cropper-modal.js';
14
+ import { MediaConstraintsHint } from './media-constraints-hint.js';
15
+ import { PhotoCaptureModal } from './photo-capture-modal.js';
16
+ import { readMediaMeta } from '../../lib/read-media-meta.js';
17
+ import { validateMediaDuration } from '../../lib/validate-media-duration.js';
20
18
  const DEFAULT_BACKUP_NOTE = "Keep your own copy of files you upload — this isn't a backup or storage service.";
21
19
  function err(code, message, details) {
22
20
  return { code, message, details };
@@ -32,7 +30,7 @@ function hasConstraints(spec) {
32
30
  spec.maxDurationSec ||
33
31
  procVid?.video?.maxDurationSec ||
34
32
  procAud?.audio?.maxDurationSec ||
35
- (spec.videoOrientation && spec.videoOrientation !== "any") ||
33
+ (spec.videoOrientation && spec.videoOrientation !== 'any') ||
36
34
  spec.requiredAspectRatio ||
37
35
  (procVid?.requiredWidth && procVid?.requiredHeight) ||
38
36
  (procImg?.requiredWidth && procImg?.requiredHeight) ||
@@ -45,9 +43,9 @@ function matchMime(accepted, actual) {
45
43
  const m = actual.trim().toLowerCase();
46
44
  if (!a)
47
45
  return true;
48
- if (a === "*/*")
46
+ if (a === '*/*')
49
47
  return true;
50
- if (a.endsWith("/*"))
48
+ if (a.endsWith('/*'))
51
49
  return m.startsWith(a.slice(0, -1));
52
50
  return a === m;
53
51
  }
@@ -55,21 +53,17 @@ function accepts(spec, file) {
55
53
  const accept = spec.accept;
56
54
  if (!accept)
57
55
  return true;
58
- // FAIL-OPEN on unknown browser metadata (canonical-upload-content-
59
- // classification): an empty or neutral type is not evidence of anything —
60
- // rejecting it here used to force MIME fabrication upstream. The file passes
61
- // through and the SERVER's byte inspector decides. Only a DEFINITIVELY known
62
- // wrong kind still rejects fast (better UX than a round trip).
63
56
  if (!file.type || file.type === NEUTRAL_CONTENT_TYPE)
64
57
  return true;
65
58
  const kinds = accept.kinds?.filter(Boolean) ?? [];
66
59
  const mimes = accept.mimes?.filter(Boolean) ?? [];
67
60
  const simplified = getSimplifiedMediaType(file);
68
- const kind = simplified === "image" || simplified === "video" || simplified === "audio" ? simplified : "file";
61
+ const kind = simplified === 'image' || simplified === 'video' || simplified === 'audio'
62
+ ? simplified
63
+ : 'file';
69
64
  const kindOk = kinds.length === 0 ? true : kinds.includes(kind);
70
65
  if (!kindOk)
71
66
  return false;
72
- // If mimes list is empty, accept anything
73
67
  if (mimes.length === 0)
74
68
  return true;
75
69
  return mimes.some((a) => matchMime(a, file.type));
@@ -80,10 +74,10 @@ function computeAspect(width, height) {
80
74
  return width / height;
81
75
  }
82
76
  function orientationOk(required, actual) {
83
- if (!required || required === "any")
77
+ if (!required || required === 'any')
78
+ return true;
79
+ if (!actual || actual === 'any')
84
80
  return true;
85
- if (!actual || actual === "any")
86
- return true; // treat square/unknown as ok
87
81
  return required === actual;
88
82
  }
89
83
  function aspectOk(required, actual) {
@@ -91,9 +85,8 @@ function aspectOk(required, actual) {
91
85
  return true;
92
86
  if (!actual)
93
87
  return false;
94
- return Math.abs(actual - required) <= 0.02; // ~2% tolerance
88
+ return Math.abs(actual - required) <= 0.02;
95
89
  }
96
- /** Counts how many input methods are enabled. */
97
90
  function countActions(spec) {
98
91
  const c = spec.client;
99
92
  let n = 0;
@@ -115,33 +108,25 @@ function resolveActiveSpec(entry, kind) {
115
108
  return rest;
116
109
  }
117
110
  export const MediaInput = forwardRef(function MediaInput(props, ref) {
118
- const { spec, cropOverride, disabled = false, isLoading = false, className, buttonLabel = "Add media", clearLabel = "Clear selected file", uploadState, progressBarMinBytes, selectedFile, onClear, onCancel, onBeforeSelect, onChange, helpNotes, } = props;
111
+ const { spec, cropOverride, disabled = false, isLoading = false, className, buttonLabel = 'Add media', clearLabel = 'Clear selected file', uploadState, progressBarMinBytes, selectedFile, onClear, onCancel, onBeforeSelect, onChange, helpNotes, } = props;
119
112
  const id = useId();
120
113
  const pickerRef = useRef(null);
121
114
  const cropSpec = cropOverride ?? spec.imageCrop;
122
115
  const [localError, setLocalError] = useState(null);
123
116
  const [showInfo, setShowInfo] = useState(false);
124
- // Every MediaInput carries the default backup advisory + any surface-specific notes, so the Info
125
- // affordance always renders (constraints, if any, are shown under the notes).
126
117
  const infoNotes = useMemo(() => [DEFAULT_BACKUP_NOTE, ...(helpNotes ?? [])], [helpNotes]);
127
118
  const specHasConstraints = hasConstraints(spec);
128
119
  const showInfoToggle = specHasConstraints || infoNotes.length > 0;
129
120
  const [cropOpen, setCropOpen] = useState(false);
130
121
  const [cropSrc, setCropSrc] = useState(null);
131
122
  const [pendingCropFile, setPendingCropFile] = useState(null);
132
- // The claim captured when the crop flow started — cropping/normalization
133
- // preserves the ORIGINAL action claim (a captured photo stays camera-capture).
134
123
  const [pendingCropClaim, setPendingCropClaim] = useState(null);
135
124
  const [photoOpen, setPhotoOpen] = useState(false);
136
125
  const [recordOpen, setRecordOpen] = useState(false);
137
- const [recordKind, setRecordKind] = useState("video");
138
- // Controlled state for the multi-action choice dropdown. Human clicks toggle it
139
- // via the trigger button; the imperative `openSelection()` handle opens it when
140
- // more than one action is enabled.
126
+ const [recordKind, setRecordKind] = useState('video');
141
127
  const [menuOpen, setMenuOpen] = useState(false);
142
128
  const [validating, setValidating] = useState(false);
143
129
  const [emittedSize, setEmittedSize] = useState(null);
144
- // Prevent memory leaks from object URLs
145
130
  const lastObjectUrlRef = useRef(null);
146
131
  const revokeLastObjectUrl = useCallback(() => {
147
132
  if (!lastObjectUrlRef.current)
@@ -164,43 +149,35 @@ export const MediaInput = forwardRef(function MediaInput(props, ref) {
164
149
  };
165
150
  }, [revokeLastObjectUrl]);
166
151
  const acceptAttr = useMemo(() => {
167
- // Explicit enabled-format registry projection FIRST (canonical-upload-
168
- // content-classification): when the origin selects formats, the picker
169
- // advertises exactly those (MIME aliases + extensions, kind-narrowed for
170
- // shared A/V containers) instead of wildcards. Discoverability only — the
171
- // server inspector remains the authority.
172
152
  const formats = spec.accept?.formats?.filter(Boolean) ?? [];
173
153
  if (formats.length) {
174
154
  const tokens = projectAcceptTokens(formats, spec.accept?.kinds ?? undefined);
175
155
  if (tokens.length)
176
- return tokens.join(", ");
156
+ return tokens.join(', ');
177
157
  }
178
158
  const m = spec.accept?.mimes?.filter(Boolean) ?? [];
179
159
  const k = spec.accept?.kinds?.filter(Boolean) ?? [];
180
160
  if (m.length)
181
- return m.join(", ");
182
- if (k.includes("image") && k.length === 1)
183
- return "image/*";
184
- if (k.includes("video") && k.length === 1)
185
- return "video/*";
186
- if (k.includes("audio") && k.length === 1)
187
- return "audio/*";
188
- return undefined; // accept anything
161
+ return m.join(', ');
162
+ if (k.includes('image') && k.length === 1)
163
+ return 'image/*';
164
+ if (k.includes('video') && k.length === 1)
165
+ return 'video/*';
166
+ if (k.includes('audio') && k.length === 1)
167
+ return 'audio/*';
168
+ return undefined;
189
169
  }, [spec]);
190
- // Simplified kind of the currently-selected file, used to size and type the
191
- // inline preview. Null when nothing is selected.
192
170
  const previewKind = useMemo(() => (selectedFile ? getSimplifiedMediaType(selectedFile) : null), [selectedFile]);
193
- const selectedFileLabel = previewKind === "image"
194
- ? "Image selected"
195
- : previewKind === "video"
196
- ? "Video selected"
197
- : previewKind === "audio"
198
- ? "Audio selected"
199
- : "File selected";
171
+ const selectedFileLabel = previewKind === 'image'
172
+ ? 'Image selected'
173
+ : previewKind === 'video'
174
+ ? 'Video selected'
175
+ : previewKind === 'audio'
176
+ ? 'Audio selected'
177
+ : 'File selected';
200
178
  const emit = useCallback((payload) => {
201
- // fallback resolution; pre-validation error path when kind is unknown or "file"
202
179
  const rawKind = payload.meta?.kind;
203
- const mediaKind = rawKind === "image" || rawKind === "video" || rawKind === "audio" ? rawKind : "image";
180
+ const mediaKind = rawKind === 'image' || rawKind === 'video' || rawKind === 'audio' ? rawKind : 'image';
204
181
  const resolvedSpec = resolveActiveSpec(spec, mediaKind);
205
182
  if (payload.file)
206
183
  setEmittedSize(payload.file.size);
@@ -212,51 +189,41 @@ export const MediaInput = forwardRef(function MediaInput(props, ref) {
212
189
  }, [emit]);
213
190
  const validateAndEmit = useCallback(async (file, previewUrl, action = undefined) => {
214
191
  setLocalError(null);
215
- // Defense-in-depth: normalize the content type before any downstream
216
- // handling. Recorder/capture files carry their declared kind; a picker
217
- // file with unknown metadata passes through NEUTRALLY (never fabricated —
218
- // the server inspector owns classification).
219
- const declaredKind = action?.source === "media-recorder" || action?.source === "camera-capture"
220
- ? action.kind === "image" || action.kind === "video" || action.kind === "audio"
192
+ const declaredKind = action?.source === 'media-recorder' || action?.source === 'camera-capture'
193
+ ? action.kind === 'image' || action.kind === 'video' || action.kind === 'audio'
221
194
  ? action.kind
222
195
  : undefined
223
196
  : undefined;
224
197
  const safeFile = ensureFileWithContentType(file, declaredKind);
225
198
  if (!accepts(spec, safeFile)) {
226
- fail(err("invalid_type", "Invalid file type for this upload."));
199
+ fail(err('invalid_type', 'Invalid file type for this upload.'));
227
200
  return;
228
201
  }
229
202
  const meta = await readMediaMeta(safeFile);
230
- // The CLIENT CLAIM: what the user actually did. Recorder/capture claims
231
- // are strong (the app requested that capture kind); a picker claim is an
232
- // advisory browser-metadata inference — `file` when nothing narrower is
233
- // known. Never derived from the (already-normalized) MIME for strong
234
- // sources; for the picker the meta kind IS the inference.
235
203
  const claim = action ?? {
236
- source: "file-picker",
237
- kind: meta.kind === "image" || meta.kind === "video" || meta.kind === "audio" ? meta.kind : "file",
204
+ source: 'file-picker',
205
+ kind: meta.kind === 'image' || meta.kind === 'video' || meta.kind === 'audio'
206
+ ? meta.kind
207
+ : 'file',
238
208
  };
239
- // bytes
240
209
  if (spec.maxBytes && safeFile.size > spec.maxBytes) {
241
- fail(err("too_large", `File too large. Max ${Math.round(spec.maxBytes / 1024 / 1024)}MB.`, {
210
+ fail(err('too_large', `File too large. Max ${Math.round(spec.maxBytes / 1024 / 1024)}MB.`, {
242
211
  size: safeFile.size,
243
212
  maxBytes: spec.maxBytes,
244
213
  }));
245
214
  return;
246
215
  }
247
- // duration
248
216
  const maxDur = spec.maxDurationSec ??
249
217
  spec.processing?.video?.video?.maxDurationSec ??
250
218
  spec.processing?.audio?.audio?.maxDurationSec;
251
- if ((meta.kind === "video" || meta.kind === "audio") && maxDur) {
219
+ if ((meta.kind === 'video' || meta.kind === 'audio') && maxDur) {
252
220
  const ok = await validateMediaDuration(safeFile, maxDur);
253
221
  if (!ok) {
254
- fail(err("too_long", `Media too long. Max ${maxDur} seconds.`));
222
+ fail(err('too_long', `Media too long. Max ${maxDur} seconds.`));
255
223
  return;
256
224
  }
257
225
  }
258
- // image crop enforcement
259
- if (meta.kind === "image" && cropSpec) {
226
+ if (meta.kind === 'image' && cropSpec) {
260
227
  const reader = new FileReader();
261
228
  reader.onload = () => {
262
229
  setPendingCropFile(safeFile);
@@ -264,12 +231,11 @@ export const MediaInput = forwardRef(function MediaInput(props, ref) {
264
231
  setCropSrc(reader.result);
265
232
  setCropOpen(true);
266
233
  };
267
- reader.onerror = () => fail(err("read_failed", "Failed to read image for cropping."));
234
+ reader.onerror = () => fail(err('read_failed', 'Failed to read image for cropping.'));
268
235
  reader.readAsDataURL(safeFile);
269
236
  return;
270
237
  }
271
- // video uniform enforcement (orientation/aspect/dimensions)
272
- if (meta.kind === "video") {
238
+ if (meta.kind === 'video') {
273
239
  const procVid = spec.processing?.video;
274
240
  const requiredOri = spec.videoOrientation;
275
241
  const requiredAspect = spec.requiredAspectRatio;
@@ -283,27 +249,25 @@ export const MediaInput = forwardRef(function MediaInput(props, ref) {
283
249
  const okDims = !requiredW || !requiredH ? true : meta.width === requiredW && meta.height === requiredH;
284
250
  if (!okOri || !okAspect || !okDims) {
285
251
  if (allowAutoFormat) {
286
- // Auto-format is always silent — no confirmation prompt. The file is
287
- // emitted with autoFormat:true and reformatted after upload.
288
252
  emit({ file: safeFile, previewUrl, meta, claim, autoFormat: true });
289
253
  return;
290
254
  }
291
255
  if (!okOri) {
292
- fail(err("orientation_mismatch", "Video orientation does not match the required format.", {
256
+ fail(err('orientation_mismatch', 'Video orientation does not match the required format.', {
293
257
  requiredOri,
294
258
  actualOri,
295
259
  }));
296
260
  return;
297
261
  }
298
262
  if (!okAspect) {
299
- fail(err("aspect_ratio_mismatch", "Video aspect ratio does not match the required format.", {
263
+ fail(err('aspect_ratio_mismatch', 'Video aspect ratio does not match the required format.', {
300
264
  requiredAspect,
301
265
  actualAspect,
302
266
  }));
303
267
  return;
304
268
  }
305
269
  if (!okDims) {
306
- fail(err("dimensions_mismatch", "Video dimensions do not match the required format.", {
270
+ fail(err('dimensions_mismatch', 'Video dimensions do not match the required format.', {
307
271
  requiredW,
308
272
  requiredH,
309
273
  width: meta.width,
@@ -315,9 +279,6 @@ export const MediaInput = forwardRef(function MediaInput(props, ref) {
315
279
  }
316
280
  emit({ file: safeFile, previewUrl, meta, claim });
317
281
  }, [spec, cropSpec, emit, fail]);
318
- // A picked, captured, or recorded file is validated (type, meta, duration,
319
- // dimensions, crop) before it is emitted. The trigger shows pending for that
320
- // whole window, so a second pick cannot race the first.
321
282
  const handleSelected = useCallback(async (file, previewUrl, action = undefined) => {
322
283
  setValidating(true);
323
284
  try {
@@ -329,7 +290,7 @@ export const MediaInput = forwardRef(function MediaInput(props, ref) {
329
290
  }, [validateAndEmit]);
330
291
  const onPick = useCallback(async (e) => {
331
292
  const file = e.target.files?.[0];
332
- e.target.value = "";
293
+ e.target.value = '';
333
294
  if (!file)
334
295
  return;
335
296
  const url = makeObjectUrl(file);
@@ -342,28 +303,34 @@ export const MediaInput = forwardRef(function MediaInput(props, ref) {
342
303
  setPendingCropFile(null);
343
304
  setPendingCropClaim(null);
344
305
  if (!blob || !original) {
345
- fail(err("crop_failed", "Cropping failed."));
306
+ fail(err('crop_failed', 'Cropping failed.'));
346
307
  setCropSrc(null);
347
308
  return;
348
309
  }
349
- const type = blob.type || "image/jpeg";
350
- const name = original.name?.replace(/\.[^/.]+$/, "") || "image";
351
- const ext = type.includes("png")
352
- ? "png"
353
- : type.includes("webp")
354
- ? "webp"
355
- : type.includes("avif")
356
- ? "avif"
357
- : "jpg";
310
+ const type = blob.type || 'image/jpeg';
311
+ const name = original.name?.replace(/\.[^/.]+$/, '') || 'image';
312
+ const ext = type.includes('png')
313
+ ? 'png'
314
+ : type.includes('webp')
315
+ ? 'webp'
316
+ : type.includes('avif')
317
+ ? 'avif'
318
+ : 'jpg';
358
319
  const croppedFile = new File([blob], `${name}.cropped.${ext}`, { type });
359
320
  const url = makeObjectUrl(croppedFile);
360
- readMediaMeta(croppedFile).then((meta) => emit({ file: croppedFile, previewUrl: url, meta, claim: preservedClaim ?? undefined, croppedBlob: blob }));
321
+ readMediaMeta(croppedFile).then((meta) => emit({
322
+ file: croppedFile,
323
+ previewUrl: url,
324
+ meta,
325
+ claim: preservedClaim ?? undefined,
326
+ croppedBlob: blob,
327
+ }));
361
328
  setCropSrc(null);
362
329
  }, [emit, fail, pendingCropFile, pendingCropClaim, makeObjectUrl]);
363
330
  const onPhotoCapture = useCallback(async (file) => {
364
331
  setPhotoOpen(false);
365
332
  const url = makeObjectUrl(file);
366
- await handleSelected(file, url, { kind: "image", source: "camera-capture" });
333
+ await handleSelected(file, url, { kind: 'image', source: 'camera-capture' });
367
334
  }, [handleSelected, makeObjectUrl]);
368
335
  const phase = uploadState?.phase ?? null;
369
336
  const percent = uploadState?.percent ?? null;
@@ -371,8 +338,7 @@ export const MediaInput = forwardRef(function MediaInput(props, ref) {
371
338
  const isPreparing = phase === 'preparing' && isLoading;
372
339
  const isFinalizing = phase === 'finalizing' && isLoading;
373
340
  const sizeThreshold = progressBarMinBytes ?? DEFAULT_PROGRESS_BAR_MIN_BYTES;
374
- // Consumers usually pass selectedFile={null} once the file is handed off, so
375
- // the bar judges the size of the file this input emitted.
341
+ // Consumers usually clear selectedFile once it is handed off, so the bar sizes the file this input emitted.
376
342
  const trackedSize = selectedFile?.size ?? emittedSize;
377
343
  const showRealBar = isUploading &&
378
344
  typeof percent === 'number' &&
@@ -383,38 +349,30 @@ export const MediaInput = forwardRef(function MediaInput(props, ref) {
383
349
  const canRecVideo = spec.client?.allowRecordVideo ?? true;
384
350
  const canRecAudio = spec.client?.allowRecordAudio ?? true;
385
351
  const actionCount = countActions(spec);
386
- // Warn in dev if no actions are enabled
387
352
  useEffect(() => {
388
353
  if (actionCount === 0) {
389
- console.warn("[MediaInput] No input methods enabled in spec.client. " +
390
- "At least one of allowPick, allowCapturePhoto, allowRecordVideo, or allowRecordAudio must be true.");
354
+ console.warn('[MediaInput] No input methods enabled in spec.client. ' +
355
+ 'At least one of allowPick, allowCapturePhoto, allowRecordVideo, or allowRecordAudio must be true.');
391
356
  }
392
357
  }, [actionCount]);
393
- /** Execute an action directly (used for single-action shortcut). */
394
358
  const executeAction = useCallback((action) => {
395
359
  switch (action) {
396
- case "pick":
360
+ case 'pick':
397
361
  pickerRef.current?.click();
398
362
  break;
399
- case "photo":
363
+ case 'photo':
400
364
  setPhotoOpen(true);
401
365
  break;
402
- case "recordVideo":
403
- setRecordKind("video");
366
+ case 'recordVideo':
367
+ setRecordKind('video');
404
368
  setRecordOpen(true);
405
369
  break;
406
- case "recordAudio":
407
- setRecordKind("audio");
370
+ case 'recordAudio':
371
+ setRecordKind('audio');
408
372
  setRecordOpen(true);
409
373
  break;
410
374
  }
411
375
  }, []);
412
- /**
413
- * Run the optional pre-select guard, then execute the action. When the guard
414
- * resolves false, the picker/capture never opens. This is the single choke
415
- * point for all four actions, so the gate fires on the click for every entry
416
- * point (single-action button and dropdown items alike).
417
- */
418
376
  const runAction = useCallback(async (action) => {
419
377
  if (onBeforeSelect) {
420
378
  const ok = await onBeforeSelect();
@@ -423,50 +381,45 @@ export const MediaInput = forwardRef(function MediaInput(props, ref) {
423
381
  }
424
382
  executeAction(action);
425
383
  }, [onBeforeSelect, executeAction]);
426
- /** Build the list of enabled actions. */
427
384
  const actions = useMemo(() => {
428
385
  const list = [];
429
386
  if (canPick)
430
- list.push({ kind: "pick", label: "Choose file", icon: _jsx(Upload, { className: "icon-xs" }) });
387
+ list.push({ kind: 'pick', label: 'Choose file', icon: _jsx(Upload, { className: "icon-xs" }) });
431
388
  if (canPhoto)
432
- list.push({ kind: "photo", label: "Take photo", icon: _jsx(Camera, { className: "icon-xs" }) });
389
+ list.push({ kind: 'photo', label: 'Take photo', icon: _jsx(Camera, { className: "icon-xs" }) });
433
390
  if (canRecVideo)
434
- list.push({ kind: "recordVideo", label: "Record video", icon: _jsx(Video, { className: "icon-xs" }) });
391
+ list.push({
392
+ kind: 'recordVideo',
393
+ label: 'Record video',
394
+ icon: _jsx(Video, { className: "icon-xs" }),
395
+ });
435
396
  if (canRecAudio)
436
- list.push({ kind: "recordAudio", label: "Record audio", icon: _jsx(Mic, { className: "icon-xs" }) });
397
+ list.push({
398
+ kind: 'recordAudio',
399
+ label: 'Record audio',
400
+ icon: _jsx(Mic, { className: "icon-xs" }),
401
+ });
437
402
  return list;
438
403
  }, [canPick, canPhoto, canRecVideo, canRecAudio]);
439
- /** Label & icon for the single trigger button. */
440
404
  const triggerIcon = useMemo(() => {
441
405
  if (actions.length === 1)
442
406
  return actions[0].icon;
443
- if (buttonLabel !== "Add media")
407
+ if (buttonLabel !== 'Add media')
444
408
  return _jsx(Upload, { className: "icon-xs" });
445
409
  return _jsx(Plus, { className: "icon-xs" });
446
410
  }, [actions, buttonLabel]);
447
411
  const triggerLabel = useMemo(() => {
448
- // If the consumer explicitly set a custom buttonLabel, always respect it.
449
- // Only use the action's built-in label for single-action when using the default.
450
- if (buttonLabel !== "Add media")
412
+ if (buttonLabel !== 'Add media')
451
413
  return buttonLabel;
452
414
  if (actions.length === 1)
453
415
  return actions[0].label;
454
416
  return buttonLabel;
455
417
  }, [actions, buttonLabel]);
456
- /** When there's exactly 1 action, click goes straight to it. Otherwise open menu. */
457
418
  const handleTriggerClick = useCallback(() => {
458
419
  if (actions.length === 1) {
459
420
  void runAction(actions[0].kind);
460
421
  }
461
- // If > 1 action, the DropdownMenuTrigger handles opening the menu.
462
422
  }, [actions, runAction]);
463
- /**
464
- * Imperative entry point: activates the SAME trigger semantics as a human click
465
- * (single action → run it through `runAction`, honoring `onBeforeSelect`/validation/
466
- * crop; multiple actions → open the choice dropdown). Guards on disabled/loading and
467
- * no-enabled-actions exactly as the rendered trigger's `disabled` attribute would.
468
- * Never touches the hidden input directly, so none of the gates are bypassed.
469
- */
470
423
  const openSelection = useCallback(() => {
471
424
  if (disabled || isLoading || validating || actions.length === 0)
472
425
  return;
@@ -477,20 +430,20 @@ export const MediaInput = forwardRef(function MediaInput(props, ref) {
477
430
  setMenuOpen(true);
478
431
  }, [disabled, isLoading, validating, actions, runAction]);
479
432
  useImperativeHandle(ref, () => ({ openSelection }), [openSelection]);
480
- return (_jsxs(Card, { className: cn("p-3", className), children: [_jsx(Input, { id: `media-pick-${id}`, ref: pickerRef, type: "file", accept: acceptAttr, className: "hidden", onChange: onPick, disabled: disabled || isLoading || validating }), selectedFile && !isLoading ? (_jsxs("div", { className: "mb-2 space-y-2", children: [previewKind === "image" || previewKind === "video" || previewKind === "audio" ? (_jsx("div", { className: cn("relative w-full rounded-md bg-muted", previewKind === "audio" ? "p-2" : "h-48 overflow-hidden"), children: _jsx(MediaPreview, { url: selectedFile, type: previewKind, mime: selectedFile.type, controls: true, className: previewKind === "audio" ? "w-full" : "h-full w-full" }) })) : null, _jsxs("div", { className: "flex items-center gap-2", children: [_jsx("span", { className: "text-sm truncate flex-1", children: selectedFileLabel }), onClear && (_jsx(Button, { type: "button", variant: "ghost", size: "icon", className: "icon-sm shrink-0", onClick: onClear, "aria-label": clearLabel, children: _jsx(X, { className: "icon-xs" }) }))] })] })) : null, isLoading ? (_jsxs("div", { className: "flex items-center gap-2 mb-2", children: [_jsx(Spinner, { size: "xs" }), _jsx("span", { className: "text-sm text-muted-foreground", children: isPreparing
481
- ? "Preparing..."
433
+ return (_jsxs(Card, { className: cn('p-3', className), children: [_jsx(Input, { id: `media-pick-${id}`, ref: pickerRef, type: "file", accept: acceptAttr, className: "hidden", onChange: onPick, disabled: disabled || isLoading || validating }), selectedFile && !isLoading ? (_jsxs("div", { className: "mb-2 space-y-2", children: [previewKind === 'image' || previewKind === 'video' || previewKind === 'audio' ? (_jsx("div", { className: cn('relative w-full rounded-md bg-muted', previewKind === 'audio' ? 'p-2' : 'h-48 overflow-hidden'), children: _jsx(MediaPreview, { url: selectedFile, type: previewKind, mime: selectedFile.type, controls: true, className: previewKind === 'audio' ? 'w-full' : 'h-full w-full' }) })) : null, _jsxs("div", { className: "flex items-center gap-2", children: [_jsx("span", { className: "text-sm truncate flex-1", children: selectedFileLabel }), onClear && (_jsx(Button, { type: "button", variant: "ghost", size: "icon", className: "icon-sm shrink-0", onClick: onClear, "aria-label": clearLabel, children: _jsx(X, { className: "icon-xs" }) }))] })] })) : null, isLoading ? (_jsxs("div", { className: "flex items-center gap-2 mb-2", children: [_jsx(Spinner, { size: "xs" }), _jsx("span", { className: "text-sm text-muted-foreground", children: isPreparing
434
+ ? 'Preparing...'
482
435
  : isUploading && showRealBar
483
436
  ? `Uploading ${Math.round(percent ?? 0)}%...`
484
437
  : isUploading
485
- ? "Uploading..."
438
+ ? 'Uploading...'
486
439
  : isFinalizing
487
- ? "Finalizing..."
488
- : "Finalizing..." }), (isPreparing || isUploading) && onCancel ? (_jsx(Button, { type: "button", variant: "ghost", size: "icon", className: "icon-sm shrink-0 ml-auto", onClick: onCancel, "aria-label": "Cancel upload", children: _jsx(X, { className: "icon-xs" }) })) : null] })) : null, showRealBar ? _jsx(Progress, { value: percent ?? 0, className: "mb-2 h-1.5" }) : null, _jsxs("div", { className: "flex flex-wrap gap-2", children: [actions.length <= 1 ? (_jsx(Button, { type: "button", variant: "default", onClick: handleTriggerClick, disabled: disabled || isLoading || actions.length === 0, pending: validating, icon: triggerIcon, children: triggerLabel })) : (_jsxs(DropdownMenu, { open: menuOpen, onOpenChange: setMenuOpen, children: [_jsx(DropdownMenuTrigger, { asChild: true, children: _jsx(Button, { type: "button", variant: "default", disabled: disabled || isLoading, pending: validating, icon: triggerIcon, children: triggerLabel }) }), _jsx(DropdownMenuContent, { align: "start", children: actions.map((a) => (_jsx(DropdownMenuItem, { onClick: () => void runAction(a.kind), icon: a.icon, children: a.label }, a.kind))) })] })), showInfoToggle ? (_jsxs(Button, { type: "button", variant: "outline", onClick: () => setShowInfo((v) => !v), disabled: disabled || isLoading, children: [_jsx(Info, { className: "mr-2 icon-xs" }), showInfo ? "Hide info" : "Info"] })) : null] }), showInfo && showInfoToggle ? (_jsxs("div", { className: "mt-3 space-y-2", children: [infoNotes.length > 0 ? (_jsx("ul", { className: "text-xs text-muted-foreground list-disc pl-4 space-y-1", children: infoNotes.map((n, i) => (_jsx("li", { children: n }, i))) })) : null, specHasConstraints ? _jsx(MediaConstraintsHint, { spec: spec }) : null] })) : null, localError && (_jsx(Alert, { variant: "destructive", className: "mt-3", children: _jsx(AlertDescription, { children: localError.message }) })), cropSpec && (_jsx(ImageCropperModal, { isOpen: cropOpen, onClose: () => {
440
+ ? 'Finalizing...'
441
+ : 'Finalizing...' }), (isPreparing || isUploading) && onCancel ? (_jsx(Button, { type: "button", variant: "ghost", size: "icon", className: "icon-sm shrink-0 ml-auto", onClick: onCancel, "aria-label": "Cancel upload", children: _jsx(X, { className: "icon-xs" }) })) : null] })) : null, showRealBar ? _jsx(Progress, { value: percent ?? 0, className: "mb-2 h-1.5" }) : null, _jsxs("div", { className: "flex flex-wrap gap-2", children: [actions.length <= 1 ? (_jsx(Button, { type: "button", variant: "default", onClick: handleTriggerClick, disabled: disabled || isLoading || actions.length === 0, pending: validating, icon: triggerIcon, children: triggerLabel })) : (_jsxs(DropdownMenu, { open: menuOpen, onOpenChange: setMenuOpen, children: [_jsx(DropdownMenuTrigger, { asChild: true, children: _jsx(Button, { type: "button", variant: "default", disabled: disabled || isLoading, pending: validating, icon: triggerIcon, children: triggerLabel }) }), _jsx(DropdownMenuContent, { align: "start", children: actions.map((a) => (_jsx(DropdownMenuItem, { onClick: () => void runAction(a.kind), icon: a.icon, children: a.label }, a.kind))) })] })), showInfoToggle ? (_jsxs(Button, { type: "button", variant: "outline", onClick: () => setShowInfo((v) => !v), disabled: disabled || isLoading, children: [_jsx(Info, { className: "mr-2 icon-xs" }), showInfo ? 'Hide info' : 'Info'] })) : null] }), showInfo && showInfoToggle ? (_jsxs("div", { className: "mt-3 space-y-2", children: [infoNotes.length > 0 ? (_jsx("ul", { className: "text-xs text-muted-foreground list-disc pl-4 space-y-1", children: infoNotes.map((n, i) => (_jsx("li", { children: n }, i))) })) : null, specHasConstraints ? _jsx(MediaConstraintsHint, { spec: spec }) : null] })) : null, localError && (_jsx(Alert, { variant: "destructive", className: "mt-3", children: _jsx(AlertDescription, { children: localError.message }) })), cropSpec && (_jsx(ImageCropperModal, { isOpen: cropOpen, onClose: () => {
489
442
  setCropOpen(false);
490
443
  setCropSrc(null);
491
- }, imageSrc: cropSrc, aspectRatio: cropSpec.aspectRatio, shape: cropSpec.shape ?? "rect", outputWidth: cropSpec.outputWidth, outputHeight: cropSpec.outputHeight, encoding: { format: cropSpec.format, quality: cropSpec.quality }, onCropComplete: onCropComplete })), _jsx(PhotoCaptureModal, { open: photoOpen, facingMode: spec.client?.cameraFacingMode ?? "user", onCapture: onPhotoCapture, onClose: () => setPhotoOpen(false) }), _jsx(RecordDialog, { open: recordOpen, onOpenChange: setRecordOpen, initialKind: recordKind, canPhoto: canPhoto, canRecVideo: canRecVideo, canRecAudio: canRecAudio, cameraFacingMode: spec.client?.cameraFacingMode, maxRecordDurationSec: spec.client?.maxRecordDurationSec, disabled: disabled, isLoading: isLoading, onRecorded: async (file, url, recordedKind) => {
492
- await handleSelected(file, url, { kind: recordedKind, source: "media-recorder" });
444
+ }, imageSrc: cropSrc, aspectRatio: cropSpec.aspectRatio, shape: cropSpec.shape ?? 'rect', outputWidth: cropSpec.outputWidth, outputHeight: cropSpec.outputHeight, encoding: { format: cropSpec.format, quality: cropSpec.quality }, onCropComplete: onCropComplete })), _jsx(PhotoCaptureModal, { open: photoOpen, facingMode: spec.client?.cameraFacingMode ?? 'user', onCapture: onPhotoCapture, onClose: () => setPhotoOpen(false) }), _jsx(RecordDialog, { open: recordOpen, onOpenChange: setRecordOpen, initialKind: recordKind, canPhoto: canPhoto, canRecVideo: canRecVideo, canRecAudio: canRecAudio, cameraFacingMode: spec.client?.cameraFacingMode, maxRecordDurationSec: spec.client?.maxRecordDurationSec, disabled: disabled, isLoading: isLoading, onRecorded: async (file, url, recordedKind) => {
445
+ await handleSelected(file, url, { kind: recordedKind, source: 'media-recorder' });
493
446
  }, onRequestPhoto: () => setPhotoOpen(true) })] }));
494
447
  });
495
- MediaInput.displayName = "MediaInput";
448
+ MediaInput.displayName = 'MediaInput';
496
449
  //# sourceMappingURL=media-input.js.map