@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.
- package/dist/index.d.ts +4 -4
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +4 -4
- package/dist/index.js.map +1 -1
- package/dist/lib/image-utils.d.ts +3 -4
- package/dist/lib/image-utils.d.ts.map +1 -1
- package/dist/lib/image-utils.js +11 -12
- package/dist/lib/image-utils.js.map +1 -1
- package/dist/lib/infer-content-type.d.ts +1 -24
- package/dist/lib/infer-content-type.d.ts.map +1 -1
- package/dist/lib/infer-content-type.js +31 -85
- package/dist/lib/infer-content-type.js.map +1 -1
- package/dist/lib/read-media-meta.d.ts +1 -1
- package/dist/lib/read-media-meta.d.ts.map +1 -1
- package/dist/lib/read-media-meta.js +20 -13
- package/dist/lib/read-media-meta.js.map +1 -1
- package/dist/lib/validate-media-duration.d.ts.map +1 -1
- package/dist/lib/validate-media-duration.js +5 -10
- package/dist/lib/validate-media-duration.js.map +1 -1
- package/dist/lib/validate-upload-kind.d.ts +0 -34
- package/dist/lib/validate-upload-kind.d.ts.map +1 -1
- package/dist/lib/validate-upload-kind.js +0 -17
- package/dist/lib/validate-upload-kind.js.map +1 -1
- package/dist/progress-bar.d.ts +2 -0
- package/dist/progress-bar.d.ts.map +1 -0
- package/dist/progress-bar.js +2 -0
- package/dist/progress-bar.js.map +1 -0
- package/dist/react/components/audio-input.d.ts +2 -2
- package/dist/react/components/audio-input.js +2 -2
- package/dist/react/components/audio-input.js.map +1 -1
- package/dist/react/components/file-input.d.ts +1 -1
- package/dist/react/components/file-input.d.ts.map +1 -1
- package/dist/react/components/file-input.js +59 -44
- package/dist/react/components/file-input.js.map +1 -1
- package/dist/react/components/image-cropper-modal.d.ts +3 -3
- package/dist/react/components/image-cropper-modal.d.ts.map +1 -1
- package/dist/react/components/image-cropper-modal.js +6 -8
- package/dist/react/components/image-cropper-modal.js.map +1 -1
- package/dist/react/components/image-input.d.ts +2 -2
- package/dist/react/components/image-input.js +2 -2
- package/dist/react/components/image-input.js.map +1 -1
- package/dist/react/components/media-constraints-hint.d.ts +1 -1
- package/dist/react/components/media-constraints-hint.d.ts.map +1 -1
- package/dist/react/components/media-constraints-hint.js +26 -25
- package/dist/react/components/media-constraints-hint.js.map +1 -1
- package/dist/react/components/media-input.d.ts +2 -2
- package/dist/react/components/media-input.d.ts.map +1 -1
- package/dist/react/components/media-input.js +111 -158
- package/dist/react/components/media-input.js.map +1 -1
- package/dist/react/components/photo-capture-modal.d.ts +1 -1
- package/dist/react/components/photo-capture-modal.d.ts.map +1 -1
- package/dist/react/components/photo-capture-modal.js +16 -20
- package/dist/react/components/photo-capture-modal.js.map +1 -1
- package/dist/react/components/record-dialog.d.ts +3 -7
- package/dist/react/components/record-dialog.d.ts.map +1 -1
- package/dist/react/components/record-dialog.js +60 -138
- package/dist/react/components/record-dialog.js.map +1 -1
- package/dist/react/components/video-input.d.ts +2 -2
- package/dist/react/components/video-input.js +2 -2
- package/dist/react/components/video-input.js.map +1 -1
- package/dist/react/index.d.ts +8 -8
- package/dist/react/index.js +8 -8
- package/dist/react/index.js.map +1 -1
- package/dist/types.d.ts +3 -59
- package/dist/types.d.ts.map +1 -1
- package/dist/types.js.map +1 -1
- package/package.json +4 -4
|
@@ -1,22 +1,20 @@
|
|
|
1
|
-
|
|
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
|
|
4
|
-
import { getSimplifiedMediaType } from
|
|
5
|
-
import { Info, Camera, Mic, Video, Upload, X, Plus } from
|
|
6
|
-
import { Alert, AlertDescription, Button, Card, DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuTrigger, Input, Progress, Spinner, } from
|
|
7
|
-
import { cn } from
|
|
8
|
-
import { MediaPreview } from
|
|
9
|
-
import { DEFAULT_PROGRESS_BAR_MIN_BYTES } from
|
|
10
|
-
import { RecordDialog } from
|
|
11
|
-
import { ensureFileWithContentType } from
|
|
12
|
-
import { NEUTRAL_CONTENT_TYPE, projectAcceptTokens } from
|
|
13
|
-
import { ImageCropperModal } from
|
|
14
|
-
import { MediaConstraintsHint } from
|
|
15
|
-
import { PhotoCaptureModal } from
|
|
16
|
-
import { readMediaMeta } from
|
|
17
|
-
import { validateMediaDuration } from
|
|
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 !==
|
|
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 ===
|
|
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 ===
|
|
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;
|
|
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 =
|
|
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(
|
|
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(
|
|
183
|
-
return
|
|
184
|
-
if (k.includes(
|
|
185
|
-
return
|
|
186
|
-
if (k.includes(
|
|
187
|
-
return
|
|
188
|
-
return undefined;
|
|
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 ===
|
|
194
|
-
?
|
|
195
|
-
: previewKind ===
|
|
196
|
-
?
|
|
197
|
-
: previewKind ===
|
|
198
|
-
?
|
|
199
|
-
:
|
|
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 ===
|
|
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
|
-
|
|
216
|
-
|
|
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(
|
|
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:
|
|
237
|
-
kind: meta.kind ===
|
|
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(
|
|
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 ===
|
|
219
|
+
if ((meta.kind === 'video' || meta.kind === 'audio') && maxDur) {
|
|
252
220
|
const ok = await validateMediaDuration(safeFile, maxDur);
|
|
253
221
|
if (!ok) {
|
|
254
|
-
fail(err(
|
|
222
|
+
fail(err('too_long', `Media too long. Max ${maxDur} seconds.`));
|
|
255
223
|
return;
|
|
256
224
|
}
|
|
257
225
|
}
|
|
258
|
-
|
|
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(
|
|
234
|
+
reader.onerror = () => fail(err('read_failed', 'Failed to read image for cropping.'));
|
|
268
235
|
reader.readAsDataURL(safeFile);
|
|
269
236
|
return;
|
|
270
237
|
}
|
|
271
|
-
|
|
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(
|
|
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(
|
|
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(
|
|
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(
|
|
306
|
+
fail(err('crop_failed', 'Cropping failed.'));
|
|
346
307
|
setCropSrc(null);
|
|
347
308
|
return;
|
|
348
309
|
}
|
|
349
|
-
const type = blob.type ||
|
|
350
|
-
const name = original.name?.replace(/\.[^/.]+$/,
|
|
351
|
-
const ext = type.includes(
|
|
352
|
-
?
|
|
353
|
-
: type.includes(
|
|
354
|
-
?
|
|
355
|
-
: type.includes(
|
|
356
|
-
?
|
|
357
|
-
:
|
|
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({
|
|
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:
|
|
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
|
|
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(
|
|
390
|
-
|
|
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
|
|
360
|
+
case 'pick':
|
|
397
361
|
pickerRef.current?.click();
|
|
398
362
|
break;
|
|
399
|
-
case
|
|
363
|
+
case 'photo':
|
|
400
364
|
setPhotoOpen(true);
|
|
401
365
|
break;
|
|
402
|
-
case
|
|
403
|
-
setRecordKind(
|
|
366
|
+
case 'recordVideo':
|
|
367
|
+
setRecordKind('video');
|
|
404
368
|
setRecordOpen(true);
|
|
405
369
|
break;
|
|
406
|
-
case
|
|
407
|
-
setRecordKind(
|
|
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:
|
|
387
|
+
list.push({ kind: 'pick', label: 'Choose file', icon: _jsx(Upload, { className: "icon-xs" }) });
|
|
431
388
|
if (canPhoto)
|
|
432
|
-
list.push({ kind:
|
|
389
|
+
list.push({ kind: 'photo', label: 'Take photo', icon: _jsx(Camera, { className: "icon-xs" }) });
|
|
433
390
|
if (canRecVideo)
|
|
434
|
-
list.push({
|
|
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({
|
|
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 !==
|
|
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
|
-
|
|
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(
|
|
481
|
-
?
|
|
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
|
-
?
|
|
438
|
+
? 'Uploading...'
|
|
486
439
|
: isFinalizing
|
|
487
|
-
?
|
|
488
|
-
:
|
|
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 ??
|
|
492
|
-
await handleSelected(file, url, { kind: recordedKind, source:
|
|
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 =
|
|
448
|
+
MediaInput.displayName = 'MediaInput';
|
|
496
449
|
//# sourceMappingURL=media-input.js.map
|