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

View File

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

View File

@ -188,7 +188,8 @@ export default class API {
// doesn't.)
...(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 (
path: string,
@ -294,7 +295,7 @@ export default class API {
})),
])((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(
path,
depth,
@ -311,7 +312,7 @@ export default class API {
entries.push(...newEntries);
currentCursor = newCursor;
}
return this.processFiles(entries);
return this.processFiles(entries, folderSupport);
};
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) {
return [];
}
return this.api!.listAllFiles(mediaFolder, 1, this.branch).then(files =>
files.map(({ id, name, path }) => ({ id, name, path, displayURL: { id, path } })),
return this.api!.listAllFiles(mediaFolder, 1, this.branch, folderSupport).then(files =>
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);
}
getMedia(mediaFolder = this.mediaFolder) {
return this.backend!.getMedia(mediaFolder);
getMedia(mediaFolder = this.mediaFolder, folderSupport?: boolean) {
return this.backend!.getMedia(mediaFolder, folderSupport);
}
// this method memoizes this._getLargeMediaClient so that there can

View File

@ -323,6 +323,7 @@ export default class API {
async listFiles(
path: string,
{ repoURL = this.repoURL, branch = this.branch, depth = 1 } = {},
folderSupport?: boolean,
): Promise<{ type: string; id: string; name: string; path: string; size: number }[]> {
const folder = trim(path, '/');
try {
@ -336,10 +337,11 @@ export default class API {
);
return (
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' && decodeURIComponent(file.path).split('/').length <= depth,
(!folderSupport ? file.type === 'blob' : true) &&
decodeURIComponent(file.path).split('/').length <= depth,
)
.map(file => ({
type: file.type,

View File

@ -281,5 +281,49 @@ describe('gitea API', () => {
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: '' }));
}
async getMedia(mediaFolder = this.mediaFolder) {
async getMedia(mediaFolder = this.mediaFolder, folderSupport?: boolean) {
if (!mediaFolder) {
return [];
}
return this.api!.listFiles(mediaFolder).then(files =>
files.map(({ id, name, size, path }) => {
// load media using getMediaDisplayURL to avoid token expiration with Gitlab raw content urls
// for private repositories
return { id, name, size, displayURL: { id, path }, path };
return this.api!.listFiles(mediaFolder, undefined, folderSupport).then(files =>
files.map(({ id, name, size, path, type }) => {
return { id, name, size, displayURL: { id, path }, path, isDirectory: type === 'tree' };
}),
);
}

View File

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

View File

@ -314,5 +314,49 @@ describe('github API', () => {
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: '' }));
}
async getMedia(mediaFolder = this.mediaFolder) {
async getMedia(mediaFolder = this.mediaFolder, folderSupport?: boolean) {
if (!mediaFolder) {
return [];
}
return this.api!.listFiles(mediaFolder).then(files =>
files.map(({ id, name, size, path }) => {
return this.api!.listFiles(mediaFolder, undefined, folderSupport).then(files =>
files.map(({ id, name, size, path, type }) => {
// load media using getMediaDisplayURL to avoid token expiration with GitHub raw content urls
// 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 = [];
// eslint-disable-next-line prefer-const
let { cursor, entries: initialEntries } = await this.fetchCursorAndEntries({
@ -333,7 +338,7 @@ export default class API {
entries.push(...newEntries);
cursor = newCursor;
}
return entries.filter(({ type }) => type === 'blob');
return entries.filter(({ type }) => (!folderSupport ? type === 'blob' : true));
};
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)) {
const sourceDir = dirname(item.oldPath as string);
const destDir = dirname(item.path);
const children = await this.listAllFiles(sourceDir, true, branch);
const children = await this.listAllFiles(sourceDir, undefined, true, branch);
children
.filter(f => f.path !== item.oldPath)
.forEach(file => {

View File

@ -172,7 +172,7 @@ export default class GitLab implements BackendClass {
}
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));
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) {
return [];
}
return this.api!.listAllFiles(mediaFolder).then(files =>
files.map(({ id, name, path }) => {
return { id, name, path, displayURL: { id, name, path } };
return this.api!.listAllFiles(mediaFolder, folderSupport).then(files =>
files.map(({ id, name, path, type }) => {
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) {
const files: { path: string; url: string }[] = await this.request({
async getMedia(
mediaFolder = this.mediaFolder,
folderSupport?: boolean,
publicFolder = this.publicFolder,
) {
const files: { path: string; url: string; isDirectory: boolean }[] = await this.request({
action: 'getMedia',
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 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 { 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 isEmpty from 'lodash/isEmpty';
import React, { useCallback, useEffect, useRef, useState } from 'react';
import { translate } from 'react-polyglot';
import { dirname } from 'path';
import trim from 'lodash/trim';
import {
closeMediaLibrary,
@ -25,6 +30,9 @@ import EmptyMessage from './EmptyMessage';
import FileUploadButton from './FileUploadButton';
import MediaLibraryCardGrid from './MediaLibraryCardGrid';
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 { ChangeEvent, FC, KeyboardEvent } from 'react';
@ -51,6 +59,7 @@ interface MediaLibraryProps {
}
const MediaLibrary: FC<TranslatedProps<MediaLibraryProps>> = ({ canInsert = false, t }) => {
const [currentFolder, setCurrentFolder] = useState<string | undefined>(undefined);
const [selectedFile, setSelectedFile] = useState<MediaFile | null>(null);
const [query, setQuery] = useState<string | undefined>(undefined);
@ -74,17 +83,20 @@ const MediaLibrary: FC<TranslatedProps<MediaLibraryProps>> = ({ canInsert = fals
insertOptions,
} = useAppSelector(selectMediaLibraryState);
const config = useAppSelector(selectConfig);
const entry = useAppSelector(selectEditingDraft);
const [url, setUrl] = useState<string | string[] | undefined>(initialValue ?? '');
const [alt, setAlt] = useState<string | undefined>(initialAlt);
const [prevIsVisible, setPrevIsVisible] = useState(false);
const files = useMediaFiles(field);
useEffect(() => {
if (!prevIsVisible && isVisible) {
setSelectedFile(null);
setQuery('');
setCurrentFolder(undefined);
dispatch(loadMedia());
} else if (prevIsVisible && !isVisible) {
window.dispatchEvent(new MediaLibraryCloseEvent());
@ -93,6 +105,8 @@ const MediaLibrary: FC<TranslatedProps<MediaLibraryProps>> = ({ canInsert = fals
setPrevIsVisible(isVisible);
}, [isVisible, dispatch, prevIsVisible]);
const files = useMediaFiles(field, currentFolder);
const loadDisplayURL = useCallback(
(file: MediaFile) => {
dispatch(loadMediaDisplayURL(file));
@ -106,7 +120,7 @@ const MediaLibrary: FC<TranslatedProps<MediaLibraryProps>> = ({ canInsert = fals
const filterImages = useCallback((files: MediaFile[]) => {
return files.filter(file => {
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 tableData =
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();
return {
key,
@ -130,6 +144,7 @@ const MediaLibrary: FC<TranslatedProps<MediaLibraryProps>> = ({ canInsert = fals
draft,
isImage: IMAGE_EXTENSIONS.includes(ext),
isViewableImage: IMAGE_EXTENSIONS_VIEWABLE.includes(ext),
isDirectory,
};
});
@ -157,7 +172,7 @@ const MediaLibrary: FC<TranslatedProps<MediaLibraryProps>> = ({ canInsert = fals
*/
const handleAssetSelect = useCallback(
(asset: MediaFile) => {
if (!canInsert || selectedFile?.key === asset.key) {
if (!canInsert || selectedFile?.key === asset.key || asset.isDirectory) {
return;
}
@ -214,7 +229,7 @@ const MediaLibrary: FC<TranslatedProps<MediaLibraryProps>> = ({ canInsert = fals
},
});
} else {
await dispatch(persistMedia(file, { field }));
await dispatch(persistMedia(file, { field }, currentFolder));
setSelectedFile(files[0] as unknown as MediaFile);
@ -225,15 +240,15 @@ const MediaLibrary: FC<TranslatedProps<MediaLibraryProps>> = ({ canInsert = fals
event.target.value = '';
}
},
[mediaConfig.max_file_size, field, dispatch],
[mediaConfig.max_file_size, field, dispatch, currentFolder],
);
const handleURLChange = useCallback(
(url: string) => {
setUrl(url);
dispatch(insertMedia(url, field, alt));
dispatch(insertMedia(url, field, alt, currentFolder));
},
[alt, dispatch, field],
[alt, dispatch, field, currentFolder],
);
const handleAltChange = useCallback(
@ -243,11 +258,51 @@ const MediaLibrary: FC<TranslatedProps<MediaLibraryProps>> = ({ canInsert = fals
}
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
* editor field that launched the media library can retrieve it.
@ -259,12 +314,12 @@ const MediaLibrary: FC<TranslatedProps<MediaLibraryProps>> = ({ canInsert = fals
const { path } = selectedFile;
setUrl(path);
dispatch(insertMedia(path, field, alt));
dispatch(insertMedia(path, field, alt, currentFolder));
if (!insertOptions?.chooseUrl && !insertOptions?.showAlt) {
handleClose();
}
}, [selectedFile, dispatch, field, alt, insertOptions, handleClose]);
}, [selectedFile, dispatch, field, alt, insertOptions, handleClose, currentFolder]);
/**
* Removes the selected file from the backend.
@ -364,7 +419,7 @@ const MediaLibrary: FC<TranslatedProps<MediaLibraryProps>> = ({ canInsert = fals
onUrlChange={handleURLChange}
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">
<h2
className="
@ -382,6 +437,24 @@ const MediaLibrary: FC<TranslatedProps<MediaLibraryProps>> = ({ canInsert = fals
</div>
{t('app.header.media')}
</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
value={query}
onChange={handleSearchChange}
@ -391,6 +464,14 @@ const MediaLibrary: FC<TranslatedProps<MediaLibraryProps>> = ({ canInsert = fals
/>
</div>
<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} />
{canInsert ? (
<Button
@ -416,6 +497,8 @@ const MediaLibrary: FC<TranslatedProps<MediaLibraryProps>> = ({ canInsert = fals
onAssetSelect={handleAssetSelect}
canLoadMore={hasNextPage}
onLoadMore={handleLoadMore}
onDirectoryOpen={handleOpenDirectory}
currentFolder={currentFolder}
isPaginating={isPaginating}
paginatingMessage={t('mediaLibrary.mediaLibraryModal.loading')}
cardDraftText={t('mediaLibrary.mediaLibraryCard.draft')}

View File

@ -1,5 +1,6 @@
import { Delete as DeleteIcon } from '@styled-icons/material/Delete';
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 { translate } from 'react-polyglot';
@ -30,9 +31,12 @@ interface MediaLibraryCardProps<T extends MediaField, EF extends BaseField = Unk
type?: string;
isViewableImage: boolean;
isDraft?: boolean;
isDirectory?: boolean;
collection?: Collection<EF>;
field?: T;
currentFolder?: string;
onSelect: () => void;
onDirectoryOpen: () => void;
loadDisplayURL: () => void;
onDelete: () => void;
}
@ -45,15 +49,18 @@ const MediaLibraryCard = <T extends MediaField, EF extends BaseField = UnknownFi
type,
isViewableImage,
isDraft,
isDirectory,
collection,
field,
currentFolder,
onSelect,
onDirectoryOpen,
loadDisplayURL,
onDelete,
t,
}: TranslatedProps<MediaLibraryCardProps<T, EF>>) => {
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 url = displayURL.url;
@ -109,6 +116,7 @@ const MediaLibraryCard = <T extends MediaField, EF extends BaseField = UnknownFi
>
<div
onClick={onSelect}
onDoubleClick={isDirectory ? onDirectoryOpen : undefined}
data-testid={`media-card-${displayURL.url}`}
className="
w-media-card
@ -168,6 +176,7 @@ const MediaLibraryCard = <T extends MediaField, EF extends BaseField = UnknownFi
z-20
"
>
{!isDirectory ? (
<div
className="
absolute
@ -209,6 +218,7 @@ const MediaLibraryCard = <T extends MediaField, EF extends BaseField = UnknownFi
<DeleteIcon className="w-5 h-5" />
</Button>
</div>
) : null}
</div>
<div className="relative">
{isDraft ? (
@ -218,6 +228,25 @@ const MediaLibraryCard = <T extends MediaField, EF extends BaseField = UnknownFi
) : null}
{url && isViewableImage ? (
<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
data-testid="card-file-icon"

View File

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

View File

@ -490,7 +490,11 @@ export abstract class BackendClass {
abstract entriesByFiles(files: ImplementationFile[]): Promise<ImplementationEntry[]>;
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 persistEntry(entry: BackendEntry, opts: PersistOptions): Promise<void>;
@ -813,6 +817,7 @@ export interface Config<EF extends BaseField = UnknownField> {
local_backend?: boolean | LocalBackend;
editor?: EditorConfig;
search?: boolean;
media_library_folder_support?: boolean;
}
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>,
field: T,
entry: Entry,
currentFolder?: string,
): boolean {
const dispatch = useAppDispatch();
const [exists, setExists] = useState(false);
@ -29,14 +30,16 @@ export default function useIsMediaAsset<T extends MediaField, EF extends BaseFie
}
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(
Boolean(asset && asset !== emptyAsset && isNotEmpty(asset.toString()) && asset.fileObj),
);
};
checkMediaExistence();
}, [collection, dispatch, entry, field, debouncedUrl]);
}, [collection, dispatch, entry, field, debouncedUrl, currentFolder]);
return exists;
}

View File

@ -18,6 +18,7 @@ export default function useMediaAsset<T extends MediaField, EF extends BaseField
collection?: Collection<EF>,
field?: T,
entry?: Entry,
currentFolder?: string,
): string {
const isAbsolute = useMemo(
() => (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 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) {
setAssetSource(asset?.toString() ?? '');
}

View File

@ -1,5 +1,6 @@
import { useEffect, useMemo, useState } from 'react';
import { dirname } from 'path';
import trim from 'lodash/trim';
import { selectMediaLibraryFiles } from '@staticcms/core/reducers/selectors/mediaLibrary';
import { selectEditingDraft } from '@staticcms/core/reducers/selectors/entryDraft';
@ -24,7 +25,7 @@ export default function useMediaFiles(field?: MediaField, currentFolder?: string
const collection = useAppSelector(collectionSelector);
useEffect(() => {
if (!currentFolder || !config) {
if (!currentFolder || !config || !entry) {
setCurrentFolderMediaFiles(null);
return;
}
@ -33,7 +34,13 @@ export default function useMediaFiles(field?: MediaField, currentFolder?: string
const getMediaFiles = async () => {
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) {
setCurrentFolderMediaFiles(files);
@ -45,23 +52,29 @@ export default function useMediaFiles(field?: MediaField, currentFolder?: string
return () => {
alive = false;
};
}, [currentFolder, config]);
}, [currentFolder, config, entry]);
return useMemo(() => {
if (currentFolderMediaFiles) {
return currentFolderMediaFiles;
}
if (entry) {
const entryFiles = entry.mediaFiles ?? [];
if (config) {
const mediaFolder = selectMediaFolder(config, collection, entry, field);
return entryFiles
.filter(f => dirname(f.path) === mediaFolder)
const mediaFolder = selectMediaFolder(config, collection, entry, field, currentFolder);
const entryFolderFiles = entryFiles
.filter(f => {
return dirname(f.path) === mediaFolder;
})
.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 ?? [];
}, [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,
entryMap: Entry | null | undefined,
field: MediaField | undefined,
currentFolder?: string,
) {
const name = 'media_folder';
let mediaFolder = config[name];
let mediaFolder = config['media_folder'] ?? '';
if (hasCustomFolder(name, collection, entryMap?.slug, field)) {
const folder = evaluateFolder(name, config, collection!, entryMap, field);
if (currentFolder) {
mediaFolder = currentFolder;
} else if (hasCustomFolder('media_folder', collection, entryMap?.slug, field)) {
const folder = evaluateFolder('media_folder', config, collection!, entryMap, field);
if (folder.startsWith('/')) {
// return absolute paths as is
mediaFolder = join(folder);
} else {
const entryPath = entryMap?.path;
@ -260,25 +261,35 @@ export function selectMediaFilePublicPath<EF extends BaseField>(
mediaPath: string,
entryMap: Entry | undefined,
field: Field<EF> | undefined,
currentFolder?: string,
) {
if (isAbsolutePath(mediaPath)) {
return mediaPath;
}
const name = 'public_folder';
let publicFolder = config[name]!;
let publicFolder = config['public_folder']!;
let selectedPublicFolder = publicFolder;
const customFolder = hasCustomFolder(name, collection, entryMap?.slug, field);
const customPublicFolder = hasCustomFolder('public_folder', collection, entryMap?.slug, field);
if (customFolder) {
publicFolder = evaluateFolder(name, config, collection!, entryMap, field);
if (customPublicFolder) {
publicFolder = evaluateFolder('public_folder', config, collection!, entryMap, field);
selectedPublicFolder = publicFolder;
}
if (isAbsolutePath(publicFolder)) {
return joinUrlPath(publicFolder, basename(mediaPath));
if (currentFolder) {
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(
@ -287,12 +298,34 @@ export function selectMediaFilePath(
entryMap: Entry | null | undefined,
mediaPath: string,
field: Field | undefined,
currentFolder?: string,
) {
if (isAbsolutePath(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));
}

View File

@ -248,6 +248,12 @@ const en: LocalePhrasesRoot = {
deleteSelected: 'Delete 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: {
common: {