feat: folder creation (#696)

This commit is contained in:
Daniel Lautzenheiser
2023-04-12 15:18:32 -04:00
committed by GitHub
parent bc0331483a
commit 5384b5c7a2
24 changed files with 620 additions and 189 deletions

View File

@ -9,6 +9,7 @@ import {
selectFields,
selectTemplateName,
} from '@staticcms/core/lib/util/collection.util';
import { isNullish } from '@staticcms/core/lib/util/null.util';
import { selectConfig } from '@staticcms/core/reducers/selectors/config';
import { selectTheme } from '@staticcms/core/reducers/selectors/globalUI';
import { useAppSelector } from '@staticcms/core/store/hooks';
@ -120,6 +121,8 @@ const EntryCard = ({
value={value}
theme={theme}
/>
) : isNullish(value) ? (
''
) : (
String(value)
)}

View File

@ -8,7 +8,7 @@ import type { CSSProperties, FC, MouseEventHandler, ReactNode, Ref } from 'react
export interface BaseBaseProps {
variant?: 'contained' | 'outlined' | 'text';
color?: 'primary' | 'success' | 'error';
color?: 'primary' | 'secondary' | 'success' | 'error';
size?: 'medium' | 'small';
rounded?: boolean | 'no-padding';
className?: string;

View File

@ -8,16 +8,19 @@ const classes: Record<
> = {
contained: {
primary: 'btn-contained-primary',
secondary: 'btn-contained-secondary',
success: 'btn-contained-success',
error: 'btn-contained-error',
},
outlined: {
primary: 'btn-outlined-primary',
secondary: 'btn-outlined-secondary',
success: 'btn-outlined-success',
error: 'btn-outlined-error',
},
text: {
primary: 'btn-text-primary',
secondary: 'btn-text-secondary',
success: 'btn-text-success',
error: 'btn-text-error',
},

View File

@ -7,13 +7,16 @@ import classNames from '@staticcms/core/lib/util/classNames.util';
import type { ChangeEventHandler, FC, MouseEventHandler, Ref } from 'react';
export interface BaseTextFieldProps {
id?: string;
readonly?: boolean;
disabled?: boolean;
'data-testid'?: string;
onChange?: ChangeEventHandler<HTMLInputElement>;
onClick?: MouseEventHandler<HTMLInputElement>;
cursor?: 'default' | 'pointer' | 'text';
variant?: 'borderless' | 'contained';
inputRef?: Ref<HTMLInputElement>;
placeholder?: string;
}
export interface NumberTextFieldProps extends BaseTextFieldProps {
@ -36,6 +39,7 @@ const TextField: FC<TextFieldProps> = ({
type,
'data-testid': dataTestId,
cursor = 'default',
variant = 'borderless',
inputRef,
readonly,
disabled = false,
@ -66,17 +70,38 @@ const TextField: FC<TextFieldProps> = ({
className: classNames(
`
w-full
text-sm
`,
variant === 'borderless' &&
`
h-6
px-3
bg-transparent
outline-none
text-sm
font-medium
text-gray-900
disabled:text-gray-300
dark:text-gray-100
dark:disabled:text-gray-500
`,
variant === 'contained' &&
`
bg-gray-50
border
border-gray-300
text-gray-900
rounded-lg
focus:ring-blue-500
focus:border-blue-500
block
p-2.5
dark:bg-gray-700
dark:border-gray-600
dark:placeholder-gray-400
dark:text-white
dark:focus:ring-blue-500
dark:focus:border-blue-500
`,
finalCursor === 'pointer' && 'cursor-pointer',
finalCursor === 'text' && 'cursor-text',
finalCursor === 'default' && 'cursor-default',

View File

@ -44,7 +44,7 @@ const MediaLibraryModal: FC = () => {
>
<CloseIcon className="w-5 h-5" />
</IconButton>
<MediaLibrary canInsert />
<MediaLibrary canInsert isDialog />
</Modal>
);
};

View File

@ -42,6 +42,7 @@ const CurrentMediaDetails: FC<CurrentMediaDetailsProps> = ({
py-4
border-b
border-gray-200/75
dark:border-slate-500/75
"
>
<Image

View File

@ -0,0 +1,115 @@
import { Close as CloseIcon } from '@styled-icons/material/Close';
import React, { useCallback, useState } from 'react';
import { isEmpty } from '@staticcms/core/lib/util/string.util';
import Button from '../../common/button/Button';
import IconButton from '../../common/button/IconButton';
import Modal from '../../common/modal/Modal';
import TextField from '../../common/text-field/TextField';
import type { TranslatedProps } from '@staticcms/core/interface';
import type { ChangeEventHandler, FC } from 'react';
interface FolderCreationDialogProps {
open: boolean;
onClose: () => void;
onCreate: (folderName: string) => void;
}
const FolderCreationDialog: FC<TranslatedProps<FolderCreationDialogProps>> = ({
open,
onClose,
onCreate,
t,
}) => {
const [folderName, setFolderName] = useState('');
const handleFolderNameChange: ChangeEventHandler<HTMLInputElement> = useCallback(event => {
setFolderName(event.target.value);
}, []);
const handleCreate = useCallback(() => {
if (isEmpty(folderName)) {
return;
}
onCreate(folderName);
}, [folderName, onCreate]);
return (
<Modal
open={open}
onClose={onClose}
className="
w-[50%]
min-w-[300px]
max-w-[600px]
"
>
<div
className="
flex
items-start
justify-between
px-4
pt-4
pb-3
"
>
<h3
className="
text-xl
font-semibold
text-gray-900
dark:text-white
"
>
{t('mediaLibrary.folderSupport.createNewFolder')}
</h3>
<IconButton variant="text" aria-label="add" onClick={onClose}>
<CloseIcon className="w-5 h-5" />
</IconButton>
</div>
<div
className="
px-4
py-2
"
>
<TextField
id="folder_name"
type="text"
value={folderName}
onChange={handleFolderNameChange}
key="mobile-time-input"
data-testid="time-input"
cursor="pointer"
variant="contained"
placeholder={t('mediaLibrary.folderSupport.enterFolderName')}
/>
</div>
<div
className="
flex
items-center
justify-end
p-4
space-x-2
"
>
<Button variant="text" aria-label="cancel" onClick={onClose}>
Cancel
</Button>
<Button
variant="contained"
aria-label="create"
onClick={handleCreate}
disabled={isEmpty(folderName)}
>
Create
</Button>
</div>
</Modal>
);
};
export default FolderCreationDialog;

View File

@ -1,13 +1,14 @@
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 { FolderOpen as FolderOpenIcon } from '@styled-icons/material/FolderOpen';
import { Home as HomeIcon } from '@styled-icons/material/Home';
import { Photo as PhotoIcon } from '@styled-icons/material/Photo';
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 { dirname, join } from 'path';
import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react';
import { translate } from 'react-polyglot';
import {
closeMediaLibrary,
@ -19,20 +20,23 @@ import {
} from '@staticcms/core/actions/mediaLibrary';
import useMediaFiles from '@staticcms/core/lib/hooks/useMediaFiles';
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';
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 { 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';
import CurrentMediaDetails from './CurrentMediaDetails';
import EmptyMessage from './EmptyMessage';
import FileUploadButton from './FileUploadButton';
import FolderCreationDialog from './FolderCreationDialog';
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';
@ -56,9 +60,14 @@ const IMAGE_EXTENSIONS = [...IMAGE_EXTENSIONS_VIEWABLE];
interface MediaLibraryProps {
canInsert?: boolean;
isDialog?: boolean;
}
const MediaLibrary: FC<TranslatedProps<MediaLibraryProps>> = ({ canInsert = false, t }) => {
const MediaLibrary: FC<TranslatedProps<MediaLibraryProps>> = ({
canInsert = false,
isDialog = false,
t,
}) => {
const [currentFolder, setCurrentFolder] = useState<string | undefined>(undefined);
const [selectedFile, setSelectedFile] = useState<MediaFile | null>(null);
const [query, setQuery] = useState<string | undefined>(undefined);
@ -265,9 +274,13 @@ const MediaLibrary: FC<TranslatedProps<MediaLibraryProps>> = ({ canInsert = fals
const handleOpenDirectory = useCallback(
(dir: string) => {
if (!config) {
return;
}
const newDirectory = selectMediaFilePath(
config!,
collection!,
config,
collection,
entry,
dir,
field,
@ -281,28 +294,72 @@ const MediaLibrary: FC<TranslatedProps<MediaLibraryProps>> = ({ canInsert = fals
[dispatch, currentFolder, collection, config, entry, field],
);
const handleGoBack = useCallback(
(toHome?: boolean) => {
const mediaFolder = useMemo(() => {
if (!config) {
return undefined;
}
return trim(selectMediaFolder(config, collection, entry, field), '/');
}, [collection, config, entry, field]);
const parentFolder = useMemo(() => {
if (!config || !currentFolder) {
return undefined;
}
return dirname(currentFolder);
}, [config, currentFolder]);
const goToFolder = useCallback(
(folder: string | undefined) => {
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 }));
setCurrentFolder(folder);
dispatch(loadMedia({ currentFolder: folder }));
},
[dispatch, config, collection, entry, field, currentFolder],
[dispatch],
);
const handleHome = useCallback(() => {
goToFolder(undefined);
}, [goToFolder]);
const handleGoBack = useCallback(() => {
if (!mediaFolder) {
return;
}
goToFolder(
parentFolder?.includes(mediaFolder) && parentFolder !== mediaFolder
? parentFolder
: undefined,
);
}, [goToFolder, mediaFolder, parentFolder]);
const [folderCreationOpen, setFolderCreationOpen] = useState(false);
const handleCreateFolder = useCallback(() => {
console.log('[createFolder]');
setFolderCreationOpen(true);
}, []);
const handleFolderCreationDialogClose = useCallback(() => {
setFolderCreationOpen(false);
}, []);
const handleFolderCreate = useCallback(
async (folderName: string) => {
const folder = currentFolder ?? mediaFolder;
if (!folder) {
return;
}
setFolderCreationOpen(false);
const file = new File([''], '.gitkeep', { type: 'text/plain' });
await dispatch(
persistMedia(file, { field }, join(folder, folderName), currentFolder ?? mediaFolder),
);
},
[currentFolder, dispatch, field, mediaFolder],
);
/**
* Stores the public path of the file in the application store, where the
* editor field that launched the media library can retrieve it.
@ -408,108 +465,154 @@ const MediaLibrary: FC<TranslatedProps<MediaLibraryProps>> = ({ canInsert = fals
const hasSelection = hasMedia && !isEmpty(selectedFile);
return (
<div className="flex flex-col w-full h-full">
<CurrentMediaDetails
collection={collection}
field={field}
canInsert={canInsert}
url={url}
alt={alt}
insertOptions={insertOptions}
onUrlChange={handleURLChange}
onAltChange={handleAltChange}
/>
<div className="flex items-center px-5 pt-4 mb-4">
<div className="flex flex-grow gap-4 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>
{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}
onKeyDown={handleSearchKeyDown}
placeholder={t('mediaLibrary.mediaLibraryModal.search')}
disabled={!dynamicSearchActive && !hasFilteredFiles}
/>
</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
key="choose-selected"
color="success"
variant="contained"
onClick={handleInsert}
disabled={!hasSelection}
data-testid="choose-selected"
>
{t('mediaLibrary.mediaLibraryModal.chooseSelected')}
</Button>
) : null}
</div>
</div>
{!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}
<>
<div className="flex flex-col w-full h-full">
<CurrentMediaDetails
collection={collection}
field={field}
onDelete={handleDelete}
canInsert={canInsert}
url={url}
alt={alt}
insertOptions={insertOptions}
onUrlChange={handleURLChange}
onAltChange={handleAltChange}
/>
)}
</div>
<div
className={classNames(
`
flex
items-center
px-5
pt-4
`,
config?.media_library_folder_support &&
`
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}
/>
{config?.media_library_folder_support ? (
<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>
{config?.media_library_folder_support ? (
<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}
onClose={handleFolderCreationDialogClose}
onCreate={handleFolderCreate}
t={t}
/>
</>
);
};

View File

@ -1,6 +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 { FolderOpen as FolderOpenIcon } from '@styled-icons/material/FolderOpen';
import React, { useCallback, useEffect, useMemo } from 'react';
import { translate } from 'react-polyglot';
@ -245,7 +245,7 @@ const MediaLibraryCard = <T extends MediaField, EF extends BaseField = UnknownFi
text-5xl
"
>
<FolderIcon className="w-24 h-24" />
<FolderOpenIcon className="w-24 h-24" />
</div>
) : (
<div

View File

@ -9,6 +9,9 @@ import {
MEDIA_CARD_WIDTH,
MEDIA_LIBRARY_PADDING,
} from '@staticcms/core/constants/mediaLibrary';
import classNames from '@staticcms/core/lib/util/classNames.util';
import { selectConfig } from '@staticcms/core/reducers/selectors/config';
import { useAppSelector } from '@staticcms/core/store/hooks';
import MediaLibraryCard from './MediaLibraryCard';
import type {
@ -49,6 +52,7 @@ export interface MediaLibraryCardGridProps {
displayURLs: MediaLibraryState['displayURLs'];
collection?: Collection;
field?: Field;
isDialog: boolean;
onDelete: (file: MediaFile) => void;
}
@ -89,9 +93,7 @@ const CardWrapper = ({
);
const top = useMemo(
() =>
parseFloat(`${typeof style.top === 'number' ? style.top ?? 0 : style.top}`) +
MEDIA_LIBRARY_PADDING,
() => parseFloat(`${typeof style.top === 'number' ? style.top ?? 0 : style.top}`),
[style.top],
);
@ -134,7 +136,8 @@ const CardWrapper = ({
};
const MediaLibraryCardGrid: FC<MediaLibraryCardGridProps> = props => {
const { mediaItems, scrollContainerRef, canLoadMore, onLoadMore } = props;
const { mediaItems, scrollContainerRef, canLoadMore, isDialog, onLoadMore } = props;
const config = useAppSelector(selectConfig);
const [version, setVersion] = useState(0);
@ -154,7 +157,20 @@ const MediaLibraryCardGrid: FC<MediaLibraryCardGridProps> = props => {
const rowCount = Math.ceil(mediaItems.length / columnCount);
return (
<div key={version} ref={scrollContainerRef}>
<div
key={version}
className={classNames(
`
overflow-hidden
`,
isDialog && 'rounded-b-lg',
!config?.media_library_folder_support && 'pt-[20px]',
)}
style={{
width,
height,
}}
>
<Grid
columnCount={columnCount}
columnWidth={index =>
@ -163,20 +179,26 @@ const MediaLibraryCardGrid: FC<MediaLibraryCardGridProps> = props => {
rowCount={rowCount}
rowHeight={() => rowHeightWithGutter}
width={width}
height={height}
height={
height - (!config?.media_library_folder_support ? MEDIA_LIBRARY_PADDING : 0)
}
itemData={
{
...props,
columnCount,
} as CardGridItemData
}
className="
px-5
py-4
overflow-hidden
overflow-y-auto
styled-scrollbars
"
outerRef={scrollContainerRef}
className={classNames(
`
px-5
pb-2
overflow-hidden
overflow-y-auto
styled-scrollbars
`,
isDialog && 'styled-scrollbars-secondary',
)}
style={{ position: 'unset' }}
>
{CardWrapper}