feat: folder support in media library (#687)

This commit is contained in:
Denys Konovalov 2023-04-11 20:51:40 +02:00 committed by GitHub
parent 49507d0b17
commit e6d3c1535a
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
24 changed files with 426 additions and 111 deletions

View File

@ -77,6 +77,7 @@ export function getAsset<T extends MediaField, EF extends BaseField = UnknownFie
entry: Entry | null | undefined, entry: Entry | null | undefined,
path: string, path: string,
field?: T, field?: T,
currentFolder?: string,
) { ) {
return ( return (
dispatch: ThunkDispatch<RootState, {}, AnyAction>, dispatch: ThunkDispatch<RootState, {}, AnyAction>,
@ -93,6 +94,7 @@ export function getAsset<T extends MediaField, EF extends BaseField = UnknownFie
entry, entry,
path, path,
field as Field, field as Field,
currentFolder,
); );
const { asset, isLoading } = state.medias[resolvedPath] || {}; const { asset, isLoading } = state.medias[resolvedPath] || {};

View File

@ -139,7 +139,12 @@ export function closeMediaLibrary() {
}; };
} }
export function insertMedia(mediaPath: string | string[], field: Field | undefined, alt?: string) { export function insertMedia(
mediaPath: string | string[],
field: Field | undefined,
alt?: string,
currentFolder?: string,
) {
return (dispatch: ThunkDispatch<RootState, {}, AnyAction>, getState: () => RootState) => { return (dispatch: ThunkDispatch<RootState, {}, AnyAction>, getState: () => RootState) => {
const state = getState(); const state = getState();
const config = state.config.config; const config = state.config.config;
@ -152,10 +157,17 @@ export function insertMedia(mediaPath: string | string[], field: Field | undefin
const collection = state.collections[collectionName]; const collection = state.collections[collectionName];
if (Array.isArray(mediaPath)) { if (Array.isArray(mediaPath)) {
mediaPath = mediaPath.map(path => mediaPath = mediaPath.map(path =>
selectMediaFilePublicPath(config, collection, path, entry, field), selectMediaFilePublicPath(config, collection, path, entry, field, currentFolder),
); );
} else { } else {
mediaPath = selectMediaFilePublicPath(config, collection, mediaPath as string, entry, field); mediaPath = selectMediaFilePublicPath(
config,
collection,
mediaPath as string,
entry,
field,
currentFolder,
);
} }
dispatch(mediaInserted(mediaPath, alt)); dispatch(mediaInserted(mediaPath, alt));
}; };
@ -165,8 +177,10 @@ export function removeInsertedMedia(controlID: string) {
return { type: MEDIA_REMOVE_INSERTED, payload: { controlID } } as const; return { type: MEDIA_REMOVE_INSERTED, payload: { controlID } } as const;
} }
export function loadMedia(opts: { delay?: number; query?: string; page?: number } = {}) { export function loadMedia(
const { delay = 0, page = 1 } = opts; opts: { delay?: number; query?: string; page?: number; currentFolder?: string } = {},
) {
const { delay = 0, page = 1, currentFolder } = opts;
return async (dispatch: ThunkDispatch<RootState, {}, AnyAction>, getState: () => RootState) => { return async (dispatch: ThunkDispatch<RootState, {}, AnyAction>, getState: () => RootState) => {
const state = getState(); const state = getState();
const config = state.config.config; const config = state.config.config;
@ -179,7 +193,7 @@ export function loadMedia(opts: { delay?: number; query?: string; page?: number
function loadFunction() { function loadFunction() {
return backend return backend
.getMedia() .getMedia(currentFolder, config?.media_library_folder_support ?? false)
.then(files => dispatch(mediaLoaded(files))) .then(files => dispatch(mediaLoaded(files)))
.catch((error: { status?: number }) => { .catch((error: { status?: number }) => {
console.error(error); console.error(error);
@ -227,7 +241,7 @@ function createMediaFileFromAsset({
return mediaFile; return mediaFile;
} }
export function persistMedia(file: File, opts: MediaOptions = {}) { export function persistMedia(file: File, opts: MediaOptions = {}, currentFolder?: string) {
const { field } = opts; const { field } = opts;
return async (dispatch: ThunkDispatch<RootState, {}, AnyAction>, getState: () => RootState) => { return async (dispatch: ThunkDispatch<RootState, {}, AnyAction>, getState: () => RootState) => {
const state = getState(); const state = getState();
@ -273,7 +287,7 @@ export function persistMedia(file: File, opts: MediaOptions = {}) {
try { try {
const entry = state.entryDraft.entry; const entry = state.entryDraft.entry;
const collection = entry?.collection ? state.collections[entry.collection] : null; const collection = entry?.collection ? state.collections[entry.collection] : null;
const path = selectMediaFilePath(config, collection, entry, fileName, field); const path = selectMediaFilePath(config, collection, entry, fileName, field, currentFolder);
const assetProxy = createAssetProxy({ const assetProxy = createAssetProxy({
file, file,
path, path,

View File

@ -251,6 +251,7 @@ export interface MediaFile {
queryOrder?: unknown; queryOrder?: unknown;
isViewableImage?: boolean; isViewableImage?: boolean;
type?: string; type?: string;
isDirectory?: boolean;
} }
interface BackupEntry { interface BackupEntry {
@ -749,8 +750,8 @@ export class Backend<EF extends BaseField = UnknownField, BC extends BackendClas
return entryValue; return entryValue;
} }
getMedia(folder?: string | undefined, mediaPath?: string | undefined) { getMedia(folder?: string | undefined, folderSupport?: boolean, mediaPath?: string | undefined) {
return this.implementation.getMedia(folder, mediaPath); return this.implementation.getMedia(folder, folderSupport, mediaPath);
} }
getMediaFile(path: string) { getMediaFile(path: string) {
@ -804,7 +805,11 @@ export class Backend<EF extends BaseField = UnknownField, BC extends BackendClas
entry, entry,
undefined, undefined,
); );
return this.implementation.getMedia(folder, mediaPath); return this.implementation.getMedia(
folder,
configState.config?.media_library_folder_support ?? false,
mediaPath,
);
}), }),
); );
entry.mediaFiles = entry.mediaFiles.concat(...files); entry.mediaFiles = entry.mediaFiles.concat(...files);

View File

@ -188,7 +188,8 @@ export default class API {
// doesn't.) // doesn't.)
...(file.commit && file.commit.hash ? { id: this.getFileId(file.commit.hash, file.path) } : {}), ...(file.commit && file.commit.hash ? { id: this.getFileId(file.commit.hash, file.path) } : {}),
}); });
processFiles = (files: BitBucketFile[]) => files.filter(this.isFile).map(this.processFile); processFiles = (files: BitBucketFile[], folderSupport?: boolean) =>
files.filter(file => (!folderSupport ? this.isFile(file) : true)).map(this.processFile);
readFile = async ( readFile = async (
path: string, path: string,
@ -294,7 +295,7 @@ export default class API {
})), })),
])((cursor.data?.links as Record<string, unknown>)[action]); ])((cursor.data?.links as Record<string, unknown>)[action]);
listAllFiles = async (path: string, depth: number, branch: string) => { listAllFiles = async (path: string, depth: number, branch: string, folderSupport?: boolean) => {
const { cursor: initialCursor, entries: initialEntries } = await this.listFiles( const { cursor: initialCursor, entries: initialEntries } = await this.listFiles(
path, path,
depth, depth,
@ -311,7 +312,7 @@ export default class API {
entries.push(...newEntries); entries.push(...newEntries);
currentCursor = newCursor; currentCursor = newCursor;
} }
return this.processFiles(entries); return this.processFiles(entries, folderSupport);
}; };
async uploadFiles( async uploadFiles(

View File

@ -351,12 +351,18 @@ export default class BitbucketBackend implements BackendClass {
})); }));
} }
async getMedia(mediaFolder = this.mediaFolder) { async getMedia(mediaFolder = this.mediaFolder, folderSupport?: boolean) {
if (!mediaFolder) { if (!mediaFolder) {
return []; return [];
} }
return this.api!.listAllFiles(mediaFolder, 1, this.branch).then(files => return this.api!.listAllFiles(mediaFolder, 1, this.branch, folderSupport).then(files =>
files.map(({ id, name, path }) => ({ id, name, path, displayURL: { id, path } })), files.map(({ id, name, path, type }) => ({
id,
name,
path,
displayURL: { id, path },
isDirectory: type === 'commit_directory',
})),
); );
} }

View File

@ -391,8 +391,8 @@ export default class GitGateway implements BackendClass {
return client.enabled && client.matchPath(path); return client.enabled && client.matchPath(path);
} }
getMedia(mediaFolder = this.mediaFolder) { getMedia(mediaFolder = this.mediaFolder, folderSupport?: boolean) {
return this.backend!.getMedia(mediaFolder); return this.backend!.getMedia(mediaFolder, folderSupport);
} }
// this method memoizes this._getLargeMediaClient so that there can // this method memoizes this._getLargeMediaClient so that there can

View File

@ -323,6 +323,7 @@ export default class API {
async listFiles( async listFiles(
path: string, path: string,
{ repoURL = this.repoURL, branch = this.branch, depth = 1 } = {}, { repoURL = this.repoURL, branch = this.branch, depth = 1 } = {},
folderSupport?: boolean,
): Promise<{ type: string; id: string; name: string; path: string; size: number }[]> { ): Promise<{ type: string; id: string; name: string; path: string; size: number }[]> {
const folder = trim(path, '/'); const folder = trim(path, '/');
try { try {
@ -336,10 +337,11 @@ export default class API {
); );
return ( return (
result.tree result.tree
// filter only files and up to the required depth // filter only files and/or folders up to the required depth
.filter( .filter(
file => file =>
file.type === 'blob' && decodeURIComponent(file.path).split('/').length <= depth, (!folderSupport ? file.type === 'blob' : true) &&
decodeURIComponent(file.path).split('/').length <= depth,
) )
.map(file => ({ .map(file => ({
type: file.type, type: file.type,

View File

@ -281,5 +281,49 @@ describe('gitea API', () => {
params: { recursive: 1 }, params: { recursive: 1 },
}); });
}); });
it('should get files and folders', async () => {
const api = new API({ branch: 'master', repo: 'owner/repo' });
const tree = [
{
path: 'image.png',
type: 'blob',
},
{
path: 'dir1',
type: 'tree',
},
{
path: 'dir1/nested-image.png',
type: 'blob',
},
{
path: 'dir1/dir2',
type: 'tree',
},
{
path: 'dir1/dir2/nested-image.png',
type: 'blob',
},
];
api.request = jest.fn().mockResolvedValue({ tree });
await expect(api.listFiles('media', {}, true)).resolves.toEqual([
{
path: 'media/image.png',
type: 'blob',
name: 'image.png',
},
{
path: 'media/dir1',
type: 'tree',
name: 'dir1',
},
]);
expect(api.request).toHaveBeenCalledTimes(1);
expect(api.request).toHaveBeenCalledWith('/repos/owner/repo/git/trees/master:media', {
params: {},
});
});
}); });
}); });

View File

@ -285,15 +285,13 @@ export default class Gitea implements BackendClass {
.catch(() => ({ file: { path, id: null }, data: '' })); .catch(() => ({ file: { path, id: null }, data: '' }));
} }
async getMedia(mediaFolder = this.mediaFolder) { async getMedia(mediaFolder = this.mediaFolder, folderSupport?: boolean) {
if (!mediaFolder) { if (!mediaFolder) {
return []; return [];
} }
return this.api!.listFiles(mediaFolder).then(files => return this.api!.listFiles(mediaFolder, undefined, folderSupport).then(files =>
files.map(({ id, name, size, path }) => { files.map(({ id, name, size, path, type }) => {
// load media using getMediaDisplayURL to avoid token expiration with Gitlab raw content urls return { id, name, size, displayURL: { id, path }, path, isDirectory: type === 'tree' };
// for private repositories
return { id, name, size, displayURL: { id, path }, path };
}), }),
); );
} }

View File

@ -338,6 +338,7 @@ export default class API {
async listFiles( async listFiles(
path: string, path: string,
{ repoURL = this.repoURL, branch = this.branch, depth = 1 } = {}, { repoURL = this.repoURL, branch = this.branch, depth = 1 } = {},
folderSupport?: boolean,
): Promise<{ type: string; id: string; name: string; path: string; size: number }[]> { ): Promise<{ type: string; id: string; name: string; path: string; size: number }[]> {
const folder = trim(path, '/'); const folder = trim(path, '/');
try { try {
@ -351,8 +352,12 @@ export default class API {
); );
return ( return (
result.tree result.tree
// filter only files and up to the required depth // filter only files and/or folders up to the required depth
.filter(file => file.type === 'blob' && file.path.split('/').length <= depth) .filter(
file =>
(!folderSupport ? file.type === 'blob' : true) &&
file.path.split('/').length <= depth,
)
.map(file => ({ .map(file => ({
type: file.type, type: file.type,
id: file.sha, id: file.sha,

View File

@ -314,5 +314,49 @@ describe('github API', () => {
params: { recursive: 1 }, params: { recursive: 1 },
}); });
}); });
it('should get files and folders', async () => {
const api = new API({ branch: 'master', repo: 'owner/repo' });
const tree = [
{
path: 'image.png',
type: 'blob',
},
{
path: 'dir1',
type: 'tree',
},
{
path: 'dir1/nested-image.png',
type: 'blob',
},
{
path: 'dir1/dir2',
type: 'tree',
},
{
path: 'dir1/dir2/nested-image.png',
type: 'blob',
},
];
api.request = jest.fn().mockResolvedValue({ tree });
await expect(api.listFiles('media', {}, true)).resolves.toEqual([
{
path: 'media/image.png',
type: 'blob',
name: 'image.png',
},
{
path: 'media/dir1',
type: 'tree',
name: 'dir1',
},
]);
expect(api.request).toHaveBeenCalledTimes(1);
expect(api.request).toHaveBeenCalledWith('/repos/owner/repo/git/trees/master:media', {
params: {},
});
});
}); });
}); });

View File

@ -314,15 +314,15 @@ export default class GitHub implements BackendClass {
.catch(() => ({ file: { path, id: null }, data: '' })); .catch(() => ({ file: { path, id: null }, data: '' }));
} }
async getMedia(mediaFolder = this.mediaFolder) { async getMedia(mediaFolder = this.mediaFolder, folderSupport?: boolean) {
if (!mediaFolder) { if (!mediaFolder) {
return []; return [];
} }
return this.api!.listFiles(mediaFolder).then(files => return this.api!.listFiles(mediaFolder, undefined, folderSupport).then(files =>
files.map(({ id, name, size, path }) => { files.map(({ id, name, size, path, type }) => {
// load media using getMediaDisplayURL to avoid token expiration with GitHub raw content urls // load media using getMediaDisplayURL to avoid token expiration with GitHub raw content urls
// for private repositories // for private repositories
return { id, name, size, displayURL: { id, path }, path }; return { id, name, size, displayURL: { id, path }, path, isDirectory: type == 'tree' };
}), }),
); );
} }

View File

@ -318,7 +318,12 @@ export default class API {
}; };
}; };
listAllFiles = async (path: string, recursive = false, branch = this.branch) => { listAllFiles = async (
path: string,
folderSupport?: boolean,
recursive = false,
branch = this.branch,
) => {
const entries = []; const entries = [];
// eslint-disable-next-line prefer-const // eslint-disable-next-line prefer-const
let { cursor, entries: initialEntries } = await this.fetchCursorAndEntries({ let { cursor, entries: initialEntries } = await this.fetchCursorAndEntries({
@ -333,7 +338,7 @@ export default class API {
entries.push(...newEntries); entries.push(...newEntries);
cursor = newCursor; cursor = newCursor;
} }
return entries.filter(({ type }) => type === 'blob'); return entries.filter(({ type }) => (!folderSupport ? type === 'blob' : true));
}; };
toBase64 = (str: string) => Promise.resolve(Base64.encode(str)); toBase64 = (str: string) => Promise.resolve(Base64.encode(str));
@ -421,7 +426,7 @@ export default class API {
for (const item of items.filter(i => i.oldPath && i.action === CommitAction.MOVE)) { for (const item of items.filter(i => i.oldPath && i.action === CommitAction.MOVE)) {
const sourceDir = dirname(item.oldPath as string); const sourceDir = dirname(item.oldPath as string);
const destDir = dirname(item.path); const destDir = dirname(item.path);
const children = await this.listAllFiles(sourceDir, true, branch); const children = await this.listAllFiles(sourceDir, undefined, true, branch);
children children
.filter(f => f.path !== item.oldPath) .filter(f => f.path !== item.oldPath)
.forEach(file => { .forEach(file => {

View File

@ -172,7 +172,7 @@ export default class GitLab implements BackendClass {
} }
async listAllFiles(folder: string, extension: string, depth: number) { async listAllFiles(folder: string, extension: string, depth: number) {
const files = await this.api!.listAllFiles(folder, depth > 1); const files = await this.api!.listAllFiles(folder, undefined, depth > 1);
const filtered = files.filter(file => this.filterFile(folder, file, extension, depth)); const filtered = files.filter(file => this.filterFile(folder, file, extension, depth));
return filtered; return filtered;
} }
@ -217,13 +217,13 @@ export default class GitLab implements BackendClass {
})); }));
} }
async getMedia(mediaFolder = this.mediaFolder) { async getMedia(mediaFolder = this.mediaFolder, folderSupport?: boolean) {
if (!mediaFolder) { if (!mediaFolder) {
return []; return [];
} }
return this.api!.listAllFiles(mediaFolder).then(files => return this.api!.listAllFiles(mediaFolder, folderSupport).then(files =>
files.map(({ id, name, path }) => { files.map(({ id, name, path, type }) => {
return { id, name, path, displayURL: { id, name, path } }; return { id, name, path, displayURL: { id, name, path }, isDirectory: type === 'tree' };
}), }),
); );
} }

View File

@ -146,17 +146,23 @@ export default class ProxyBackend implements BackendClass {
}); });
} }
async getMedia(mediaFolder = this.mediaFolder, publicFolder = this.publicFolder) { async getMedia(
const files: { path: string; url: string }[] = await this.request({ mediaFolder = this.mediaFolder,
folderSupport?: boolean,
publicFolder = this.publicFolder,
) {
const files: { path: string; url: string; isDirectory: boolean }[] = await this.request({
action: 'getMedia', action: 'getMedia',
params: { branch: this.branch, mediaFolder, publicFolder }, params: { branch: this.branch, mediaFolder, publicFolder },
}); });
return files.map(({ url, path }) => { const filteredFiles = folderSupport ? files : files.filter(f => !f.isDirectory);
return filteredFiles.map(({ url, path, isDirectory }) => {
const id = url; const id = url;
const name = basename(path); const name = basename(path);
return { id, name, displayURL: { id, path: url }, path }; return { id, name, displayURL: { id, path: url }, path, isDirectory };
}); });
} }

View File

@ -1,8 +1,13 @@
import { Photo as PhotoIcon } from '@styled-icons/material/Photo'; import { Photo as PhotoIcon } from '@styled-icons/material/Photo';
import { ArrowUpward as UpwardIcon } from '@styled-icons/material/ArrowUpward';
import { Home as HomeIcon } from '@styled-icons/material/Home';
import { CreateNewFolder as NewFolderIcon } from '@styled-icons/material/CreateNewFolder';
import fuzzy from 'fuzzy'; import fuzzy from 'fuzzy';
import isEmpty from 'lodash/isEmpty'; import isEmpty from 'lodash/isEmpty';
import React, { useCallback, useEffect, useRef, useState } from 'react'; import React, { useCallback, useEffect, useRef, useState } from 'react';
import { translate } from 'react-polyglot'; import { translate } from 'react-polyglot';
import { dirname } from 'path';
import trim from 'lodash/trim';
import { import {
closeMediaLibrary, closeMediaLibrary,
@ -25,6 +30,9 @@ import EmptyMessage from './EmptyMessage';
import FileUploadButton from './FileUploadButton'; import FileUploadButton from './FileUploadButton';
import MediaLibraryCardGrid from './MediaLibraryCardGrid'; import MediaLibraryCardGrid from './MediaLibraryCardGrid';
import MediaLibrarySearch from './MediaLibrarySearch'; import MediaLibrarySearch from './MediaLibrarySearch';
import { selectMediaFilePath, selectMediaFolder } from '@staticcms/core/lib/util/media.util';
import { selectConfig } from '@staticcms/core/reducers/selectors/config';
import { selectEditingDraft } from '@staticcms/core/reducers/selectors/entryDraft';
import type { MediaFile, TranslatedProps } from '@staticcms/core/interface'; import type { MediaFile, TranslatedProps } from '@staticcms/core/interface';
import type { ChangeEvent, FC, KeyboardEvent } from 'react'; import type { ChangeEvent, FC, KeyboardEvent } from 'react';
@ -51,6 +59,7 @@ interface MediaLibraryProps {
} }
const MediaLibrary: FC<TranslatedProps<MediaLibraryProps>> = ({ canInsert = false, t }) => { const MediaLibrary: FC<TranslatedProps<MediaLibraryProps>> = ({ canInsert = false, t }) => {
const [currentFolder, setCurrentFolder] = useState<string | undefined>(undefined);
const [selectedFile, setSelectedFile] = useState<MediaFile | null>(null); const [selectedFile, setSelectedFile] = useState<MediaFile | null>(null);
const [query, setQuery] = useState<string | undefined>(undefined); const [query, setQuery] = useState<string | undefined>(undefined);
@ -74,17 +83,20 @@ const MediaLibrary: FC<TranslatedProps<MediaLibraryProps>> = ({ canInsert = fals
insertOptions, insertOptions,
} = useAppSelector(selectMediaLibraryState); } = useAppSelector(selectMediaLibraryState);
const config = useAppSelector(selectConfig);
const entry = useAppSelector(selectEditingDraft);
const [url, setUrl] = useState<string | string[] | undefined>(initialValue ?? ''); const [url, setUrl] = useState<string | string[] | undefined>(initialValue ?? '');
const [alt, setAlt] = useState<string | undefined>(initialAlt); const [alt, setAlt] = useState<string | undefined>(initialAlt);
const [prevIsVisible, setPrevIsVisible] = useState(false); const [prevIsVisible, setPrevIsVisible] = useState(false);
const files = useMediaFiles(field);
useEffect(() => { useEffect(() => {
if (!prevIsVisible && isVisible) { if (!prevIsVisible && isVisible) {
setSelectedFile(null); setSelectedFile(null);
setQuery(''); setQuery('');
setCurrentFolder(undefined);
dispatch(loadMedia()); dispatch(loadMedia());
} else if (prevIsVisible && !isVisible) { } else if (prevIsVisible && !isVisible) {
window.dispatchEvent(new MediaLibraryCloseEvent()); window.dispatchEvent(new MediaLibraryCloseEvent());
@ -93,6 +105,8 @@ const MediaLibrary: FC<TranslatedProps<MediaLibraryProps>> = ({ canInsert = fals
setPrevIsVisible(isVisible); setPrevIsVisible(isVisible);
}, [isVisible, dispatch, prevIsVisible]); }, [isVisible, dispatch, prevIsVisible]);
const files = useMediaFiles(field, currentFolder);
const loadDisplayURL = useCallback( const loadDisplayURL = useCallback(
(file: MediaFile) => { (file: MediaFile) => {
dispatch(loadMediaDisplayURL(file)); dispatch(loadMediaDisplayURL(file));
@ -106,7 +120,7 @@ const MediaLibrary: FC<TranslatedProps<MediaLibraryProps>> = ({ canInsert = fals
const filterImages = useCallback((files: MediaFile[]) => { const filterImages = useCallback((files: MediaFile[]) => {
return files.filter(file => { return files.filter(file => {
const ext = fileExtension(file.name).toLowerCase(); const ext = fileExtension(file.name).toLowerCase();
return IMAGE_EXTENSIONS.includes(ext); return IMAGE_EXTENSIONS.includes(ext) || file.isDirectory;
}); });
}, []); }, []);
@ -116,7 +130,7 @@ const MediaLibrary: FC<TranslatedProps<MediaLibraryProps>> = ({ canInsert = fals
const toTableData = useCallback((files: MediaFile[]) => { const toTableData = useCallback((files: MediaFile[]) => {
const tableData = const tableData =
files && files &&
files.map(({ key, name, id, size, path, queryOrder, displayURL, draft }) => { files.map(({ key, name, id, size, path, queryOrder, displayURL, draft, isDirectory }) => {
const ext = fileExtension(name).toLowerCase(); const ext = fileExtension(name).toLowerCase();
return { return {
key, key,
@ -130,6 +144,7 @@ const MediaLibrary: FC<TranslatedProps<MediaLibraryProps>> = ({ canInsert = fals
draft, draft,
isImage: IMAGE_EXTENSIONS.includes(ext), isImage: IMAGE_EXTENSIONS.includes(ext),
isViewableImage: IMAGE_EXTENSIONS_VIEWABLE.includes(ext), isViewableImage: IMAGE_EXTENSIONS_VIEWABLE.includes(ext),
isDirectory,
}; };
}); });
@ -157,7 +172,7 @@ const MediaLibrary: FC<TranslatedProps<MediaLibraryProps>> = ({ canInsert = fals
*/ */
const handleAssetSelect = useCallback( const handleAssetSelect = useCallback(
(asset: MediaFile) => { (asset: MediaFile) => {
if (!canInsert || selectedFile?.key === asset.key) { if (!canInsert || selectedFile?.key === asset.key || asset.isDirectory) {
return; return;
} }
@ -214,7 +229,7 @@ const MediaLibrary: FC<TranslatedProps<MediaLibraryProps>> = ({ canInsert = fals
}, },
}); });
} else { } else {
await dispatch(persistMedia(file, { field })); await dispatch(persistMedia(file, { field }, currentFolder));
setSelectedFile(files[0] as unknown as MediaFile); setSelectedFile(files[0] as unknown as MediaFile);
@ -225,15 +240,15 @@ const MediaLibrary: FC<TranslatedProps<MediaLibraryProps>> = ({ canInsert = fals
event.target.value = ''; event.target.value = '';
} }
}, },
[mediaConfig.max_file_size, field, dispatch], [mediaConfig.max_file_size, field, dispatch, currentFolder],
); );
const handleURLChange = useCallback( const handleURLChange = useCallback(
(url: string) => { (url: string) => {
setUrl(url); setUrl(url);
dispatch(insertMedia(url, field, alt)); dispatch(insertMedia(url, field, alt, currentFolder));
}, },
[alt, dispatch, field], [alt, dispatch, field, currentFolder],
); );
const handleAltChange = useCallback( const handleAltChange = useCallback(
@ -243,11 +258,51 @@ const MediaLibrary: FC<TranslatedProps<MediaLibraryProps>> = ({ canInsert = fals
} }
setAlt(alt); setAlt(alt);
dispatch(insertMedia((url ?? selectedFile?.path) as string, field, alt)); dispatch(insertMedia((url ?? selectedFile?.path) as string, field, alt, currentFolder));
}, },
[dispatch, field, selectedFile?.path, url], [dispatch, field, selectedFile?.path, url, currentFolder],
); );
const handleOpenDirectory = useCallback(
(dir: string) => {
const newDirectory = selectMediaFilePath(
config!,
collection!,
entry,
dir,
field,
currentFolder,
);
setSelectedFile(null);
setQuery('');
setCurrentFolder(newDirectory);
dispatch(loadMedia({ currentFolder: newDirectory }));
},
[dispatch, currentFolder, collection, config, entry, field],
);
const handleGoBack = useCallback(
(toHome?: boolean) => {
setSelectedFile(null);
setQuery('');
let newDirectory: string | undefined;
if (toHome) {
setCurrentFolder(undefined);
} else {
const mediaFolder = trim(selectMediaFolder(config!, collection, entry, field), '/');
const dir = dirname(currentFolder!);
newDirectory = dir.includes(mediaFolder) && trim(dir, '/') != mediaFolder ? dir : undefined;
setCurrentFolder(newDirectory);
}
dispatch(loadMedia({ currentFolder: newDirectory }));
},
[dispatch, config, collection, entry, field, currentFolder],
);
const handleCreateFolder = useCallback(() => {
console.log('[createFolder]');
}, []);
/** /**
* Stores the public path of the file in the application store, where the * Stores the public path of the file in the application store, where the
* editor field that launched the media library can retrieve it. * editor field that launched the media library can retrieve it.
@ -259,12 +314,12 @@ const MediaLibrary: FC<TranslatedProps<MediaLibraryProps>> = ({ canInsert = fals
const { path } = selectedFile; const { path } = selectedFile;
setUrl(path); setUrl(path);
dispatch(insertMedia(path, field, alt)); dispatch(insertMedia(path, field, alt, currentFolder));
if (!insertOptions?.chooseUrl && !insertOptions?.showAlt) { if (!insertOptions?.chooseUrl && !insertOptions?.showAlt) {
handleClose(); handleClose();
} }
}, [selectedFile, dispatch, field, alt, insertOptions, handleClose]); }, [selectedFile, dispatch, field, alt, insertOptions, handleClose, currentFolder]);
/** /**
* Removes the selected file from the backend. * Removes the selected file from the backend.
@ -364,7 +419,7 @@ const MediaLibrary: FC<TranslatedProps<MediaLibraryProps>> = ({ canInsert = fals
onUrlChange={handleURLChange} onUrlChange={handleURLChange}
onAltChange={handleAltChange} onAltChange={handleAltChange}
/> />
<div className="flex items-center px-5 pt-4"> <div className="flex items-center px-5 pt-4 mb-4">
<div className="flex flex-grow gap-4 mr-8"> <div className="flex flex-grow gap-4 mr-8">
<h2 <h2
className=" className="
@ -382,6 +437,24 @@ const MediaLibrary: FC<TranslatedProps<MediaLibraryProps>> = ({ canInsert = fals
</div> </div>
{t('app.header.media')} {t('app.header.media')}
</h2> </h2>
{config?.media_library_folder_support ? (
<div className="flex gap-3 items-center">
<Button
onClick={() => handleGoBack(true)}
title={t('mediaLibrary.folderSupport.goBackToHome')}
disabled={!currentFolder}
>
<HomeIcon className="h-5 w-5" />
</Button>
<Button
onClick={() => handleGoBack()}
title={t('mediaLibrary.folderSupport.goBack')}
disabled={!currentFolder}
>
<UpwardIcon className="h-5 w-5" />
</Button>
</div>
) : null}
<MediaLibrarySearch <MediaLibrarySearch
value={query} value={query}
onChange={handleSearchChange} onChange={handleSearchChange}
@ -391,6 +464,14 @@ const MediaLibrary: FC<TranslatedProps<MediaLibraryProps>> = ({ canInsert = fals
/> />
</div> </div>
<div className="flex gap-3 items-center relative z-20"> <div className="flex gap-3 items-center relative z-20">
{config?.media_library_folder_support ? (
<Button
onClick={() => handleCreateFolder()}
title={t('mediaLibrary.folderSupport.onCreateTitle')}
>
<NewFolderIcon className="h-5 w-5"></NewFolderIcon>
</Button>
) : null}
<FileUploadButton imagesOnly={forImage} onChange={handlePersist} /> <FileUploadButton imagesOnly={forImage} onChange={handlePersist} />
{canInsert ? ( {canInsert ? (
<Button <Button
@ -416,6 +497,8 @@ const MediaLibrary: FC<TranslatedProps<MediaLibraryProps>> = ({ canInsert = fals
onAssetSelect={handleAssetSelect} onAssetSelect={handleAssetSelect}
canLoadMore={hasNextPage} canLoadMore={hasNextPage}
onLoadMore={handleLoadMore} onLoadMore={handleLoadMore}
onDirectoryOpen={handleOpenDirectory}
currentFolder={currentFolder}
isPaginating={isPaginating} isPaginating={isPaginating}
paginatingMessage={t('mediaLibrary.mediaLibraryModal.loading')} paginatingMessage={t('mediaLibrary.mediaLibraryModal.loading')}
cardDraftText={t('mediaLibrary.mediaLibraryCard.draft')} cardDraftText={t('mediaLibrary.mediaLibraryCard.draft')}

View File

@ -1,5 +1,6 @@
import { Delete as DeleteIcon } from '@styled-icons/material/Delete'; import { Delete as DeleteIcon } from '@styled-icons/material/Delete';
import { Download as DownloadIcon } from '@styled-icons/material/Download'; import { Download as DownloadIcon } from '@styled-icons/material/Download';
import { FolderOpen as FolderIcon } from '@styled-icons/material/FolderOpen';
import React, { useCallback, useEffect, useMemo } from 'react'; import React, { useCallback, useEffect, useMemo } from 'react';
import { translate } from 'react-polyglot'; import { translate } from 'react-polyglot';
@ -30,9 +31,12 @@ interface MediaLibraryCardProps<T extends MediaField, EF extends BaseField = Unk
type?: string; type?: string;
isViewableImage: boolean; isViewableImage: boolean;
isDraft?: boolean; isDraft?: boolean;
isDirectory?: boolean;
collection?: Collection<EF>; collection?: Collection<EF>;
field?: T; field?: T;
currentFolder?: string;
onSelect: () => void; onSelect: () => void;
onDirectoryOpen: () => void;
loadDisplayURL: () => void; loadDisplayURL: () => void;
onDelete: () => void; onDelete: () => void;
} }
@ -45,15 +49,18 @@ const MediaLibraryCard = <T extends MediaField, EF extends BaseField = UnknownFi
type, type,
isViewableImage, isViewableImage,
isDraft, isDraft,
isDirectory,
collection, collection,
field, field,
currentFolder,
onSelect, onSelect,
onDirectoryOpen,
loadDisplayURL, loadDisplayURL,
onDelete, onDelete,
t, t,
}: TranslatedProps<MediaLibraryCardProps<T, EF>>) => { }: TranslatedProps<MediaLibraryCardProps<T, EF>>) => {
const entry = useAppSelector(selectEditingDraft); const entry = useAppSelector(selectEditingDraft);
const url = useMediaAsset(displayURL.url, collection, field, entry); const url = useMediaAsset(displayURL.url, collection, field, entry, currentFolder);
const handleDownload = useCallback(() => { const handleDownload = useCallback(() => {
const url = displayURL.url; const url = displayURL.url;
@ -109,6 +116,7 @@ const MediaLibraryCard = <T extends MediaField, EF extends BaseField = UnknownFi
> >
<div <div
onClick={onSelect} onClick={onSelect}
onDoubleClick={isDirectory ? onDirectoryOpen : undefined}
data-testid={`media-card-${displayURL.url}`} data-testid={`media-card-${displayURL.url}`}
className=" className="
w-media-card w-media-card
@ -168,6 +176,7 @@ const MediaLibraryCard = <T extends MediaField, EF extends BaseField = UnknownFi
z-20 z-20
" "
> >
{!isDirectory ? (
<div <div
className=" className="
absolute absolute
@ -209,6 +218,7 @@ const MediaLibraryCard = <T extends MediaField, EF extends BaseField = UnknownFi
<DeleteIcon className="w-5 h-5" /> <DeleteIcon className="w-5 h-5" />
</Button> </Button>
</div> </div>
) : null}
</div> </div>
<div className="relative"> <div className="relative">
{isDraft ? ( {isDraft ? (
@ -218,6 +228,25 @@ const MediaLibraryCard = <T extends MediaField, EF extends BaseField = UnknownFi
) : null} ) : null}
{url && isViewableImage ? ( {url && isViewableImage ? (
<Image src={url} className="w-media-card h-media-card-image rounded-md" /> <Image src={url} className="w-media-card h-media-card-image rounded-md" />
) : isDirectory ? (
<div
data-testid="card-file-icon"
className="
w-media-card
h-media-card-image
bg-gray-500
dark:bg-slate-700
text-gray-200
dark:text-slate-400
font-bold
flex
items-center
justify-center
text-5xl
"
>
<FolderIcon className="w-24 h-24" />
</div>
) : ( ) : (
<div <div
data-testid="card-file-icon" data-testid="card-file-icon"

View File

@ -29,6 +29,7 @@ export interface MediaLibraryCardItem {
type: string; type: string;
draft: boolean; draft: boolean;
isViewableImage?: boolean; isViewableImage?: boolean;
isDirectory?: boolean;
url?: string; url?: string;
} }
@ -39,6 +40,8 @@ export interface MediaLibraryCardGridProps {
onAssetSelect: (asset: MediaFile) => void; onAssetSelect: (asset: MediaFile) => void;
canLoadMore?: boolean; canLoadMore?: boolean;
onLoadMore: () => void; onLoadMore: () => void;
onDirectoryOpen: (dir: string) => void;
currentFolder?: string;
isPaginating?: boolean; isPaginating?: boolean;
paginatingMessage?: string; paginatingMessage?: string;
cardDraftText: string; cardDraftText: string;
@ -62,6 +65,8 @@ const CardWrapper = ({
mediaItems, mediaItems,
isSelectedFile, isSelectedFile,
onAssetSelect, onAssetSelect,
onDirectoryOpen,
currentFolder,
cardDraftText, cardDraftText,
displayURLs, displayURLs,
loadDisplayURL, loadDisplayURL,
@ -111,12 +116,15 @@ const CardWrapper = ({
isSelected={isSelectedFile(file)} isSelected={isSelectedFile(file)}
text={file.name} text={file.name}
onSelect={() => onAssetSelect(file)} onSelect={() => onAssetSelect(file)}
onDirectoryOpen={() => onDirectoryOpen(file.path)}
currentFolder={currentFolder}
isDraft={file.draft} isDraft={file.draft}
draftText={cardDraftText} draftText={cardDraftText}
displayURL={displayURLs[file.id] ?? (file.url ? { url: file.url } : {})} displayURL={displayURLs[file.id] ?? (file.url ? { url: file.url } : {})}
loadDisplayURL={() => loadDisplayURL(file)} loadDisplayURL={() => loadDisplayURL(file)}
type={file.type} type={file.type}
isViewableImage={file.isViewableImage ?? false} isViewableImage={file.isViewableImage ?? false}
isDirectory={file.isDirectory ?? false}
collection={collection} collection={collection}
field={field} field={field}
onDelete={() => onDelete(file)} onDelete={() => onDelete(file)}

View File

@ -490,7 +490,11 @@ export abstract class BackendClass {
abstract entriesByFiles(files: ImplementationFile[]): Promise<ImplementationEntry[]>; abstract entriesByFiles(files: ImplementationFile[]): Promise<ImplementationEntry[]>;
abstract getMediaDisplayURL(displayURL: DisplayURL): Promise<string>; abstract getMediaDisplayURL(displayURL: DisplayURL): Promise<string>;
abstract getMedia(folder?: string, mediaPath?: string): Promise<ImplementationMediaFile[]>; abstract getMedia(
folder?: string,
folderSupport?: boolean,
mediaPath?: string,
): Promise<ImplementationMediaFile[]>;
abstract getMediaFile(path: string): Promise<ImplementationMediaFile>; abstract getMediaFile(path: string): Promise<ImplementationMediaFile>;
abstract persistEntry(entry: BackendEntry, opts: PersistOptions): Promise<void>; abstract persistEntry(entry: BackendEntry, opts: PersistOptions): Promise<void>;
@ -813,6 +817,7 @@ export interface Config<EF extends BaseField = UnknownField> {
local_backend?: boolean | LocalBackend; local_backend?: boolean | LocalBackend;
editor?: EditorConfig; editor?: EditorConfig;
search?: boolean; search?: boolean;
media_library_folder_support?: boolean;
} }
export interface InitOptions<EF extends BaseField = UnknownField> { export interface InitOptions<EF extends BaseField = UnknownField> {

View File

@ -18,6 +18,7 @@ export default function useIsMediaAsset<T extends MediaField, EF extends BaseFie
collection: Collection<EF>, collection: Collection<EF>,
field: T, field: T,
entry: Entry, entry: Entry,
currentFolder?: string,
): boolean { ): boolean {
const dispatch = useAppDispatch(); const dispatch = useAppDispatch();
const [exists, setExists] = useState(false); const [exists, setExists] = useState(false);
@ -29,14 +30,16 @@ export default function useIsMediaAsset<T extends MediaField, EF extends BaseFie
} }
const checkMediaExistence = async () => { const checkMediaExistence = async () => {
const asset = await dispatch(getAsset<T, EF>(collection, entry, debouncedUrl, field)); const asset = await dispatch(
getAsset<T, EF>(collection, entry, debouncedUrl, field, currentFolder),
);
setExists( setExists(
Boolean(asset && asset !== emptyAsset && isNotEmpty(asset.toString()) && asset.fileObj), Boolean(asset && asset !== emptyAsset && isNotEmpty(asset.toString()) && asset.fileObj),
); );
}; };
checkMediaExistence(); checkMediaExistence();
}, [collection, dispatch, entry, field, debouncedUrl]); }, [collection, dispatch, entry, field, debouncedUrl, currentFolder]);
return exists; return exists;
} }

View File

@ -18,6 +18,7 @@ export default function useMediaAsset<T extends MediaField, EF extends BaseField
collection?: Collection<EF>, collection?: Collection<EF>,
field?: T, field?: T,
entry?: Entry, entry?: Entry,
currentFolder?: string,
): string { ): string {
const isAbsolute = useMemo( const isAbsolute = useMemo(
() => (isNotEmpty(url) ? /^(?:[a-z+]+:)?\/\//g.test(url) : false), () => (isNotEmpty(url) ? /^(?:[a-z+]+:)?\/\//g.test(url) : false),
@ -34,7 +35,9 @@ export default function useMediaAsset<T extends MediaField, EF extends BaseField
} }
const fetchMedia = async () => { const fetchMedia = async () => {
const asset = await dispatch(getAsset<T, EF>(collection, entry, debouncedUrl, field)); const asset = await dispatch(
getAsset<T, EF>(collection, entry, debouncedUrl, field, currentFolder),
);
if (asset !== emptyAsset) { if (asset !== emptyAsset) {
setAssetSource(asset?.toString() ?? ''); setAssetSource(asset?.toString() ?? '');
} }

View File

@ -1,5 +1,6 @@
import { useEffect, useMemo, useState } from 'react'; import { useEffect, useMemo, useState } from 'react';
import { dirname } from 'path'; import { dirname } from 'path';
import trim from 'lodash/trim';
import { selectMediaLibraryFiles } from '@staticcms/core/reducers/selectors/mediaLibrary'; import { selectMediaLibraryFiles } from '@staticcms/core/reducers/selectors/mediaLibrary';
import { selectEditingDraft } from '@staticcms/core/reducers/selectors/entryDraft'; import { selectEditingDraft } from '@staticcms/core/reducers/selectors/entryDraft';
@ -24,7 +25,7 @@ export default function useMediaFiles(field?: MediaField, currentFolder?: string
const collection = useAppSelector(collectionSelector); const collection = useAppSelector(collectionSelector);
useEffect(() => { useEffect(() => {
if (!currentFolder || !config) { if (!currentFolder || !config || !entry) {
setCurrentFolderMediaFiles(null); setCurrentFolderMediaFiles(null);
return; return;
} }
@ -33,7 +34,13 @@ export default function useMediaFiles(field?: MediaField, currentFolder?: string
const getMediaFiles = async () => { const getMediaFiles = async () => {
const backend = currentBackend(config); const backend = currentBackend(config);
const files = await backend.getMedia(currentFolder); const files = await backend.getMedia(
currentFolder,
config.media_library_folder_support ?? false,
config.public_folder
? trim(currentFolder, '/').replace(trim(config.media_folder!), config.public_folder)
: currentFolder,
);
if (alive) { if (alive) {
setCurrentFolderMediaFiles(files); setCurrentFolderMediaFiles(files);
@ -45,23 +52,29 @@ export default function useMediaFiles(field?: MediaField, currentFolder?: string
return () => { return () => {
alive = false; alive = false;
}; };
}, [currentFolder, config]); }, [currentFolder, config, entry]);
return useMemo(() => { return useMemo(() => {
if (currentFolderMediaFiles) {
return currentFolderMediaFiles;
}
if (entry) { if (entry) {
const entryFiles = entry.mediaFiles ?? []; const entryFiles = entry.mediaFiles ?? [];
if (config) { if (config) {
const mediaFolder = selectMediaFolder(config, collection, entry, field); const mediaFolder = selectMediaFolder(config, collection, entry, field, currentFolder);
return entryFiles const entryFolderFiles = entryFiles
.filter(f => dirname(f.path) === mediaFolder) .filter(f => {
return dirname(f.path) === mediaFolder;
})
.map(file => ({ key: file.id, ...file })); .map(file => ({ key: file.id, ...file }));
if (currentFolderMediaFiles) {
if (entryFiles.length > 0) {
const draftFiles = entryFolderFiles.filter(file => file.draft == true);
currentFolderMediaFiles.unshift(...draftFiles);
}
return currentFolderMediaFiles.map(file => ({ key: file.id, ...file }));
}
return entryFolderFiles;
} }
} }
return mediaLibraryFiles ?? []; return mediaLibraryFiles ?? [];
}, [collection, config, currentFolderMediaFiles, entry, field, mediaLibraryFiles]); }, [collection, config, currentFolderMediaFiles, entry, field, mediaLibraryFiles, currentFolder]);
} }

View File

@ -234,14 +234,15 @@ export function selectMediaFolder<EF extends BaseField>(
collection: Collection<EF> | undefined | null, collection: Collection<EF> | undefined | null,
entryMap: Entry | null | undefined, entryMap: Entry | null | undefined,
field: MediaField | undefined, field: MediaField | undefined,
currentFolder?: string,
) { ) {
const name = 'media_folder'; let mediaFolder = config['media_folder'] ?? '';
let mediaFolder = config[name];
if (hasCustomFolder(name, collection, entryMap?.slug, field)) { if (currentFolder) {
const folder = evaluateFolder(name, config, collection!, entryMap, field); mediaFolder = currentFolder;
} else if (hasCustomFolder('media_folder', collection, entryMap?.slug, field)) {
const folder = evaluateFolder('media_folder', config, collection!, entryMap, field);
if (folder.startsWith('/')) { if (folder.startsWith('/')) {
// return absolute paths as is
mediaFolder = join(folder); mediaFolder = join(folder);
} else { } else {
const entryPath = entryMap?.path; const entryPath = entryMap?.path;
@ -260,25 +261,35 @@ export function selectMediaFilePublicPath<EF extends BaseField>(
mediaPath: string, mediaPath: string,
entryMap: Entry | undefined, entryMap: Entry | undefined,
field: Field<EF> | undefined, field: Field<EF> | undefined,
currentFolder?: string,
) { ) {
if (isAbsolutePath(mediaPath)) { if (isAbsolutePath(mediaPath)) {
return mediaPath; return mediaPath;
} }
const name = 'public_folder'; let publicFolder = config['public_folder']!;
let publicFolder = config[name]!; let selectedPublicFolder = publicFolder;
const customFolder = hasCustomFolder(name, collection, entryMap?.slug, field); const customPublicFolder = hasCustomFolder('public_folder', collection, entryMap?.slug, field);
if (customFolder) { if (customPublicFolder) {
publicFolder = evaluateFolder(name, config, collection!, entryMap, field); publicFolder = evaluateFolder('public_folder', config, collection!, entryMap, field);
selectedPublicFolder = publicFolder;
} }
if (isAbsolutePath(publicFolder)) { if (currentFolder) {
return joinUrlPath(publicFolder, basename(mediaPath)); const customMediaFolder = hasCustomFolder('media_folder', collection, entryMap?.slug, field);
const mediaFolder = customMediaFolder
? evaluateFolder('media_folder', config, collection!, entryMap, field)
: config['media_folder'];
selectedPublicFolder = trim(currentFolder, '/').replace(trim(mediaFolder!, '/'), publicFolder);
} }
return join(publicFolder, basename(mediaPath)); if (isAbsolutePath(selectedPublicFolder)) {
return joinUrlPath(selectedPublicFolder, basename(mediaPath));
}
return join(selectedPublicFolder, basename(mediaPath));
} }
export function selectMediaFilePath( export function selectMediaFilePath(
@ -287,12 +298,34 @@ export function selectMediaFilePath(
entryMap: Entry | null | undefined, entryMap: Entry | null | undefined,
mediaPath: string, mediaPath: string,
field: Field | undefined, field: Field | undefined,
currentFolder?: string,
) { ) {
if (isAbsolutePath(mediaPath)) { if (isAbsolutePath(mediaPath)) {
return mediaPath; return mediaPath;
} }
const mediaFolder = selectMediaFolder(config, collection, entryMap, field); let mediaFolder = selectMediaFolder(config, collection, entryMap, field, currentFolder);
if (!currentFolder) {
let publicFolder = trim(config['public_folder'] ?? mediaFolder, '/');
const mediaPathDir = trim(dirname(mediaPath), '/');
if (hasCustomFolder('public_folder', collection, entryMap?.slug, field)) {
publicFolder = trim(
evaluateFolder('public_folder', config, collection!, entryMap, field),
'/',
);
}
if (mediaPathDir.includes(publicFolder) && mediaPathDir != mediaFolder) {
mediaFolder = selectMediaFolder(
config,
collection,
entryMap,
field,
mediaPathDir.replace(publicFolder, mediaFolder),
);
}
}
return join(mediaFolder, basename(mediaPath)); return join(mediaFolder, basename(mediaPath));
} }

View File

@ -248,6 +248,12 @@ const en: LocalePhrasesRoot = {
deleteSelected: 'Delete selected', deleteSelected: 'Delete selected',
chooseSelected: 'Choose selected', chooseSelected: 'Choose selected',
}, },
folderSupport: {
onCreateTitle: 'Create new folder',
onCreateBody: 'Please enter a name for the new folder.',
goBackToHome: 'Go back to media folder.',
goBack: 'Go back to previous folder.',
},
}, },
ui: { ui: {
common: { common: {