@fanvue/ui 1.3.0 → 1.4.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/cjs/components/AudioUpload/AudioUpload.cjs +286 -0
- package/dist/cjs/components/AudioUpload/AudioUpload.cjs.map +1 -0
- package/dist/cjs/components/AudioUpload/AudioWaveform.cjs +121 -0
- package/dist/cjs/components/AudioUpload/AudioWaveform.cjs.map +1 -0
- package/dist/cjs/components/AudioUpload/audioUtils.cjs +44 -0
- package/dist/cjs/components/AudioUpload/audioUtils.cjs.map +1 -0
- package/dist/cjs/components/AudioUpload/constants.cjs +21 -0
- package/dist/cjs/components/AudioUpload/constants.cjs.map +1 -0
- package/dist/cjs/components/AudioUpload/useAudioRecorder.cjs +191 -0
- package/dist/cjs/components/AudioUpload/useAudioRecorder.cjs.map +1 -0
- package/dist/cjs/components/DatePicker/DatePicker.cjs +1 -1
- package/dist/cjs/components/DatePicker/DatePicker.cjs.map +1 -1
- package/dist/cjs/components/Icons/CheckOutlineIcon.cjs +55 -0
- package/dist/cjs/components/Icons/CheckOutlineIcon.cjs.map +1 -0
- package/dist/cjs/components/Icons/UploadCloudIcon.cjs +61 -0
- package/dist/cjs/components/Icons/UploadCloudIcon.cjs.map +1 -0
- package/dist/cjs/components/TextArea/TextArea.cjs +209 -0
- package/dist/cjs/components/TextArea/TextArea.cjs.map +1 -0
- package/dist/cjs/components/TextField/TextField.cjs +22 -34
- package/dist/cjs/components/TextField/TextField.cjs.map +1 -1
- package/dist/cjs/index.cjs +10 -0
- package/dist/cjs/index.cjs.map +1 -1
- package/dist/components/AudioUpload/AudioUpload.mjs +269 -0
- package/dist/components/AudioUpload/AudioUpload.mjs.map +1 -0
- package/dist/components/AudioUpload/AudioWaveform.mjs +104 -0
- package/dist/components/AudioUpload/AudioWaveform.mjs.map +1 -0
- package/dist/components/AudioUpload/audioUtils.mjs +44 -0
- package/dist/components/AudioUpload/audioUtils.mjs.map +1 -0
- package/dist/components/AudioUpload/constants.mjs +21 -0
- package/dist/components/AudioUpload/constants.mjs.map +1 -0
- package/dist/components/AudioUpload/useAudioRecorder.mjs +174 -0
- package/dist/components/AudioUpload/useAudioRecorder.mjs.map +1 -0
- package/dist/components/DatePicker/DatePicker.mjs +1 -1
- package/dist/components/DatePicker/DatePicker.mjs.map +1 -1
- package/dist/components/Icons/CheckOutlineIcon.mjs +38 -0
- package/dist/components/Icons/CheckOutlineIcon.mjs.map +1 -0
- package/dist/components/Icons/UploadCloudIcon.mjs +44 -0
- package/dist/components/Icons/UploadCloudIcon.mjs.map +1 -0
- package/dist/components/TextArea/TextArea.mjs +192 -0
- package/dist/components/TextArea/TextArea.mjs.map +1 -0
- package/dist/components/TextField/TextField.mjs +22 -34
- package/dist/components/TextField/TextField.mjs.map +1 -1
- package/dist/index.d.ts +164 -0
- package/dist/index.mjs +11 -1
- package/dist/index.mjs.map +1 -1
- package/package.json +1 -1
|
@@ -0,0 +1,286 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
"use strict";
|
|
3
|
+
Object.defineProperty(exports, Symbol.toStringTag, { value: "Module" });
|
|
4
|
+
const jsxRuntime = require("react/jsx-runtime");
|
|
5
|
+
const React = require("react");
|
|
6
|
+
const cn = require("../../utils/cn.cjs");
|
|
7
|
+
const Button = require("../Button/Button.cjs");
|
|
8
|
+
const MicrophoneIcon = require("../Icons/MicrophoneIcon.cjs");
|
|
9
|
+
const StopIcon = require("../Icons/StopIcon.cjs");
|
|
10
|
+
const UploadCloudIcon = require("../Icons/UploadCloudIcon.cjs");
|
|
11
|
+
const AudioWaveform = require("./AudioWaveform.cjs");
|
|
12
|
+
const audioUtils = require("./audioUtils.cjs");
|
|
13
|
+
const constants = require("./constants.cjs");
|
|
14
|
+
const useAudioRecorder = require("./useAudioRecorder.cjs");
|
|
15
|
+
function _interopNamespaceDefault(e) {
|
|
16
|
+
const n = Object.create(null, { [Symbol.toStringTag]: { value: "Module" } });
|
|
17
|
+
if (e) {
|
|
18
|
+
for (const k in e) {
|
|
19
|
+
if (k !== "default") {
|
|
20
|
+
const d = Object.getOwnPropertyDescriptor(e, k);
|
|
21
|
+
Object.defineProperty(n, k, d.get ? d : {
|
|
22
|
+
enumerable: true,
|
|
23
|
+
get: () => e[k]
|
|
24
|
+
});
|
|
25
|
+
}
|
|
26
|
+
}
|
|
27
|
+
}
|
|
28
|
+
n.default = e;
|
|
29
|
+
return Object.freeze(n);
|
|
30
|
+
}
|
|
31
|
+
const React__namespace = /* @__PURE__ */ _interopNamespaceDefault(React);
|
|
32
|
+
function partitionFiles(files, maxFileSize, accept, maxFiles) {
|
|
33
|
+
const accepted = [];
|
|
34
|
+
const rejected = [];
|
|
35
|
+
for (const file of files) {
|
|
36
|
+
const errors = audioUtils.validateAudioFile(file, { maxFileSize, acceptedTypes: accept });
|
|
37
|
+
if (errors.length > 0) {
|
|
38
|
+
rejected.push({ file, errors });
|
|
39
|
+
} else {
|
|
40
|
+
accepted.push(file);
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
if (maxFiles > 0 && accepted.length > maxFiles) {
|
|
44
|
+
const excess = accepted.splice(maxFiles);
|
|
45
|
+
for (const file of excess) {
|
|
46
|
+
rejected.push({
|
|
47
|
+
file,
|
|
48
|
+
errors: [{ code: "too-many-files", message: `Too many files. Maximum is ${maxFiles}` }]
|
|
49
|
+
});
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
return { accepted, rejected };
|
|
53
|
+
}
|
|
54
|
+
const AudioUpload = React__namespace.forwardRef(
|
|
55
|
+
({
|
|
56
|
+
className,
|
|
57
|
+
maxFileSize = constants.DEFAULT_MAX_FILE_SIZE,
|
|
58
|
+
accept = constants.DEFAULT_ACCEPTED_TYPES,
|
|
59
|
+
maxFiles = 1,
|
|
60
|
+
allowRecording = true,
|
|
61
|
+
maxRecordingDuration = constants.DEFAULT_MAX_RECORDING_DURATION,
|
|
62
|
+
minRecordingDuration = constants.DEFAULT_MIN_RECORDING_DURATION,
|
|
63
|
+
onFilesAccepted,
|
|
64
|
+
onFilesRejected,
|
|
65
|
+
onRecordingComplete,
|
|
66
|
+
onRecordingTooShort,
|
|
67
|
+
onPermissionError,
|
|
68
|
+
onRecordingError,
|
|
69
|
+
uploadTitle = "Click to upload, or drag & drop",
|
|
70
|
+
uploadDescription = "Audio files only, up to 10MB each",
|
|
71
|
+
separatorText = "or",
|
|
72
|
+
recordButtonLabel = "Record audio",
|
|
73
|
+
stopButtonAriaLabel = "Stop recording",
|
|
74
|
+
disabled = false,
|
|
75
|
+
...props
|
|
76
|
+
}, ref) => {
|
|
77
|
+
const inputId = React__namespace.useId();
|
|
78
|
+
const descriptionId = React__namespace.useId();
|
|
79
|
+
const [isDragActive, setIsDragActive] = React__namespace.useState(false);
|
|
80
|
+
const stopButtonRef = React__namespace.useRef(null);
|
|
81
|
+
const {
|
|
82
|
+
isRecording,
|
|
83
|
+
elapsedMs,
|
|
84
|
+
startRecording,
|
|
85
|
+
stopRecording,
|
|
86
|
+
analyserNode,
|
|
87
|
+
isSupported: isRecordingSupported
|
|
88
|
+
} = useAudioRecorder.useAudioRecorder({
|
|
89
|
+
maxDuration: maxRecordingDuration,
|
|
90
|
+
minDuration: minRecordingDuration,
|
|
91
|
+
onComplete: onRecordingComplete,
|
|
92
|
+
onTooShort: onRecordingTooShort,
|
|
93
|
+
onPermissionError,
|
|
94
|
+
onError: onRecordingError
|
|
95
|
+
});
|
|
96
|
+
const acceptString = accept.join(",");
|
|
97
|
+
React__namespace.useEffect(() => {
|
|
98
|
+
if (isRecording) {
|
|
99
|
+
stopButtonRef.current?.focus();
|
|
100
|
+
}
|
|
101
|
+
}, [isRecording]);
|
|
102
|
+
const validateAndAcceptFiles = React__namespace.useCallback(
|
|
103
|
+
(files) => {
|
|
104
|
+
const { accepted, rejected } = partitionFiles(
|
|
105
|
+
Array.from(files),
|
|
106
|
+
maxFileSize,
|
|
107
|
+
accept,
|
|
108
|
+
maxFiles
|
|
109
|
+
);
|
|
110
|
+
if (accepted.length > 0) onFilesAccepted?.(accepted);
|
|
111
|
+
if (rejected.length > 0) onFilesRejected?.(rejected);
|
|
112
|
+
},
|
|
113
|
+
[maxFileSize, accept, maxFiles, onFilesAccepted, onFilesRejected]
|
|
114
|
+
);
|
|
115
|
+
const handleDrop = (e) => {
|
|
116
|
+
e.preventDefault();
|
|
117
|
+
e.stopPropagation();
|
|
118
|
+
setIsDragActive(false);
|
|
119
|
+
if (disabled) return;
|
|
120
|
+
const { files } = e.dataTransfer;
|
|
121
|
+
if (files.length > 0) {
|
|
122
|
+
validateAndAcceptFiles(files);
|
|
123
|
+
}
|
|
124
|
+
};
|
|
125
|
+
const handleDragOver = (e) => {
|
|
126
|
+
e.preventDefault();
|
|
127
|
+
e.stopPropagation();
|
|
128
|
+
if (!disabled) {
|
|
129
|
+
setIsDragActive(true);
|
|
130
|
+
}
|
|
131
|
+
};
|
|
132
|
+
const handleDragLeave = (e) => {
|
|
133
|
+
e.preventDefault();
|
|
134
|
+
e.stopPropagation();
|
|
135
|
+
setIsDragActive(false);
|
|
136
|
+
};
|
|
137
|
+
const handleFileInputChange = (e) => {
|
|
138
|
+
const { files } = e.target;
|
|
139
|
+
if (files && files.length > 0) {
|
|
140
|
+
validateAndAcceptFiles(files);
|
|
141
|
+
}
|
|
142
|
+
e.target.value = "";
|
|
143
|
+
};
|
|
144
|
+
const handleRecordClick = (e) => {
|
|
145
|
+
e.stopPropagation();
|
|
146
|
+
startRecording();
|
|
147
|
+
};
|
|
148
|
+
const handleStopClick = () => {
|
|
149
|
+
stopRecording();
|
|
150
|
+
};
|
|
151
|
+
if (isRecording) {
|
|
152
|
+
const formattedElapsed = audioUtils.formatAudioTime(elapsedMs);
|
|
153
|
+
return (
|
|
154
|
+
// biome-ignore lint/a11y/useSemanticElements: <fieldset> would break the public HTMLDivElement ref/props API
|
|
155
|
+
/* @__PURE__ */ jsxRuntime.jsxs(
|
|
156
|
+
"div",
|
|
157
|
+
{
|
|
158
|
+
ref,
|
|
159
|
+
role: "group",
|
|
160
|
+
"aria-label": "Audio recording in progress",
|
|
161
|
+
"data-testid": "audio-upload",
|
|
162
|
+
"data-state": "recording",
|
|
163
|
+
className: cn.cn(
|
|
164
|
+
"flex flex-col items-center justify-center gap-2 rounded-xl bg-neutral-100 px-4 py-3",
|
|
165
|
+
className
|
|
166
|
+
),
|
|
167
|
+
...props,
|
|
168
|
+
children: [
|
|
169
|
+
/* @__PURE__ */ jsxRuntime.jsxs("div", { className: "flex flex-1 flex-col items-center gap-2", children: [
|
|
170
|
+
/* @__PURE__ */ jsxRuntime.jsx(
|
|
171
|
+
"div",
|
|
172
|
+
{
|
|
173
|
+
className: "flex size-[72px] items-center justify-center rounded-full bg-neutral-400",
|
|
174
|
+
"aria-hidden": "true",
|
|
175
|
+
children: /* @__PURE__ */ jsxRuntime.jsx(MicrophoneIcon.MicrophoneIcon, { className: "size-5 text-body-300" })
|
|
176
|
+
}
|
|
177
|
+
),
|
|
178
|
+
/* @__PURE__ */ jsxRuntime.jsxs(
|
|
179
|
+
"p",
|
|
180
|
+
{
|
|
181
|
+
role: "timer",
|
|
182
|
+
"aria-label": "Recording time",
|
|
183
|
+
className: "typography-body-1-regular text-body-100",
|
|
184
|
+
children: [
|
|
185
|
+
formattedElapsed,
|
|
186
|
+
" / ",
|
|
187
|
+
audioUtils.formatAudioTime(maxRecordingDuration * 1e3)
|
|
188
|
+
]
|
|
189
|
+
}
|
|
190
|
+
)
|
|
191
|
+
] }),
|
|
192
|
+
/* @__PURE__ */ jsxRuntime.jsx("div", { className: "flex w-full items-center gap-2.5", "aria-hidden": "true", children: /* @__PURE__ */ jsxRuntime.jsx(
|
|
193
|
+
AudioWaveform.AudioWaveform,
|
|
194
|
+
{
|
|
195
|
+
analyserNode,
|
|
196
|
+
isRecording,
|
|
197
|
+
className: "flex-1"
|
|
198
|
+
}
|
|
199
|
+
) }),
|
|
200
|
+
/* @__PURE__ */ jsxRuntime.jsx(
|
|
201
|
+
"button",
|
|
202
|
+
{
|
|
203
|
+
ref: stopButtonRef,
|
|
204
|
+
type: "button",
|
|
205
|
+
onClick: handleStopClick,
|
|
206
|
+
className: "mt-1 flex size-11 items-center justify-center rounded-full bg-error-500 text-body-white-solid-constant transition-colors hover:bg-error-500/80 focus:shadow-focus-ring focus-visible:outline-none",
|
|
207
|
+
"aria-label": stopButtonAriaLabel,
|
|
208
|
+
children: /* @__PURE__ */ jsxRuntime.jsx(StopIcon.StopIcon, { className: "size-5" })
|
|
209
|
+
}
|
|
210
|
+
)
|
|
211
|
+
]
|
|
212
|
+
}
|
|
213
|
+
)
|
|
214
|
+
);
|
|
215
|
+
}
|
|
216
|
+
return (
|
|
217
|
+
// biome-ignore lint/a11y/useSemanticElements: <fieldset> would break the public HTMLDivElement ref/props API
|
|
218
|
+
/* @__PURE__ */ jsxRuntime.jsxs(
|
|
219
|
+
"div",
|
|
220
|
+
{
|
|
221
|
+
ref,
|
|
222
|
+
role: "group",
|
|
223
|
+
"aria-label": "Audio upload",
|
|
224
|
+
"data-testid": "audio-upload",
|
|
225
|
+
"data-state": "idle",
|
|
226
|
+
"aria-disabled": disabled || void 0,
|
|
227
|
+
onDrop: handleDrop,
|
|
228
|
+
onDragOver: handleDragOver,
|
|
229
|
+
onDragLeave: handleDragLeave,
|
|
230
|
+
className: cn.cn(
|
|
231
|
+
"flex flex-col items-center justify-center gap-2 rounded-xl bg-neutral-100 px-4 py-3 transition-colors",
|
|
232
|
+
isDragActive && "bg-brand-green-50 ring-2 ring-brand-green-500",
|
|
233
|
+
disabled && "pointer-events-none opacity-50",
|
|
234
|
+
className
|
|
235
|
+
),
|
|
236
|
+
...props,
|
|
237
|
+
children: [
|
|
238
|
+
/* @__PURE__ */ jsxRuntime.jsx(
|
|
239
|
+
"input",
|
|
240
|
+
{
|
|
241
|
+
id: inputId,
|
|
242
|
+
type: "file",
|
|
243
|
+
accept: acceptString,
|
|
244
|
+
multiple: maxFiles > 1,
|
|
245
|
+
onChange: handleFileInputChange,
|
|
246
|
+
className: "peer sr-only",
|
|
247
|
+
disabled,
|
|
248
|
+
"aria-describedby": descriptionId
|
|
249
|
+
}
|
|
250
|
+
),
|
|
251
|
+
/* @__PURE__ */ jsxRuntime.jsxs(
|
|
252
|
+
"label",
|
|
253
|
+
{
|
|
254
|
+
htmlFor: inputId,
|
|
255
|
+
className: "flex cursor-pointer flex-col items-center gap-2 rounded-lg px-2 py-1 peer-focus-visible:shadow-focus-ring",
|
|
256
|
+
children: [
|
|
257
|
+
/* @__PURE__ */ jsxRuntime.jsx(UploadCloudIcon.UploadCloudIcon, { className: "size-5 text-body-100" }),
|
|
258
|
+
/* @__PURE__ */ jsxRuntime.jsx("span", { className: "typography-body-1-semibold text-center text-body-100", children: uploadTitle }),
|
|
259
|
+
/* @__PURE__ */ jsxRuntime.jsx("span", { id: descriptionId, className: "typography-body-2-regular text-center text-body-100", children: uploadDescription })
|
|
260
|
+
]
|
|
261
|
+
}
|
|
262
|
+
),
|
|
263
|
+
allowRecording && isRecordingSupported && /* @__PURE__ */ jsxRuntime.jsxs(jsxRuntime.Fragment, { children: [
|
|
264
|
+
/* @__PURE__ */ jsxRuntime.jsx("p", { className: "typography-body-2-regular text-center text-body-100", children: separatorText }),
|
|
265
|
+
/* @__PURE__ */ jsxRuntime.jsx(
|
|
266
|
+
Button.Button,
|
|
267
|
+
{
|
|
268
|
+
variant: "brand",
|
|
269
|
+
size: "40",
|
|
270
|
+
leftIcon: /* @__PURE__ */ jsxRuntime.jsx(MicrophoneIcon.MicrophoneIcon, { className: "size-5" }),
|
|
271
|
+
onClick: handleRecordClick,
|
|
272
|
+
disabled,
|
|
273
|
+
type: "button",
|
|
274
|
+
children: recordButtonLabel
|
|
275
|
+
}
|
|
276
|
+
)
|
|
277
|
+
] })
|
|
278
|
+
]
|
|
279
|
+
}
|
|
280
|
+
)
|
|
281
|
+
);
|
|
282
|
+
}
|
|
283
|
+
);
|
|
284
|
+
AudioUpload.displayName = "AudioUpload";
|
|
285
|
+
exports.AudioUpload = AudioUpload;
|
|
286
|
+
//# sourceMappingURL=AudioUpload.cjs.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"AudioUpload.cjs","sources":["../../../../src/components/AudioUpload/AudioUpload.tsx"],"sourcesContent":["import * as React from \"react\";\nimport { cn } from \"@/utils/cn\";\nimport { Button } from \"../Button/Button\";\nimport { MicrophoneIcon } from \"../Icons/MicrophoneIcon\";\nimport { StopIcon } from \"../Icons/StopIcon\";\nimport { UploadCloudIcon } from \"../Icons/UploadCloudIcon\";\nimport { AudioWaveform } from \"./AudioWaveform\";\nimport { type AudioValidationError, formatAudioTime, validateAudioFile } from \"./audioUtils\";\nimport {\n DEFAULT_ACCEPTED_TYPES,\n DEFAULT_MAX_FILE_SIZE,\n DEFAULT_MAX_RECORDING_DURATION,\n DEFAULT_MIN_RECORDING_DURATION,\n} from \"./constants\";\nimport { useAudioRecorder } from \"./useAudioRecorder\";\n\n/** A file that was rejected during drop or browse, along with the reasons. */\nexport interface AudioFileRejection {\n /** The rejected file. */\n file: File;\n /** One or more validation errors explaining why the file was rejected. */\n errors: AudioValidationError[];\n}\n\nexport interface AudioUploadProps extends Omit<React.HTMLAttributes<HTMLDivElement>, \"onDrop\"> {\n /** Maximum file size in bytes. @default 10_485_760 (10MB) */\n maxFileSize?: number;\n /** Accepted audio MIME types. @default DEFAULT_ACCEPTED_TYPES */\n accept?: readonly string[];\n /** Maximum number of files per drop. @default 1 */\n maxFiles?: number;\n /** Whether to show the record audio button. @default true */\n allowRecording?: boolean;\n /** Maximum recording duration in seconds. @default 30 */\n maxRecordingDuration?: number;\n /** Minimum recording duration in seconds. @default 5 */\n minRecordingDuration?: number;\n\n /** Called when valid files are accepted via drop or browse */\n onFilesAccepted?: (files: File[]) => void;\n /** Called when files are rejected (wrong type, too large, etc.) */\n onFilesRejected?: (rejections: AudioFileRejection[]) => void;\n /** Called when a recording completes and meets minimum duration */\n onRecordingComplete?: (blob: Blob, durationMs: number) => void;\n /** Called when recording is stopped but does not meet minimum duration */\n onRecordingTooShort?: (durationMs: number, minDurationMs: number) => void;\n /** Called when microphone permission is denied or unavailable */\n onPermissionError?: (error: Error) => void;\n /** Called when an unexpected recording error occurs */\n onRecordingError?: (error: Error) => void;\n\n /** Upload area title text. @default \"Click to upload, or drag & drop\" */\n uploadTitle?: string;\n /** Upload area description text. @default \"Audio files only, up to 10MB each\" */\n uploadDescription?: string;\n /** Separator text between upload and record. @default \"or\" */\n separatorText?: string;\n /** Record button label. @default \"Record audio\" */\n recordButtonLabel?: string;\n /** Stop recording button aria-label. @default \"Stop recording\" */\n stopButtonAriaLabel?: string;\n\n /** Whether the component is disabled. @default false */\n disabled?: boolean;\n}\n\nfunction partitionFiles(\n files: File[],\n maxFileSize: number,\n accept: readonly string[],\n maxFiles: number,\n): { accepted: File[]; rejected: AudioFileRejection[] } {\n const accepted: File[] = [];\n const rejected: AudioFileRejection[] = [];\n\n for (const file of files) {\n const errors = validateAudioFile(file, { maxFileSize, acceptedTypes: accept });\n if (errors.length > 0) {\n rejected.push({ file, errors });\n } else {\n accepted.push(file);\n }\n }\n\n if (maxFiles > 0 && accepted.length > maxFiles) {\n const excess = accepted.splice(maxFiles);\n for (const file of excess) {\n rejected.push({\n file,\n errors: [{ code: \"too-many-files\", message: `Too many files. Maximum is ${maxFiles}` }],\n });\n }\n }\n\n return { accepted, rejected };\n}\n\n/**\n * Audio file upload with drag-and-drop and optional in-browser recording.\n * Supports file validation, multiple files, and real-time waveform visualization during recording.\n *\n * @example\n * ```tsx\n * <AudioUpload\n * onFilesAccepted={(files) => console.log(files)}\n * onRecordingComplete={(blob, duration) => console.log(blob, duration)}\n * />\n * ```\n */\nexport const AudioUpload = React.forwardRef<HTMLDivElement, AudioUploadProps>(\n (\n {\n className,\n maxFileSize = DEFAULT_MAX_FILE_SIZE,\n accept = DEFAULT_ACCEPTED_TYPES,\n maxFiles = 1,\n allowRecording = true,\n maxRecordingDuration = DEFAULT_MAX_RECORDING_DURATION,\n minRecordingDuration = DEFAULT_MIN_RECORDING_DURATION,\n onFilesAccepted,\n onFilesRejected,\n onRecordingComplete,\n onRecordingTooShort,\n onPermissionError,\n onRecordingError,\n uploadTitle = \"Click to upload, or drag & drop\",\n uploadDescription = \"Audio files only, up to 10MB each\",\n separatorText = \"or\",\n recordButtonLabel = \"Record audio\",\n stopButtonAriaLabel = \"Stop recording\",\n disabled = false,\n ...props\n },\n ref,\n ) => {\n const inputId = React.useId();\n const descriptionId = React.useId();\n const [isDragActive, setIsDragActive] = React.useState(false);\n const stopButtonRef = React.useRef<HTMLButtonElement>(null);\n\n const {\n isRecording,\n elapsedMs,\n startRecording,\n stopRecording,\n analyserNode,\n isSupported: isRecordingSupported,\n } = useAudioRecorder({\n maxDuration: maxRecordingDuration,\n minDuration: minRecordingDuration,\n onComplete: onRecordingComplete,\n onTooShort: onRecordingTooShort,\n onPermissionError,\n onError: onRecordingError,\n });\n\n const acceptString = accept.join(\",\");\n\n // Move focus to stop button when recording starts\n React.useEffect(() => {\n if (isRecording) {\n stopButtonRef.current?.focus();\n }\n }, [isRecording]);\n\n const validateAndAcceptFiles = React.useCallback(\n (files: FileList | File[]) => {\n const { accepted, rejected } = partitionFiles(\n Array.from(files),\n maxFileSize,\n accept,\n maxFiles,\n );\n if (accepted.length > 0) onFilesAccepted?.(accepted);\n if (rejected.length > 0) onFilesRejected?.(rejected);\n },\n [maxFileSize, accept, maxFiles, onFilesAccepted, onFilesRejected],\n );\n\n const handleDrop = (e: React.DragEvent<HTMLDivElement>) => {\n e.preventDefault();\n e.stopPropagation();\n setIsDragActive(false);\n\n if (disabled) return;\n\n const { files } = e.dataTransfer;\n if (files.length > 0) {\n validateAndAcceptFiles(files);\n }\n };\n\n const handleDragOver = (e: React.DragEvent<HTMLDivElement>) => {\n e.preventDefault();\n e.stopPropagation();\n if (!disabled) {\n setIsDragActive(true);\n }\n };\n\n const handleDragLeave = (e: React.DragEvent<HTMLDivElement>) => {\n e.preventDefault();\n e.stopPropagation();\n setIsDragActive(false);\n };\n\n const handleFileInputChange = (e: React.ChangeEvent<HTMLInputElement>) => {\n const { files } = e.target;\n if (files && files.length > 0) {\n validateAndAcceptFiles(files);\n }\n // Reset input so same file can be selected again\n e.target.value = \"\";\n };\n\n const handleRecordClick = (e: React.MouseEvent) => {\n e.stopPropagation();\n startRecording();\n };\n\n const handleStopClick = () => {\n stopRecording();\n };\n\n if (isRecording) {\n const formattedElapsed = formatAudioTime(elapsedMs);\n\n return (\n // biome-ignore lint/a11y/useSemanticElements: <fieldset> would break the public HTMLDivElement ref/props API\n <div\n ref={ref}\n role=\"group\"\n aria-label=\"Audio recording in progress\"\n data-testid=\"audio-upload\"\n data-state=\"recording\"\n className={cn(\n \"flex flex-col items-center justify-center gap-2 rounded-xl bg-neutral-100 px-4 py-3\",\n className,\n )}\n {...props}\n >\n <div className=\"flex flex-1 flex-col items-center gap-2\">\n <div\n className=\"flex size-[72px] items-center justify-center rounded-full bg-neutral-400\"\n aria-hidden=\"true\"\n >\n <MicrophoneIcon className=\"size-5 text-body-300\" />\n </div>\n\n <p\n role=\"timer\"\n aria-label=\"Recording time\"\n className=\"typography-body-1-regular text-body-100\"\n >\n {formattedElapsed} / {formatAudioTime(maxRecordingDuration * 1000)}\n </p>\n </div>\n\n <div className=\"flex w-full items-center gap-2.5\" aria-hidden=\"true\">\n <AudioWaveform\n analyserNode={analyserNode}\n isRecording={isRecording}\n className=\"flex-1\"\n />\n </div>\n\n <button\n ref={stopButtonRef}\n type=\"button\"\n onClick={handleStopClick}\n className=\"mt-1 flex size-11 items-center justify-center rounded-full bg-error-500 text-body-white-solid-constant transition-colors hover:bg-error-500/80 focus:shadow-focus-ring focus-visible:outline-none\"\n aria-label={stopButtonAriaLabel}\n >\n <StopIcon className=\"size-5\" />\n </button>\n </div>\n );\n }\n\n return (\n // biome-ignore lint/a11y/useSemanticElements: <fieldset> would break the public HTMLDivElement ref/props API\n <div\n ref={ref}\n role=\"group\"\n aria-label=\"Audio upload\"\n data-testid=\"audio-upload\"\n data-state=\"idle\"\n aria-disabled={disabled || undefined}\n onDrop={handleDrop}\n onDragOver={handleDragOver}\n onDragLeave={handleDragLeave}\n className={cn(\n \"flex flex-col items-center justify-center gap-2 rounded-xl bg-neutral-100 px-4 py-3 transition-colors\",\n isDragActive && \"bg-brand-green-50 ring-2 ring-brand-green-500\",\n disabled && \"pointer-events-none opacity-50\",\n className,\n )}\n {...props}\n >\n <input\n id={inputId}\n type=\"file\"\n accept={acceptString}\n multiple={maxFiles > 1}\n onChange={handleFileInputChange}\n className=\"peer sr-only\"\n disabled={disabled}\n aria-describedby={descriptionId}\n />\n\n <label\n htmlFor={inputId}\n className=\"flex cursor-pointer flex-col items-center gap-2 rounded-lg px-2 py-1 peer-focus-visible:shadow-focus-ring\"\n >\n <UploadCloudIcon className=\"size-5 text-body-100\" />\n\n <span className=\"typography-body-1-semibold text-center text-body-100\">\n {uploadTitle}\n </span>\n\n <span id={descriptionId} className=\"typography-body-2-regular text-center text-body-100\">\n {uploadDescription}\n </span>\n </label>\n\n {allowRecording && isRecordingSupported && (\n <>\n <p className=\"typography-body-2-regular text-center text-body-100\">{separatorText}</p>\n\n <Button\n variant=\"brand\"\n size=\"40\"\n leftIcon={<MicrophoneIcon className=\"size-5\" />}\n onClick={handleRecordClick}\n disabled={disabled}\n type=\"button\"\n >\n {recordButtonLabel}\n </Button>\n </>\n )}\n </div>\n );\n },\n);\n\nAudioUpload.displayName = \"AudioUpload\";\n"],"names":["validateAudioFile","React","DEFAULT_MAX_FILE_SIZE","DEFAULT_ACCEPTED_TYPES","DEFAULT_MAX_RECORDING_DURATION","DEFAULT_MIN_RECORDING_DURATION","useAudioRecorder","formatAudioTime","jsxs","cn","jsx","MicrophoneIcon","AudioWaveform","StopIcon","UploadCloudIcon","Fragment","Button"],"mappings":"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"}
|
|
@@ -0,0 +1,121 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
"use strict";
|
|
3
|
+
Object.defineProperty(exports, Symbol.toStringTag, { value: "Module" });
|
|
4
|
+
const jsxRuntime = require("react/jsx-runtime");
|
|
5
|
+
const React = require("react");
|
|
6
|
+
const cn = require("../../utils/cn.cjs");
|
|
7
|
+
function _interopNamespaceDefault(e) {
|
|
8
|
+
const n = Object.create(null, { [Symbol.toStringTag]: { value: "Module" } });
|
|
9
|
+
if (e) {
|
|
10
|
+
for (const k in e) {
|
|
11
|
+
if (k !== "default") {
|
|
12
|
+
const d = Object.getOwnPropertyDescriptor(e, k);
|
|
13
|
+
Object.defineProperty(n, k, d.get ? d : {
|
|
14
|
+
enumerable: true,
|
|
15
|
+
get: () => e[k]
|
|
16
|
+
});
|
|
17
|
+
}
|
|
18
|
+
}
|
|
19
|
+
}
|
|
20
|
+
n.default = e;
|
|
21
|
+
return Object.freeze(n);
|
|
22
|
+
}
|
|
23
|
+
const React__namespace = /* @__PURE__ */ _interopNamespaceDefault(React);
|
|
24
|
+
const BAR_WIDTH = 3;
|
|
25
|
+
const BAR_GAP = 4;
|
|
26
|
+
const BAR_RADIUS = 1.5;
|
|
27
|
+
const MIN_BAR_HEIGHT = 3;
|
|
28
|
+
const IDLE_DOT_SIZE = 3;
|
|
29
|
+
function drawRoundedRect(ctx, x, y, w, h, r) {
|
|
30
|
+
ctx.beginPath();
|
|
31
|
+
if (ctx.roundRect) {
|
|
32
|
+
ctx.roundRect(x, y, w, h, r);
|
|
33
|
+
} else {
|
|
34
|
+
ctx.rect(x, y, w, h);
|
|
35
|
+
}
|
|
36
|
+
ctx.fill();
|
|
37
|
+
}
|
|
38
|
+
function AudioWaveform({ analyserNode, isRecording, className }) {
|
|
39
|
+
const canvasRef = React__namespace.useRef(null);
|
|
40
|
+
const rafRef = React__namespace.useRef(0);
|
|
41
|
+
React__namespace.useEffect(() => {
|
|
42
|
+
const canvas = canvasRef.current;
|
|
43
|
+
if (!canvas) return;
|
|
44
|
+
const ctx = canvas.getContext("2d");
|
|
45
|
+
if (!ctx) return;
|
|
46
|
+
let cachedColor = "";
|
|
47
|
+
let cachedWidth = 0;
|
|
48
|
+
let cachedHeight = 0;
|
|
49
|
+
let dataArray = null;
|
|
50
|
+
const resizeCanvas = () => {
|
|
51
|
+
const rect = canvas.getBoundingClientRect();
|
|
52
|
+
const dpr = window.devicePixelRatio || 1;
|
|
53
|
+
canvas.width = rect.width * dpr;
|
|
54
|
+
canvas.height = rect.height * dpr;
|
|
55
|
+
ctx.scale(dpr, dpr);
|
|
56
|
+
cachedColor = getComputedStyle(canvas).color || "#151515";
|
|
57
|
+
cachedWidth = rect.width;
|
|
58
|
+
cachedHeight = rect.height;
|
|
59
|
+
};
|
|
60
|
+
resizeCanvas();
|
|
61
|
+
const drawIdle = () => {
|
|
62
|
+
ctx.clearRect(0, 0, cachedWidth, cachedHeight);
|
|
63
|
+
ctx.fillStyle = cachedColor;
|
|
64
|
+
const totalBarSpace = IDLE_DOT_SIZE + BAR_GAP;
|
|
65
|
+
const barCount = Math.floor(cachedWidth / totalBarSpace);
|
|
66
|
+
const startX = (cachedWidth - barCount * totalBarSpace + BAR_GAP) / 2;
|
|
67
|
+
const y = cachedHeight / 2 - IDLE_DOT_SIZE / 2;
|
|
68
|
+
for (let i = 0; i < barCount; i++) {
|
|
69
|
+
const x = startX + i * totalBarSpace;
|
|
70
|
+
ctx.beginPath();
|
|
71
|
+
ctx.arc(x + IDLE_DOT_SIZE / 2, y + IDLE_DOT_SIZE / 2, IDLE_DOT_SIZE / 2, 0, Math.PI * 2);
|
|
72
|
+
ctx.fill();
|
|
73
|
+
}
|
|
74
|
+
};
|
|
75
|
+
const drawRecording = () => {
|
|
76
|
+
if (!analyserNode) {
|
|
77
|
+
drawIdle();
|
|
78
|
+
return;
|
|
79
|
+
}
|
|
80
|
+
ctx.clearRect(0, 0, cachedWidth, cachedHeight);
|
|
81
|
+
const bufferLength = analyserNode.frequencyBinCount;
|
|
82
|
+
if (!dataArray || dataArray.length !== bufferLength) {
|
|
83
|
+
dataArray = new Uint8Array(bufferLength);
|
|
84
|
+
}
|
|
85
|
+
analyserNode.getByteFrequencyData(dataArray);
|
|
86
|
+
ctx.fillStyle = cachedColor;
|
|
87
|
+
const totalBarSpace = BAR_WIDTH + BAR_GAP;
|
|
88
|
+
const barCount = Math.floor(cachedWidth / totalBarSpace);
|
|
89
|
+
const startX = (cachedWidth - barCount * totalBarSpace + BAR_GAP) / 2;
|
|
90
|
+
const step = bufferLength / barCount;
|
|
91
|
+
for (let i = 0; i < barCount; i++) {
|
|
92
|
+
const dataIndex = Math.floor(i * step);
|
|
93
|
+
const value = (dataArray[dataIndex] ?? 0) / 255;
|
|
94
|
+
const barHeight = Math.max(MIN_BAR_HEIGHT, value * (cachedHeight - 4));
|
|
95
|
+
const x = startX + i * totalBarSpace;
|
|
96
|
+
const y = (cachedHeight - barHeight) / 2;
|
|
97
|
+
drawRoundedRect(ctx, x, y, BAR_WIDTH, barHeight, BAR_RADIUS);
|
|
98
|
+
}
|
|
99
|
+
rafRef.current = requestAnimationFrame(drawRecording);
|
|
100
|
+
};
|
|
101
|
+
if (isRecording && analyserNode) {
|
|
102
|
+
rafRef.current = requestAnimationFrame(drawRecording);
|
|
103
|
+
} else {
|
|
104
|
+
drawIdle();
|
|
105
|
+
}
|
|
106
|
+
const observer = new ResizeObserver(() => {
|
|
107
|
+
resizeCanvas();
|
|
108
|
+
if (!isRecording || !analyserNode) {
|
|
109
|
+
drawIdle();
|
|
110
|
+
}
|
|
111
|
+
});
|
|
112
|
+
observer.observe(canvas);
|
|
113
|
+
return () => {
|
|
114
|
+
cancelAnimationFrame(rafRef.current);
|
|
115
|
+
observer.disconnect();
|
|
116
|
+
};
|
|
117
|
+
}, [analyserNode, isRecording]);
|
|
118
|
+
return /* @__PURE__ */ jsxRuntime.jsx("canvas", { ref: canvasRef, className: cn.cn("h-5 w-full text-body-200", className) });
|
|
119
|
+
}
|
|
120
|
+
exports.AudioWaveform = AudioWaveform;
|
|
121
|
+
//# sourceMappingURL=AudioWaveform.cjs.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"AudioWaveform.cjs","sources":["../../../../src/components/AudioUpload/AudioWaveform.tsx"],"sourcesContent":["import * as React from \"react\";\nimport { cn } from \"@/utils/cn\";\n\ninterface AudioWaveformProps {\n /** AnalyserNode from useAudioRecorder for frequency data */\n analyserNode: AnalyserNode | null;\n /** Whether recording is active (affects rendering mode) */\n isRecording: boolean;\n /** Additional className */\n className?: string;\n}\n\nconst BAR_WIDTH = 3;\nconst BAR_GAP = 4;\nconst BAR_RADIUS = 1.5;\nconst MIN_BAR_HEIGHT = 3;\nconst IDLE_DOT_SIZE = 3;\n\n/** Draw a rounded rect, falling back to a plain rect if unsupported. */\nfunction drawRoundedRect(\n ctx: CanvasRenderingContext2D,\n x: number,\n y: number,\n w: number,\n h: number,\n r: number,\n) {\n ctx.beginPath();\n if (ctx.roundRect) {\n ctx.roundRect(x, y, w, h, r);\n } else {\n ctx.rect(x, y, w, h);\n }\n ctx.fill();\n}\n\n/**\n * Canvas-based waveform visualization for audio recording.\n * Shows animated frequency bars when recording, static dots when idle.\n *\n * @internal Not exported from the library — used internally by AudioUpload.\n */\nexport function AudioWaveform({ analyserNode, isRecording, className }: AudioWaveformProps) {\n const canvasRef = React.useRef<HTMLCanvasElement>(null);\n const rafRef = React.useRef<number>(0);\n\n React.useEffect(() => {\n const canvas = canvasRef.current;\n if (!canvas) return;\n\n const ctx = canvas.getContext(\"2d\");\n if (!ctx) return;\n\n // Cache color, dimensions, and frequency buffer outside the animation loop\n let cachedColor = \"\";\n let cachedWidth = 0;\n let cachedHeight = 0;\n let dataArray: Uint8Array<ArrayBuffer> | null = null;\n\n const resizeCanvas = () => {\n const rect = canvas.getBoundingClientRect();\n const dpr = window.devicePixelRatio || 1;\n canvas.width = rect.width * dpr;\n canvas.height = rect.height * dpr;\n ctx.scale(dpr, dpr);\n cachedColor = getComputedStyle(canvas).color || \"#151515\";\n cachedWidth = rect.width;\n cachedHeight = rect.height;\n };\n\n resizeCanvas();\n\n const drawIdle = () => {\n ctx.clearRect(0, 0, cachedWidth, cachedHeight);\n ctx.fillStyle = cachedColor;\n\n const totalBarSpace = IDLE_DOT_SIZE + BAR_GAP;\n const barCount = Math.floor(cachedWidth / totalBarSpace);\n const startX = (cachedWidth - barCount * totalBarSpace + BAR_GAP) / 2;\n const y = cachedHeight / 2 - IDLE_DOT_SIZE / 2;\n\n for (let i = 0; i < barCount; i++) {\n const x = startX + i * totalBarSpace;\n ctx.beginPath();\n ctx.arc(x + IDLE_DOT_SIZE / 2, y + IDLE_DOT_SIZE / 2, IDLE_DOT_SIZE / 2, 0, Math.PI * 2);\n ctx.fill();\n }\n };\n\n const drawRecording = () => {\n if (!analyserNode) {\n drawIdle();\n return;\n }\n\n ctx.clearRect(0, 0, cachedWidth, cachedHeight);\n\n // Reuse typed array across frames\n const bufferLength = analyserNode.frequencyBinCount;\n if (!dataArray || dataArray.length !== bufferLength) {\n dataArray = new Uint8Array(bufferLength);\n }\n analyserNode.getByteFrequencyData(dataArray);\n\n ctx.fillStyle = cachedColor;\n\n const totalBarSpace = BAR_WIDTH + BAR_GAP;\n const barCount = Math.floor(cachedWidth / totalBarSpace);\n const startX = (cachedWidth - barCount * totalBarSpace + BAR_GAP) / 2;\n\n const step = bufferLength / barCount;\n\n for (let i = 0; i < barCount; i++) {\n const dataIndex = Math.floor(i * step);\n const value = (dataArray[dataIndex] ?? 0) / 255;\n const barHeight = Math.max(MIN_BAR_HEIGHT, value * (cachedHeight - 4));\n const x = startX + i * totalBarSpace;\n const y = (cachedHeight - barHeight) / 2;\n\n drawRoundedRect(ctx, x, y, BAR_WIDTH, barHeight, BAR_RADIUS);\n }\n\n rafRef.current = requestAnimationFrame(drawRecording);\n };\n\n if (isRecording && analyserNode) {\n rafRef.current = requestAnimationFrame(drawRecording);\n } else {\n drawIdle();\n }\n\n const observer = new ResizeObserver(() => {\n resizeCanvas();\n if (!isRecording || !analyserNode) {\n drawIdle();\n }\n });\n observer.observe(canvas);\n\n return () => {\n cancelAnimationFrame(rafRef.current);\n observer.disconnect();\n };\n }, [analyserNode, isRecording]);\n\n return <canvas ref={canvasRef} className={cn(\"h-5 w-full text-body-200\", className)} />;\n}\n"],"names":["React","jsx","cn"],"mappings":"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"}
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
"use strict";
|
|
3
|
+
Object.defineProperty(exports, Symbol.toStringTag, { value: "Module" });
|
|
4
|
+
const constants = require("./constants.cjs");
|
|
5
|
+
function validateAudioFile(file, options = {}) {
|
|
6
|
+
const { maxFileSize = constants.DEFAULT_MAX_FILE_SIZE, acceptedTypes = constants.DEFAULT_ACCEPTED_TYPES } = options;
|
|
7
|
+
const errors = [];
|
|
8
|
+
if (file.size > maxFileSize) {
|
|
9
|
+
const maxMB = Math.round(maxFileSize / (1024 * 1024));
|
|
10
|
+
errors.push({
|
|
11
|
+
code: "file-too-large",
|
|
12
|
+
message: `File "${file.name}" exceeds ${maxMB}MB limit`
|
|
13
|
+
});
|
|
14
|
+
}
|
|
15
|
+
if (acceptedTypes.length > 0 && !acceptedTypes.includes(file.type)) {
|
|
16
|
+
errors.push({
|
|
17
|
+
code: "file-invalid-type",
|
|
18
|
+
message: `File "${file.name}" is not a supported audio format`
|
|
19
|
+
});
|
|
20
|
+
}
|
|
21
|
+
return errors;
|
|
22
|
+
}
|
|
23
|
+
function formatAudioTime(ms) {
|
|
24
|
+
const totalSeconds = Math.floor(ms / 1e3);
|
|
25
|
+
const minutes = Math.floor(totalSeconds / 60);
|
|
26
|
+
const seconds = totalSeconds % 60;
|
|
27
|
+
return `${minutes}:${String(seconds).padStart(2, "0")}`;
|
|
28
|
+
}
|
|
29
|
+
function getRecordingMimeType() {
|
|
30
|
+
if (typeof MediaRecorder === "undefined") {
|
|
31
|
+
return "";
|
|
32
|
+
}
|
|
33
|
+
if (MediaRecorder.isTypeSupported("audio/webm")) {
|
|
34
|
+
return "audio/webm";
|
|
35
|
+
}
|
|
36
|
+
if (MediaRecorder.isTypeSupported("audio/mp4")) {
|
|
37
|
+
return "audio/mp4";
|
|
38
|
+
}
|
|
39
|
+
return "";
|
|
40
|
+
}
|
|
41
|
+
exports.formatAudioTime = formatAudioTime;
|
|
42
|
+
exports.getRecordingMimeType = getRecordingMimeType;
|
|
43
|
+
exports.validateAudioFile = validateAudioFile;
|
|
44
|
+
//# sourceMappingURL=audioUtils.cjs.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"audioUtils.cjs","sources":["../../../../src/components/AudioUpload/audioUtils.ts"],"sourcesContent":["import { DEFAULT_ACCEPTED_TYPES, DEFAULT_MAX_FILE_SIZE } from \"./constants\";\n\n/** An error returned when an audio file fails validation. */\nexport interface AudioValidationError {\n /** Machine-readable error code. */\n code: \"file-too-large\" | \"file-invalid-type\" | \"too-many-files\";\n /** Human-readable error message. */\n message: string;\n}\n\nexport interface AudioValidationOptions {\n /** Maximum file size in bytes. @default 10_485_760 (10MB) */\n maxFileSize?: number;\n /** Accepted MIME types. @default DEFAULT_ACCEPTED_TYPES */\n acceptedTypes?: readonly string[];\n}\n\n/**\n * Validate an audio file's size and MIME type synchronously.\n * Returns an array of validation errors (empty if valid).\n */\nexport function validateAudioFile(\n file: File,\n options: AudioValidationOptions = {},\n): AudioValidationError[] {\n const { maxFileSize = DEFAULT_MAX_FILE_SIZE, acceptedTypes = DEFAULT_ACCEPTED_TYPES } = options;\n const errors: AudioValidationError[] = [];\n\n if (file.size > maxFileSize) {\n const maxMB = Math.round(maxFileSize / (1024 * 1024));\n errors.push({\n code: \"file-too-large\",\n message: `File \"${file.name}\" exceeds ${maxMB}MB limit`,\n });\n }\n\n if (acceptedTypes.length > 0 && !acceptedTypes.includes(file.type)) {\n errors.push({\n code: \"file-invalid-type\",\n message: `File \"${file.name}\" is not a supported audio format`,\n });\n }\n\n return errors;\n}\n\n/**\n * Format milliseconds to \"m:ss\" display string.\n *\n * @example\n * formatAudioTime(0) // \"0:00\"\n * formatAudioTime(5000) // \"0:05\"\n * formatAudioTime(65000) // \"1:05\"\n */\nexport function formatAudioTime(ms: number): string {\n const totalSeconds = Math.floor(ms / 1000);\n const minutes = Math.floor(totalSeconds / 60);\n const seconds = totalSeconds % 60;\n return `${minutes}:${String(seconds).padStart(2, \"0\")}`;\n}\n\n/**\n * Get the appropriate recording MIME type for the current browser.\n * Safari/iOS use audio/mp4; others use audio/webm.\n */\nexport function getRecordingMimeType(): string {\n if (typeof MediaRecorder === \"undefined\") {\n return \"\";\n }\n\n if (MediaRecorder.isTypeSupported(\"audio/webm\")) {\n return \"audio/webm\";\n }\n\n if (MediaRecorder.isTypeSupported(\"audio/mp4\")) {\n return \"audio/mp4\";\n }\n\n return \"\";\n}\n"],"names":["DEFAULT_MAX_FILE_SIZE","DEFAULT_ACCEPTED_TYPES"],"mappings":";;;;AAqBO,SAAS,kBACd,MACA,UAAkC,IACV;AACxB,QAAM,EAAE,cAAcA,UAAAA,uBAAuB,gBAAgBC,UAAAA,2BAA2B;AACxF,QAAM,SAAiC,CAAA;AAEvC,MAAI,KAAK,OAAO,aAAa;AAC3B,UAAM,QAAQ,KAAK,MAAM,eAAe,OAAO,KAAK;AACpD,WAAO,KAAK;AAAA,MACV,MAAM;AAAA,MACN,SAAS,SAAS,KAAK,IAAI,aAAa,KAAK;AAAA,IAAA,CAC9C;AAAA,EACH;AAEA,MAAI,cAAc,SAAS,KAAK,CAAC,cAAc,SAAS,KAAK,IAAI,GAAG;AAClE,WAAO,KAAK;AAAA,MACV,MAAM;AAAA,MACN,SAAS,SAAS,KAAK,IAAI;AAAA,IAAA,CAC5B;AAAA,EACH;AAEA,SAAO;AACT;AAUO,SAAS,gBAAgB,IAAoB;AAClD,QAAM,eAAe,KAAK,MAAM,KAAK,GAAI;AACzC,QAAM,UAAU,KAAK,MAAM,eAAe,EAAE;AAC5C,QAAM,UAAU,eAAe;AAC/B,SAAO,GAAG,OAAO,IAAI,OAAO,OAAO,EAAE,SAAS,GAAG,GAAG,CAAC;AACvD;AAMO,SAAS,uBAA+B;AAC7C,MAAI,OAAO,kBAAkB,aAAa;AACxC,WAAO;AAAA,EACT;AAEA,MAAI,cAAc,gBAAgB,YAAY,GAAG;AAC/C,WAAO;AAAA,EACT;AAEA,MAAI,cAAc,gBAAgB,WAAW,GAAG;AAC9C,WAAO;AAAA,EACT;AAEA,SAAO;AACT;;;;"}
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
"use strict";
|
|
3
|
+
Object.defineProperty(exports, Symbol.toStringTag, { value: "Module" });
|
|
4
|
+
const DEFAULT_MAX_FILE_SIZE = 10 * 1024 * 1024;
|
|
5
|
+
const DEFAULT_MAX_RECORDING_DURATION = 30;
|
|
6
|
+
const DEFAULT_MIN_RECORDING_DURATION = 5;
|
|
7
|
+
const DEFAULT_ACCEPTED_TYPES = [
|
|
8
|
+
"audio/mpeg",
|
|
9
|
+
"audio/ogg",
|
|
10
|
+
"audio/wav",
|
|
11
|
+
"audio/x-m4a",
|
|
12
|
+
"audio/mp4",
|
|
13
|
+
"audio/flac",
|
|
14
|
+
"audio/webm",
|
|
15
|
+
"audio/aac"
|
|
16
|
+
];
|
|
17
|
+
exports.DEFAULT_ACCEPTED_TYPES = DEFAULT_ACCEPTED_TYPES;
|
|
18
|
+
exports.DEFAULT_MAX_FILE_SIZE = DEFAULT_MAX_FILE_SIZE;
|
|
19
|
+
exports.DEFAULT_MAX_RECORDING_DURATION = DEFAULT_MAX_RECORDING_DURATION;
|
|
20
|
+
exports.DEFAULT_MIN_RECORDING_DURATION = DEFAULT_MIN_RECORDING_DURATION;
|
|
21
|
+
//# sourceMappingURL=constants.cjs.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"constants.cjs","sources":["../../../../src/components/AudioUpload/constants.ts"],"sourcesContent":["/** Maximum audio file size in bytes (10MB) */\nexport const DEFAULT_MAX_FILE_SIZE = 10 * 1024 * 1024;\n\n/** Maximum recording duration in seconds */\nexport const DEFAULT_MAX_RECORDING_DURATION = 30;\n\n/** Minimum recording duration in seconds */\nexport const DEFAULT_MIN_RECORDING_DURATION = 5;\n\n/** Default accepted audio MIME types */\nexport const DEFAULT_ACCEPTED_TYPES = [\n \"audio/mpeg\",\n \"audio/ogg\",\n \"audio/wav\",\n \"audio/x-m4a\",\n \"audio/mp4\",\n \"audio/flac\",\n \"audio/webm\",\n \"audio/aac\",\n] as const;\n"],"names":[],"mappings":";;;AACO,MAAM,wBAAwB,KAAK,OAAO;AAG1C,MAAM,iCAAiC;AAGvC,MAAM,iCAAiC;AAGvC,MAAM,yBAAyB;AAAA,EACpC;AAAA,EACA;AAAA,EACA;AAAA,EACA;AAAA,EACA;AAAA,EACA;AAAA,EACA;AAAA,EACA;AACF;;;;;"}
|