feat: file drag and drop, multiple file select in media library (#783)
This commit is contained in:
parent
4ff3967e18
commit
e78ebbe65e
@ -215,11 +215,14 @@ export function persistMedia(
|
||||
currentFolder?: string,
|
||||
) {
|
||||
const { field } = opts;
|
||||
return async (dispatch: ThunkDispatch<RootState, {}, AnyAction>, getState: () => RootState) => {
|
||||
return async (
|
||||
dispatch: ThunkDispatch<RootState, {}, AnyAction>,
|
||||
getState: () => RootState,
|
||||
): Promise<AssetProxy | null> => {
|
||||
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;
|
||||
}
|
||||
};
|
||||
}
|
||||
|
117
packages/core/src/components/common/checkbox/Checkbox.tsx
Normal file
117
packages/core/src/components/common/checkbox/Checkbox.tsx
Normal file
@ -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<HTMLInputElement>;
|
||||
}
|
||||
|
||||
const Checkbox: FC<CheckboxProps> = ({ checked, disabled = false, onChange }) => {
|
||||
const inputRef = useRef<HTMLInputElement | null>(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 (
|
||||
<label
|
||||
className={classNames(
|
||||
`
|
||||
relative
|
||||
inline-flex
|
||||
items-center
|
||||
cursor-pointer
|
||||
`,
|
||||
disabled && 'cursor-default',
|
||||
)}
|
||||
onClick={handleNoop}
|
||||
onKeyDown={handleKeydown}
|
||||
>
|
||||
<input
|
||||
data-testid="switch-input"
|
||||
ref={inputRef}
|
||||
type="checkbox"
|
||||
checked={checked}
|
||||
className="sr-only peer"
|
||||
disabled={disabled}
|
||||
onChange={onChange}
|
||||
onClick={handleNoop}
|
||||
onKeyDown={handleKeydown}
|
||||
/>
|
||||
<div
|
||||
className={classNames(
|
||||
`
|
||||
w-6
|
||||
h-6
|
||||
peer
|
||||
peer-focus:ring-4
|
||||
peer-focus:ring-blue-300
|
||||
dark:peer-focus:ring-blue-800
|
||||
peer-checked:after:translate-x-full
|
||||
text-blue-600
|
||||
border-gray-300
|
||||
rounded
|
||||
focus:ring-blue-500
|
||||
dark:focus:ring-blue-600
|
||||
dark:ring-offset-gray-800
|
||||
focus:ring-2
|
||||
dark:border-gray-600
|
||||
select-none
|
||||
flex
|
||||
items-center
|
||||
justify-center
|
||||
`,
|
||||
disabled
|
||||
? `
|
||||
peer-checked:bg-blue-600/25
|
||||
peer-checked:after:border-gray-500/75
|
||||
bg-gray-100/75
|
||||
dark:bg-gray-700/75
|
||||
`
|
||||
: `
|
||||
peer-checked:bg-blue-600
|
||||
peer-checked:after:border-white
|
||||
bg-gray-100
|
||||
dark:bg-gray-700
|
||||
`,
|
||||
)}
|
||||
onClick={handleClick}
|
||||
onKeyDown={handleKeydown}
|
||||
>
|
||||
{checked ? (
|
||||
<CheckIcon
|
||||
className="
|
||||
w-5
|
||||
h-5
|
||||
text-white
|
||||
"
|
||||
onClick={handleClick}
|
||||
onKeyDown={handleKeydown}
|
||||
/>
|
||||
) : null}
|
||||
</div>
|
||||
</label>
|
||||
);
|
||||
};
|
||||
|
||||
export default Checkbox;
|
@ -29,14 +29,15 @@ const MediaLibraryModal: FC = () => {
|
||||
>
|
||||
<IconButton
|
||||
className="
|
||||
absolute
|
||||
-top-3.5
|
||||
-left-3.5
|
||||
bg-white
|
||||
hover:bg-gray-100
|
||||
dark:bg-slate-800
|
||||
dark:hover:bg-slate-900
|
||||
"
|
||||
absolute
|
||||
-top-3.5
|
||||
-left-3.5
|
||||
bg-white
|
||||
hover:bg-gray-100
|
||||
dark:bg-slate-800
|
||||
dark:hover:bg-slate-900
|
||||
z-[1]
|
||||
"
|
||||
variant="outlined"
|
||||
aria-label="add"
|
||||
onClick={handleClose}
|
||||
|
@ -17,8 +17,10 @@ import {
|
||||
loadMediaDisplayURL,
|
||||
persistMedia,
|
||||
} from '@staticcms/core/actions/mediaLibrary';
|
||||
import useDragHandlers from '@staticcms/core/lib/hooks/useDragHandlers';
|
||||
import useFolderSupport from '@staticcms/core/lib/hooks/useFolderSupport';
|
||||
import useMediaFiles from '@staticcms/core/lib/hooks/useMediaFiles';
|
||||
import useMediaPersist from '@staticcms/core/lib/hooks/useMediaPersist';
|
||||
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';
|
||||
@ -27,7 +29,6 @@ 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';
|
||||
@ -69,7 +70,7 @@ const MediaLibrary: FC<TranslatedProps<MediaLibraryProps>> = ({
|
||||
t,
|
||||
}) => {
|
||||
const [currentFolder, setCurrentFolder] = useState<string | undefined>(undefined);
|
||||
const [selectedFile, setSelectedFile] = useState<MediaFile | null>(null);
|
||||
const [selectedFile, setSelectedFile] = useState<string | string[] | null>(null);
|
||||
const [query, setQuery] = useState<string | undefined>(undefined);
|
||||
|
||||
const config = useAppSelector(selectConfig);
|
||||
@ -184,19 +185,60 @@ const MediaLibrary: FC<TranslatedProps<MediaLibraryProps>> = ({
|
||||
* 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<HTMLDivElement | null>(null);
|
||||
@ -209,58 +251,23 @@ const MediaLibrary: FC<TranslatedProps<MediaLibraryProps>> = ({
|
||||
/**
|
||||
* Upload a file.
|
||||
*/
|
||||
const handlePersist = useCallback(
|
||||
async (event: ChangeEvent<HTMLInputElement> | 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<TranslatedProps<MediaLibraryProps>> = ({
|
||||
|
||||
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<TranslatedProps<MediaLibraryProps>> = ({
|
||||
* 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<TranslatedProps<MediaLibraryProps>> = ({
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="flex flex-col w-full h-full">
|
||||
<CurrentMediaDetails
|
||||
collection={collection}
|
||||
field={field}
|
||||
canInsert={canInsert}
|
||||
url={url}
|
||||
alt={alt}
|
||||
insertOptions={insertOptions}
|
||||
forImage={forImage}
|
||||
replaceIndex={replaceIndex}
|
||||
onUrlChange={handleURLChange}
|
||||
onAltChange={handleAltChange}
|
||||
/>
|
||||
<div
|
||||
onDrop={handleDrop}
|
||||
onDragEnter={handleDragEnter}
|
||||
onDragLeave={handleDragLeave}
|
||||
onDragOver={handleDragOver}
|
||||
className={classNames(
|
||||
`
|
||||
relative
|
||||
border-2
|
||||
transition-colors
|
||||
w-full
|
||||
h-full
|
||||
`,
|
||||
isDialog && 'rounded-lg',
|
||||
dragOverActive ? 'border-blue-500' : 'border-transparent',
|
||||
)}
|
||||
>
|
||||
<div
|
||||
className={classNames(
|
||||
`
|
||||
flex
|
||||
items-center
|
||||
px-5
|
||||
pt-4
|
||||
`,
|
||||
folderSupport &&
|
||||
`
|
||||
pb-4
|
||||
border-b
|
||||
border-gray-200/75
|
||||
dark:border-slate-500/75
|
||||
`,
|
||||
)}
|
||||
className="
|
||||
-m-0.5
|
||||
w-full
|
||||
h-full
|
||||
"
|
||||
>
|
||||
<div className="flex flex-grow gap-3 mr-8">
|
||||
<h2
|
||||
className="
|
||||
text-xl
|
||||
font-semibold
|
||||
<div className="flex flex-col w-full h-full">
|
||||
<CurrentMediaDetails
|
||||
collection={collection}
|
||||
field={field}
|
||||
canInsert={canInsert}
|
||||
url={url}
|
||||
alt={alt}
|
||||
insertOptions={insertOptions}
|
||||
forImage={forImage}
|
||||
replaceIndex={replaceIndex}
|
||||
onUrlChange={handleURLChange}
|
||||
onAltChange={handleAltChange}
|
||||
/>
|
||||
<div
|
||||
className={classNames(
|
||||
`
|
||||
flex
|
||||
items-center
|
||||
px-5
|
||||
pt-4
|
||||
`,
|
||||
folderSupport &&
|
||||
`
|
||||
pb-4
|
||||
border-b
|
||||
border-gray-200/75
|
||||
dark:border-slate-500/75
|
||||
`,
|
||||
)}
|
||||
>
|
||||
<div className="flex flex-grow gap-3 mr-8">
|
||||
<h2
|
||||
className="
|
||||
text-xl
|
||||
font-semibold
|
||||
flex
|
||||
items-center
|
||||
gap-2
|
||||
text-gray-800
|
||||
dark:text-gray-300
|
||||
"
|
||||
>
|
||||
<div className="flex items-center">
|
||||
<PhotoIcon className="w-5 h-5" />
|
||||
</div>
|
||||
{t('app.header.media')}
|
||||
</h2>
|
||||
<MediaLibrarySearch
|
||||
value={query}
|
||||
onChange={handleSearchChange}
|
||||
onKeyDown={handleSearchKeyDown}
|
||||
placeholder={t('mediaLibrary.mediaLibraryModal.search')}
|
||||
disabled={!dynamicSearchActive && !hasFilteredFiles}
|
||||
/>
|
||||
{folderSupport ? (
|
||||
<div className="flex gap-1.5 items-center">
|
||||
<IconButton
|
||||
onClick={handleHome}
|
||||
title={t('mediaLibrary.folderSupport.home')}
|
||||
color="secondary"
|
||||
disabled={!currentFolder}
|
||||
>
|
||||
<HomeIcon className="h-5 w-5" />
|
||||
</IconButton>
|
||||
<IconButton
|
||||
onClick={handleGoBack}
|
||||
title={
|
||||
parentFolder
|
||||
? t('mediaLibrary.folderSupport.upToFolder', { folder: parentFolder })
|
||||
: t('mediaLibrary.folderSupport.up')
|
||||
}
|
||||
color="secondary"
|
||||
disabled={!parentFolder}
|
||||
>
|
||||
<UpwardIcon className="h-5 w-5" />
|
||||
</IconButton>
|
||||
<IconButton
|
||||
onClick={handleCreateFolder}
|
||||
title={t('mediaLibrary.folderSupport.newFolder')}
|
||||
color="secondary"
|
||||
>
|
||||
<NewFolderIcon className="h-5 w-5"></NewFolderIcon>
|
||||
</IconButton>
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
<div className="flex gap-3 items-center relative">
|
||||
<FileUploadButton imagesOnly={forImage} onChange={handlePersist} />
|
||||
{canInsert ? (
|
||||
<Button
|
||||
key="choose-selected"
|
||||
color="success"
|
||||
variant="contained"
|
||||
onClick={handleInsert}
|
||||
disabled={!hasSelection}
|
||||
data-testid="choose-selected"
|
||||
>
|
||||
{t('mediaLibrary.mediaLibraryModal.chooseSelected')}
|
||||
</Button>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
{folderSupport ? (
|
||||
<div
|
||||
className="
|
||||
flex
|
||||
gap-2
|
||||
items-center
|
||||
px-5
|
||||
py-4
|
||||
font-bold
|
||||
text-xl
|
||||
"
|
||||
>
|
||||
<FolderOpenIcon className="w-6 h-6" />
|
||||
{currentFolder ?? mediaFolder}
|
||||
</div>
|
||||
) : null}
|
||||
{!hasMedia ? (
|
||||
<EmptyMessage content={emptyMessage} />
|
||||
) : (
|
||||
<MediaLibraryCardGrid
|
||||
scrollContainerRef={scrollContainerRef}
|
||||
mediaItems={tableData}
|
||||
isSelectedFile={file =>
|
||||
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)}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
<div
|
||||
className={classNames(
|
||||
`
|
||||
absolute
|
||||
inset-0
|
||||
flex
|
||||
items-center
|
||||
gap-2
|
||||
text-gray-800
|
||||
dark:text-gray-300
|
||||
"
|
||||
>
|
||||
<div className="flex items-center">
|
||||
<PhotoIcon className="w-5 h-5" />
|
||||
</div>
|
||||
{t('app.header.media')}
|
||||
</h2>
|
||||
<MediaLibrarySearch
|
||||
value={query}
|
||||
onChange={handleSearchChange}
|
||||
onKeyDown={handleSearchKeyDown}
|
||||
placeholder={t('mediaLibrary.mediaLibraryModal.search')}
|
||||
disabled={!dynamicSearchActive && !hasFilteredFiles}
|
||||
/>
|
||||
{folderSupport ? (
|
||||
<div className="flex gap-1.5 items-center">
|
||||
<IconButton
|
||||
onClick={handleHome}
|
||||
title={t('mediaLibrary.folderSupport.home')}
|
||||
color="secondary"
|
||||
disabled={!currentFolder}
|
||||
>
|
||||
<HomeIcon className="h-5 w-5" />
|
||||
</IconButton>
|
||||
<IconButton
|
||||
onClick={handleGoBack}
|
||||
title={
|
||||
parentFolder
|
||||
? t('mediaLibrary.folderSupport.upToFolder', { folder: parentFolder })
|
||||
: t('mediaLibrary.folderSupport.up')
|
||||
}
|
||||
color="secondary"
|
||||
disabled={!parentFolder}
|
||||
>
|
||||
<UpwardIcon className="h-5 w-5" />
|
||||
</IconButton>
|
||||
<IconButton
|
||||
onClick={handleCreateFolder}
|
||||
title={t('mediaLibrary.folderSupport.newFolder')}
|
||||
color="secondary"
|
||||
>
|
||||
<NewFolderIcon className="h-5 w-5"></NewFolderIcon>
|
||||
</IconButton>
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
<div className="flex gap-3 items-center relative z-20">
|
||||
<FileUploadButton imagesOnly={forImage} onChange={handlePersist} />
|
||||
{canInsert ? (
|
||||
<Button
|
||||
key="choose-selected"
|
||||
color="success"
|
||||
variant="contained"
|
||||
onClick={handleInsert}
|
||||
disabled={!hasSelection}
|
||||
data-testid="choose-selected"
|
||||
>
|
||||
{t('mediaLibrary.mediaLibraryModal.chooseSelected')}
|
||||
</Button>
|
||||
) : 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'}`)}
|
||||
</div>
|
||||
</div>
|
||||
{folderSupport ? (
|
||||
<div
|
||||
className="
|
||||
flex
|
||||
gap-2
|
||||
items-center
|
||||
px-5
|
||||
py-4
|
||||
font-bold
|
||||
text-xl
|
||||
"
|
||||
>
|
||||
<FolderOpenIcon className="w-6 h-6" />
|
||||
{currentFolder ?? mediaFolder}
|
||||
</div>
|
||||
) : null}
|
||||
{!hasMedia ? (
|
||||
<EmptyMessage content={emptyMessage} />
|
||||
) : (
|
||||
<MediaLibraryCardGrid
|
||||
scrollContainerRef={scrollContainerRef}
|
||||
mediaItems={tableData}
|
||||
isSelectedFile={file => 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}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
<FolderCreationDialog
|
||||
open={folderCreationOpen}
|
||||
|
@ -9,6 +9,7 @@ import useMediaAsset from '@staticcms/core/lib/hooks/useMediaAsset';
|
||||
import { selectEditingDraft } from '@staticcms/core/reducers/selectors/entryDraft';
|
||||
import { useAppSelector } from '@staticcms/core/store/hooks';
|
||||
import Button from '../../common/button/Button';
|
||||
import Checkbox from '../../common/checkbox/Checkbox';
|
||||
import Image from '../../common/image/Image';
|
||||
import Pill from '../../common/pill/Pill';
|
||||
import CopyToClipBoardButton from './CopyToClipBoardButton';
|
||||
@ -21,7 +22,7 @@ import type {
|
||||
TranslatedProps,
|
||||
UnknownField,
|
||||
} from '@staticcms/core/interface';
|
||||
import type { FC, KeyboardEvent } from 'react';
|
||||
import type { ChangeEvent, FC, KeyboardEvent } from 'react';
|
||||
|
||||
interface MediaLibraryCardProps<T extends MediaField, EF extends BaseField = UnknownField> {
|
||||
isSelected?: boolean;
|
||||
@ -36,7 +37,9 @@ interface MediaLibraryCardProps<T extends MediaField, EF extends BaseField = Unk
|
||||
collection?: Collection<EF>;
|
||||
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 = <T extends MediaField, EF extends BaseField = UnknownFi
|
||||
collection,
|
||||
field,
|
||||
currentFolder,
|
||||
hasSelection,
|
||||
allowMultiple,
|
||||
onSelect,
|
||||
onDirectoryOpen,
|
||||
loadDisplayURL,
|
||||
@ -100,13 +105,26 @@ const MediaLibraryCard = <T extends MediaField, EF extends BaseField = UnknownFi
|
||||
|
||||
const handleOnKeyUp = useCallback(
|
||||
(event: KeyboardEvent) => {
|
||||
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<HTMLInputElement>) => {
|
||||
event.stopPropagation();
|
||||
event.preventDefault();
|
||||
onSelect(event.target.checked ? 'add' : 'remove');
|
||||
},
|
||||
[onSelect],
|
||||
);
|
||||
|
||||
return (
|
||||
<div
|
||||
className="
|
||||
@ -117,7 +135,7 @@ const MediaLibraryCard = <T extends MediaField, EF extends BaseField = UnknownFi
|
||||
tabIndex={-1}
|
||||
>
|
||||
<div
|
||||
onClick={onSelect}
|
||||
onClick={handleClick}
|
||||
onDoubleClick={isDirectory ? onDirectoryOpen : undefined}
|
||||
data-testid={`media-card-${displayURL.url}`}
|
||||
className="
|
||||
@ -181,12 +199,12 @@ const MediaLibraryCard = <T extends MediaField, EF extends BaseField = UnknownFi
|
||||
{!isDirectory ? (
|
||||
<div
|
||||
className="
|
||||
absolute
|
||||
top-2
|
||||
right-2
|
||||
flex
|
||||
gap-1
|
||||
"
|
||||
absolute
|
||||
top-2
|
||||
right-2
|
||||
flex
|
||||
gap-1
|
||||
"
|
||||
>
|
||||
<CopyToClipBoardButton path={displayURL.url} name={text} draft={isDraft} />
|
||||
<Button
|
||||
@ -194,12 +212,12 @@ const MediaLibraryCard = <T extends MediaField, EF extends BaseField = UnknownFi
|
||||
onClick={handleDownload}
|
||||
title={t('mediaLibrary.mediaLibraryModal.download')}
|
||||
className="
|
||||
text-white
|
||||
dark:text-white
|
||||
bg-gray-900/25
|
||||
dark:hover:text-blue-100
|
||||
dark:hover:bg-blue-800/80
|
||||
"
|
||||
text-white
|
||||
dark:text-white
|
||||
bg-gray-900/25
|
||||
dark:hover:text-blue-100
|
||||
dark:hover:bg-blue-800/80
|
||||
"
|
||||
>
|
||||
<DownloadIcon className="w-5 h-5" />
|
||||
</Button>
|
||||
@ -209,13 +227,13 @@ const MediaLibraryCard = <T extends MediaField, EF extends BaseField = UnknownFi
|
||||
onClick={onDelete}
|
||||
title={t('mediaLibrary.mediaLibraryModal.deleteSelected')}
|
||||
className="
|
||||
position: relative;
|
||||
text-red-400
|
||||
bg-gray-900/25
|
||||
dark:hover:text-red-600
|
||||
dark:hover:bg-red-800/40
|
||||
z-30
|
||||
"
|
||||
position: relative;
|
||||
text-red-400
|
||||
bg-gray-900/25
|
||||
dark:hover:text-red-600
|
||||
dark:hover:bg-red-800/40
|
||||
z-30
|
||||
"
|
||||
>
|
||||
<DeleteIcon className="w-5 h-5" />
|
||||
</Button>
|
||||
@ -223,11 +241,26 @@ const MediaLibraryCard = <T extends MediaField, EF extends BaseField = UnknownFi
|
||||
) : null}
|
||||
</div>
|
||||
<div className="relative">
|
||||
{isDraft ? (
|
||||
<Pill data-testid="draft-text" color="primary" className="absolute top-3 left-3 z-20">
|
||||
{draftText}
|
||||
</Pill>
|
||||
) : null}
|
||||
<div
|
||||
className="
|
||||
absolute
|
||||
top-3
|
||||
left-3
|
||||
flex
|
||||
items-center
|
||||
gap-1
|
||||
z-20
|
||||
"
|
||||
>
|
||||
{hasSelection && allowMultiple ? (
|
||||
<Checkbox checked={isSelected} onChange={handleCheckboxChange} />
|
||||
) : null}
|
||||
{isDraft ? (
|
||||
<Pill data-testid="draft-text" color="primary" className="">
|
||||
{draftText}
|
||||
</Pill>
|
||||
) : null}
|
||||
</div>
|
||||
{url && isViewableImage ? (
|
||||
<Image src={url} className="w-media-card h-media-card-image rounded-md" />
|
||||
) : isDirectory ? (
|
||||
|
@ -42,7 +42,7 @@ export interface MediaLibraryCardGridProps {
|
||||
scrollContainerRef: React.MutableRefObject<HTMLDivElement | null>;
|
||||
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<CardGridItemData>) => {
|
||||
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}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
|
@ -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';
|
||||
|
58
packages/core/src/lib/hooks/useDragHandlers.ts
Normal file
58
packages/core/src/lib/hooks/useDragHandlers.ts
Normal file
@ -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<UseDragHandlersState>({
|
||||
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],
|
||||
);
|
||||
}
|
@ -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],
|
||||
);
|
||||
}
|
||||
|
78
packages/core/src/lib/hooks/useMediaPersist.ts
Normal file
78
packages/core/src/lib/hooks/useMediaPersist.ts
Normal file
@ -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<HTMLInputElement> | 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],
|
||||
);
|
||||
}
|
@ -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',
|
||||
|
@ -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(
|
||||
() => (
|
||||
<Field
|
||||
inputRef={allowsMultiple ? undefined : uploadButtonRef}
|
||||
label={label}
|
||||
errors={errors}
|
||||
noPadding={!hasErrors}
|
||||
hint={field.hint}
|
||||
forSingleList={forSingleList}
|
||||
cursor={allowsMultiple ? 'default' : 'pointer'}
|
||||
disabled={disabled}
|
||||
<div
|
||||
onDrop={handleDrop}
|
||||
onDragEnter={handleDragEnter}
|
||||
onDragLeave={handleDragLeave}
|
||||
onDragOver={handleDragOver}
|
||||
className={classNames(
|
||||
`
|
||||
relative
|
||||
border-2
|
||||
transition-colors
|
||||
`,
|
||||
dragOverActive ? 'border-blue-500' : 'border-transparent',
|
||||
)}
|
||||
>
|
||||
{content}
|
||||
</Field>
|
||||
<div className="-m-0.5">
|
||||
<Field
|
||||
inputRef={allowsMultiple ? undefined : uploadButtonRef}
|
||||
label={label}
|
||||
errors={errors}
|
||||
noPadding={!hasErrors}
|
||||
hint={field.hint}
|
||||
forSingleList={forSingleList}
|
||||
cursor={allowsMultiple ? 'default' : 'pointer'}
|
||||
disabled={disabled}
|
||||
>
|
||||
{content}
|
||||
</Field>
|
||||
<div
|
||||
className={classNames(
|
||||
`
|
||||
absolute
|
||||
inset-0
|
||||
flex
|
||||
items-center
|
||||
justify-center
|
||||
pointer-events-none
|
||||
font-bold
|
||||
text-blue-500
|
||||
bg-white/75
|
||||
dark:text-blue-400
|
||||
dark:bg-slate-800/75
|
||||
transition-opacity
|
||||
`,
|
||||
dragOverActive ? 'opacity-100' : 'opacity-0',
|
||||
)}
|
||||
>
|
||||
{t(`mediaLibrary.mediaLibraryModal.${forImage ? 'dropImages' : 'dropFiles'}`)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
),
|
||||
[content, disabled, errors, field.hint, allowsMultiple, forSingleList, hasErrors, label],
|
||||
[
|
||||
handleDrop,
|
||||
handleDragEnter,
|
||||
handleDragLeave,
|
||||
handleDragOver,
|
||||
dragOverActive,
|
||||
allowsMultiple,
|
||||
label,
|
||||
errors,
|
||||
hasErrors,
|
||||
field.hint,
|
||||
forSingleList,
|
||||
disabled,
|
||||
content,
|
||||
t,
|
||||
],
|
||||
);
|
||||
},
|
||||
);
|
||||
|
Loading…
x
Reference in New Issue
Block a user