From 46fd5d0ac8f79e27e2c996825676993670b0b126 Mon Sep 17 00:00:00 2001 From: Josh Berman Date: Fri, 21 Aug 2026 16:31:11 +1000 Subject: [PATCH 1/4] define accept type to image --- .../@tinacms/schema-tools/src/types/index.ts | 76 +++++++++++++++++++ 1 file changed, 76 insertions(+) diff --git a/packages/@tinacms/schema-tools/src/types/index.ts b/packages/@tinacms/schema-tools/src/types/index.ts index e73a5706b8..53bd30bac3 100644 --- a/packages/@tinacms/schema-tools/src/types/index.ts +++ b/packages/@tinacms/schema-tools/src/types/index.ts @@ -271,6 +271,69 @@ export type DateTimeField = ( type: 'datetime'; }; +/** + * File extensions the media manager can filter on. Not a list of what may be + * uploaded — that is `media.accept` — but the vocabulary a field or filter + * uses to narrow what is shown. + */ +export const MEDIA_EXTENSIONS = [ + 'jpg', + 'jpeg', + 'png', + 'gif', + 'webp', + 'svg', + 'avif', + 'ico', + 'mp4', + 'webm', + 'mov', + 'mp3', + 'wav', + 'ogg', + 'pdf', + 'json', + 'csv', + 'txt', +] as const; + +export type MediaExtension = (typeof MEDIA_EXTENSIONS)[number]; + +export const MEDIA_CATEGORIES = { + image: ['jpg', 'jpeg', 'png', 'gif', 'webp', 'svg', 'avif', 'ico'], + video: ['mp4', 'webm', 'mov'], + audio: ['mp3', 'wav', 'ogg'], + document: ['pdf', 'json', 'csv', 'txt'], +} as const satisfies Record; + +export type MediaCategory = keyof typeof MEDIA_CATEGORIES; + +export type MediaAccept = MediaExtension | MediaCategory; + +/** + * Flattens a field's `accept` into concrete extensions, expanding any + * category shorthand. Unknown values are dropped rather than passed through, + * so a typo narrows to nothing visible instead of silently disabling the + * filter. + */ +export const resolveMediaAccept = ( + accept: MediaAccept | MediaAccept[] | undefined +): MediaExtension[] => { + if (!accept) return []; + const requested = Array.isArray(accept) ? accept : [accept]; + const resolved = new Set(); + for (const entry of requested) { + if (entry in MEDIA_CATEGORIES) { + for (const ext of MEDIA_CATEGORIES[entry as MediaCategory]) { + resolved.add(ext); + } + } else if ((MEDIA_EXTENSIONS as readonly string[]).includes(entry)) { + resolved.add(entry as MediaExtension); + } + } + return [...resolved]; +}; + export type ImageField = ( | FieldGeneric | FieldGeneric @@ -288,6 +351,19 @@ export type ImageField = ( * ``` */ uploadDir?: (formValues: Record) => string; + /** + * Restricts this field to specific file types. Narrows what the media + * manager offers when browsing and what the field will accept on upload, + * overriding the global `media.accept`. + * + * @example + * ```ts + * accept: 'pdf' + * accept: ['png', 'svg'] + * accept: 'image' + * ``` + */ + accept?: MediaAccept | MediaAccept[]; }; type ReferenceFieldOptions = { From 780ab543f268d75913b5656aa0811bf6743ab00a Mon Sep 17 00:00:00 2001 From: Josh Berman Date: Fri, 21 Aug 2026 16:53:57 +1000 Subject: [PATCH 2/4] media contract --- packages/tinacms/src/toolkit/core/media.ts | 22 ++++++++++++++++++++++ 1 file changed, 22 insertions(+) diff --git a/packages/tinacms/src/toolkit/core/media.ts b/packages/tinacms/src/toolkit/core/media.ts index e118fdb1be..11a2917141 100644 --- a/packages/tinacms/src/toolkit/core/media.ts +++ b/packages/tinacms/src/toolkit/core/media.ts @@ -1,3 +1,7 @@ +import type { + MediaAccept, + MediaExtension, +} from '@tinacms/schema-tools'; import { EventBus } from './event'; import { DummyMediaStore } from './media-store.default'; @@ -119,6 +123,15 @@ export interface MediaStore { */ searchable?: boolean; + /** + * Indicates that `list` honours the `ext` option. Stores that ignore it + * should leave this unset, so a field declaring `accept` falls back to + * showing everything rather than silently listing the wrong files. + * + * @default false + */ + extensionFilterable?: boolean; + /** * Converts a Media object to the value stored in a form field. * @@ -139,6 +152,11 @@ export interface MediaListOptions { thumbnailSizes?: { w: number; h: number }[]; filesOnly?: boolean; search?: string; + /** + * Restricts the listing to these file extensions. Resolved from categories + * before it reaches the store, so it is always concrete extensions here. + */ + ext?: MediaExtension[]; } /** @@ -296,6 +314,10 @@ export interface SelectMediaOptions { allowDelete?: boolean; directory?: string; onSelect?(media: Media): void; + /** + * Narrows the picker to these file types, from a field's `accept`. + */ + accept?: MediaAccept | MediaAccept[]; } interface MediaListErrorConfig { From 57ea7f238786aea2f5bcf61b16fd971b56542150 Mon Sep 17 00:00:00 2001 From: Josh Berman Date: Fri, 21 Aug 2026 16:59:05 +1000 Subject: [PATCH 3/4] media manager accept --- .../src/types/media-accept.test.ts | 25 +++++++++++++++ .../components/media/media-manager.tsx | 32 +++++++++++++++++-- .../src/toolkit/core/media-store.default.ts | 12 ++++++- 3 files changed, 66 insertions(+), 3 deletions(-) create mode 100644 packages/@tinacms/schema-tools/src/types/media-accept.test.ts diff --git a/packages/@tinacms/schema-tools/src/types/media-accept.test.ts b/packages/@tinacms/schema-tools/src/types/media-accept.test.ts new file mode 100644 index 0000000000..10292601f0 --- /dev/null +++ b/packages/@tinacms/schema-tools/src/types/media-accept.test.ts @@ -0,0 +1,25 @@ +import { resolveMediaAccept } from './index'; + +describe('resolveMediaAccept', () => { + it('treats undefined as no filter', () => { + expect(resolveMediaAccept(undefined)).toEqual([]); + }); + + it('resolves a single extension', () => { + expect(resolveMediaAccept('pdf')).toEqual(['pdf']); + }); + + it('expands a category into its extensions', () => { + expect(resolveMediaAccept('video')).toEqual(['mp4', 'webm', 'mov']); + }); + + it('dedupes when a category and one of its extensions overlap', () => { + expect( + resolveMediaAccept(['png', 'image']).filter((e) => e === 'png') + ).toHaveLength(1); + }); + + it('drops unknown values instead of disabling the filter', () => { + expect(resolveMediaAccept(['pgn' as never, 'png'])).toEqual(['png']); + }); +}); diff --git a/packages/tinacms/src/toolkit/components/media/media-manager.tsx b/packages/tinacms/src/toolkit/components/media/media-manager.tsx index 9851009bf9..5de8ed10e8 100644 --- a/packages/tinacms/src/toolkit/components/media/media-manager.tsx +++ b/packages/tinacms/src/toolkit/components/media/media-manager.tsx @@ -4,6 +4,10 @@ import { MediaListError, MediaListOffset, } from '@toolkit/core'; +import { + type MediaAccept, + resolveMediaAccept, +} from '@tinacms/schema-tools'; import { LoadingDots } from '@toolkit/form-builder'; import { CloseIcon, TrashIcon } from '@toolkit/icons'; import { FullscreenModal, Modal, ModalBody } from '@toolkit/react-modals'; @@ -94,6 +98,7 @@ export interface MediaRequest { onSelect?(_media: Media): void; close?(): void; allowDelete?: boolean; + accept?: MediaAccept | MediaAccept[]; } export function MediaManager() { @@ -145,6 +150,7 @@ export function MediaPicker({ allowDelete, onSelect, close, + accept: fieldAccept, ...props }: MediaRequest) { const cms = useCMS(); @@ -205,6 +211,17 @@ export function MediaPicker({ const listRequestRef = useRef(0); const newMediaSrcsRef = useRef>(new Set()); + // A store that ignores `ext` would return everything, so honour the field's + // `accept` only when the store says it filters. The dropzone still enforces + // it on upload either way. + const acceptedExtensions = React.useMemo( + () => + cms.media.store.extensionFilterable + ? resolveMediaAccept(fieldAccept) + : [], + [fieldAccept, cms.media.store.extensionFilterable] + ); + async function loadMedia(loadFolders = true) { const requestId = ++listRequestRef.current; setListState('loading'); @@ -220,6 +237,7 @@ export function MediaPicker({ ], filesOnly: !loadFolders, search: debouncedSearch || undefined, + ext: acceptedExtensions.length ? acceptedExtensions : undefined, }); if (requestId !== listRequestRef.current) return; setList((prev) => { @@ -290,7 +308,13 @@ export function MediaPicker({ resetList(); } ); - }, [offset, directory, debouncedSearch, cms.media.isConfigured]); + }, [ + offset, + directory, + debouncedSearch, + acceptedExtensions, + cms.media.isConfigured, + ]); const onClickMediaItem = (item: Media) => { if (!item) { @@ -339,11 +363,15 @@ export function MediaPicker({ } const [uploading, setUploading] = useState(false); - const accept = Array.isArray( + const globalAccept = Array.isArray( cms.api.tina.schema.schema?.config?.media?.accept ) ? cms.api.tina.schema.schema?.config?.media?.accept.join(',') : cms.api.tina.schema.schema?.config?.media?.accept; + const fieldExtensions = resolveMediaAccept(fieldAccept); + const accept = fieldExtensions.length + ? fieldExtensions.map((ext) => `.${ext}`).join(',') + : globalAccept; const { getRootProps, getInputProps, isDragActive } = useDropzone({ accept: dropzoneAcceptFromString( accept || cms.media.accept || DEFAULT_MEDIA_UPLOAD_TYPES diff --git a/packages/tinacms/src/toolkit/core/media-store.default.ts b/packages/tinacms/src/toolkit/core/media-store.default.ts index 4a92dc8f61..63981c6ad9 100644 --- a/packages/tinacms/src/toolkit/core/media-store.default.ts +++ b/packages/tinacms/src/toolkit/core/media-store.default.ts @@ -191,6 +191,10 @@ export class TinaMediaStore implements MediaStore { searchable = true; + // Only the v2 list endpoint filters by extension; the local branch below + // and `staticMedia` both ignore it. + extensionFilterable = true; + // allow up to 100MB uploads maxSize = 100 * 1024 * 1024; @@ -876,7 +880,13 @@ export class TinaMediaStore implements MediaStore { options.limit || 20 }${options.offset ? `&cursor=${options.offset}` : ''}${ encodedBranch ? `&branch=${encodedBranch}` : '' - }${options.search ? `&search=${encodeURIComponent(options.search)}` : ''}` + }${ + options.search ? `&search=${encodeURIComponent(options.search)}` : '' + }${ + options.ext?.length + ? `&ext=${encodeURIComponent(options.ext.join(','))}` + : '' + }` ); if (res.status == 401) { From a711b88eff1f0736f43b3e58c3b616aebb6e1b47 Mon Sep 17 00:00:00 2001 From: Josh Berman Date: Fri, 21 Aug 2026 16:59:14 +1000 Subject: [PATCH 4/4] image field plugin accpet --- .../tinacms/src/toolkit/fields/plugins/image-field-plugin.tsx | 3 +++ 1 file changed, 3 insertions(+) diff --git a/packages/tinacms/src/toolkit/fields/plugins/image-field-plugin.tsx b/packages/tinacms/src/toolkit/fields/plugins/image-field-plugin.tsx index 611d4cfe16..acb7e0a05c 100644 --- a/packages/tinacms/src/toolkit/fields/plugins/image-field-plugin.tsx +++ b/packages/tinacms/src/toolkit/fields/plugins/image-field-plugin.tsx @@ -2,6 +2,7 @@ import * as React from 'react'; import { wrapFieldsWithMeta } from './wrap-field-with-meta'; import { InputProps, ImageUpload } from '../components'; import { Media } from '@toolkit/core'; +import type { MediaAccept } from '@tinacms/schema-tools'; import { useCMS } from '@toolkit/react-core'; import { parse } from './text-format'; import { useState } from 'react'; @@ -11,6 +12,7 @@ interface ImageProps { path: string; uploadDir?(formValues: any): string; clearable?: boolean; + accept?: MediaAccept | MediaAccept[]; } export const ImageField = wrapFieldsWithMeta( @@ -56,6 +58,7 @@ export const ImageField = wrapFieldsWithMeta( allowDelete: true, directory, onSelect: onChange, + accept: props.field.accept, }); }} onDrop={async ([file]: File[], fileRejections) => {