diff --git a/README.md b/README.md index 521a033b..3436644a 100644 --- a/README.md +++ b/README.md @@ -309,6 +309,34 @@ export default defineConfig({ If your videos are always spoken in a specific language and you want to include captions by default, you can use `disableTextTrackConfig: true` together with `defaultAutogeneratedSubtitleLang` to transcribe captions for every uploaded asset without needing user interaction. +### Accepted File Types + +By default, the plugin accepts both video and audio files (`['video/*', 'audio/*']`). You can configure which file types are accepted by the input: + +```js +import {muxInput} from 'sanity-plugin-mux-input' + +export default defineConfig({ + plugins: [ + muxInput({ + acceptedMimeTypes: ['video/*'], // Only accept video files + // or + acceptedMimeTypes: ['audio/*'], // Only accept audio files + // or + acceptedMimeTypes: ['video/*', 'audio/*'], // Accept both (default) + }), + ], +}) +``` + +The `acceptedMimeTypes` option controls the `accept` attribute on the file input, which filters which file types users can select when uploading. This affects both the file picker dialog and drag-and-drop file validation. + +📌 **Note**: This option accepts an array of MIME type patterns. The valid values are: +- `'video/*'` - Accepts all video file types +- `'audio/*'` - Accepts all audio file types + +For more information on the `accept` attribute, refer to the [MDN documentation](https://developer.mozilla.org/en-US/docs/Web/HTML/Attributes/accept). + ## Contributing Issues are actively monitored and PRs are welcome. When developing this plugin the easiest setup is: diff --git a/src/_exports/index.ts b/src/_exports/index.ts index 2be6c99d..8151cfb0 100644 --- a/src/_exports/index.ts +++ b/src/_exports/index.ts @@ -16,6 +16,7 @@ export const defaultConfig: PluginConfig = { defaultSigned: false, tool: DEFAULT_TOOL_CONFIG, allowedRolesForConfiguration: [], + acceptedMimeTypes: ['video/*', 'audio/*'], } /** diff --git a/src/components/FileInputArea.tsx b/src/components/FileInputArea.tsx deleted file mode 100644 index e4636a1c..00000000 --- a/src/components/FileInputArea.tsx +++ /dev/null @@ -1,93 +0,0 @@ -import {UploadIcon} from '@sanity/icons' -import {Card, CardTone, Flex, Inline} from '@sanity/ui' -import {PropsWithChildren, useRef, useState} from 'react' - -import {extractDroppedFiles} from '../util/extractFiles' -import {FileInputButton} from './FileInputButton' - -interface FileInputAreaProps extends PropsWithChildren { - accept?: string - acceptMIMETypes?: string[] - label: React.ReactNode - onSelect: (files: FileList | File[]) => void -} - -export default function FileInputArea({ - label, - accept, - acceptMIMETypes, - onSelect, -}: FileInputAreaProps) { - const dragEnteredEls = useRef([]) - const [dragState, setDragState] = useState<'valid' | 'invalid' | null>(null) - - // Stages and validates an upload from dragging+dropping files or folders - const handleDrop: React.DragEventHandler = (event) => { - setDragState(null) - event.preventDefault() - event.stopPropagation() - extractDroppedFiles(event.nativeEvent.dataTransfer!).then(onSelect) - } - - /* ------------------------------- Drag State ------------------------------- */ - - const handleDragOver: React.DragEventHandler = (event) => { - event.preventDefault() - event.stopPropagation() - } - - const handleDragEnter: React.DragEventHandler = (event) => { - event.stopPropagation() - dragEnteredEls.current.push(event.target) - const type = event.dataTransfer.items?.[0]?.type - setDragState( - !acceptMIMETypes || acceptMIMETypes.some((mimeType) => type?.match(mimeType)) - ? 'valid' - : 'invalid' - ) - } - - const handleDragLeave: React.DragEventHandler = (event) => { - event.stopPropagation() - const idx = dragEnteredEls.current.indexOf(event.target) - if (idx > -1) { - dragEnteredEls.current.splice(idx, 1) - } - if (dragEnteredEls.current.length === 0) { - setDragState(null) - } - } - - let tone: CardTone = 'inherit' - if (dragState) tone = dragState === 'valid' ? 'positive' : 'critical' - return ( - - - - {label} - - - - - - - ) -} diff --git a/src/components/FileInputButton.tsx b/src/components/FileInputButton.tsx index 911e0527..628b141f 100644 --- a/src/components/FileInputButton.tsx +++ b/src/components/FileInputButton.tsx @@ -17,7 +17,7 @@ const Label = styled.label` export interface FileInputButtonProps extends ButtonProps { onSelect: (files: FileList) => void - accept?: string + accept: string } export const FileInputButton = ({onSelect, accept, ...props}: FileInputButtonProps) => { const inputId = `FileSelect${useId()}` @@ -34,7 +34,7 @@ export const FileInputButton = ({onSelect, accept, ...props}: FileInputButtonPro return ( setDialogState('select-video'), [setDialogState]) const handleConfigureApi = useCallback(() => setDialogState('secrets'), [setDialogState]) const {hasConfigAccess} = useAccessControl(props.config) @@ -54,6 +54,7 @@ export default function UploadPlaceholder(props: UploadPlaceholderProps) { )) | {action: 'progress'; percent: number} - | {action: 'error'; error: any} + | {action: 'error'; error: Error} | {action: 'complete' | 'reset'} /** @@ -101,6 +101,7 @@ export default function Uploader(props: Props) { case 'commitUpload': return Object.assign({}, prev, {uploadStatus: {progress: 0}}) case 'progressInfo': { + // eslint-disable-next-line @typescript-eslint/no-unused-vars const {type, action: _, ...payload} = action return Object.assign({}, prev, { uploadStatus: { @@ -257,12 +258,44 @@ export default function Uploader(props: Props) { }) } + const invalidFileToast = useCallback(() => { + toast.push({ + status: 'error', + title: `Invalid file type. Accepted types: ${props.config.acceptedMimeTypes?.join(', ')}`, + }) + }, [props.config.acceptedMimeTypes, toast]) + + /** + * Validates if any file in the provided FileList or File array has an unsupported MIME type + * @param files - FileList or File array to validate + * @returns true if any file has an invalid MIME type, false if all files are valid + */ + const isInvalidFile = (files: FileList | File[]) => { + const isInvalid = Array.from(files).some((file) => { + return !props.config.acceptedMimeTypes?.some((acceptedType) => { + // Convert mime type pattern to regex (e.g., 'audio/*' -> /^audio\/.*$/) + const pattern = `^${acceptedType.replace('*', '.*')}$` + return new RegExp(pattern).test(file.type) + }) + }) + + if (isInvalid) { + // Use setTimeout to ensure toast is called after the current render cycle + setTimeout(() => { + invalidFileToast() + }, 0) + return true + } + return false + } + /* -------------------------- Upload Initialization ------------------------- */ // The below populate the uploadInput state field, which then triggers the // upload configuration, or the startUpload function if no config is required. // Stages an upload from the file selector const handleUpload = (files: FileList | File[]) => { + if (isInvalidFile(files)) return dispatch({ action: 'stageUpload', input: {type: 'file', files}, @@ -273,10 +306,14 @@ export default function Uploader(props: Props) { const handlePaste: React.ClipboardEventHandler = (event) => { event.preventDefault() event.stopPropagation() - const clipboardData = event.clipboardData || (window as any).clipboardData - const url = clipboardData.getData('text')?.trim() + const clipboardData = + event.clipboardData || (window as Window & {clipboardData?: DataTransfer}).clipboardData + const url = clipboardData?.getData('text')?.trim() if (!isValidUrl(url)) { - toast.push({status: 'error', title: 'Invalid URL for Mux video input.'}) + // Use setTimeout to ensure toast is called after the current render cycle + setTimeout(() => { + toast.push({status: 'error', title: 'Invalid URL for Mux video input.'}) + }, 0) return } dispatch({action: 'stageUpload', input: {type: 'url', url: url}}) @@ -284,9 +321,14 @@ export default function Uploader(props: Props) { // Stages and validates an upload from dragging+dropping files or folders const handleDrop: React.DragEventHandler = (event) => { - setDragState(null) event.preventDefault() event.stopPropagation() + if (dragState === 'invalid') { + invalidFileToast() + setDragState(null) + return + } + setDragState(null) extractDroppedFiles(event.nativeEvent.dataTransfer!).then((files) => { dispatch({ action: 'stageUpload', @@ -306,7 +348,16 @@ export default function Uploader(props: Props) { event.stopPropagation() dragEnteredEls.current.push(event.target) const type = event.dataTransfer.items?.[0]?.type - setDragState(type?.startsWith('video/') ? 'valid' : 'invalid') + const mimeTypes = props.config.acceptedMimeTypes + + // Check if the dragged file type matches any of the accepted mime types + const isValidType = mimeTypes?.some((acceptedType) => { + // Convert mime type pattern to regex (e.g., 'video/*' -> /^video\/.*$/) + const pattern = `^${acceptedType.replace('*', '.*')}$` + return new RegExp(pattern).test(type) + }) + + setDragState(isValidType ? 'valid' : 'invalid') } const handleDragLeave: React.DragEventHandler = (event) => { @@ -370,6 +421,10 @@ export default function Uploader(props: Props) { let tone: CardTone | undefined if (dragState) tone = dragState === 'valid' ? 'positive' : 'critical' + const acceptMimeString = props.config?.acceptedMimeTypes?.length + ? props.config.acceptedMimeTypes.join(',') + : 'video/*, audio/*' + return ( <> ) : ( - + {videoSrc && ( <> {children} diff --git a/src/util/types.ts b/src/util/types.ts index 48529226..bf37ad2e 100644 --- a/src/util/types.ts +++ b/src/util/types.ts @@ -122,6 +122,15 @@ export interface MuxInputConfig { * @defaultValue false */ disableTextTrackConfig?: boolean + + /** + * The mime types that are accepted by the input. + * + * @see {@link https://developer.mozilla.org/en-US/docs/Web/HTML/Reference/Attributes/accept} + * @defaultValue ['video/*','audio/*'] + + */ + acceptedMimeTypes?: ('audio/*' | 'video/*')[] } export interface PluginConfig extends MuxInputConfig {