diff --git a/packages/decap-cms-backend-github/src/API.ts b/packages/decap-cms-backend-github/src/API.ts index 244a3e228509..57d0a2115f9b 100644 --- a/packages/decap-cms-backend-github/src/API.ts +++ b/packages/decap-cms-backend-github/src/API.ts @@ -62,8 +62,13 @@ export interface Config { getUser: ({ token }: { token: string }) => Promise; } +export enum TreeFileType { + TREE = 'tree', + BLOB = 'blob', +} + interface TreeFile { - type: 'blob' | 'tree'; + type: TreeFileType; sha: string; path: string; raw?: string; @@ -382,7 +387,9 @@ export default class API { this.request(`${this.repoURL}/git/trees`, { method: 'POST', body: JSON.stringify({ - tree: [{ path: 'README.md', mode: '100644', type: 'blob', sha: item.sha }], + tree: [ + { path: 'README.md', mode: '100644', type: TreeFileType.BLOB, sha: item.sha }, + ], }), }), ) @@ -676,8 +683,8 @@ export default class API { async listFiles( path: string, - { repoURL = this.repoURL, branch = this.branch, depth = 1 } = {}, - ): Promise<{ type: string; id: string; name: string; path: string; size: number }[]> { + { repoURL = this.repoURL, branch = this.branch, depth = 1, types = [TreeFileType.BLOB] } = {}, + ): Promise<{ type: TreeFileType; id: string; name: string; path: string; size: number }[]> { const folder = trim(path, '/'); try { const result: Octokit.GitGetTreeResponse = await this.request( @@ -691,9 +698,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( + file => + types.includes(file.type as TreeFileType) && file.path.split('/').length <= depth, + ) .map(file => ({ - type: file.type, + type: file.type as TreeFileType, id: file.sha, name: basename(file.path), path: `${folder}/${file.path}`, @@ -1394,7 +1404,7 @@ export default class API { const entry = { path: trimStart(file.path, '/'), mode: '100644', - type: 'blob', + type: TreeFileType.BLOB, sha: file.sha, } as TreeEntry; @@ -1416,14 +1426,14 @@ export default class API { tree.push({ path: file.path, mode: '100644', - type: 'blob', + type: TreeFileType.BLOB, sha: null, }); // create in new path tree.push({ path: file.path.replace(sourceDir, destDir), mode: '100644', - type: 'blob', + type: TreeFileType.BLOB, sha: file.path === from ? sha : file.id, }); } diff --git a/packages/decap-cms-backend-github/src/__tests__/GraphQLAPI.spec.js b/packages/decap-cms-backend-github/src/__tests__/GraphQLAPI.spec.js index bb1e77ae1ac6..ae5cbdcc987f 100644 --- a/packages/decap-cms-backend-github/src/__tests__/GraphQLAPI.spec.js +++ b/packages/decap-cms-backend-github/src/__tests__/GraphQLAPI.spec.js @@ -41,7 +41,7 @@ describe('github GraphQL API', () => { ]; const path = 'posts'; - expect(api.getAllFiles(entries, path)).toEqual([ + expect(api.getAllFiles(entries, path, ['blob'])).toEqual([ { name: 'post-1.md', id: 'sha-1', @@ -65,5 +65,70 @@ describe('github GraphQL API', () => { }, ]); }); + + it('should should return directories when types includes `tree`', () => { + const api = new GraphQLAPI({ branch: 'gh-pages', repo: 'owner/my-repo' }); + const entries = [ + { + name: 'post-1.md', + sha: 'sha-1', + type: 'blob', + blob: { size: 1 }, + }, + { + name: 'post-2.md', + sha: 'sha-2', + type: 'blob', + blob: { size: 2 }, + }, + { + name: '2019', + sha: 'dir-sha', + type: 'tree', + object: { + entries: [ + { + name: 'nested-post.md', + sha: 'nested-post-sha', + type: 'blob', + blob: { size: 3 }, + }, + ], + }, + }, + ]; + const path = 'posts'; + + expect(api.getAllFiles(entries, path, ['blob', 'tree'])).toEqual([ + { + name: 'post-1.md', + id: 'sha-1', + type: 'blob', + size: 1, + path: 'posts/post-1.md', + }, + { + name: 'post-2.md', + id: 'sha-2', + type: 'blob', + size: 2, + path: 'posts/post-2.md', + }, + { + name: '2019', + id: 'dir-sha', + type: 'tree', + size: 0, + path: 'posts/2019', + }, + { + name: 'nested-post.md', + id: 'nested-post-sha', + type: 'blob', + size: 3, + path: 'posts/2019/nested-post.md', + }, + ]); + }); }); }); diff --git a/packages/decap-cms-core/src/actions/media.ts b/packages/decap-cms-core/src/actions/media.ts index 8cf7abab6be3..f1dfc5026b34 100644 --- a/packages/decap-cms-core/src/actions/media.ts +++ b/packages/decap-cms-core/src/actions/media.ts @@ -99,7 +99,6 @@ export function getAsset({ collection, entry, path, field }: GetAssetArgs) { const state = getState(); const resolvedPath = selectMediaFilePath(state.config, collection, entry, path, field); - let { asset, isLoading, error } = state.medias[resolvedPath] || {}; if (isLoading) { return emptyAsset; diff --git a/packages/decap-cms-core/src/actions/mediaLibrary.ts b/packages/decap-cms-core/src/actions/mediaLibrary.ts index 705825b6b8f2..3b4993b0b773 100644 --- a/packages/decap-cms-core/src/actions/mediaLibrary.ts +++ b/packages/decap-cms-core/src/actions/mediaLibrary.ts @@ -211,15 +211,24 @@ function createMediaFileFromAsset({ } export function persistMedia(file: File, opts: MediaOptions = {}) { - const { privateUpload, field } = opts; + const { privateUpload, field, currentMediaFolder } = opts; return async (dispatch: ThunkDispatch, getState: () => State) => { const state = getState(); const backend = currentBackend(state.config); const integration = selectIntegration(state, null, 'assetStore'); - const files: MediaFile[] = selectMediaFiles(state, field); + const files: MediaFile[] = selectMediaFiles(state); const fileName = sanitizeSlug(file.name.toLowerCase(), state.config.slug); - const existingFile = files.find(existingFile => existingFile.name.toLowerCase() === fileName); - + const entry = state.entryDraft.get('entry'); + const collection = state.collections.get(entry?.get('collection')); + const path = selectMediaFilePath( + state.config, + collection, + entry, + fileName, + field, + currentMediaFolder, + ); + const existingFile = files.find(existingFile => existingFile.path.toLowerCase() === path); const editingDraft = selectEditingDraft(state.entryDraft); /** @@ -265,7 +274,14 @@ export function persistMedia(file: File, opts: MediaOptions = {}) { } else { const entry = state.entryDraft.get('entry'); const collection = state.collections.get(entry?.get('collection')); - const path = selectMediaFilePath(state.config, collection, entry, fileName, field); + const path = selectMediaFilePath( + state.config, + collection, + entry, + fileName, + field, + currentMediaFolder, + ); assetProxy = createAssetProxy({ file, path, @@ -437,6 +453,7 @@ export function mediaLoading(page: number) { interface MediaOptions { privateUpload?: boolean; field?: EntryField; + currentMediaFolder?: string; page?: number; canPaginate?: boolean; dynamicSearch?: boolean; diff --git a/packages/decap-cms-core/src/backend.ts b/packages/decap-cms-core/src/backend.ts index bcb8881de045..d13026fc398b 100644 --- a/packages/decap-cms-core/src/backend.ts +++ b/packages/decap-cms-core/src/backend.ts @@ -261,6 +261,7 @@ export interface MediaFile { url?: string; file?: File; field?: EntryField; + isDirectory?: boolean; } interface BackupEntry { diff --git a/packages/decap-cms-core/src/components/MediaLibrary/MediaLibrary.js b/packages/decap-cms-core/src/components/MediaLibrary/MediaLibrary.js index 254886b5f554..93396eef8dd0 100644 --- a/packages/decap-cms-core/src/components/MediaLibrary/MediaLibrary.js +++ b/packages/decap-cms-core/src/components/MediaLibrary/MediaLibrary.js @@ -6,6 +6,7 @@ import { orderBy, map } from 'lodash'; import { translate } from 'react-polyglot'; import fuzzy from 'fuzzy'; import { fileExtension } from 'decap-cms-lib-util'; +import { dirname } from 'path'; import { loadMedia as loadMediaAction, @@ -15,7 +16,11 @@ import { loadMediaDisplayURL as loadMediaDisplayURLAction, closeMediaLibrary as closeMediaLibraryAction, } from '../../actions/mediaLibrary'; -import { selectMediaFiles } from '../../reducers/mediaLibrary'; +import { + selectMediaFiles, + getInitialMediaFolder, + getMediaFolderNavDisabled, +} from '../../reducers/mediaLibrary'; import MediaLibraryModal, { fileShape } from './MediaLibraryModal'; /** @@ -59,6 +64,7 @@ class MediaLibrary extends React.Component { deleteMedia: PropTypes.func.isRequired, insertMedia: PropTypes.func.isRequired, closeMediaLibrary: PropTypes.func.isRequired, + defaultMediaFolder: PropTypes.string.isRequired, t: PropTypes.func.isRequired, }; @@ -125,7 +131,7 @@ class MediaLibrary extends React.Component { filterImages = files => { return files.filter(file => { const ext = fileExtension(file.name).toLowerCase(); - return IMAGE_EXTENSIONS.includes(ext); + return IMAGE_EXTENSIONS.includes(ext) || file.isDirectory; }); }; @@ -135,22 +141,37 @@ class MediaLibrary extends React.Component { toTableData = files => { const tableData = files && - files.map(({ key, name, id, size, path, queryOrder, displayURL, draft }) => { - const ext = fileExtension(name).toLowerCase(); - return { + files.map( + ({ key, - id, name, - path, - type: ext.toUpperCase(), + id, size, + path, queryOrder, displayURL, draft, - isImage: IMAGE_EXTENSIONS.includes(ext), - isViewableImage: IMAGE_EXTENSIONS_VIEWABLE.includes(ext), - }; - }); + isDirectory, + hasChildren, + }) => { + const ext = fileExtension(name).toLowerCase(); + return { + key, + id, + name, + path, + type: ext.toUpperCase(), + size, + queryOrder, + displayURL, + draft, + isDirectory, + hasChildren, + isImage: IMAGE_EXTENSIONS.includes(ext), + isViewableImage: IMAGE_EXTENSIONS_VIEWABLE.includes(ext), + }; + }, + ); /** * Get the sort order for use with `lodash.orderBy`, and always add the @@ -163,17 +184,27 @@ class MediaLibrary extends React.Component { }; handleClose = () => { + this.setState({ currentMediaFolder: null }); this.props.closeMediaLibrary(); }; - /** - * Toggle asset selection on click. - */ - handleAssetClick = asset => { + updateSelectedFile = asset => { const selectedFile = this.state.selectedFile.key === asset.key ? {} : asset; this.setState({ selectedFile }); }; + handleAssetClick = asset => { + if (asset.isDirectory) { + this.setState({ currentMediaFolder: asset.path, selectedAssets: [] }); + } else { + this.updateSelectedFile(asset); + } + }; + + handleBreadcrumbClick = currentMediaFolder => { + this.setState({ currentMediaFolder, selectedAssets: [] }); + }; + /** * Upload a file. */ @@ -186,6 +217,8 @@ class MediaLibrary extends React.Component { event.persist(); event.stopPropagation(); event.preventDefault(); + const { initialMediaFolder } = this.props; + const currentMediaFolder = this.state.currentMediaFolder || initialMediaFolder; const { persistMedia, privateUpload, config, t, field } = this.props; const { files: fileList } = event.dataTransfer || event.target; const files = [...fileList]; @@ -199,7 +232,7 @@ class MediaLibrary extends React.Component { }), ); } else { - await persistMedia(file, { privateUpload, field }); + await persistMedia(file, { privateUpload, field, currentMediaFolder }); this.setState({ isPersisted: true }); @@ -236,6 +269,30 @@ class MediaLibrary extends React.Component { }); }; + handleCreateFolder = dirName => { + this.setState({ isLoading: true }); + function byteToHex(byte) { + return ('0' + byte.toString(16)).slice(-2); + } + function generateId(len = 40) { + var arr = new Uint8Array(len / 2); + window.crypto.getRandomValues(arr); + return Array.from(arr, byteToHex).join(''); + } + const { defaultMediaFolder } = this.props; + const currentMediaFolder = this.state.currentMediaFolder || defaultMediaFolder; + this.props.files.push({ + id: generateId(40), + hasChildren: false, + key: generateId(40), + name: dirName, + path: `${currentMediaFolder}/${dirName}`, + displayURL: { path: `${currentMediaFolder}/${dirName}` }, + isDirectory: true, + }); + this.setState({ isLoading: false }); + }; + /** * Downloads the selected file. */ @@ -330,14 +387,24 @@ class MediaLibrary extends React.Component { isPaginating, privateUpload, displayURLs, + defaultMediaFolder, + initialMediaFolder, + mediaFolderNavDisabled, t, } = this.props; + const currentMediaFolder = this.state.currentMediaFolder || initialMediaFolder; + const currentDirFiles = files.filter(file => dirname(file.path) === currentMediaFolder); + const currentDirFolders = (currentDirFiles || []).filter(file => file.isDirectory); + const currentDirFilesOrderedByTreeType = currentDirFolders.concat( + (currentDirFiles || []).filter(file => !file.isDirectory), + ); return ( (this.scrollContainerRef = ref)} handleAssetClick={this.handleAssetClick} + handleBreadcrumbClick={this.handleBreadcrumbClick} + currentMediaFolder={currentMediaFolder} + defaultMediaFolder={defaultMediaFolder} + mediaFolderNavDisabled={mediaFolderNavDisabled} handleLoadMore={this.handleLoadMore} displayURLs={displayURLs} loadDisplayURL={this.loadDisplayURL} @@ -371,12 +443,14 @@ class MediaLibrary extends React.Component { } function mapStateToProps(state) { - const { mediaLibrary } = state; + const { mediaLibrary, config } = state; const field = mediaLibrary.get('field'); const mediaLibraryProps = { isVisible: mediaLibrary.get('isVisible'), canInsert: mediaLibrary.get('canInsert'), files: selectMediaFiles(state, field), + initialMediaFolder: getInitialMediaFolder(state, field), + mediaFolderNavDisabled: getMediaFolderNavDisabled(state, field), displayURLs: mediaLibrary.get('displayURLs'), dynamicSearch: mediaLibrary.get('dynamicSearch'), dynamicSearchActive: mediaLibrary.get('dynamicSearchActive'), @@ -391,6 +465,7 @@ function mapStateToProps(state) { hasNextPage: mediaLibrary.get('hasNextPage'), isPaginating: mediaLibrary.get('isPaginating'), field, + defaultMediaFolder: config.media_folder, }; return { ...mediaLibraryProps }; } diff --git a/packages/decap-cms-core/src/components/MediaLibrary/MediaLibraryBreadCrumbs.js b/packages/decap-cms-core/src/components/MediaLibrary/MediaLibraryBreadCrumbs.js new file mode 100644 index 000000000000..7a9ce473ca9a --- /dev/null +++ b/packages/decap-cms-core/src/components/MediaLibrary/MediaLibraryBreadCrumbs.js @@ -0,0 +1,85 @@ +import React from 'react'; +import PropTypes from 'prop-types'; +import { trim } from 'lodash'; +import styled from '@emotion/styled'; +import { Icon } from 'decap-cms-ui-default'; + +const BreadCrumbsContainer = styled.div` + display: flex; + justify-content: flex-start; + margin: 20px 0; +`; +const BreadCrumbsItem = styled.div` + display: flex; +`; + +const BreadCrumbsItemLabel = styled.div` + display: flex; + justify-content: center; + align-items: center; + padding: 6px 8px; +`; + +const BreadCrumbsItemButton = styled.div` + display: flex; + justify-content: center; + align-items: center; + cursor: pointer; + padding: 6px 8px; + background: #eff0f4; + border-radius: 5px; +`; + +const BreadCrumbsItemDivider = styled.div` + display: flex; + justify-content: center; + align-items: center; +`; + +class MediaLibraryBreadcrumbs extends React.Component { + render() { + const { + handleBreadcrumbClick, + currentMediaFolder, + defaultMediaFolder, + mediaFolderNavDisabled, + } = this.props; + const hiddenPath = trim(defaultMediaFolder, '/').split('/').slice(0, -1).join('/'); + const currentMediaFolderParts = trim( + (currentMediaFolder || '').replace(hiddenPath, ''), + '/', + ).split('/'); + const breadcrumbsArray = currentMediaFolderParts.map((part, index) => { + return { + isDefaultMediaDirectory: index === 0, + path: `${hiddenPath}/${currentMediaFolderParts.slice(0, index + 1).join('/')}`, + label: part, + }; + }); + + this.BreadCrumbsContent = breadcrumbsArray.map((item, index) => { + return ( + + {mediaFolderNavDisabled ? ( + + {item.isDefaultMediaDirectory ? : item.label} + + ) : ( + handleBreadcrumbClick(item.path)}> + {item.isDefaultMediaDirectory ? : item.label} + + )} + / + + ); + }); + return {this.BreadCrumbsContent}; + } +} + +MediaLibraryBreadcrumbs.propTypes = { + handleBreadcrumbClick: PropTypes.func.isRequired, + currentMediaFolder: PropTypes.string, +}; + +export default MediaLibraryBreadcrumbs; diff --git a/packages/decap-cms-core/src/components/MediaLibrary/MediaLibraryButtons.js b/packages/decap-cms-core/src/components/MediaLibrary/MediaLibraryButtons.js index f3d7e726b335..91175b4381b3 100644 --- a/packages/decap-cms-core/src/components/MediaLibrary/MediaLibraryButtons.js +++ b/packages/decap-cms-core/src/components/MediaLibrary/MediaLibraryButtons.js @@ -60,6 +60,11 @@ export const InsertButton = styled.button` ${buttons.green}; `; +export const CreateFolderButton = styled.button` + ${styles.button}; + ${buttons.gray}; +`; + const ActionButton = styled.button` ${styles.button}; ${props => diff --git a/packages/decap-cms-core/src/components/MediaLibrary/MediaLibraryCardGrid.js b/packages/decap-cms-core/src/components/MediaLibrary/MediaLibraryCardGrid.js index 1fa7cb176c0c..a1684bab6baf 100644 --- a/packages/decap-cms-core/src/components/MediaLibrary/MediaLibraryCardGrid.js +++ b/packages/decap-cms-core/src/components/MediaLibrary/MediaLibraryCardGrid.js @@ -26,6 +26,7 @@ function CardWrapper(props) { loadDisplayURL, columnCount, gutter, + mediaFolderNavDisabled, }, } = props; const index = rowIndex * columnCount + columnIndex; @@ -60,6 +61,10 @@ function CardWrapper(props) { loadDisplayURL={() => loadDisplayURL(file)} type={file.type} isViewableImage={file.isViewableImage} + isDirectory={file.isDirectory} + hasChildren={file.hasChildren} + size={file.size} + mediaFolderNavDisabled={mediaFolderNavDisabled} /> ); @@ -114,6 +119,7 @@ function PaginatedGrid({ onLoadMore, isPaginating, paginatingMessage, + mediaFolderNavDisabled, }) { return ( @@ -134,6 +140,7 @@ function PaginatedGrid({ loadDisplayURL={() => loadDisplayURL(file)} type={file.type} isViewableImage={file.isViewableImage} + mediaFolderNavDisabled={mediaFolderNavDisabled} /> ))} {!canLoadMore ? null : } diff --git a/packages/decap-cms-core/src/components/MediaLibrary/MediaLibraryCreateFolder.js b/packages/decap-cms-core/src/components/MediaLibrary/MediaLibraryCreateFolder.js new file mode 100644 index 000000000000..30c8e2f8e312 --- /dev/null +++ b/packages/decap-cms-core/src/components/MediaLibrary/MediaLibraryCreateFolder.js @@ -0,0 +1,87 @@ +import React from 'react'; +import PropTypes from 'prop-types'; +import styled from '@emotion/styled'; +import { Icon, lengths, colors, zIndex } from 'decap-cms-ui-default'; + +const CreateFolderContainer = styled.div` + height: 37px; + display: flex; + align-items: center; + position: relative; + width: 400px; +`; + +const CreateFolderInput = styled.input` + background-color: #eff0f4; + border-radius: ${lengths.borderRadius}; + + font-size: 14px; + padding: 10px 6px 10px 32px; + width: 100%; + position: relative; + z-index: ${zIndex.zIndex1}; + + &:focus { + outline: none; + box-shadow: inset 0 0 0 2px ${colors.active}; + } +`; + +const CreateFolderIcon = styled(Icon)` + position: absolute; + top: 50%; + left: 6px; + z-index: ${zIndex.zIndex2}; + transform: translate(0, -50%); +`; + +class MediaLibraryCreateFolder extends React.Component { + constructor(props) { + super(props); + this.state = { value: '', icon: 'folder' }; + + this.handleChange = this.handleChange.bind(this); + this.handleKeyDown = this.handleKeyDown.bind(this); + } + + handleChange(event) { + const { folders } = this.props; + this.setState({ + value: event.target.value, + folderExists: folders.find(folder => folder.name === event.target.value), + }); + } + + handleKeyDown(event) { + const { onKeyDown } = this.props; + if (event.key === 'Enter' && !this.state.folderExists) { + onKeyDown(this.state.value); + this.setState({ value: '' }); + } + } + + render() { + const { placeholder } = this.props; + return ( + + + + + ); + } +} + +MediaLibraryCreateFolder.propTypes = { + value: PropTypes.string, + onKeyDown: PropTypes.func.isRequired, + placeholder: PropTypes.string.isRequired, + disabled: PropTypes.bool, +}; + +export default MediaLibraryCreateFolder; diff --git a/packages/decap-cms-core/src/components/MediaLibrary/MediaLibraryModal.js b/packages/decap-cms-core/src/components/MediaLibrary/MediaLibraryModal.js index d91bb5b92916..66fec21a0e9b 100644 --- a/packages/decap-cms-core/src/components/MediaLibrary/MediaLibraryModal.js +++ b/packages/decap-cms-core/src/components/MediaLibrary/MediaLibraryModal.js @@ -15,7 +15,7 @@ import EmptyMessage from './EmptyMessage'; * Responsive styling needs to be overhauled. Current setup requires specifying * widths per breakpoint. */ -const cardWidth = `280px`; +const cardWidth = `240px`; const cardHeight = `240px`; const cardMargin = `10px`; @@ -27,7 +27,7 @@ const cardOutsideWidth = `300px`; const StyledModal = styled(Modal)` display: grid; - grid-template-rows: 120px auto; + grid-template-rows: 170px auto; width: calc(${cardOutsideWidth} + 20px); background-color: ${props => props.isPrivate && colors.grayDark}; @@ -65,6 +65,7 @@ function MediaLibraryModal({ isVisible, canInsert, files, + folders, dynamicSearch, dynamicSearchActive, forImage, @@ -84,14 +85,19 @@ function MediaLibraryModal({ handleSearchKeyDown, handlePersist, handleDelete, + handleCreateFolder, handleInsert, handleDownload, setScrollContainerRef, handleAssetClick, + handleBreadcrumbClick, handleLoadMore, loadDisplayURL, displayURLs, t, + currentMediaFolder, + defaultMediaFolder, + mediaFolderNavDisabled, }) { const filteredFiles = forImage ? handleFilter(files) : files; const queriedFiles = !dynamicSearch && query ? handleQuery(query, filteredFiles) : filteredFiles; @@ -124,13 +130,20 @@ function MediaLibraryModal({ onSearchKeyDown={handleSearchKeyDown} searchDisabled={!dynamicSearchActive && !hasFilteredFiles} onDelete={handleDelete} + onCreateFolder={handleCreateFolder} canInsert={canInsert} onInsert={handleInsert} hasSelection={hasSelection} isPersisting={isPersisting} isDeleting={isDeleting} + handleBreadcrumbClick={handleBreadcrumbClick} + currentMediaFolder={currentMediaFolder} + defaultMediaFolder={defaultMediaFolder} + mediaFolderNavDisabled={mediaFolderNavDisabled} selectedFile={selectedFile} + folders={folders} /> + {!shouldShowEmptyMessage ? null : ( )} @@ -150,6 +163,7 @@ function MediaLibraryModal({ isPrivate={privateUpload} loadDisplayURL={loadDisplayURL} displayURLs={displayURLs} + mediaFolderNavDisabled={mediaFolderNavDisabled} /> ); @@ -189,8 +203,10 @@ MediaLibraryModal.propTypes = { handlePersist: PropTypes.func.isRequired, handleDelete: PropTypes.func.isRequired, handleInsert: PropTypes.func.isRequired, + handleCreateFolder: PropTypes.func.isRequired, setScrollContainerRef: PropTypes.func.isRequired, handleAssetClick: PropTypes.func.isRequired, + handleBreadcrumbClick: PropTypes.func.isRequired, handleLoadMore: PropTypes.func.isRequired, loadDisplayURL: PropTypes.func.isRequired, t: PropTypes.func.isRequired, diff --git a/packages/decap-cms-core/src/components/MediaLibrary/MediaLibraryTop.js b/packages/decap-cms-core/src/components/MediaLibrary/MediaLibraryTop.js index 00b3ed06ac05..aad5ec85de31 100644 --- a/packages/decap-cms-core/src/components/MediaLibrary/MediaLibraryTop.js +++ b/packages/decap-cms-core/src/components/MediaLibrary/MediaLibraryTop.js @@ -3,7 +3,9 @@ import PropTypes from 'prop-types'; import styled from '@emotion/styled'; import MediaLibrarySearch from './MediaLibrarySearch'; +import MediaLibraryCreateFolder from './MediaLibraryCreateFolder'; import MediaLibraryHeader from './MediaLibraryHeader'; +import MediaLibraryBreadcrumbs from './MediaLibraryBreadCrumbs'; import { UploadButton, DeleteButton, @@ -38,13 +40,19 @@ function MediaLibraryTop({ onSearchChange, onSearchKeyDown, searchDisabled, + onCreateFolder, onDelete, canInsert, onInsert, hasSelection, isPersisting, isDeleting, + handleBreadcrumbClick, + currentMediaFolder, + defaultMediaFolder, + mediaFolderNavDisabled, selectedFile, + folders, }) { const shouldShowButtonLoader = isPersisting || isDeleting; const uploadEnabled = !shouldShowButtonLoader; @@ -98,6 +106,13 @@ function MediaLibraryTop({ placeholder={t('mediaLibrary.mediaLibraryModal.search')} disabled={searchDisabled} /> + {!mediaFolderNavDisabled ? ( + + ) : null} {deleteButtonLabel} @@ -109,6 +124,14 @@ function MediaLibraryTop({ )} + + + ); } @@ -123,6 +146,7 @@ MediaLibraryTop.propTypes = { query: PropTypes.string, onSearchChange: PropTypes.func.isRequired, onSearchKeyDown: PropTypes.func.isRequired, + onCreateFolder: PropTypes.func.isRequired, searchDisabled: PropTypes.bool.isRequired, onDelete: PropTypes.func.isRequired, canInsert: PropTypes.bool, diff --git a/packages/decap-cms-core/src/components/MediaLibrary/__tests__/__snapshots__/MediaLibraryCard.spec.js.snap b/packages/decap-cms-core/src/components/MediaLibrary/__tests__/__snapshots__/MediaLibraryCard.spec.js.snap index 24bbf107412d..a50f7d53f79b 100644 --- a/packages/decap-cms-core/src/components/MediaLibrary/__tests__/__snapshots__/MediaLibraryCard.spec.js.snap +++ b/packages/decap-cms-core/src/components/MediaLibrary/__tests__/__snapshots__/MediaLibraryCard.spec.js.snap @@ -224,7 +224,7 @@ exports[`MediaLibraryCard should match snapshot for non viewable image 1`] = ` .emotion-4 { width: 100%; height: 160px; - object-fit: cover; + object-fit: contain; border-radius: 2px 2px 0 0; padding: 1em; font-size: 3em; diff --git a/packages/decap-cms-core/src/reducers/__tests__/entries.spec.js b/packages/decap-cms-core/src/reducers/__tests__/entries.spec.js index 45aed7c5a121..0e65b80acc04 100644 --- a/packages/decap-cms-core/src/reducers/__tests__/entries.spec.js +++ b/packages/decap-cms-core/src/reducers/__tests__/entries.spec.js @@ -576,6 +576,18 @@ describe('entries', () => { ), ).toBe('/images/image.png'); }); + + it('should use full file path', () => { + expect( + selectMediaFilePublicPath( + { media_folder: 'static/media', public_folder: '/media' }, + null, + 'static/media/images/foo/image.png', + undefined, + undefined, + ), + ).toBe('/media/images/foo/image.png'); + }); }); describe('selectEntries', () => { diff --git a/packages/decap-cms-core/src/reducers/__tests__/mediaLibrary.spec.js b/packages/decap-cms-core/src/reducers/__tests__/mediaLibrary.spec.js index 4460bf68157f..809e73c52308 100644 --- a/packages/decap-cms-core/src/reducers/__tests__/mediaLibrary.spec.js +++ b/packages/decap-cms-core/src/reducers/__tests__/mediaLibrary.spec.js @@ -60,6 +60,7 @@ describe('mediaLibrary', () => { { id: 3, path: '/static/images/posts/index.png' }, ], data: {}, + mediaLibrary: {}, }); const state = { config: {}, @@ -99,6 +100,7 @@ describe('mediaLibrary', () => { entryDraft: fromJS({ entry, }), + mediaLibrary: {}, }; expect(selectMediaFiles(state, imageField)).toEqual([ diff --git a/packages/decap-cms-core/src/reducers/entries.ts b/packages/decap-cms-core/src/reducers/entries.ts index 068d486a0e75..14e5d90aa06f 100644 --- a/packages/decap-cms-core/src/reducers/entries.ts +++ b/packages/decap-cms-core/src/reducers/entries.ts @@ -727,6 +727,22 @@ function evaluateFolder( return currentFolder; } +export function selectPublicFolder( + config: CmsConfig, + collection: Collection | null, + entryMap: EntryMap | undefined, + field: EntryField | undefined, +) { + const name = 'public_folder'; + let publicFolder = config[name]; + + const customFolder = hasCustomFolder(name, collection, entryMap?.get('slug'), field); + if (customFolder) { + publicFolder = evaluateFolder(name, config, collection!, entryMap, field); + } + return publicFolder; +} + export function selectMediaFolder( config: CmsConfig, collection: Collection | null, @@ -737,7 +753,6 @@ export function selectMediaFolder( let mediaFolder = config[name]; const customFolder = hasCustomFolder(name, collection, entryMap?.get('slug'), field); - if (customFolder) { const folder = evaluateFolder(name, config, collection!, entryMap, field); if (folder.startsWith('/')) { @@ -750,26 +765,69 @@ export function selectMediaFolder( : join(collection!.get('folder') as string, DRAFT_MEDIA_FILES); } } - return trim(mediaFolder, '/'); } +export function selectCurrentMediaFolder( + config: CmsConfig, + collection: Collection | null, + entryMap: EntryMap | undefined, + mediaPath: string, + field: EntryField | undefined, +) { + const mediaFolder = selectMediaFolder(config, collection, entryMap, field); + const publicFolder = trim(selectPublicFolder(config, collection, entryMap, field), '/'); + mediaPath = trim(mediaPath, '/'); + + let currentMediaFolder = mediaPath.startsWith(publicFolder) + ? dirname(join(mediaFolder, mediaPath.replace(publicFolder, ''))) + : dirname(mediaPath); + + const customFolder = hasCustomFolder('media_folder', collection, entryMap?.get('slug'), field); + if (customFolder) { + const folder = evaluateFolder('media_folder', config, collection!, entryMap, field); + if (!folder.startsWith('/')) { + currentMediaFolder = mediaFolder; + } + } + return currentMediaFolder; +} + export function selectMediaFilePath( config: CmsConfig, collection: Collection | null, entryMap: EntryMap | undefined, mediaPath: string, field: EntryField | undefined, + currentMediaFolder?: string, ) { if (isAbsolutePath(mediaPath)) { return mediaPath; } - const mediaFolder = selectMediaFolder(config, collection, entryMap, field); - + const mediaFolder = currentMediaFolder + ? currentMediaFolder + : selectCurrentMediaFolder(config, collection, entryMap, mediaPath, field); return join(mediaFolder, basename(mediaPath)); } +export function removeMediaFolderFromPath( + config: CmsConfig, + collection: Collection | null, + mediaPath: string, + entryMap: EntryMap | undefined, + field: EntryField | undefined, +) { + const trimmedMediaPath = trim(mediaPath, '/'); + let mediaFolder = selectMediaFolder(config, collection, entryMap, field); + if (!mediaFolder || mediaFolder === '') { + mediaFolder = selectPublicFolder(config, collection, entryMap, field) || ''; + } + return trimmedMediaPath.startsWith(mediaFolder) + ? trimmedMediaPath.replace(mediaFolder, '') + : mediaPath; +} + export function selectMediaFilePublicPath( config: CmsConfig, collection: Collection | null, @@ -781,22 +839,44 @@ export function selectMediaFilePublicPath( return mediaPath; } - const name = 'public_folder'; - let publicFolder = config[name]!; - - const customFolder = hasCustomFolder(name, collection, entryMap?.get('slug'), field); - - if (customFolder) { - publicFolder = evaluateFolder(name, config, collection!, entryMap, field); - } + const publicFolder = selectPublicFolder(config, collection, entryMap, field) || ''; if (isAbsolutePath(publicFolder)) { - return joinUrlPath(publicFolder, basename(mediaPath)); + return joinUrlPath( + publicFolder, + removeMediaFolderFromPath(config, collection, mediaPath, entryMap, field), + ); } - return join(publicFolder, basename(mediaPath)); + return join( + publicFolder, + removeMediaFolderFromPath(config, collection, mediaPath, entryMap, field), + ); } +// export function selectMediaFilePublicPath( +// config: CmsConfig, +// collection: Collection | null, +// mediaPath: string, +// entryMap: EntryMap | undefined, +// field: EntryField | undefined, +// ) { +// if (isAbsolutePath(mediaPath)) { +// return mediaPath; +// } + +// const name = 'public_folder'; +// let publicFolder = config[name]!; + +// const customFolder = hasCustomFolder(name, collection, entryMap?.get('slug'), field); + +// if (customFolder) { +// publicFolder = evaluateFolder(name, config, collection!, entryMap, field); +// } + +// return join(publicFolder, basename(mediaPath)); +// } + export function selectEditingDraft(state: EntryDraft) { const entry = state.get('entry'); const workflowDraft = entry && !entry.isEmpty(); diff --git a/packages/decap-cms-core/src/reducers/mediaLibrary.ts b/packages/decap-cms-core/src/reducers/mediaLibrary.ts index 52b8a0199f26..f4ba4d66e8bc 100644 --- a/packages/decap-cms-core/src/reducers/mediaLibrary.ts +++ b/packages/decap-cms-core/src/reducers/mediaLibrary.ts @@ -1,6 +1,5 @@ import { Map, List } from 'immutable'; import { v4 as uuid } from 'uuid'; -import { dirname } from 'path'; import { MEDIA_LIBRARY_OPEN, @@ -255,12 +254,33 @@ function mediaLibrary(state = Map(defaultState), action: MediaLibraryAction) { return state; } } +export function getInitialMediaFolder(state: State, field?: EntryField) { + const { entryDraft } = state; + const entry = entryDraft.get('entry'); + const collection = state.collections.get(entry?.get('collection')); + const integration = selectIntegration(state, null, 'assetStore'); + if (integration) { + return null; + } + return selectMediaFolder(state.config, collection, entry, field); +} +export function getMediaFolderNavDisabled(state: State, field?: EntryField) { + let disableMediaFolderNav = false; + const { entryDraft } = state; + const entry = entryDraft.get('entry'); + const collection = state.collections.get(entry?.get('collection')); + if (field && field.has('media_folder')) { + disableMediaFolderNav = field.get('media_folder') ? true : false; + } else if (collection && collection.has('media_folder')) { + disableMediaFolderNav = collection.get('media_folder') ? true : false; + } + return disableMediaFolderNav; +} export function selectMediaFiles(state: State, field?: EntryField) { const { mediaLibrary, entryDraft } = state; const editingDraft = selectEditingDraft(state.entryDraft); const integration = selectIntegration(state, null, 'assetStore'); - let files; if (editingDraft && !integration) { const entryFiles = entryDraft @@ -269,13 +289,18 @@ export function selectMediaFiles(state: State, field?: EntryField) { const entry = entryDraft.get('entry'); const collection = state.collections.get(entry?.get('collection')); const mediaFolder = selectMediaFolder(state.config, collection, entry, field); - files = entryFiles - .filter(f => dirname(f.path) === mediaFolder) + const uniqMediaFiles: MediaFile[] = []; + entryFiles.concat(mediaLibrary.get('files') || []).forEach(mediaFile => { + if (!uniqMediaFiles.find(uniqueMediaFile => uniqueMediaFile.id === mediaFile.id)) { + uniqMediaFiles.push(mediaFile); + } + }); + files = uniqMediaFiles + .filter(f => f.path.startsWith(mediaFolder)) .map(file => ({ key: file.id, ...file })); } else { files = mediaLibrary.get('files') || []; } - return files; } diff --git a/packages/decap-cms-core/src/types/redux.ts b/packages/decap-cms-core/src/types/redux.ts index b69a82311532..fea4867daffc 100644 --- a/packages/decap-cms-core/src/types/redux.ts +++ b/packages/decap-cms-core/src/types/redux.ts @@ -557,6 +557,7 @@ export type EntryField = StaticallyTypedRecord<{ default: string | null | boolean | List; media_folder?: string; public_folder?: string; + disable_media_folder_navigation: boolean; comment?: string; meta?: boolean; i18n: 'translate' | 'duplicate' | 'none'; @@ -609,6 +610,7 @@ type CollectionObject = { fields: EntryFields; isFetching: boolean; media_folder?: string; + disable_media_folder_navigation: boolean; public_folder?: string; preview_path?: string; preview_path_date_field?: string; diff --git a/packages/decap-cms-lib-util/src/implementation.ts b/packages/decap-cms-lib-util/src/implementation.ts index 8b8a05cfe0a0..2e5ec3289a7a 100644 --- a/packages/decap-cms-lib-util/src/implementation.ts +++ b/packages/decap-cms-lib-util/src/implementation.ts @@ -21,6 +21,7 @@ export interface ImplementationMediaFile { draft?: boolean; url?: string; file?: File; + isDirectory?: boolean; } export interface UnpublishedEntryMediaFile { diff --git a/packages/decap-cms-locales/src/en/index.js b/packages/decap-cms-locales/src/en/index.js index 5cba0d853d43..9e0490b5d45f 100644 --- a/packages/decap-cms-locales/src/en/index.js +++ b/packages/decap-cms-locales/src/en/index.js @@ -242,6 +242,7 @@ const en = { deleting: 'Deleting...', deleteSelected: 'Delete selected', chooseSelected: 'Choose selected', + createFolder: 'Create folder', }, }, ui: { diff --git a/packages/decap-cms-ui-default/src/Icon.js b/packages/decap-cms-ui-default/src/Icon.js index 11da3de53959..7b4ff056fd43 100644 --- a/packages/decap-cms-ui-default/src/Icon.js +++ b/packages/decap-cms-ui-default/src/Icon.js @@ -50,6 +50,7 @@ const sizes = { small: '18px', medium: '24px', large: '32px', + max: '100%', }; function Icon({ type, direction, size = 'medium', className }) { diff --git a/packages/decap-cms-ui-default/src/Icon/images/_index.js b/packages/decap-cms-ui-default/src/Icon/images/_index.js index 1a80a6260a9e..dda5d986a33a 100644 --- a/packages/decap-cms-ui-default/src/Icon/images/_index.js +++ b/packages/decap-cms-ui-default/src/Icon/images/_index.js @@ -34,6 +34,7 @@ import iconMedia from './media.svg'; import iconMediaAlt from './media-alt.svg'; import iconDecap from './decap.svg'; import iconNewTab from './new-tab.svg'; +import iconNotAllowed from './not-allowed.svg'; import iconPage from './page.svg'; import iconPages from './pages.svg'; import iconPagesAlt from './pages-alt.svg'; @@ -86,6 +87,7 @@ const images = { decap: iconDecap, 'decap-cms': iconDecap, 'new-tab': iconNewTab, + 'not-allowed': iconNotAllowed, page: iconPage, pages: iconPages, 'pages-alt': iconPagesAlt, diff --git a/packages/decap-cms-ui-default/src/Icon/images/not-allowed.svg b/packages/decap-cms-ui-default/src/Icon/images/not-allowed.svg new file mode 100644 index 000000000000..d603157e99f4 --- /dev/null +++ b/packages/decap-cms-ui-default/src/Icon/images/not-allowed.svg @@ -0,0 +1 @@ + \ No newline at end of file