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,
|
currentFolder?: string,
|
||||||
) {
|
) {
|
||||||
const { field } = opts;
|
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 state = getState();
|
||||||
const config = state.config.config;
|
const config = state.config.config;
|
||||||
if (!config) {
|
if (!config) {
|
||||||
return;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
const backend = currentBackend(config);
|
const backend = currentBackend(config);
|
||||||
@ -246,7 +249,7 @@ export function persistMedia(
|
|||||||
color: 'error',
|
color: 'error',
|
||||||
}))
|
}))
|
||||||
) {
|
) {
|
||||||
return;
|
return null;
|
||||||
} else {
|
} else {
|
||||||
await dispatch(deleteMedia(existingFile));
|
await dispatch(deleteMedia(existingFile));
|
||||||
}
|
}
|
||||||
@ -277,12 +280,14 @@ export function persistMedia(
|
|||||||
assetProxy,
|
assetProxy,
|
||||||
draft: Boolean(editingDraft),
|
draft: Boolean(editingDraft),
|
||||||
});
|
});
|
||||||
return dispatch(addDraftEntryMediaFile(mediaFile));
|
await dispatch(addDraftEntryMediaFile(mediaFile));
|
||||||
|
return assetProxy;
|
||||||
} else {
|
} else {
|
||||||
mediaFile = await backend.persistMedia(config, assetProxy);
|
mediaFile = await backend.persistMedia(config, assetProxy);
|
||||||
}
|
}
|
||||||
|
|
||||||
return dispatch(mediaPersisted(mediaFile, currentFolder));
|
await dispatch(mediaPersisted(mediaFile, currentFolder));
|
||||||
|
return assetProxy;
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error(error);
|
console.error(error);
|
||||||
dispatch(
|
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
|
<IconButton
|
||||||
className="
|
className="
|
||||||
absolute
|
absolute
|
||||||
-top-3.5
|
-top-3.5
|
||||||
-left-3.5
|
-left-3.5
|
||||||
bg-white
|
bg-white
|
||||||
hover:bg-gray-100
|
hover:bg-gray-100
|
||||||
dark:bg-slate-800
|
dark:bg-slate-800
|
||||||
dark:hover:bg-slate-900
|
dark:hover:bg-slate-900
|
||||||
"
|
z-[1]
|
||||||
|
"
|
||||||
variant="outlined"
|
variant="outlined"
|
||||||
aria-label="add"
|
aria-label="add"
|
||||||
onClick={handleClose}
|
onClick={handleClose}
|
||||||
|
@ -17,8 +17,10 @@ import {
|
|||||||
loadMediaDisplayURL,
|
loadMediaDisplayURL,
|
||||||
persistMedia,
|
persistMedia,
|
||||||
} from '@staticcms/core/actions/mediaLibrary';
|
} from '@staticcms/core/actions/mediaLibrary';
|
||||||
|
import useDragHandlers from '@staticcms/core/lib/hooks/useDragHandlers';
|
||||||
import useFolderSupport from '@staticcms/core/lib/hooks/useFolderSupport';
|
import useFolderSupport from '@staticcms/core/lib/hooks/useFolderSupport';
|
||||||
import useMediaFiles from '@staticcms/core/lib/hooks/useMediaFiles';
|
import useMediaFiles from '@staticcms/core/lib/hooks/useMediaFiles';
|
||||||
|
import useMediaPersist from '@staticcms/core/lib/hooks/useMediaPersist';
|
||||||
import { fileExtension } from '@staticcms/core/lib/util';
|
import { fileExtension } from '@staticcms/core/lib/util';
|
||||||
import classNames from '@staticcms/core/lib/util/classNames.util';
|
import classNames from '@staticcms/core/lib/util/classNames.util';
|
||||||
import MediaLibraryCloseEvent from '@staticcms/core/lib/util/events/MediaLibraryCloseEvent';
|
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 { selectEditingDraft } from '@staticcms/core/reducers/selectors/entryDraft';
|
||||||
import { selectMediaLibraryState } from '@staticcms/core/reducers/selectors/mediaLibrary';
|
import { selectMediaLibraryState } from '@staticcms/core/reducers/selectors/mediaLibrary';
|
||||||
import { useAppDispatch, useAppSelector } from '@staticcms/core/store/hooks';
|
import { useAppDispatch, useAppSelector } from '@staticcms/core/store/hooks';
|
||||||
import alert from '../../common/alert/Alert';
|
|
||||||
import Button from '../../common/button/Button';
|
import Button from '../../common/button/Button';
|
||||||
import IconButton from '../../common/button/IconButton';
|
import IconButton from '../../common/button/IconButton';
|
||||||
import confirm from '../../common/confirm/Confirm';
|
import confirm from '../../common/confirm/Confirm';
|
||||||
@ -69,7 +70,7 @@ const MediaLibrary: FC<TranslatedProps<MediaLibraryProps>> = ({
|
|||||||
t,
|
t,
|
||||||
}) => {
|
}) => {
|
||||||
const [currentFolder, setCurrentFolder] = useState<string | undefined>(undefined);
|
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 [query, setQuery] = useState<string | undefined>(undefined);
|
||||||
|
|
||||||
const config = useAppSelector(selectConfig);
|
const config = useAppSelector(selectConfig);
|
||||||
@ -184,19 +185,60 @@ const MediaLibrary: FC<TranslatedProps<MediaLibraryProps>> = ({
|
|||||||
* Toggle asset selection on click.
|
* Toggle asset selection on click.
|
||||||
*/
|
*/
|
||||||
const handleAssetSelect = useCallback(
|
const handleAssetSelect = useCallback(
|
||||||
(asset: MediaFile) => {
|
(asset: MediaFile, action: 'add' | 'remove' | 'replace') => {
|
||||||
if (
|
if (!canInsert || (!forFolder && asset.isDirectory) || (forFolder && !asset.isDirectory)) {
|
||||||
!canInsert ||
|
|
||||||
selectedFile?.key === asset.key ||
|
|
||||||
(!forFolder && asset.isDirectory) ||
|
|
||||||
(forFolder && !asset.isDirectory)
|
|
||||||
) {
|
|
||||||
return;
|
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);
|
const scrollContainerRef = useRef<HTMLDivElement | null>(null);
|
||||||
@ -209,58 +251,23 @@ const MediaLibrary: FC<TranslatedProps<MediaLibraryProps>> = ({
|
|||||||
/**
|
/**
|
||||||
* Upload a file.
|
* Upload a file.
|
||||||
*/
|
*/
|
||||||
const handlePersist = useCallback(
|
const handlePersist = useMediaPersist({
|
||||||
async (event: ChangeEvent<HTMLInputElement> | DragEvent) => {
|
mediaConfig,
|
||||||
/**
|
field,
|
||||||
* Stop the browser from automatically handling the file input click, and
|
currentFolder,
|
||||||
* get the file for upload, and retain the synthetic event for access after
|
callback: (_files, assets) => {
|
||||||
* the asynchronous persist operation.
|
if (assets.length === 1 && assets[0]) {
|
||||||
*/
|
setSelectedFile(assets[0].path);
|
||||||
|
} else if (field?.multiple) {
|
||||||
let fileList: FileList | null;
|
setSelectedFile(assets.filter(f => f).map(f => f!.path));
|
||||||
if ('dataTransfer' in event) {
|
|
||||||
fileList = event.dataTransfer?.files ?? null;
|
|
||||||
} else {
|
|
||||||
event.persist();
|
|
||||||
fileList = event.target.files;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!fileList) {
|
scrollToTop();
|
||||||
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 = '';
|
|
||||||
}
|
|
||||||
},
|
},
|
||||||
[mediaConfig.max_file_size, field, dispatch, currentFolder],
|
});
|
||||||
);
|
|
||||||
|
const { dragOverActive, handleDragEnter, handleDragLeave, handleDragOver, handleDrop } =
|
||||||
|
useDragHandlers(handlePersist);
|
||||||
|
|
||||||
const handleURLChange = useCallback(
|
const handleURLChange = useCallback(
|
||||||
(url: string) => {
|
(url: string) => {
|
||||||
@ -272,14 +279,14 @@ const MediaLibrary: FC<TranslatedProps<MediaLibraryProps>> = ({
|
|||||||
|
|
||||||
const handleAltChange = useCallback(
|
const handleAltChange = useCallback(
|
||||||
(alt: string) => {
|
(alt: string) => {
|
||||||
if (!url && !selectedFile?.path) {
|
if (!url && !selectedFile) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
setAlt(alt);
|
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(
|
const handleOpenDirectory = useCallback(
|
||||||
@ -375,13 +382,12 @@ const MediaLibrary: FC<TranslatedProps<MediaLibraryProps>> = ({
|
|||||||
* editor field that launched the media library can retrieve it.
|
* editor field that launched the media library can retrieve it.
|
||||||
*/
|
*/
|
||||||
const handleInsert = useCallback(() => {
|
const handleInsert = useCallback(() => {
|
||||||
if (!selectedFile?.path) {
|
if (!selectedFile) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const { path } = selectedFile;
|
setUrl(selectedFile);
|
||||||
setUrl(path);
|
dispatch(insertMedia(selectedFile, field, alt, currentFolder));
|
||||||
dispatch(insertMedia(path, field, alt, currentFolder));
|
|
||||||
|
|
||||||
if (!insertOptions?.chooseUrl && !insertOptions?.showAlt) {
|
if (!insertOptions?.chooseUrl && !insertOptions?.showAlt) {
|
||||||
handleClose();
|
handleClose();
|
||||||
@ -478,147 +484,203 @@ const MediaLibrary: FC<TranslatedProps<MediaLibraryProps>> = ({
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<div className="flex flex-col w-full h-full">
|
<div
|
||||||
<CurrentMediaDetails
|
onDrop={handleDrop}
|
||||||
collection={collection}
|
onDragEnter={handleDragEnter}
|
||||||
field={field}
|
onDragLeave={handleDragLeave}
|
||||||
canInsert={canInsert}
|
onDragOver={handleDragOver}
|
||||||
url={url}
|
className={classNames(
|
||||||
alt={alt}
|
`
|
||||||
insertOptions={insertOptions}
|
relative
|
||||||
forImage={forImage}
|
border-2
|
||||||
replaceIndex={replaceIndex}
|
transition-colors
|
||||||
onUrlChange={handleURLChange}
|
w-full
|
||||||
onAltChange={handleAltChange}
|
h-full
|
||||||
/>
|
`,
|
||||||
|
isDialog && 'rounded-lg',
|
||||||
|
dragOverActive ? 'border-blue-500' : 'border-transparent',
|
||||||
|
)}
|
||||||
|
>
|
||||||
<div
|
<div
|
||||||
className={classNames(
|
className="
|
||||||
`
|
-m-0.5
|
||||||
flex
|
w-full
|
||||||
items-center
|
h-full
|
||||||
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">
|
<div className="flex flex-col w-full h-full">
|
||||||
<h2
|
<CurrentMediaDetails
|
||||||
className="
|
collection={collection}
|
||||||
text-xl
|
field={field}
|
||||||
font-semibold
|
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
|
flex
|
||||||
items-center
|
items-center
|
||||||
gap-2
|
justify-center
|
||||||
text-gray-800
|
pointer-events-none
|
||||||
dark:text-gray-300
|
font-bold
|
||||||
"
|
text-blue-500
|
||||||
>
|
bg-white/75
|
||||||
<div className="flex items-center">
|
dark:text-blue-400
|
||||||
<PhotoIcon className="w-5 h-5" />
|
dark:bg-slate-800/75
|
||||||
</div>
|
transition-opacity
|
||||||
{t('app.header.media')}
|
`,
|
||||||
</h2>
|
isDialog && 'rounded-lg',
|
||||||
<MediaLibrarySearch
|
dragOverActive ? 'opacity-100' : 'opacity-0',
|
||||||
value={query}
|
)}
|
||||||
onChange={handleSearchChange}
|
>
|
||||||
onKeyDown={handleSearchKeyDown}
|
{t(`mediaLibrary.mediaLibraryModal.${forImage ? 'dropImages' : 'dropFiles'}`)}
|
||||||
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}
|
|
||||||
</div>
|
</div>
|
||||||
</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>
|
</div>
|
||||||
<FolderCreationDialog
|
<FolderCreationDialog
|
||||||
open={folderCreationOpen}
|
open={folderCreationOpen}
|
||||||
|
@ -9,6 +9,7 @@ import useMediaAsset from '@staticcms/core/lib/hooks/useMediaAsset';
|
|||||||
import { selectEditingDraft } from '@staticcms/core/reducers/selectors/entryDraft';
|
import { selectEditingDraft } from '@staticcms/core/reducers/selectors/entryDraft';
|
||||||
import { useAppSelector } from '@staticcms/core/store/hooks';
|
import { useAppSelector } from '@staticcms/core/store/hooks';
|
||||||
import Button from '../../common/button/Button';
|
import Button from '../../common/button/Button';
|
||||||
|
import Checkbox from '../../common/checkbox/Checkbox';
|
||||||
import Image from '../../common/image/Image';
|
import Image from '../../common/image/Image';
|
||||||
import Pill from '../../common/pill/Pill';
|
import Pill from '../../common/pill/Pill';
|
||||||
import CopyToClipBoardButton from './CopyToClipBoardButton';
|
import CopyToClipBoardButton from './CopyToClipBoardButton';
|
||||||
@ -21,7 +22,7 @@ import type {
|
|||||||
TranslatedProps,
|
TranslatedProps,
|
||||||
UnknownField,
|
UnknownField,
|
||||||
} from '@staticcms/core/interface';
|
} 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> {
|
interface MediaLibraryCardProps<T extends MediaField, EF extends BaseField = UnknownField> {
|
||||||
isSelected?: boolean;
|
isSelected?: boolean;
|
||||||
@ -36,7 +37,9 @@ interface MediaLibraryCardProps<T extends MediaField, EF extends BaseField = Unk
|
|||||||
collection?: Collection<EF>;
|
collection?: Collection<EF>;
|
||||||
field?: T;
|
field?: T;
|
||||||
currentFolder?: string;
|
currentFolder?: string;
|
||||||
onSelect: () => void;
|
hasSelection: boolean;
|
||||||
|
allowMultiple: boolean;
|
||||||
|
onSelect: (action: 'add' | 'remove' | 'replace') => void;
|
||||||
onDirectoryOpen: () => void;
|
onDirectoryOpen: () => void;
|
||||||
loadDisplayURL: () => void;
|
loadDisplayURL: () => void;
|
||||||
onDelete: () => void;
|
onDelete: () => void;
|
||||||
@ -55,6 +58,8 @@ const MediaLibraryCard = <T extends MediaField, EF extends BaseField = UnknownFi
|
|||||||
collection,
|
collection,
|
||||||
field,
|
field,
|
||||||
currentFolder,
|
currentFolder,
|
||||||
|
hasSelection,
|
||||||
|
allowMultiple,
|
||||||
onSelect,
|
onSelect,
|
||||||
onDirectoryOpen,
|
onDirectoryOpen,
|
||||||
loadDisplayURL,
|
loadDisplayURL,
|
||||||
@ -100,13 +105,26 @@ const MediaLibraryCard = <T extends MediaField, EF extends BaseField = UnknownFi
|
|||||||
|
|
||||||
const handleOnKeyUp = useCallback(
|
const handleOnKeyUp = useCallback(
|
||||||
(event: KeyboardEvent) => {
|
(event: KeyboardEvent) => {
|
||||||
if (event.key === 'Enter') {
|
if (event.code === 'Enter' || event.code === 'Space') {
|
||||||
onSelect();
|
onSelect('replace');
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
[onSelect],
|
[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 (
|
return (
|
||||||
<div
|
<div
|
||||||
className="
|
className="
|
||||||
@ -117,7 +135,7 @@ const MediaLibraryCard = <T extends MediaField, EF extends BaseField = UnknownFi
|
|||||||
tabIndex={-1}
|
tabIndex={-1}
|
||||||
>
|
>
|
||||||
<div
|
<div
|
||||||
onClick={onSelect}
|
onClick={handleClick}
|
||||||
onDoubleClick={isDirectory ? onDirectoryOpen : undefined}
|
onDoubleClick={isDirectory ? onDirectoryOpen : undefined}
|
||||||
data-testid={`media-card-${displayURL.url}`}
|
data-testid={`media-card-${displayURL.url}`}
|
||||||
className="
|
className="
|
||||||
@ -181,12 +199,12 @@ const MediaLibraryCard = <T extends MediaField, EF extends BaseField = UnknownFi
|
|||||||
{!isDirectory ? (
|
{!isDirectory ? (
|
||||||
<div
|
<div
|
||||||
className="
|
className="
|
||||||
absolute
|
absolute
|
||||||
top-2
|
top-2
|
||||||
right-2
|
right-2
|
||||||
flex
|
flex
|
||||||
gap-1
|
gap-1
|
||||||
"
|
"
|
||||||
>
|
>
|
||||||
<CopyToClipBoardButton path={displayURL.url} name={text} draft={isDraft} />
|
<CopyToClipBoardButton path={displayURL.url} name={text} draft={isDraft} />
|
||||||
<Button
|
<Button
|
||||||
@ -194,12 +212,12 @@ const MediaLibraryCard = <T extends MediaField, EF extends BaseField = UnknownFi
|
|||||||
onClick={handleDownload}
|
onClick={handleDownload}
|
||||||
title={t('mediaLibrary.mediaLibraryModal.download')}
|
title={t('mediaLibrary.mediaLibraryModal.download')}
|
||||||
className="
|
className="
|
||||||
text-white
|
text-white
|
||||||
dark:text-white
|
dark:text-white
|
||||||
bg-gray-900/25
|
bg-gray-900/25
|
||||||
dark:hover:text-blue-100
|
dark:hover:text-blue-100
|
||||||
dark:hover:bg-blue-800/80
|
dark:hover:bg-blue-800/80
|
||||||
"
|
"
|
||||||
>
|
>
|
||||||
<DownloadIcon className="w-5 h-5" />
|
<DownloadIcon className="w-5 h-5" />
|
||||||
</Button>
|
</Button>
|
||||||
@ -209,13 +227,13 @@ const MediaLibraryCard = <T extends MediaField, EF extends BaseField = UnknownFi
|
|||||||
onClick={onDelete}
|
onClick={onDelete}
|
||||||
title={t('mediaLibrary.mediaLibraryModal.deleteSelected')}
|
title={t('mediaLibrary.mediaLibraryModal.deleteSelected')}
|
||||||
className="
|
className="
|
||||||
position: relative;
|
position: relative;
|
||||||
text-red-400
|
text-red-400
|
||||||
bg-gray-900/25
|
bg-gray-900/25
|
||||||
dark:hover:text-red-600
|
dark:hover:text-red-600
|
||||||
dark:hover:bg-red-800/40
|
dark:hover:bg-red-800/40
|
||||||
z-30
|
z-30
|
||||||
"
|
"
|
||||||
>
|
>
|
||||||
<DeleteIcon className="w-5 h-5" />
|
<DeleteIcon className="w-5 h-5" />
|
||||||
</Button>
|
</Button>
|
||||||
@ -223,11 +241,26 @@ const MediaLibraryCard = <T extends MediaField, EF extends BaseField = UnknownFi
|
|||||||
) : null}
|
) : null}
|
||||||
</div>
|
</div>
|
||||||
<div className="relative">
|
<div className="relative">
|
||||||
{isDraft ? (
|
<div
|
||||||
<Pill data-testid="draft-text" color="primary" className="absolute top-3 left-3 z-20">
|
className="
|
||||||
{draftText}
|
absolute
|
||||||
</Pill>
|
top-3
|
||||||
) : null}
|
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 ? (
|
{url && isViewableImage ? (
|
||||||
<Image src={url} className="w-media-card h-media-card-image rounded-md" />
|
<Image src={url} className="w-media-card h-media-card-image rounded-md" />
|
||||||
) : isDirectory ? (
|
) : isDirectory ? (
|
||||||
|
@ -42,7 +42,7 @@ export interface MediaLibraryCardGridProps {
|
|||||||
scrollContainerRef: React.MutableRefObject<HTMLDivElement | null>;
|
scrollContainerRef: React.MutableRefObject<HTMLDivElement | null>;
|
||||||
mediaItems: MediaFile[];
|
mediaItems: MediaFile[];
|
||||||
isSelectedFile: (file: MediaFile) => boolean;
|
isSelectedFile: (file: MediaFile) => boolean;
|
||||||
onAssetSelect: (asset: MediaFile) => void;
|
onAssetSelect: (asset: MediaFile, action: 'add' | 'remove' | 'replace') => void;
|
||||||
canLoadMore?: boolean;
|
canLoadMore?: boolean;
|
||||||
onLoadMore: () => void;
|
onLoadMore: () => void;
|
||||||
onDirectoryOpen: (dir: string) => void;
|
onDirectoryOpen: (dir: string) => void;
|
||||||
@ -57,6 +57,8 @@ export interface MediaLibraryCardGridProps {
|
|||||||
field?: MediaField;
|
field?: MediaField;
|
||||||
isDialog: boolean;
|
isDialog: boolean;
|
||||||
onDelete: (file: MediaFile) => void;
|
onDelete: (file: MediaFile) => void;
|
||||||
|
hasSelection: boolean;
|
||||||
|
allowMultiple: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
export type CardGridItemData = MediaLibraryCardGridProps & {
|
export type CardGridItemData = MediaLibraryCardGridProps & {
|
||||||
@ -81,6 +83,8 @@ const CardWrapper = ({
|
|||||||
collection,
|
collection,
|
||||||
field,
|
field,
|
||||||
onDelete,
|
onDelete,
|
||||||
|
hasSelection,
|
||||||
|
allowMultiple,
|
||||||
},
|
},
|
||||||
}: GridChildComponentProps<CardGridItemData>) => {
|
}: GridChildComponentProps<CardGridItemData>) => {
|
||||||
const left = useMemo(
|
const left = useMemo(
|
||||||
@ -96,7 +100,7 @@ const CardWrapper = ({
|
|||||||
);
|
);
|
||||||
|
|
||||||
const top = useMemo(
|
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],
|
[style.top],
|
||||||
);
|
);
|
||||||
|
|
||||||
@ -120,7 +124,7 @@ const CardWrapper = ({
|
|||||||
key={file.key}
|
key={file.key}
|
||||||
isSelected={isSelectedFile(file)}
|
isSelected={isSelectedFile(file)}
|
||||||
text={file.name}
|
text={file.name}
|
||||||
onSelect={() => onAssetSelect(file)}
|
onSelect={action => onAssetSelect(file, action)}
|
||||||
onDirectoryOpen={() => onDirectoryOpen(file.path)}
|
onDirectoryOpen={() => onDirectoryOpen(file.path)}
|
||||||
currentFolder={currentFolder}
|
currentFolder={currentFolder}
|
||||||
isDraft={file.draft}
|
isDraft={file.draft}
|
||||||
@ -134,6 +138,8 @@ const CardWrapper = ({
|
|||||||
collection={collection}
|
collection={collection}
|
||||||
field={field}
|
field={field}
|
||||||
onDelete={() => onDelete(file)}
|
onDelete={() => onDelete(file)}
|
||||||
|
hasSelection={hasSelection}
|
||||||
|
allowMultiple={allowMultiple}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
@ -5,4 +5,5 @@ export { default as useIsMediaAsset } from './useIsMediaAsset';
|
|||||||
export { default as useMediaAsset } from './useMediaAsset';
|
export { default as useMediaAsset } from './useMediaAsset';
|
||||||
export { default as useMediaFiles } from './useMediaFiles';
|
export { default as useMediaFiles } from './useMediaFiles';
|
||||||
export { default as useMediaInsert } from './useMediaInsert';
|
export { default as useMediaInsert } from './useMediaInsert';
|
||||||
|
export { default as useMediaPersist } from './useMediaPersist';
|
||||||
export { default as useUUID } from './useUUID';
|
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(
|
const draftFiles = entryFolderFiles.filter(
|
||||||
file => file.draft == true && !files.find(f => f.id === file.id),
|
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 }));
|
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]);
|
}, [collection, config, currentFolderMediaFiles, entry, field, mediaLibraryFiles, currentFolder]);
|
||||||
|
|
||||||
return useMemo(
|
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],
|
[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...',
|
deleting: 'Deleting...',
|
||||||
deleteSelected: 'Delete selected',
|
deleteSelected: 'Delete selected',
|
||||||
chooseSelected: 'Choose selected',
|
chooseSelected: 'Choose selected',
|
||||||
|
dropImages: 'Drop images to upload',
|
||||||
|
dropFiles: 'Drop files to upload',
|
||||||
},
|
},
|
||||||
folderSupport: {
|
folderSupport: {
|
||||||
newFolder: 'New folder',
|
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 Field from '@staticcms/core/components/common/field/Field';
|
||||||
import Image from '@staticcms/core/components/common/image/Image';
|
import Image from '@staticcms/core/components/common/image/Image';
|
||||||
import Link from '@staticcms/core/components/common/link/Link';
|
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 useMediaInsert from '@staticcms/core/lib/hooks/useMediaInsert';
|
||||||
|
import useMediaPersist from '@staticcms/core/lib/hooks/useMediaPersist';
|
||||||
import useUUID from '@staticcms/core/lib/hooks/useUUID';
|
import useUUID from '@staticcms/core/lib/hooks/useUUID';
|
||||||
import { basename } from '@staticcms/core/lib/util';
|
import { basename } from '@staticcms/core/lib/util';
|
||||||
import classNames from '@staticcms/core/lib/util/classNames.util';
|
import classNames from '@staticcms/core/lib/util/classNames.util';
|
||||||
import { KeyboardSensor, PointerSensor } from '@staticcms/core/lib/util/dnd.util';
|
import { KeyboardSensor, PointerSensor } from '@staticcms/core/lib/util/dnd.util';
|
||||||
import { isEmpty } from '@staticcms/core/lib/util/string.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 SortableImage from './components/SortableImage';
|
||||||
import SortableLink from './components/SortableLink';
|
import SortableLink from './components/SortableLink';
|
||||||
|
|
||||||
import type { DragEndEvent } from '@dnd-kit/core';
|
import type { DragEndEvent } from '@dnd-kit/core';
|
||||||
import type { FileOrImageField, MediaPath, WidgetControlProps } from '@staticcms/core/interface';
|
import type { FileOrImageField, MediaPath, WidgetControlProps } from '@staticcms/core/interface';
|
||||||
|
import type AssetProxy from '@staticcms/core/valueObjects/AssetProxy';
|
||||||
import type { FC, MouseEvent } from 'react';
|
import type { FC, MouseEvent } from 'react';
|
||||||
|
|
||||||
const MAX_DISPLAY_LENGTH = 50;
|
const MAX_DISPLAY_LENGTH = 50;
|
||||||
@ -128,6 +133,41 @@ const withFileControl = ({ forImage = false }: WithFileControlProps = {}) => {
|
|||||||
handleOnChange,
|
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 chooseUrl = useMemo(() => field.choose_url ?? false, [field.choose_url]);
|
||||||
|
|
||||||
const handleUrl = useCallback(
|
const handleUrl = useCallback(
|
||||||
@ -417,20 +457,73 @@ const withFileControl = ({ forImage = false }: WithFileControlProps = {}) => {
|
|||||||
|
|
||||||
return useMemo(
|
return useMemo(
|
||||||
() => (
|
() => (
|
||||||
<Field
|
<div
|
||||||
inputRef={allowsMultiple ? undefined : uploadButtonRef}
|
onDrop={handleDrop}
|
||||||
label={label}
|
onDragEnter={handleDragEnter}
|
||||||
errors={errors}
|
onDragLeave={handleDragLeave}
|
||||||
noPadding={!hasErrors}
|
onDragOver={handleDragOver}
|
||||||
hint={field.hint}
|
className={classNames(
|
||||||
forSingleList={forSingleList}
|
`
|
||||||
cursor={allowsMultiple ? 'default' : 'pointer'}
|
relative
|
||||||
disabled={disabled}
|
border-2
|
||||||
|
transition-colors
|
||||||
|
`,
|
||||||
|
dragOverActive ? 'border-blue-500' : 'border-transparent',
|
||||||
|
)}
|
||||||
>
|
>
|
||||||
{content}
|
<div className="-m-0.5">
|
||||||
</Field>
|
<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