Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
76 changes: 76 additions & 0 deletions packages/@tinacms/schema-tools/src/types/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<string, readonly MediaExtension[]>;

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<MediaExtension>();
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<string, undefined>
| FieldGeneric<string, true>
Expand All @@ -288,6 +351,19 @@ export type ImageField = (
* ```
*/
uploadDir?: (formValues: Record<string, any>) => 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 = {
Expand Down
25 changes: 25 additions & 0 deletions packages/@tinacms/schema-tools/src/types/media-accept.test.ts
Original file line number Diff line number Diff line change
@@ -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']);
});
});
32 changes: 30 additions & 2 deletions packages/tinacms/src/toolkit/components/media/media-manager.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -94,6 +98,7 @@ export interface MediaRequest {
onSelect?(_media: Media): void;
close?(): void;
allowDelete?: boolean;
accept?: MediaAccept | MediaAccept[];
}

export function MediaManager() {
Expand Down Expand Up @@ -145,6 +150,7 @@ export function MediaPicker({
allowDelete,
onSelect,
close,
accept: fieldAccept,
...props
}: MediaRequest) {
const cms = useCMS();
Expand Down Expand Up @@ -205,6 +211,17 @@ export function MediaPicker({
const listRequestRef = useRef(0);
const newMediaSrcsRef = useRef<Set<string>>(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');
Expand All @@ -220,6 +237,7 @@ export function MediaPicker({
],
filesOnly: !loadFolders,
search: debouncedSearch || undefined,
ext: acceptedExtensions.length ? acceptedExtensions : undefined,
});
if (requestId !== listRequestRef.current) return;
setList((prev) => {
Expand Down Expand Up @@ -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) {
Expand Down Expand Up @@ -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
Expand Down
12 changes: 11 additions & 1 deletion packages/tinacms/src/toolkit/core/media-store.default.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;

Expand Down Expand Up @@ -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) {
Expand Down
22 changes: 22 additions & 0 deletions packages/tinacms/src/toolkit/core/media.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,7 @@
import type {
MediaAccept,
MediaExtension,
} from '@tinacms/schema-tools';
import { EventBus } from './event';
import { DummyMediaStore } from './media-store.default';

Expand Down Expand Up @@ -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.
*
Expand All @@ -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[];
}

/**
Expand Down Expand Up @@ -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 {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand All @@ -11,6 +12,7 @@ interface ImageProps {
path: string;
uploadDir?(formValues: any): string;
clearable?: boolean;
accept?: MediaAccept | MediaAccept[];
}

export const ImageField = wrapFieldsWithMeta<InputProps, ImageProps>(
Expand Down Expand Up @@ -56,6 +58,7 @@ export const ImageField = wrapFieldsWithMeta<InputProps, ImageProps>(
allowDelete: true,
directory,
onSelect: onChange,
accept: props.field.accept,
});
}}
onDrop={async ([file]: File[], fileRejections) => {
Expand Down
Loading