From e6d3c1535acd55de494d5582c10e52827a0b641d Mon Sep 17 00:00:00 2001 From: Denys Konovalov Date: Tue, 11 Apr 2023 20:51:40 +0200 Subject: [PATCH] feat: folder support in media library (#687) --- packages/core/src/actions/media.ts | 2 + packages/core/src/actions/mediaLibrary.ts | 30 +++-- packages/core/src/backend.ts | 11 +- packages/core/src/backends/bitbucket/API.ts | 7 +- .../src/backends/bitbucket/implementation.ts | 12 +- .../backends/git-gateway/implementation.tsx | 4 +- packages/core/src/backends/gitea/API.ts | 6 +- .../src/backends/gitea/__tests__/API.spec.ts | 44 +++++++ .../src/backends/gitea/implementation.tsx | 10 +- packages/core/src/backends/github/API.ts | 9 +- .../src/backends/github/__tests__/API.spec.ts | 44 +++++++ .../src/backends/github/implementation.tsx | 8 +- packages/core/src/backends/gitlab/API.ts | 11 +- .../src/backends/gitlab/implementation.ts | 10 +- .../core/src/backends/proxy/implementation.ts | 14 ++- .../media-library/common/MediaLibrary.tsx | 111 +++++++++++++++--- .../media-library/common/MediaLibraryCard.tsx | 75 ++++++++---- .../common/MediaLibraryCardGrid.tsx | 8 ++ packages/core/src/interface.ts | 7 +- .../core/src/lib/hooks/useIsMediaAsset.ts | 7 +- packages/core/src/lib/hooks/useMediaAsset.ts | 5 +- packages/core/src/lib/hooks/useMediaFiles.ts | 35 ++++-- packages/core/src/lib/util/media.util.ts | 61 +++++++--- packages/core/src/locales/en/index.ts | 6 + 24 files changed, 426 insertions(+), 111 deletions(-) diff --git a/packages/core/src/actions/media.ts b/packages/core/src/actions/media.ts index 93d2011f..f19c52e2 100644 --- a/packages/core/src/actions/media.ts +++ b/packages/core/src/actions/media.ts @@ -77,6 +77,7 @@ export function getAsset, @@ -93,6 +94,7 @@ export function getAsset, 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, 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, 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, diff --git a/packages/core/src/backend.ts b/packages/core/src/backend.ts index eef2adcf..821f6383 100644 --- a/packages/core/src/backend.ts +++ b/packages/core/src/backend.ts @@ -251,6 +251,7 @@ export interface MediaFile { queryOrder?: unknown; isViewableImage?: boolean; type?: string; + isDirectory?: boolean; } interface BackupEntry { @@ -749,8 +750,8 @@ export class Backend 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)[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( diff --git a/packages/core/src/backends/bitbucket/implementation.ts b/packages/core/src/backends/bitbucket/implementation.ts index fe4887cf..3842770c 100644 --- a/packages/core/src/backends/bitbucket/implementation.ts +++ b/packages/core/src/backends/bitbucket/implementation.ts @@ -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', + })), ); } diff --git a/packages/core/src/backends/git-gateway/implementation.tsx b/packages/core/src/backends/git-gateway/implementation.tsx index de5e6ba8..5e5c080f 100644 --- a/packages/core/src/backends/git-gateway/implementation.tsx +++ b/packages/core/src/backends/git-gateway/implementation.tsx @@ -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 diff --git a/packages/core/src/backends/gitea/API.ts b/packages/core/src/backends/gitea/API.ts index 6f5c0ff5..d08a0e9b 100644 --- a/packages/core/src/backends/gitea/API.ts +++ b/packages/core/src/backends/gitea/API.ts @@ -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, diff --git a/packages/core/src/backends/gitea/__tests__/API.spec.ts b/packages/core/src/backends/gitea/__tests__/API.spec.ts index 1b89fc65..84257499 100644 --- a/packages/core/src/backends/gitea/__tests__/API.spec.ts +++ b/packages/core/src/backends/gitea/__tests__/API.spec.ts @@ -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: {}, + }); + }); }); }); diff --git a/packages/core/src/backends/gitea/implementation.tsx b/packages/core/src/backends/gitea/implementation.tsx index b32ea94f..8d6f06d3 100644 --- a/packages/core/src/backends/gitea/implementation.tsx +++ b/packages/core/src/backends/gitea/implementation.tsx @@ -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' }; }), ); } diff --git a/packages/core/src/backends/github/API.ts b/packages/core/src/backends/github/API.ts index e65dff57..2cc227d1 100644 --- a/packages/core/src/backends/github/API.ts +++ b/packages/core/src/backends/github/API.ts @@ -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, diff --git a/packages/core/src/backends/github/__tests__/API.spec.ts b/packages/core/src/backends/github/__tests__/API.spec.ts index f5a8cc29..982df0a9 100644 --- a/packages/core/src/backends/github/__tests__/API.spec.ts +++ b/packages/core/src/backends/github/__tests__/API.spec.ts @@ -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: {}, + }); + }); }); }); diff --git a/packages/core/src/backends/github/implementation.tsx b/packages/core/src/backends/github/implementation.tsx index 664ec66c..f41612a8 100644 --- a/packages/core/src/backends/github/implementation.tsx +++ b/packages/core/src/backends/github/implementation.tsx @@ -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' }; }), ); } diff --git a/packages/core/src/backends/gitlab/API.ts b/packages/core/src/backends/gitlab/API.ts index d3d98701..cd1216cf 100644 --- a/packages/core/src/backends/gitlab/API.ts +++ b/packages/core/src/backends/gitlab/API.ts @@ -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 => { diff --git a/packages/core/src/backends/gitlab/implementation.ts b/packages/core/src/backends/gitlab/implementation.ts index 0927b5e3..b26e846c 100644 --- a/packages/core/src/backends/gitlab/implementation.ts +++ b/packages/core/src/backends/gitlab/implementation.ts @@ -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' }; }), ); } diff --git a/packages/core/src/backends/proxy/implementation.ts b/packages/core/src/backends/proxy/implementation.ts index 63e93323..48eca2b6 100644 --- a/packages/core/src/backends/proxy/implementation.ts +++ b/packages/core/src/backends/proxy/implementation.ts @@ -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 }; }); } diff --git a/packages/core/src/components/media-library/common/MediaLibrary.tsx b/packages/core/src/components/media-library/common/MediaLibrary.tsx index 27acec49..fab8a4cc 100644 --- a/packages/core/src/components/media-library/common/MediaLibrary.tsx +++ b/packages/core/src/components/media-library/common/MediaLibrary.tsx @@ -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> = ({ canInsert = false, t }) => { + const [currentFolder, setCurrentFolder] = useState(undefined); const [selectedFile, setSelectedFile] = useState(null); const [query, setQuery] = useState(undefined); @@ -74,17 +83,20 @@ const MediaLibrary: FC> = ({ canInsert = fals insertOptions, } = useAppSelector(selectMediaLibraryState); + const config = useAppSelector(selectConfig); + const entry = useAppSelector(selectEditingDraft); + const [url, setUrl] = useState(initialValue ?? ''); + const [alt, setAlt] = useState(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> = ({ 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> = ({ 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> = ({ 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> = ({ canInsert = fals draft, isImage: IMAGE_EXTENSIONS.includes(ext), isViewableImage: IMAGE_EXTENSIONS_VIEWABLE.includes(ext), + isDirectory, }; }); @@ -157,7 +172,7 @@ const MediaLibrary: FC> = ({ 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> = ({ 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> = ({ 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> = ({ 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> = ({ 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> = ({ canInsert = fals onUrlChange={handleURLChange} onAltChange={handleAltChange} /> -
+

+ + +

+ ) : null} > = ({ canInsert = fals />
+ {config?.media_library_folder_support ? ( + + ) : null} {canInsert ? ( - + -
+ > + + +
+ ) : null}
{isDraft ? ( @@ -218,6 +228,25 @@ const MediaLibraryCard = + ) : isDirectory ? ( +
+ +
) : (
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)} diff --git a/packages/core/src/interface.ts b/packages/core/src/interface.ts index e2edcc1c..88d3f4f7 100644 --- a/packages/core/src/interface.ts +++ b/packages/core/src/interface.ts @@ -490,7 +490,11 @@ export abstract class BackendClass { abstract entriesByFiles(files: ImplementationFile[]): Promise; abstract getMediaDisplayURL(displayURL: DisplayURL): Promise; - abstract getMedia(folder?: string, mediaPath?: string): Promise; + abstract getMedia( + folder?: string, + folderSupport?: boolean, + mediaPath?: string, + ): Promise; abstract getMediaFile(path: string): Promise; abstract persistEntry(entry: BackendEntry, opts: PersistOptions): Promise; @@ -813,6 +817,7 @@ export interface Config { local_backend?: boolean | LocalBackend; editor?: EditorConfig; search?: boolean; + media_library_folder_support?: boolean; } export interface InitOptions { diff --git a/packages/core/src/lib/hooks/useIsMediaAsset.ts b/packages/core/src/lib/hooks/useIsMediaAsset.ts index cb757c06..66bc7986 100644 --- a/packages/core/src/lib/hooks/useIsMediaAsset.ts +++ b/packages/core/src/lib/hooks/useIsMediaAsset.ts @@ -18,6 +18,7 @@ export default function useIsMediaAsset, field: T, entry: Entry, + currentFolder?: string, ): boolean { const dispatch = useAppDispatch(); const [exists, setExists] = useState(false); @@ -29,14 +30,16 @@ export default function useIsMediaAsset { - const asset = await dispatch(getAsset(collection, entry, debouncedUrl, field)); + const asset = await dispatch( + getAsset(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; } diff --git a/packages/core/src/lib/hooks/useMediaAsset.ts b/packages/core/src/lib/hooks/useMediaAsset.ts index 8d31c3bb..c2e19c23 100644 --- a/packages/core/src/lib/hooks/useMediaAsset.ts +++ b/packages/core/src/lib/hooks/useMediaAsset.ts @@ -18,6 +18,7 @@ export default function useMediaAsset, 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 { - const asset = await dispatch(getAsset(collection, entry, debouncedUrl, field)); + const asset = await dispatch( + getAsset(collection, entry, debouncedUrl, field, currentFolder), + ); if (asset !== emptyAsset) { setAssetSource(asset?.toString() ?? ''); } diff --git a/packages/core/src/lib/hooks/useMediaFiles.ts b/packages/core/src/lib/hooks/useMediaFiles.ts index ecd58d20..f93eabfe 100644 --- a/packages/core/src/lib/hooks/useMediaFiles.ts +++ b/packages/core/src/lib/hooks/useMediaFiles.ts @@ -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]); } diff --git a/packages/core/src/lib/util/media.util.ts b/packages/core/src/lib/util/media.util.ts index 63ea258c..44840c03 100644 --- a/packages/core/src/lib/util/media.util.ts +++ b/packages/core/src/lib/util/media.util.ts @@ -234,14 +234,15 @@ export function selectMediaFolder( collection: Collection | 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( mediaPath: string, entryMap: Entry | undefined, field: Field | 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)); } diff --git a/packages/core/src/locales/en/index.ts b/packages/core/src/locales/en/index.ts index 59f24ef2..8867e2b6 100644 --- a/packages/core/src/locales/en/index.ts +++ b/packages/core/src/locales/en/index.ts @@ -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: {