diff --git a/packages/core/dev-test/assets/uploads/Other Pics/lobby.jpg b/packages/core/dev-test/assets/uploads/Other Pics/lobby.jpg new file mode 100644 index 00000000..5bbb29fd Binary files /dev/null and b/packages/core/dev-test/assets/uploads/Other Pics/lobby.jpg differ diff --git a/packages/core/dev-test/assets/uploads/Other Pics/moby-dick.jpg b/packages/core/dev-test/assets/uploads/Other Pics/moby-dick.jpg new file mode 100644 index 00000000..3234c5c9 Binary files /dev/null and b/packages/core/dev-test/assets/uploads/Other Pics/moby-dick.jpg differ diff --git a/packages/core/dev-test/config.yml b/packages/core/dev-test/config.yml index 0c1d8ddf..fca22d73 100644 --- a/packages/core/dev-test/config.yml +++ b/packages/core/dev-test/config.yml @@ -2,6 +2,7 @@ backend: name: test-repo site_url: 'https://example.com' media_folder: assets/uploads +media_library_folder_support: true locale: en i18n: # Required and can be one of multiple_folders, multiple_files or single_file diff --git a/packages/core/dev-test/index.html b/packages/core/dev-test/index.html index 13f09246..e7342977 100644 --- a/packages/core/dev-test/index.html +++ b/packages/core/dev-test/index.html @@ -14,11 +14,7 @@ 'lobby.jpg': { content: '', }, - }, - }, - _posts: { - assets: { - uploads: { + 'Other Pics': { 'moby-dick.jpg': { content: '', }, @@ -27,6 +23,8 @@ }, }, }, + }, + _posts: { '2015-02-14-this-is-a-post.md': { content: '---\ntitle: This is a YAML front matter post\ndraft: true\nimage: /assets/uploads/moby-dick.jpg\ndate: 2015-02-14T00:00:00.000Z\n---\n\n# I Am a Title in Markdown\n\nHello, world\n\n* One Thing\n* Another Thing\n* A Third Thing\n', diff --git a/packages/core/src/actions/mediaLibrary.ts b/packages/core/src/actions/mediaLibrary.ts index 68efaad5..03e27ef1 100644 --- a/packages/core/src/actions/mediaLibrary.ts +++ b/packages/core/src/actions/mediaLibrary.ts @@ -241,7 +241,12 @@ function createMediaFileFromAsset({ return mediaFile; } -export function persistMedia(file: File, opts: MediaOptions = {}, currentFolder?: string) { +export function persistMedia( + file: File, + opts: MediaOptions = {}, + targetFolder?: string, + currentFolder?: string, +) { const { field } = opts; return async (dispatch: ThunkDispatch, getState: () => RootState) => { const state = getState(); @@ -287,7 +292,7 @@ export function persistMedia(file: File, opts: MediaOptions = {}, currentFolder? try { const entry = state.entryDraft.entry; const collection = entry?.collection ? state.collections[entry.collection] : null; - const path = selectMediaFilePath(config, collection, entry, fileName, field, currentFolder); + const path = selectMediaFilePath(config, collection, entry, fileName, field, targetFolder); const assetProxy = createAssetProxy({ file, path, @@ -310,7 +315,7 @@ export function persistMedia(file: File, opts: MediaOptions = {}, currentFolder? mediaFile = await backend.persistMedia(config, assetProxy); } - return dispatch(mediaPersisted(mediaFile)); + return dispatch(mediaPersisted(mediaFile, currentFolder)); } catch (error) { console.error(error); dispatch( @@ -487,10 +492,10 @@ export function mediaPersisting() { return { type: MEDIA_PERSIST_REQUEST } as const; } -export function mediaPersisted(file: ImplementationMediaFile) { +export function mediaPersisted(file: ImplementationMediaFile, currentFolder: string | undefined) { return { type: MEDIA_PERSIST_SUCCESS, - payload: { file }, + payload: { file, currentFolder }, } as const; } diff --git a/packages/core/src/backends/test/implementation.ts b/packages/core/src/backends/test/implementation.ts index 754e9722..826549fa 100644 --- a/packages/core/src/backends/test/implementation.ts +++ b/packages/core/src/backends/test/implementation.ts @@ -2,10 +2,11 @@ import attempt from 'lodash/attempt'; import isError from 'lodash/isError'; import take from 'lodash/take'; import unset from 'lodash/unset'; -import { extname } from 'path'; +import { basename, dirname } from 'path'; import { v4 as uuid } from 'uuid'; import { Cursor, CURSOR_COMPATIBILITY_SYMBOL } from '@staticcms/core/lib/util'; +import { isNotEmpty } from '@staticcms/core/lib/util/string.util'; import AuthenticationPage from './AuthenticationPage'; import type { @@ -20,7 +21,7 @@ import type { } from '@staticcms/core/interface'; import type AssetProxy from '@staticcms/core/valueObjects/AssetProxy'; -type RepoFile = { path: string; content: string | AssetProxy }; +type RepoFile = { path: string; content: string | AssetProxy; isDirectory?: boolean }; type RepoTree = { [key: string]: RepoFile | RepoTree }; declare global { @@ -83,20 +84,36 @@ export function getFolderFiles( depth: number, files = [] as RepoFile[], path = folder, -) { + includeFolders?: boolean, +): RepoFile[] { if (depth <= 0) { return files; } + if (includeFolders) { + files.unshift({ isDirectory: true, content: '', path }); + } + Object.keys(tree[folder] || {}).forEach(key => { - if (extname(key)) { + const parts = key.split('.'); + const keyExtension = parts.length > 1 ? parts[parts.length - 1] : ''; + + if (isNotEmpty(keyExtension)) { const file = (tree[folder] as RepoTree)[key] as RepoFile; if (!extension || key.endsWith(`.${extension}`)) { files.unshift({ content: file.content, path: `${path}/${key}` }); } } else { const subTree = tree[folder] as RepoTree; - return getFolderFiles(subTree, key, extension, depth - 1, files, `${path}/${key}`); + return getFolderFiles( + subTree, + key, + extension, + depth - 1, + files, + `${path}/${key}`, + includeFolders, + ); } }); @@ -222,18 +239,31 @@ export default class TestBackend implements BackendClass { return Promise.resolve(); } - async getMedia(mediaFolder = this.mediaFolder): Promise { + async getMedia( + mediaFolder = this.mediaFolder, + folderSupport?: boolean, + ): Promise { if (!mediaFolder) { return []; } - const files = getFolderFiles(window.repoFiles, mediaFolder.split('/')[0], '', 100).filter(f => - f.path.startsWith(mediaFolder), - ); + const files = getFolderFiles( + window.repoFiles, + mediaFolder.split('/')[0], + '', + 100, + undefined, + undefined, + folderSupport, + ).filter(f => { + return dirname(f.path) === mediaFolder; + }); + return files.map(f => ({ - name: f.path, + name: basename(f.path), id: f.path, path: f.path, displayURL: f.path, + isDirectory: f.isDirectory ?? false, })); } @@ -242,7 +272,7 @@ export default class TestBackend implements BackendClass { id: path, displayURL: path, path, - name: path, + name: basename(path), size: 1, url: path, }; diff --git a/packages/core/src/components/collections/entries/EntryCard.tsx b/packages/core/src/components/collections/entries/EntryCard.tsx index fd7f5715..b97178ca 100644 --- a/packages/core/src/components/collections/entries/EntryCard.tsx +++ b/packages/core/src/components/collections/entries/EntryCard.tsx @@ -9,6 +9,7 @@ import { selectFields, selectTemplateName, } from '@staticcms/core/lib/util/collection.util'; +import { isNullish } from '@staticcms/core/lib/util/null.util'; import { selectConfig } from '@staticcms/core/reducers/selectors/config'; import { selectTheme } from '@staticcms/core/reducers/selectors/globalUI'; import { useAppSelector } from '@staticcms/core/store/hooks'; @@ -120,6 +121,8 @@ const EntryCard = ({ value={value} theme={theme} /> + ) : isNullish(value) ? ( + '' ) : ( String(value) )} diff --git a/packages/core/src/components/common/button/Button.tsx b/packages/core/src/components/common/button/Button.tsx index 484162e6..da98f9ed 100644 --- a/packages/core/src/components/common/button/Button.tsx +++ b/packages/core/src/components/common/button/Button.tsx @@ -8,7 +8,7 @@ import type { CSSProperties, FC, MouseEventHandler, ReactNode, Ref } from 'react export interface BaseBaseProps { variant?: 'contained' | 'outlined' | 'text'; - color?: 'primary' | 'success' | 'error'; + color?: 'primary' | 'secondary' | 'success' | 'error'; size?: 'medium' | 'small'; rounded?: boolean | 'no-padding'; className?: string; diff --git a/packages/core/src/components/common/button/useButtonClassNames.tsx b/packages/core/src/components/common/button/useButtonClassNames.tsx index e1a31b78..fbb84c60 100644 --- a/packages/core/src/components/common/button/useButtonClassNames.tsx +++ b/packages/core/src/components/common/button/useButtonClassNames.tsx @@ -8,16 +8,19 @@ const classes: Record< > = { contained: { primary: 'btn-contained-primary', + secondary: 'btn-contained-secondary', success: 'btn-contained-success', error: 'btn-contained-error', }, outlined: { primary: 'btn-outlined-primary', + secondary: 'btn-outlined-secondary', success: 'btn-outlined-success', error: 'btn-outlined-error', }, text: { primary: 'btn-text-primary', + secondary: 'btn-text-secondary', success: 'btn-text-success', error: 'btn-text-error', }, diff --git a/packages/core/src/components/common/text-field/TextField.tsx b/packages/core/src/components/common/text-field/TextField.tsx index 448c706c..9048ed35 100644 --- a/packages/core/src/components/common/text-field/TextField.tsx +++ b/packages/core/src/components/common/text-field/TextField.tsx @@ -7,13 +7,16 @@ import classNames from '@staticcms/core/lib/util/classNames.util'; import type { ChangeEventHandler, FC, MouseEventHandler, Ref } from 'react'; export interface BaseTextFieldProps { + id?: string; readonly?: boolean; disabled?: boolean; 'data-testid'?: string; onChange?: ChangeEventHandler; onClick?: MouseEventHandler; cursor?: 'default' | 'pointer' | 'text'; + variant?: 'borderless' | 'contained'; inputRef?: Ref; + placeholder?: string; } export interface NumberTextFieldProps extends BaseTextFieldProps { @@ -36,6 +39,7 @@ const TextField: FC = ({ type, 'data-testid': dataTestId, cursor = 'default', + variant = 'borderless', inputRef, readonly, disabled = false, @@ -66,17 +70,38 @@ const TextField: FC = ({ className: classNames( ` w-full + text-sm + `, + variant === 'borderless' && + ` h-6 px-3 bg-transparent outline-none - text-sm font-medium text-gray-900 disabled:text-gray-300 dark:text-gray-100 dark:disabled:text-gray-500 `, + variant === 'contained' && + ` + bg-gray-50 + border + border-gray-300 + text-gray-900 + rounded-lg + focus:ring-blue-500 + focus:border-blue-500 + block + p-2.5 + dark:bg-gray-700 + dark:border-gray-600 + dark:placeholder-gray-400 + dark:text-white + dark:focus:ring-blue-500 + dark:focus:border-blue-500 + `, finalCursor === 'pointer' && 'cursor-pointer', finalCursor === 'text' && 'cursor-text', finalCursor === 'default' && 'cursor-default', diff --git a/packages/core/src/components/media-library/MediaLibraryModal.tsx b/packages/core/src/components/media-library/MediaLibraryModal.tsx index b855b8a0..6ad17385 100644 --- a/packages/core/src/components/media-library/MediaLibraryModal.tsx +++ b/packages/core/src/components/media-library/MediaLibraryModal.tsx @@ -44,7 +44,7 @@ const MediaLibraryModal: FC = () => { > - + ); }; diff --git a/packages/core/src/components/media-library/common/CurrentMediaDetails.tsx b/packages/core/src/components/media-library/common/CurrentMediaDetails.tsx index 833b73f7..b11b01f1 100644 --- a/packages/core/src/components/media-library/common/CurrentMediaDetails.tsx +++ b/packages/core/src/components/media-library/common/CurrentMediaDetails.tsx @@ -42,6 +42,7 @@ const CurrentMediaDetails: FC = ({ py-4 border-b border-gray-200/75 + dark:border-slate-500/75 " > void; + onCreate: (folderName: string) => void; +} + +const FolderCreationDialog: FC> = ({ + open, + onClose, + onCreate, + t, +}) => { + const [folderName, setFolderName] = useState(''); + const handleFolderNameChange: ChangeEventHandler = useCallback(event => { + setFolderName(event.target.value); + }, []); + + const handleCreate = useCallback(() => { + if (isEmpty(folderName)) { + return; + } + + onCreate(folderName); + }, [folderName, onCreate]); + + return ( + +
+

+ {t('mediaLibrary.folderSupport.createNewFolder')} +

+ + + +
+
+ +
+
+ + +
+
+ ); +}; + +export default FolderCreationDialog; diff --git a/packages/core/src/components/media-library/common/MediaLibrary.tsx b/packages/core/src/components/media-library/common/MediaLibrary.tsx index fab8a4cc..bcc8ea92 100644 --- a/packages/core/src/components/media-library/common/MediaLibrary.tsx +++ b/packages/core/src/components/media-library/common/MediaLibrary.tsx @@ -1,13 +1,14 @@ -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 { FolderOpen as FolderOpenIcon } from '@styled-icons/material/FolderOpen'; +import { Home as HomeIcon } from '@styled-icons/material/Home'; +import { Photo as PhotoIcon } from '@styled-icons/material/Photo'; 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 { dirname, join } from 'path'; +import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react'; +import { translate } from 'react-polyglot'; import { closeMediaLibrary, @@ -19,20 +20,23 @@ import { } from '@staticcms/core/actions/mediaLibrary'; import useMediaFiles from '@staticcms/core/lib/hooks/useMediaFiles'; import { fileExtension } from '@staticcms/core/lib/util'; +import classNames from '@staticcms/core/lib/util/classNames.util'; import MediaLibraryCloseEvent from '@staticcms/core/lib/util/events/MediaLibraryCloseEvent'; +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 { selectMediaLibraryState } from '@staticcms/core/reducers/selectors/mediaLibrary'; import { useAppDispatch, useAppSelector } from '@staticcms/core/store/hooks'; import alert from '../../common/alert/Alert'; import Button from '../../common/button/Button'; +import IconButton from '../../common/button/IconButton'; import confirm from '../../common/confirm/Confirm'; import CurrentMediaDetails from './CurrentMediaDetails'; import EmptyMessage from './EmptyMessage'; import FileUploadButton from './FileUploadButton'; +import FolderCreationDialog from './FolderCreationDialog'; 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'; @@ -56,9 +60,14 @@ const IMAGE_EXTENSIONS = [...IMAGE_EXTENSIONS_VIEWABLE]; interface MediaLibraryProps { canInsert?: boolean; + isDialog?: boolean; } -const MediaLibrary: FC> = ({ canInsert = false, t }) => { +const MediaLibrary: FC> = ({ + canInsert = false, + isDialog = false, + t, +}) => { const [currentFolder, setCurrentFolder] = useState(undefined); const [selectedFile, setSelectedFile] = useState(null); const [query, setQuery] = useState(undefined); @@ -265,9 +274,13 @@ const MediaLibrary: FC> = ({ canInsert = fals const handleOpenDirectory = useCallback( (dir: string) => { + if (!config) { + return; + } + const newDirectory = selectMediaFilePath( - config!, - collection!, + config, + collection, entry, dir, field, @@ -281,28 +294,72 @@ const MediaLibrary: FC> = ({ canInsert = fals [dispatch, currentFolder, collection, config, entry, field], ); - const handleGoBack = useCallback( - (toHome?: boolean) => { + const mediaFolder = useMemo(() => { + if (!config) { + return undefined; + } + return trim(selectMediaFolder(config, collection, entry, field), '/'); + }, [collection, config, entry, field]); + + const parentFolder = useMemo(() => { + if (!config || !currentFolder) { + return undefined; + } + + return dirname(currentFolder); + }, [config, currentFolder]); + + const goToFolder = useCallback( + (folder: string | undefined) => { 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 })); + setCurrentFolder(folder); + dispatch(loadMedia({ currentFolder: folder })); }, - [dispatch, config, collection, entry, field, currentFolder], + [dispatch], ); + const handleHome = useCallback(() => { + goToFolder(undefined); + }, [goToFolder]); + + const handleGoBack = useCallback(() => { + if (!mediaFolder) { + return; + } + + goToFolder( + parentFolder?.includes(mediaFolder) && parentFolder !== mediaFolder + ? parentFolder + : undefined, + ); + }, [goToFolder, mediaFolder, parentFolder]); + + const [folderCreationOpen, setFolderCreationOpen] = useState(false); const handleCreateFolder = useCallback(() => { - console.log('[createFolder]'); + setFolderCreationOpen(true); }, []); + const handleFolderCreationDialogClose = useCallback(() => { + setFolderCreationOpen(false); + }, []); + + const handleFolderCreate = useCallback( + async (folderName: string) => { + const folder = currentFolder ?? mediaFolder; + if (!folder) { + return; + } + + setFolderCreationOpen(false); + const file = new File([''], '.gitkeep', { type: 'text/plain' }); + await dispatch( + persistMedia(file, { field }, join(folder, folderName), currentFolder ?? mediaFolder), + ); + }, + [currentFolder, dispatch, field, mediaFolder], + ); + /** * Stores the public path of the file in the application store, where the * editor field that launched the media library can retrieve it. @@ -408,108 +465,154 @@ const MediaLibrary: FC> = ({ canInsert = fals const hasSelection = hasMedia && !isEmpty(selectedFile); return ( -
- -
-
-

-
- -
- {t('app.header.media')} -

- {config?.media_library_folder_support ? ( -
- - -
- ) : null} - -
-
- {config?.media_library_folder_support ? ( - - ) : null} - - {canInsert ? ( - - ) : null} -
-
- {!hasMedia ? ( - - ) : ( - selectedFile?.key === file.key} - onAssetSelect={handleAssetSelect} - canLoadMore={hasNextPage} - onLoadMore={handleLoadMore} - onDirectoryOpen={handleOpenDirectory} - currentFolder={currentFolder} - isPaginating={isPaginating} - paginatingMessage={t('mediaLibrary.mediaLibraryModal.loading')} - cardDraftText={t('mediaLibrary.mediaLibraryCard.draft')} - loadDisplayURL={loadDisplayURL} - displayURLs={displayURLs} + <> +
+ - )} -
+
+
+

+
+ +
+ {t('app.header.media')} +

+ + {config?.media_library_folder_support ? ( +
+ + + + + + + + + +
+ ) : null} +
+
+ + {canInsert ? ( + + ) : null} +
+
+ {config?.media_library_folder_support ? ( +
+ + {currentFolder ?? mediaFolder} +
+ ) : null} + {!hasMedia ? ( + + ) : ( + selectedFile?.key === file.key} + onAssetSelect={handleAssetSelect} + canLoadMore={hasNextPage} + onLoadMore={handleLoadMore} + onDirectoryOpen={handleOpenDirectory} + currentFolder={currentFolder} + isPaginating={isPaginating} + paginatingMessage={t('mediaLibrary.mediaLibraryModal.loading')} + cardDraftText={t('mediaLibrary.mediaLibraryCard.draft')} + loadDisplayURL={loadDisplayURL} + displayURLs={displayURLs} + collection={collection} + field={field} + isDialog={isDialog} + onDelete={handleDelete} + /> + )} +
+ + ); }; diff --git a/packages/core/src/components/media-library/common/MediaLibraryCard.tsx b/packages/core/src/components/media-library/common/MediaLibraryCard.tsx index e3262284..2d9f7240 100644 --- a/packages/core/src/components/media-library/common/MediaLibraryCard.tsx +++ b/packages/core/src/components/media-library/common/MediaLibraryCard.tsx @@ -1,6 +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 { FolderOpen as FolderOpenIcon } from '@styled-icons/material/FolderOpen'; import React, { useCallback, useEffect, useMemo } from 'react'; import { translate } from 'react-polyglot'; @@ -245,7 +245,7 @@ const MediaLibraryCard = - + ) : (
void; } @@ -89,9 +93,7 @@ const CardWrapper = ({ ); const top = useMemo( - () => - parseFloat(`${typeof style.top === 'number' ? style.top ?? 0 : style.top}`) + - MEDIA_LIBRARY_PADDING, + () => parseFloat(`${typeof style.top === 'number' ? style.top ?? 0 : style.top}`), [style.top], ); @@ -134,7 +136,8 @@ const CardWrapper = ({ }; const MediaLibraryCardGrid: FC = props => { - const { mediaItems, scrollContainerRef, canLoadMore, onLoadMore } = props; + const { mediaItems, scrollContainerRef, canLoadMore, isDialog, onLoadMore } = props; + const config = useAppSelector(selectConfig); const [version, setVersion] = useState(0); @@ -154,7 +157,20 @@ const MediaLibraryCardGrid: FC = props => { const rowCount = Math.ceil(mediaItems.length / columnCount); return ( -
+
@@ -163,20 +179,26 @@ const MediaLibraryCardGrid: FC = props => { rowCount={rowCount} rowHeight={() => rowHeightWithGutter} width={width} - height={height} + height={ + height - (!config?.media_library_folder_support ? MEDIA_LIBRARY_PADDING : 0) + } itemData={ { ...props, columnCount, } as CardGridItemData } - className=" - px-5 - py-4 - overflow-hidden - overflow-y-auto - styled-scrollbars - " + outerRef={scrollContainerRef} + className={classNames( + ` + px-5 + pb-2 + overflow-hidden + overflow-y-auto + styled-scrollbars + `, + isDialog && 'styled-scrollbars-secondary', + )} style={{ position: 'unset' }} > {CardWrapper} diff --git a/packages/core/src/lib/hooks/useMediaFiles.ts b/packages/core/src/lib/hooks/useMediaFiles.ts index f93eabfe..01ca04cb 100644 --- a/packages/core/src/lib/hooks/useMediaFiles.ts +++ b/packages/core/src/lib/hooks/useMediaFiles.ts @@ -1,14 +1,14 @@ -import { useEffect, useMemo, useState } from 'react'; -import { dirname } from 'path'; import trim from 'lodash/trim'; +import { basename, dirname } from 'path'; +import { useEffect, useMemo, useState } from 'react'; -import { selectMediaLibraryFiles } from '@staticcms/core/reducers/selectors/mediaLibrary'; -import { selectEditingDraft } from '@staticcms/core/reducers/selectors/entryDraft'; -import { useAppSelector } from '@staticcms/core/store/hooks'; +import { currentBackend } from '@staticcms/core/backend'; import { selectCollection } from '@staticcms/core/reducers/selectors/collections'; import { selectConfig } from '@staticcms/core/reducers/selectors/config'; +import { selectEditingDraft } from '@staticcms/core/reducers/selectors/entryDraft'; +import { selectMediaLibraryFiles } from '@staticcms/core/reducers/selectors/mediaLibrary'; +import { useAppSelector } from '@staticcms/core/store/hooks'; import { selectMediaFolder } from '../util/media.util'; -import { currentBackend } from '@staticcms/core/backend'; import type { MediaField, MediaFile } from '@staticcms/core/interface'; @@ -54,16 +54,35 @@ export default function useMediaFiles(field?: MediaField, currentFolder?: string }; }, [currentFolder, config, entry]); - return useMemo(() => { + const files = useMemo(() => { if (entry) { const entryFiles = entry.mediaFiles ?? []; if (config) { const mediaFolder = selectMediaFolder(config, collection, entry, field, currentFolder); const entryFolderFiles = entryFiles .filter(f => { + if (f.name === '.gitkeep') { + const folder = dirname(f.path); + return dirname(folder) === mediaFolder; + } + return dirname(f.path) === mediaFolder; }) - .map(file => ({ key: file.id, ...file })); + .map(file => { + if (file.name === '.gitkeep') { + const folder = dirname(file.path); + return { + key: folder, + id: folder, + name: basename(folder), + path: folder, + isDirectory: true, + draft: true, + } as MediaFile; + } + return { key: file.id, ...file }; + }); + if (currentFolderMediaFiles) { if (entryFiles.length > 0) { const draftFiles = entryFolderFiles.filter(file => file.draft == true); @@ -77,4 +96,6 @@ export default function useMediaFiles(field?: MediaField, currentFolder?: string return mediaLibraryFiles ?? []; }, [collection, config, currentFolderMediaFiles, entry, field, mediaLibraryFiles, currentFolder]); + + return useMemo(() => files.filter(file => file.name !== '.gitkeep'), [files]); } diff --git a/packages/core/src/lib/util/classNames.util.ts b/packages/core/src/lib/util/classNames.util.ts index ef8ece4c..cb159bf4 100644 --- a/packages/core/src/lib/util/classNames.util.ts +++ b/packages/core/src/lib/util/classNames.util.ts @@ -1,3 +1,7 @@ export default function classNames(...classes: (string | undefined | null | false)[]) { - return classes.filter(Boolean).join(' '); + const filteredClasses = classes.filter(Boolean) as string[]; + + return filteredClasses + .map(value => value.replace(/\n/g, ' ').replace(/[ ]+/g, ' ').trim()) + .join(' '); } diff --git a/packages/core/src/lib/util/media.util.ts b/packages/core/src/lib/util/media.util.ts index 44840c03..d5d0e24a 100644 --- a/packages/core/src/lib/util/media.util.ts +++ b/packages/core/src/lib/util/media.util.ts @@ -294,7 +294,7 @@ export function selectMediaFilePublicPath( export function selectMediaFilePath( config: Config, - collection: Collection | null, + collection: Collection | null | undefined, entryMap: Entry | null | undefined, mediaPath: string, field: Field | undefined, diff --git a/packages/core/src/locales/en/index.ts b/packages/core/src/locales/en/index.ts index 8867e2b6..0b8154bc 100644 --- a/packages/core/src/locales/en/index.ts +++ b/packages/core/src/locales/en/index.ts @@ -249,10 +249,12 @@ const en: LocalePhrasesRoot = { 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.', + newFolder: 'New folder', + createNewFolder: 'Create new folder', + enterFolderName: 'Enter folder name...', + home: 'Home', + up: 'Up', + upToFolder: 'Up to %{folder}', }, }, ui: { diff --git a/packages/core/src/reducers/mediaLibrary.ts b/packages/core/src/reducers/mediaLibrary.ts index 253574e0..0f017d85 100644 --- a/packages/core/src/reducers/mediaLibrary.ts +++ b/packages/core/src/reducers/mediaLibrary.ts @@ -1,3 +1,4 @@ +import { basename, dirname } from 'path'; import { v4 as uuid } from 'uuid'; import { @@ -208,13 +209,41 @@ function mediaLibrary( }; case MEDIA_PERSIST_SUCCESS: { - const { file } = action.payload; + const { file, currentFolder } = action.payload; const fileWithKey = { ...file, key: uuid() }; const files = state.files as MediaFile[]; - const updatedFiles = [fileWithKey, ...files]; + + const dir = dirname(file.path); + if (!currentFolder || dir === currentFolder) { + const updatedFiles: MediaFile[] = [fileWithKey, ...files]; + return { + ...state, + files: updatedFiles, + isPersisting: false, + }; + } + + const folder = files.find(otherFile => otherFile.isDirectory && otherFile.path === dir); + if (!folder) { + const updatedFiles: MediaFile[] = [ + { + name: basename(dir), + id: dir, + path: dir, + isDirectory: true, + }, + ...files, + ]; + + return { + ...state, + files: updatedFiles, + isPersisting: false, + }; + } + return { ...state, - files: updatedFiles, isPersisting: false, }; } diff --git a/packages/core/src/styles/main.css b/packages/core/src/styles/main.css index 6c128994..3f5a0652 100644 --- a/packages/core/src/styles/main.css +++ b/packages/core/src/styles/main.css @@ -107,6 +107,66 @@ dark:disabled:hover:bg-transparent; } + .btn-contained-secondary { + @apply border + font-medium + text-gray-600 + bg-white + border-gray-200/75 + hover:text-gray-700 + hover:bg-gray-100 + hover:border-gray-200/50 + disabled:text-gray-300/75 + disabled:bg-white/80 + disabled:border-gray-200/5 + dark:text-gray-300 + dark:bg-gray-800 + dark:border-gray-600/60 + dark:hover:bg-gray-700 + dark:hover:text-white + dark:hover:border-gray-600/80 + dark:disabled:text-gray-400/20 + dark:disabled:bg-gray-700/20 + dark:disabled:border-gray-600/20; + } + + .btn-outlined-secondary { + @apply text-gray-900 + bg-transparent + border + border-gray-200 + hover:bg-gray-100 + hover:text-gray-700 + hover:border-gray-200/50 + disabled:text-gray-300/75 + disabled:border-gray-200/40 + disabled:hover:bg-transparent + dark:bg-transparent + dark:text-gray-300 + dark:border-gray-600/60 + dark:hover:bg-gray-700 + dark:hover:text-white + dark:hover:border-gray-600/80 + dark:disabled:text-gray-400/20 + dark:disabled:border-gray-600/20 + dark:disabled:hover:bg-transparent; + } + + .btn-text-secondary { + @apply bg-transparent + text-gray-900 + hover:text-gray-700 + hover:bg-gray-100 + disabled:text-gray-300/75 + disabled:hover:bg-transparent + dark:text-gray-300 + dark:hover:text-white + dark:hover:bg-gray-700 + dark:disabled:text-gray-400/20 + dark:disabled:border-gray-600/20 + dark:disabled:hover:bg-transparent; + } + .btn-contained-success { @apply border border-transparent @@ -238,11 +298,21 @@ --scrollbar-background: rgb(248 250 252); } +.styled-scrollbars.styled-scrollbars-secondary { + --scrollbar-foreground: rgba(100, 116, 139, 0.25); + --scrollbar-background: rgb(255 255 255); +} + .dark .styled-scrollbars { --scrollbar-foreground: rgba(30, 41, 59, 0.8); --scrollbar-background: rgb(15 23 42); } +.dark .styled-scrollbars.styled-scrollbars-secondary { + --scrollbar-foreground: rgba(47, 64, 93, 0.8); + --scrollbar-background: rgb(30 41 59); +} + .styled-scrollbars { /* Foreground, Background */ scrollbar-color: var(--scrollbar-foreground) var(--scrollbar-background); diff --git a/packages/demo/public/config.yml b/packages/demo/public/config.yml index fbe78a21..bfdf3774 100644 --- a/packages/demo/public/config.yml +++ b/packages/demo/public/config.yml @@ -2,6 +2,7 @@ backend: name: test-repo site_url: 'https://staticcms.org/' media_folder: assets/uploads +media_library_folder_support: true locale: en i18n: # Required and can be one of multiple_folders, multiple_files or single_file diff --git a/packages/demo/public/index.html b/packages/demo/public/index.html index 454faf07..57b2a2c9 100644 --- a/packages/demo/public/index.html +++ b/packages/demo/public/index.html @@ -17,19 +17,17 @@ "lobby.jpg": { content: "", }, - }, - }, - _posts: { - assets: { - uploads: { - "moby-dick.jpg": { - content: "", + 'Other Pics': { + 'moby-dick.jpg': { + content: '', }, - "lobby.jpg": { - content: "", + 'lobby.jpg': { + content: '', }, }, }, + }, + _posts: { "2015-02-14-this-is-a-post.md": { content: "---\ntitle: This is a YAML front matter post\nimage: /assets/uploads/moby-dick.jpg\ndate: 2015-02-14T00:00:00.000Z\n---\n\n# I Am a Title in Markdown\n\nHello, world\n\n* One Thing\n* Another Thing\n* A Third Thing\n",