feat: folder support in media library (#687)
This commit is contained in:
@ -1,8 +1,13 @@
|
||||
import { Photo as PhotoIcon } from '@styled-icons/material/Photo';
|
||||
import { ArrowUpward as UpwardIcon } from '@styled-icons/material/ArrowUpward';
|
||||
import { Home as HomeIcon } from '@styled-icons/material/Home';
|
||||
import { CreateNewFolder as NewFolderIcon } from '@styled-icons/material/CreateNewFolder';
|
||||
import fuzzy from 'fuzzy';
|
||||
import isEmpty from 'lodash/isEmpty';
|
||||
import React, { useCallback, useEffect, useRef, useState } from 'react';
|
||||
import { translate } from 'react-polyglot';
|
||||
import { dirname } from 'path';
|
||||
import trim from 'lodash/trim';
|
||||
|
||||
import {
|
||||
closeMediaLibrary,
|
||||
@ -25,6 +30,9 @@ import EmptyMessage from './EmptyMessage';
|
||||
import FileUploadButton from './FileUploadButton';
|
||||
import MediaLibraryCardGrid from './MediaLibraryCardGrid';
|
||||
import MediaLibrarySearch from './MediaLibrarySearch';
|
||||
import { selectMediaFilePath, selectMediaFolder } from '@staticcms/core/lib/util/media.util';
|
||||
import { selectConfig } from '@staticcms/core/reducers/selectors/config';
|
||||
import { selectEditingDraft } from '@staticcms/core/reducers/selectors/entryDraft';
|
||||
|
||||
import type { MediaFile, TranslatedProps } from '@staticcms/core/interface';
|
||||
import type { ChangeEvent, FC, KeyboardEvent } from 'react';
|
||||
@ -51,6 +59,7 @@ interface MediaLibraryProps {
|
||||
}
|
||||
|
||||
const MediaLibrary: FC<TranslatedProps<MediaLibraryProps>> = ({ canInsert = false, t }) => {
|
||||
const [currentFolder, setCurrentFolder] = useState<string | undefined>(undefined);
|
||||
const [selectedFile, setSelectedFile] = useState<MediaFile | null>(null);
|
||||
const [query, setQuery] = useState<string | undefined>(undefined);
|
||||
|
||||
@ -74,17 +83,20 @@ const MediaLibrary: FC<TranslatedProps<MediaLibraryProps>> = ({ canInsert = fals
|
||||
insertOptions,
|
||||
} = useAppSelector(selectMediaLibraryState);
|
||||
|
||||
const config = useAppSelector(selectConfig);
|
||||
const entry = useAppSelector(selectEditingDraft);
|
||||
|
||||
const [url, setUrl] = useState<string | string[] | undefined>(initialValue ?? '');
|
||||
|
||||
const [alt, setAlt] = useState<string | undefined>(initialAlt);
|
||||
|
||||
const [prevIsVisible, setPrevIsVisible] = useState(false);
|
||||
|
||||
const files = useMediaFiles(field);
|
||||
|
||||
useEffect(() => {
|
||||
if (!prevIsVisible && isVisible) {
|
||||
setSelectedFile(null);
|
||||
setQuery('');
|
||||
setCurrentFolder(undefined);
|
||||
dispatch(loadMedia());
|
||||
} else if (prevIsVisible && !isVisible) {
|
||||
window.dispatchEvent(new MediaLibraryCloseEvent());
|
||||
@ -93,6 +105,8 @@ const MediaLibrary: FC<TranslatedProps<MediaLibraryProps>> = ({ canInsert = fals
|
||||
setPrevIsVisible(isVisible);
|
||||
}, [isVisible, dispatch, prevIsVisible]);
|
||||
|
||||
const files = useMediaFiles(field, currentFolder);
|
||||
|
||||
const loadDisplayURL = useCallback(
|
||||
(file: MediaFile) => {
|
||||
dispatch(loadMediaDisplayURL(file));
|
||||
@ -106,7 +120,7 @@ const MediaLibrary: FC<TranslatedProps<MediaLibraryProps>> = ({ canInsert = fals
|
||||
const filterImages = useCallback((files: MediaFile[]) => {
|
||||
return files.filter(file => {
|
||||
const ext = fileExtension(file.name).toLowerCase();
|
||||
return IMAGE_EXTENSIONS.includes(ext);
|
||||
return IMAGE_EXTENSIONS.includes(ext) || file.isDirectory;
|
||||
});
|
||||
}, []);
|
||||
|
||||
@ -116,7 +130,7 @@ const MediaLibrary: FC<TranslatedProps<MediaLibraryProps>> = ({ canInsert = fals
|
||||
const toTableData = useCallback((files: MediaFile[]) => {
|
||||
const tableData =
|
||||
files &&
|
||||
files.map(({ key, name, id, size, path, queryOrder, displayURL, draft }) => {
|
||||
files.map(({ key, name, id, size, path, queryOrder, displayURL, draft, isDirectory }) => {
|
||||
const ext = fileExtension(name).toLowerCase();
|
||||
return {
|
||||
key,
|
||||
@ -130,6 +144,7 @@ const MediaLibrary: FC<TranslatedProps<MediaLibraryProps>> = ({ canInsert = fals
|
||||
draft,
|
||||
isImage: IMAGE_EXTENSIONS.includes(ext),
|
||||
isViewableImage: IMAGE_EXTENSIONS_VIEWABLE.includes(ext),
|
||||
isDirectory,
|
||||
};
|
||||
});
|
||||
|
||||
@ -157,7 +172,7 @@ const MediaLibrary: FC<TranslatedProps<MediaLibraryProps>> = ({ canInsert = fals
|
||||
*/
|
||||
const handleAssetSelect = useCallback(
|
||||
(asset: MediaFile) => {
|
||||
if (!canInsert || selectedFile?.key === asset.key) {
|
||||
if (!canInsert || selectedFile?.key === asset.key || asset.isDirectory) {
|
||||
return;
|
||||
}
|
||||
|
||||
@ -214,7 +229,7 @@ const MediaLibrary: FC<TranslatedProps<MediaLibraryProps>> = ({ canInsert = fals
|
||||
},
|
||||
});
|
||||
} else {
|
||||
await dispatch(persistMedia(file, { field }));
|
||||
await dispatch(persistMedia(file, { field }, currentFolder));
|
||||
|
||||
setSelectedFile(files[0] as unknown as MediaFile);
|
||||
|
||||
@ -225,15 +240,15 @@ const MediaLibrary: FC<TranslatedProps<MediaLibraryProps>> = ({ canInsert = fals
|
||||
event.target.value = '';
|
||||
}
|
||||
},
|
||||
[mediaConfig.max_file_size, field, dispatch],
|
||||
[mediaConfig.max_file_size, field, dispatch, currentFolder],
|
||||
);
|
||||
|
||||
const handleURLChange = useCallback(
|
||||
(url: string) => {
|
||||
setUrl(url);
|
||||
dispatch(insertMedia(url, field, alt));
|
||||
dispatch(insertMedia(url, field, alt, currentFolder));
|
||||
},
|
||||
[alt, dispatch, field],
|
||||
[alt, dispatch, field, currentFolder],
|
||||
);
|
||||
|
||||
const handleAltChange = useCallback(
|
||||
@ -243,11 +258,51 @@ const MediaLibrary: FC<TranslatedProps<MediaLibraryProps>> = ({ canInsert = fals
|
||||
}
|
||||
|
||||
setAlt(alt);
|
||||
dispatch(insertMedia((url ?? selectedFile?.path) as string, field, alt));
|
||||
dispatch(insertMedia((url ?? selectedFile?.path) as string, field, alt, currentFolder));
|
||||
},
|
||||
[dispatch, field, selectedFile?.path, url],
|
||||
[dispatch, field, selectedFile?.path, url, currentFolder],
|
||||
);
|
||||
|
||||
const handleOpenDirectory = useCallback(
|
||||
(dir: string) => {
|
||||
const newDirectory = selectMediaFilePath(
|
||||
config!,
|
||||
collection!,
|
||||
entry,
|
||||
dir,
|
||||
field,
|
||||
currentFolder,
|
||||
);
|
||||
setSelectedFile(null);
|
||||
setQuery('');
|
||||
setCurrentFolder(newDirectory);
|
||||
dispatch(loadMedia({ currentFolder: newDirectory }));
|
||||
},
|
||||
[dispatch, currentFolder, collection, config, entry, field],
|
||||
);
|
||||
|
||||
const handleGoBack = useCallback(
|
||||
(toHome?: boolean) => {
|
||||
setSelectedFile(null);
|
||||
setQuery('');
|
||||
let newDirectory: string | undefined;
|
||||
if (toHome) {
|
||||
setCurrentFolder(undefined);
|
||||
} else {
|
||||
const mediaFolder = trim(selectMediaFolder(config!, collection, entry, field), '/');
|
||||
const dir = dirname(currentFolder!);
|
||||
newDirectory = dir.includes(mediaFolder) && trim(dir, '/') != mediaFolder ? dir : undefined;
|
||||
setCurrentFolder(newDirectory);
|
||||
}
|
||||
dispatch(loadMedia({ currentFolder: newDirectory }));
|
||||
},
|
||||
[dispatch, config, collection, entry, field, currentFolder],
|
||||
);
|
||||
|
||||
const handleCreateFolder = useCallback(() => {
|
||||
console.log('[createFolder]');
|
||||
}, []);
|
||||
|
||||
/**
|
||||
* Stores the public path of the file in the application store, where the
|
||||
* editor field that launched the media library can retrieve it.
|
||||
@ -259,12 +314,12 @@ const MediaLibrary: FC<TranslatedProps<MediaLibraryProps>> = ({ canInsert = fals
|
||||
|
||||
const { path } = selectedFile;
|
||||
setUrl(path);
|
||||
dispatch(insertMedia(path, field, alt));
|
||||
dispatch(insertMedia(path, field, alt, currentFolder));
|
||||
|
||||
if (!insertOptions?.chooseUrl && !insertOptions?.showAlt) {
|
||||
handleClose();
|
||||
}
|
||||
}, [selectedFile, dispatch, field, alt, insertOptions, handleClose]);
|
||||
}, [selectedFile, dispatch, field, alt, insertOptions, handleClose, currentFolder]);
|
||||
|
||||
/**
|
||||
* Removes the selected file from the backend.
|
||||
@ -364,7 +419,7 @@ const MediaLibrary: FC<TranslatedProps<MediaLibraryProps>> = ({ canInsert = fals
|
||||
onUrlChange={handleURLChange}
|
||||
onAltChange={handleAltChange}
|
||||
/>
|
||||
<div className="flex items-center px-5 pt-4">
|
||||
<div className="flex items-center px-5 pt-4 mb-4">
|
||||
<div className="flex flex-grow gap-4 mr-8">
|
||||
<h2
|
||||
className="
|
||||
@ -382,6 +437,24 @@ const MediaLibrary: FC<TranslatedProps<MediaLibraryProps>> = ({ canInsert = fals
|
||||
</div>
|
||||
{t('app.header.media')}
|
||||
</h2>
|
||||
{config?.media_library_folder_support ? (
|
||||
<div className="flex gap-3 items-center">
|
||||
<Button
|
||||
onClick={() => handleGoBack(true)}
|
||||
title={t('mediaLibrary.folderSupport.goBackToHome')}
|
||||
disabled={!currentFolder}
|
||||
>
|
||||
<HomeIcon className="h-5 w-5" />
|
||||
</Button>
|
||||
<Button
|
||||
onClick={() => handleGoBack()}
|
||||
title={t('mediaLibrary.folderSupport.goBack')}
|
||||
disabled={!currentFolder}
|
||||
>
|
||||
<UpwardIcon className="h-5 w-5" />
|
||||
</Button>
|
||||
</div>
|
||||
) : null}
|
||||
<MediaLibrarySearch
|
||||
value={query}
|
||||
onChange={handleSearchChange}
|
||||
@ -391,6 +464,14 @@ const MediaLibrary: FC<TranslatedProps<MediaLibraryProps>> = ({ canInsert = fals
|
||||
/>
|
||||
</div>
|
||||
<div className="flex gap-3 items-center relative z-20">
|
||||
{config?.media_library_folder_support ? (
|
||||
<Button
|
||||
onClick={() => handleCreateFolder()}
|
||||
title={t('mediaLibrary.folderSupport.onCreateTitle')}
|
||||
>
|
||||
<NewFolderIcon className="h-5 w-5"></NewFolderIcon>
|
||||
</Button>
|
||||
) : null}
|
||||
<FileUploadButton imagesOnly={forImage} onChange={handlePersist} />
|
||||
{canInsert ? (
|
||||
<Button
|
||||
@ -416,6 +497,8 @@ const MediaLibrary: FC<TranslatedProps<MediaLibraryProps>> = ({ canInsert = fals
|
||||
onAssetSelect={handleAssetSelect}
|
||||
canLoadMore={hasNextPage}
|
||||
onLoadMore={handleLoadMore}
|
||||
onDirectoryOpen={handleOpenDirectory}
|
||||
currentFolder={currentFolder}
|
||||
isPaginating={isPaginating}
|
||||
paginatingMessage={t('mediaLibrary.mediaLibraryModal.loading')}
|
||||
cardDraftText={t('mediaLibrary.mediaLibraryCard.draft')}
|
||||
|
@ -1,5 +1,6 @@
|
||||
import { Delete as DeleteIcon } from '@styled-icons/material/Delete';
|
||||
import { Download as DownloadIcon } from '@styled-icons/material/Download';
|
||||
import { FolderOpen as FolderIcon } from '@styled-icons/material/FolderOpen';
|
||||
import React, { useCallback, useEffect, useMemo } from 'react';
|
||||
import { translate } from 'react-polyglot';
|
||||
|
||||
@ -30,9 +31,12 @@ interface MediaLibraryCardProps<T extends MediaField, EF extends BaseField = Unk
|
||||
type?: string;
|
||||
isViewableImage: boolean;
|
||||
isDraft?: boolean;
|
||||
isDirectory?: boolean;
|
||||
collection?: Collection<EF>;
|
||||
field?: T;
|
||||
currentFolder?: string;
|
||||
onSelect: () => void;
|
||||
onDirectoryOpen: () => void;
|
||||
loadDisplayURL: () => void;
|
||||
onDelete: () => void;
|
||||
}
|
||||
@ -45,15 +49,18 @@ const MediaLibraryCard = <T extends MediaField, EF extends BaseField = UnknownFi
|
||||
type,
|
||||
isViewableImage,
|
||||
isDraft,
|
||||
isDirectory,
|
||||
collection,
|
||||
field,
|
||||
currentFolder,
|
||||
onSelect,
|
||||
onDirectoryOpen,
|
||||
loadDisplayURL,
|
||||
onDelete,
|
||||
t,
|
||||
}: TranslatedProps<MediaLibraryCardProps<T, EF>>) => {
|
||||
const entry = useAppSelector(selectEditingDraft);
|
||||
const url = useMediaAsset(displayURL.url, collection, field, entry);
|
||||
const url = useMediaAsset(displayURL.url, collection, field, entry, currentFolder);
|
||||
|
||||
const handleDownload = useCallback(() => {
|
||||
const url = displayURL.url;
|
||||
@ -109,6 +116,7 @@ const MediaLibraryCard = <T extends MediaField, EF extends BaseField = UnknownFi
|
||||
>
|
||||
<div
|
||||
onClick={onSelect}
|
||||
onDoubleClick={isDirectory ? onDirectoryOpen : undefined}
|
||||
data-testid={`media-card-${displayURL.url}`}
|
||||
className="
|
||||
w-media-card
|
||||
@ -168,36 +176,37 @@ const MediaLibraryCard = <T extends MediaField, EF extends BaseField = UnknownFi
|
||||
z-20
|
||||
"
|
||||
>
|
||||
<div
|
||||
className="
|
||||
{!isDirectory ? (
|
||||
<div
|
||||
className="
|
||||
absolute
|
||||
top-2
|
||||
right-2
|
||||
flex
|
||||
gap-1
|
||||
"
|
||||
>
|
||||
<CopyToClipBoardButton path={displayURL.url} name={text} draft={isDraft} />
|
||||
<Button
|
||||
variant="text"
|
||||
onClick={handleDownload}
|
||||
title={t('mediaLibrary.mediaLibraryModal.download')}
|
||||
className="
|
||||
>
|
||||
<CopyToClipBoardButton path={displayURL.url} name={text} draft={isDraft} />
|
||||
<Button
|
||||
variant="text"
|
||||
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
|
||||
"
|
||||
>
|
||||
<DownloadIcon className="w-5 h-5" />
|
||||
</Button>
|
||||
<Button
|
||||
variant="text"
|
||||
color="error"
|
||||
onClick={onDelete}
|
||||
title={t('mediaLibrary.mediaLibraryModal.deleteSelected')}
|
||||
className="
|
||||
>
|
||||
<DownloadIcon className="w-5 h-5" />
|
||||
</Button>
|
||||
<Button
|
||||
variant="text"
|
||||
color="error"
|
||||
onClick={onDelete}
|
||||
title={t('mediaLibrary.mediaLibraryModal.deleteSelected')}
|
||||
className="
|
||||
position: relative;
|
||||
text-red-400
|
||||
bg-gray-900/25
|
||||
@ -205,10 +214,11 @@ const MediaLibraryCard = <T extends MediaField, EF extends BaseField = UnknownFi
|
||||
dark:hover:bg-red-800/40
|
||||
z-30
|
||||
"
|
||||
>
|
||||
<DeleteIcon className="w-5 h-5" />
|
||||
</Button>
|
||||
</div>
|
||||
>
|
||||
<DeleteIcon className="w-5 h-5" />
|
||||
</Button>
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
<div className="relative">
|
||||
{isDraft ? (
|
||||
@ -218,6 +228,25 @@ const MediaLibraryCard = <T extends MediaField, EF extends BaseField = UnknownFi
|
||||
) : null}
|
||||
{url && isViewableImage ? (
|
||||
<Image src={url} className="w-media-card h-media-card-image rounded-md" />
|
||||
) : isDirectory ? (
|
||||
<div
|
||||
data-testid="card-file-icon"
|
||||
className="
|
||||
w-media-card
|
||||
h-media-card-image
|
||||
bg-gray-500
|
||||
dark:bg-slate-700
|
||||
text-gray-200
|
||||
dark:text-slate-400
|
||||
font-bold
|
||||
flex
|
||||
items-center
|
||||
justify-center
|
||||
text-5xl
|
||||
"
|
||||
>
|
||||
<FolderIcon className="w-24 h-24" />
|
||||
</div>
|
||||
) : (
|
||||
<div
|
||||
data-testid="card-file-icon"
|
||||
|
@ -29,6 +29,7 @@ export interface MediaLibraryCardItem {
|
||||
type: string;
|
||||
draft: boolean;
|
||||
isViewableImage?: boolean;
|
||||
isDirectory?: boolean;
|
||||
url?: string;
|
||||
}
|
||||
|
||||
@ -39,6 +40,8 @@ export interface MediaLibraryCardGridProps {
|
||||
onAssetSelect: (asset: MediaFile) => void;
|
||||
canLoadMore?: boolean;
|
||||
onLoadMore: () => void;
|
||||
onDirectoryOpen: (dir: string) => void;
|
||||
currentFolder?: string;
|
||||
isPaginating?: boolean;
|
||||
paginatingMessage?: string;
|
||||
cardDraftText: string;
|
||||
@ -62,6 +65,8 @@ const CardWrapper = ({
|
||||
mediaItems,
|
||||
isSelectedFile,
|
||||
onAssetSelect,
|
||||
onDirectoryOpen,
|
||||
currentFolder,
|
||||
cardDraftText,
|
||||
displayURLs,
|
||||
loadDisplayURL,
|
||||
@ -111,12 +116,15 @@ const CardWrapper = ({
|
||||
isSelected={isSelectedFile(file)}
|
||||
text={file.name}
|
||||
onSelect={() => onAssetSelect(file)}
|
||||
onDirectoryOpen={() => onDirectoryOpen(file.path)}
|
||||
currentFolder={currentFolder}
|
||||
isDraft={file.draft}
|
||||
draftText={cardDraftText}
|
||||
displayURL={displayURLs[file.id] ?? (file.url ? { url: file.url } : {})}
|
||||
loadDisplayURL={() => loadDisplayURL(file)}
|
||||
type={file.type}
|
||||
isViewableImage={file.isViewableImage ?? false}
|
||||
isDirectory={file.isDirectory ?? false}
|
||||
collection={collection}
|
||||
field={field}
|
||||
onDelete={() => onDelete(file)}
|
||||
|
Reference in New Issue
Block a user