diff --git a/packages/core/src/actions/mediaLibrary.ts b/packages/core/src/actions/mediaLibrary.ts index 9a0a7bf6..8b4a5ec1 100644 --- a/packages/core/src/actions/mediaLibrary.ts +++ b/packages/core/src/actions/mediaLibrary.ts @@ -215,11 +215,14 @@ export function persistMedia( currentFolder?: string, ) { const { field } = opts; - return async (dispatch: ThunkDispatch, getState: () => RootState) => { + return async ( + dispatch: ThunkDispatch, + getState: () => RootState, + ): Promise => { const state = getState(); const config = state.config.config; if (!config) { - return; + return null; } const backend = currentBackend(config); @@ -246,7 +249,7 @@ export function persistMedia( color: 'error', })) ) { - return; + return null; } else { await dispatch(deleteMedia(existingFile)); } @@ -277,12 +280,14 @@ export function persistMedia( assetProxy, draft: Boolean(editingDraft), }); - return dispatch(addDraftEntryMediaFile(mediaFile)); + await dispatch(addDraftEntryMediaFile(mediaFile)); + return assetProxy; } else { mediaFile = await backend.persistMedia(config, assetProxy); } - return dispatch(mediaPersisted(mediaFile, currentFolder)); + await dispatch(mediaPersisted(mediaFile, currentFolder)); + return assetProxy; } catch (error) { console.error(error); dispatch( @@ -296,7 +301,8 @@ export function persistMedia( }, }), ); - return dispatch(mediaPersistFailed()); + await dispatch(mediaPersistFailed()); + return null; } }; } diff --git a/packages/core/src/components/common/checkbox/Checkbox.tsx b/packages/core/src/components/common/checkbox/Checkbox.tsx new file mode 100644 index 00000000..1bef569a --- /dev/null +++ b/packages/core/src/components/common/checkbox/Checkbox.tsx @@ -0,0 +1,117 @@ +import { Check as CheckIcon } from '@styled-icons/material/Check'; +import React, { useCallback, useRef } from 'react'; + +import classNames from '@staticcms/core/lib/util/classNames.util'; + +import type { ChangeEventHandler, FC, KeyboardEvent, MouseEvent } from 'react'; + +export interface CheckboxProps { + checked: boolean; + disabled?: boolean; + onChange: ChangeEventHandler; +} + +const Checkbox: FC = ({ checked, disabled = false, onChange }) => { + const inputRef = useRef(null); + + const handleNoop = useCallback((event: KeyboardEvent | MouseEvent) => { + event.stopPropagation(); + event.preventDefault(); + }, []); + + const handleKeydown = useCallback((event: KeyboardEvent) => { + if (event.code === 'Enter' || event.code === 'Space') { + event.stopPropagation(); + event.preventDefault(); + inputRef.current?.click(); + } + }, []); + + const handleClick = useCallback((event: MouseEvent) => { + event.stopPropagation(); + event.preventDefault(); + inputRef.current?.click(); + }, []); + + return ( + + ); +}; + +export default Checkbox; diff --git a/packages/core/src/components/media-library/MediaLibraryModal.tsx b/packages/core/src/components/media-library/MediaLibraryModal.tsx index 6ad17385..6247f2a9 100644 --- a/packages/core/src/components/media-library/MediaLibraryModal.tsx +++ b/packages/core/src/components/media-library/MediaLibraryModal.tsx @@ -29,14 +29,15 @@ const MediaLibraryModal: FC = () => { > > = ({ t, }) => { const [currentFolder, setCurrentFolder] = useState(undefined); - const [selectedFile, setSelectedFile] = useState(null); + const [selectedFile, setSelectedFile] = useState(null); const [query, setQuery] = useState(undefined); const config = useAppSelector(selectConfig); @@ -184,19 +185,60 @@ const MediaLibrary: FC> = ({ * Toggle asset selection on click. */ const handleAssetSelect = useCallback( - (asset: MediaFile) => { - if ( - !canInsert || - selectedFile?.key === asset.key || - (!forFolder && asset.isDirectory) || - (forFolder && !asset.isDirectory) - ) { + (asset: MediaFile, action: 'add' | 'remove' | 'replace') => { + if (!canInsert || (!forFolder && asset.isDirectory) || (forFolder && !asset.isDirectory)) { return; } - setSelectedFile(asset); + if (action === 'replace') { + if (selectedFile === asset.path) { + return; + } + + setSelectedFile(field?.multiple ? [asset.path] : asset.path); + return; + } + + if (action === 'add') { + if (!field?.multiple) { + return; + } + + const newValue = Array.isArray(selectedFile) + ? selectedFile + : selectedFile + ? [selectedFile] + : []; + if (newValue.includes(asset.path)) { + return; + } + + setSelectedFile([...newValue, asset.path]); + return; + } + + if (action === 'remove') { + if (!field?.multiple) { + return; + } + + const newValue = Array.isArray(selectedFile) + ? [...selectedFile] + : selectedFile + ? [selectedFile] + : []; + + const index = newValue.indexOf(asset.path); + if (index < 0) { + return; + } + + newValue.splice(index, 1); + setSelectedFile(newValue); + return; + } }, - [canInsert, forFolder, selectedFile?.key], + [canInsert, field?.multiple, forFolder, selectedFile], ); const scrollContainerRef = useRef(null); @@ -209,58 +251,23 @@ const MediaLibrary: FC> = ({ /** * Upload a file. */ - const handlePersist = useCallback( - async (event: ChangeEvent | DragEvent) => { - /** - * Stop the browser from automatically handling the file input click, and - * get the file for upload, and retain the synthetic event for access after - * the asynchronous persist operation. - */ - - let fileList: FileList | null; - if ('dataTransfer' in event) { - fileList = event.dataTransfer?.files ?? null; - } else { - event.persist(); - fileList = event.target.files; + const handlePersist = useMediaPersist({ + mediaConfig, + field, + currentFolder, + callback: (_files, assets) => { + if (assets.length === 1 && assets[0]) { + setSelectedFile(assets[0].path); + } else if (field?.multiple) { + setSelectedFile(assets.filter(f => f).map(f => f!.path)); } - if (!fileList) { - return; - } - - event.stopPropagation(); - event.preventDefault(); - const files = [...Array.from(fileList)]; - const maxFileSize = - typeof mediaConfig.max_file_size === 'number' ? mediaConfig.max_file_size : 512000; - - for (const file of files) { - if (maxFileSize && file.size > maxFileSize) { - alert({ - title: 'mediaLibrary.mediaLibrary.fileTooLargeTitle', - body: { - key: 'mediaLibrary.mediaLibrary.fileTooLargeBody', - options: { - size: Math.floor(maxFileSize / 1000), - }, - }, - }); - } else { - await dispatch(persistMedia(file, { field }, currentFolder)); - - setSelectedFile(files[0] as unknown as MediaFile); - - scrollToTop(); - } - } - - if (!('dataTransfer' in event)) { - event.target.value = ''; - } + scrollToTop(); }, - [mediaConfig.max_file_size, field, dispatch, currentFolder], - ); + }); + + const { dragOverActive, handleDragEnter, handleDragLeave, handleDragOver, handleDrop } = + useDragHandlers(handlePersist); const handleURLChange = useCallback( (url: string) => { @@ -272,14 +279,14 @@ const MediaLibrary: FC> = ({ const handleAltChange = useCallback( (alt: string) => { - if (!url && !selectedFile?.path) { + if (!url && !selectedFile) { return; } setAlt(alt); - dispatch(insertMedia((url ?? selectedFile?.path) as string, field, alt, currentFolder)); + dispatch(insertMedia((url ?? selectedFile) as string, field, alt, currentFolder)); }, - [dispatch, field, selectedFile?.path, url, currentFolder], + [dispatch, field, selectedFile, url, currentFolder], ); const handleOpenDirectory = useCallback( @@ -375,13 +382,12 @@ const MediaLibrary: FC> = ({ * editor field that launched the media library can retrieve it. */ const handleInsert = useCallback(() => { - if (!selectedFile?.path) { + if (!selectedFile) { return; } - const { path } = selectedFile; - setUrl(path); - dispatch(insertMedia(path, field, alt, currentFolder)); + setUrl(selectedFile); + dispatch(insertMedia(selectedFile, field, alt, currentFolder)); if (!insertOptions?.chooseUrl && !insertOptions?.showAlt) { handleClose(); @@ -478,147 +484,203 @@ const MediaLibrary: FC> = ({ return ( <> -
- +
-
-

+ +
+
+

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

+ + {folderSupport ? ( +
+ + + + + + + + + +
+ ) : null} +
+
+ + {canInsert ? ( + + ) : null} +
+
+ {folderSupport ? ( +
+ + {currentFolder ?? mediaFolder} +
+ ) : null} + {!hasMedia ? ( + + ) : ( + + Array.isArray(selectedFile) && field?.multiple + ? selectedFile.includes(file.path) + : selectedFile === file.path + } + 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} + hasSelection={ + Array.isArray(selectedFile) ? selectedFile.length > 0 : Boolean(selectedFile) + } + allowMultiple={replaceIndex === undefined && (field?.multiple ?? false)} + /> + )} +

+
-
- -
- {t('app.header.media')} - - - {folderSupport ? ( -
- - - - - - - - - -
- ) : null} -
-
- - {canInsert ? ( - - ) : null} + justify-center + pointer-events-none + font-bold + text-blue-500 + bg-white/75 + dark:text-blue-400 + dark:bg-slate-800/75 + transition-opacity + `, + isDialog && 'rounded-lg', + dragOverActive ? 'opacity-100' : 'opacity-0', + )} + > + {t(`mediaLibrary.mediaLibraryModal.${forImage ? 'dropImages' : 'dropFiles'}`)}
- {folderSupport ? ( -
- - {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} - /> - )}
{ isSelected?: boolean; @@ -36,7 +37,9 @@ interface MediaLibraryCardProps; field?: T; currentFolder?: string; - onSelect: () => void; + hasSelection: boolean; + allowMultiple: boolean; + onSelect: (action: 'add' | 'remove' | 'replace') => void; onDirectoryOpen: () => void; loadDisplayURL: () => void; onDelete: () => void; @@ -55,6 +58,8 @@ const MediaLibraryCard = { - if (event.key === 'Enter') { - onSelect(); + if (event.code === 'Enter' || event.code === 'Space') { + onSelect('replace'); } }, [onSelect], ); + const handleClick = useCallback(() => { + onSelect('replace'); + }, [onSelect]); + + const handleCheckboxChange = useCallback( + (event: ChangeEvent) => { + event.stopPropagation(); + event.preventDefault(); + onSelect(event.target.checked ? 'add' : 'remove'); + }, + [onSelect], + ); + return (
@@ -209,13 +227,13 @@ const MediaLibraryCard = @@ -223,11 +241,26 @@ const MediaLibraryCard =
- {isDraft ? ( - - {draftText} - - ) : null} +
+ {hasSelection && allowMultiple ? ( + + ) : null} + {isDraft ? ( + + {draftText} + + ) : null} +
{url && isViewableImage ? ( ) : isDirectory ? ( diff --git a/packages/core/src/components/media-library/common/MediaLibraryCardGrid.tsx b/packages/core/src/components/media-library/common/MediaLibraryCardGrid.tsx index e90e3a44..8d67f964 100644 --- a/packages/core/src/components/media-library/common/MediaLibraryCardGrid.tsx +++ b/packages/core/src/components/media-library/common/MediaLibraryCardGrid.tsx @@ -42,7 +42,7 @@ export interface MediaLibraryCardGridProps { scrollContainerRef: React.MutableRefObject; mediaItems: MediaFile[]; isSelectedFile: (file: MediaFile) => boolean; - onAssetSelect: (asset: MediaFile) => void; + onAssetSelect: (asset: MediaFile, action: 'add' | 'remove' | 'replace') => void; canLoadMore?: boolean; onLoadMore: () => void; onDirectoryOpen: (dir: string) => void; @@ -57,6 +57,8 @@ export interface MediaLibraryCardGridProps { field?: MediaField; isDialog: boolean; onDelete: (file: MediaFile) => void; + hasSelection: boolean; + allowMultiple: boolean; } export type CardGridItemData = MediaLibraryCardGridProps & { @@ -81,6 +83,8 @@ const CardWrapper = ({ collection, field, onDelete, + hasSelection, + allowMultiple, }, }: GridChildComponentProps) => { const left = useMemo( @@ -96,7 +100,7 @@ const CardWrapper = ({ ); const top = useMemo( - () => parseFloat(`${typeof style.top === 'number' ? style.top ?? 0 : style.top}`), + () => parseFloat(`${typeof style.top === 'number' ? style.top ?? 0 : style.top}`) + 4, [style.top], ); @@ -120,7 +124,7 @@ const CardWrapper = ({ key={file.key} isSelected={isSelectedFile(file)} text={file.name} - onSelect={() => onAssetSelect(file)} + onSelect={action => onAssetSelect(file, action)} onDirectoryOpen={() => onDirectoryOpen(file.path)} currentFolder={currentFolder} isDraft={file.draft} @@ -134,6 +138,8 @@ const CardWrapper = ({ collection={collection} field={field} onDelete={() => onDelete(file)} + hasSelection={hasSelection} + allowMultiple={allowMultiple} />
); diff --git a/packages/core/src/lib/hooks/index.ts b/packages/core/src/lib/hooks/index.ts index 8d762a4a..46d45120 100644 --- a/packages/core/src/lib/hooks/index.ts +++ b/packages/core/src/lib/hooks/index.ts @@ -5,4 +5,5 @@ export { default as useIsMediaAsset } from './useIsMediaAsset'; export { default as useMediaAsset } from './useMediaAsset'; export { default as useMediaFiles } from './useMediaFiles'; export { default as useMediaInsert } from './useMediaInsert'; +export { default as useMediaPersist } from './useMediaPersist'; export { default as useUUID } from './useUUID'; diff --git a/packages/core/src/lib/hooks/useDragHandlers.ts b/packages/core/src/lib/hooks/useDragHandlers.ts new file mode 100644 index 00000000..487acdf0 --- /dev/null +++ b/packages/core/src/lib/hooks/useDragHandlers.ts @@ -0,0 +1,58 @@ +import { useCallback, useMemo, useState } from 'react'; + +import type { DragEvent } from 'react'; + +interface UseDragHandlersState { + dragOverActive: boolean; + counter: number; +} + +export default function useDragHandlers(onDrop: (event: DragEvent) => void) { + const [{ dragOverActive }, setState] = useState({ + dragOverActive: false, + counter: 0, + }); + + const handleDragEnter = useCallback((event: DragEvent) => { + event.preventDefault(); + setState(old => ({ + dragOverActive: true, + counter: old.counter + 1, + })); + }, []); + + const handleDragOver = useCallback((event: DragEvent) => { + event.preventDefault(); + }, []); + + const handleDragLeave = useCallback((event: DragEvent) => { + event.preventDefault(); + setState(old => ({ + dragOverActive: old.counter - 1 <= 0 ? false : old.dragOverActive, + counter: old.counter - 1, + })); + }, []); + + const handleDrop = useCallback( + (event: DragEvent) => { + event.preventDefault(); + setState({ + dragOverActive: false, + counter: 0, + }); + onDrop(event); + }, + [onDrop], + ); + + return useMemo( + () => ({ + dragOverActive, + handleDragEnter, + handleDragOver, + handleDragLeave, + handleDrop, + }), + [dragOverActive, handleDragEnter, handleDragLeave, handleDragOver, handleDrop], + ); +} diff --git a/packages/core/src/lib/hooks/useMediaFiles.ts b/packages/core/src/lib/hooks/useMediaFiles.ts index 53804e84..1ecb60f3 100644 --- a/packages/core/src/lib/hooks/useMediaFiles.ts +++ b/packages/core/src/lib/hooks/useMediaFiles.ts @@ -101,7 +101,7 @@ export default function useMediaFiles(field?: MediaField, currentFolder?: string const draftFiles = entryFolderFiles.filter( file => file.draft == true && !files.find(f => f.id === file.id), ); - files.unshift(...draftFiles); + files.push(...draftFiles); } return files.map(file => ({ key: file.id, ...file })); } @@ -109,7 +109,32 @@ export default function useMediaFiles(field?: MediaField, currentFolder?: string }, [collection, config, currentFolderMediaFiles, entry, field, mediaLibraryFiles, currentFolder]); return useMemo( - () => files.filter(file => file.name !== '.gitkeep' && (folderSupport || !file.isDirectory)), + () => + files + .filter(file => file.name !== '.gitkeep' && (folderSupport || !file.isDirectory)) + .sort((a, b) => { + const aIsDirectory = a.isDirectory ?? false; + const bIsDirectory = b.isDirectory ?? false; + if (aIsDirectory !== bIsDirectory) { + if (aIsDirectory) { + return -1; + } + + return 1; + } + + const aIsDraft = a.draft ?? false; + const bIsDraft = b.draft ?? false; + if (aIsDraft !== bIsDraft) { + if (aIsDraft) { + return -1; + } + + return 1; + } + + return a.name.localeCompare(b.name); + }), [files, folderSupport], ); } diff --git a/packages/core/src/lib/hooks/useMediaPersist.ts b/packages/core/src/lib/hooks/useMediaPersist.ts new file mode 100644 index 00000000..8e9d955e --- /dev/null +++ b/packages/core/src/lib/hooks/useMediaPersist.ts @@ -0,0 +1,78 @@ +import { useCallback } from 'react'; + +import { persistMedia } from '@staticcms/core/actions/mediaLibrary'; +import { useAppDispatch } from '@staticcms/core/store/hooks'; +import alert from '../../components/common/alert/Alert'; + +import type { MediaField, MediaLibraryConfig } from '@staticcms/core/interface'; +import type { AssetProxy } from '@staticcms/core/valueObjects'; +import type { ChangeEvent, DragEvent } from 'react'; + +export interface UseMediaPersistProps { + mediaConfig?: MediaLibraryConfig; + field?: MediaField; + currentFolder?: string; + callback?: (files: File[], assetProxies: (AssetProxy | null)[]) => void; +} + +export default function useMediaPersist({ + mediaConfig, + field, + currentFolder, + callback, +}: UseMediaPersistProps) { + const dispatch = useAppDispatch(); + + return useCallback( + async (event: ChangeEvent | DragEvent) => { + /** + * Stop the browser from automatically handling the file input click, and + * get the file for upload, and retain the synthetic event for access after + * the asynchronous persist operation. + */ + + let fileList: FileList | null; + if ('dataTransfer' in event) { + fileList = event.dataTransfer?.files ?? null; + } else { + event.persist(); + fileList = event.target.files; + } + + if (!fileList) { + return; + } + + event.stopPropagation(); + event.preventDefault(); + const files = [...Array.from(fileList)]; + const maxFileSize = + typeof mediaConfig?.max_file_size === 'number' ? mediaConfig.max_file_size : 512000; + + const assetProxies: (AssetProxy | null)[] = []; + for (const file of files) { + if (maxFileSize && file.size > maxFileSize) { + alert({ + title: 'mediaLibrary.mediaLibrary.fileTooLargeTitle', + body: { + key: 'mediaLibrary.mediaLibrary.fileTooLargeBody', + options: { + size: Math.floor(maxFileSize / 1000), + }, + }, + }); + } else { + const assetProxy = await dispatch(persistMedia(file, { field }, currentFolder)); + assetProxies.push(assetProxy); + } + } + + callback?.(files, assetProxies); + + if (!('dataTransfer' in event)) { + event.target.value = ''; + } + }, + [mediaConfig?.max_file_size, dispatch, field, currentFolder, callback], + ); +} diff --git a/packages/core/src/locales/en/index.ts b/packages/core/src/locales/en/index.ts index 25f7d011..e30b8f73 100644 --- a/packages/core/src/locales/en/index.ts +++ b/packages/core/src/locales/en/index.ts @@ -234,6 +234,8 @@ const en: LocalePhrasesRoot = { deleting: 'Deleting...', deleteSelected: 'Delete selected', chooseSelected: 'Choose selected', + dropImages: 'Drop images to upload', + dropFiles: 'Drop files to upload', }, folderSupport: { newFolder: 'New folder', diff --git a/packages/core/src/widgets/file/withFileControl.tsx b/packages/core/src/widgets/file/withFileControl.tsx index d476215f..e3696896 100644 --- a/packages/core/src/widgets/file/withFileControl.tsx +++ b/packages/core/src/widgets/file/withFileControl.tsx @@ -13,17 +13,22 @@ import Button from '@staticcms/core/components/common/button/Button'; import Field from '@staticcms/core/components/common/field/Field'; import Image from '@staticcms/core/components/common/image/Image'; import Link from '@staticcms/core/components/common/link/Link'; +import useDragHandlers from '@staticcms/core/lib/hooks/useDragHandlers'; import useMediaInsert from '@staticcms/core/lib/hooks/useMediaInsert'; +import useMediaPersist from '@staticcms/core/lib/hooks/useMediaPersist'; import useUUID from '@staticcms/core/lib/hooks/useUUID'; import { basename } from '@staticcms/core/lib/util'; import classNames from '@staticcms/core/lib/util/classNames.util'; import { KeyboardSensor, PointerSensor } from '@staticcms/core/lib/util/dnd.util'; import { isEmpty } from '@staticcms/core/lib/util/string.util'; +import { selectConfig } from '@staticcms/core/reducers/selectors/config'; +import { useAppSelector } from '@staticcms/core/store/hooks'; import SortableImage from './components/SortableImage'; import SortableLink from './components/SortableLink'; import type { DragEndEvent } from '@dnd-kit/core'; import type { FileOrImageField, MediaPath, WidgetControlProps } from '@staticcms/core/interface'; +import type AssetProxy from '@staticcms/core/valueObjects/AssetProxy'; import type { FC, MouseEvent } from 'react'; const MAX_DISPLAY_LENGTH = 50; @@ -128,6 +133,41 @@ const withFileControl = ({ forImage = false }: WithFileControlProps = {}) => { handleOnChange, ); + const config = useAppSelector(selectConfig); + + const handlePersistCallback = useCallback( + (_files: File[], assetProxies: (AssetProxy | null)[]) => { + const newPath = + assetProxies.length > 1 && allowsMultiple + ? [ + ...(Array.isArray(internalValue) ? internalValue : [internalValue]), + ...assetProxies.filter(f => f).map(f => f!.path), + ] + : assetProxies[0]?.path; + + if ((Array.isArray(newPath) && newPath.length === 0) || !newPath) { + return; + } + + handleOnChange({ + path: newPath, + }); + }, + [allowsMultiple, handleOnChange, internalValue], + ); + + /** + * Upload a file. + */ + const handlePersist = useMediaPersist({ + mediaConfig: field.media_library ?? config?.media_library, + field, + callback: handlePersistCallback, + }); + + const { dragOverActive, handleDragEnter, handleDragLeave, handleDragOver, handleDrop } = + useDragHandlers(handlePersist); + const chooseUrl = useMemo(() => field.choose_url ?? false, [field.choose_url]); const handleUrl = useCallback( @@ -417,20 +457,73 @@ const withFileControl = ({ forImage = false }: WithFileControlProps = {}) => { return useMemo( () => ( - - {content} - +
+ + {content} + +
+ {t(`mediaLibrary.mediaLibraryModal.${forImage ? 'dropImages' : 'dropFiles'}`)} +
+
+
), - [content, disabled, errors, field.hint, allowsMultiple, forSingleList, hasErrors, label], + [ + handleDrop, + handleDragEnter, + handleDragLeave, + handleDragOver, + dragOverActive, + allowsMultiple, + label, + errors, + hasErrors, + field.hint, + forSingleList, + disabled, + content, + t, + ], ); }, );