feat: folder support in media library (#687)

This commit is contained in:
2023-04-11 20:51:40 +02:00
committed by GitHub
parent 49507d0b17
commit e6d3c1535a
24 changed files with 426 additions and 111 deletions

View File

@ -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')}

View File

@ -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"

View File

@ -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)}