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
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
24 changed files with 620 additions and 189 deletions

Binary file not shown.

After

Width:  |  Height:  |  Size: 808 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 310 KiB

View File

@ -2,6 +2,7 @@ backend:
name: test-repo
site_url: 'https://example.com'
media_folder: assets/uploads
media_library_folder_support: true
locale: en
i18n:
# Required and can be one of multiple_folders, multiple_files or single_file

View File

@ -14,11 +14,7 @@
'lobby.jpg': {
content: '',
},
},
},
_posts: {
assets: {
uploads: {
'Other Pics': {
'moby-dick.jpg': {
content: '',
},
@ -27,6 +23,8 @@
},
},
},
},
_posts: {
'2015-02-14-this-is-a-post.md': {
content:
'---\ntitle: This is a YAML front matter post\ndraft: true\nimage: /assets/uploads/moby-dick.jpg\ndate: 2015-02-14T00:00:00.000Z\n---\n\n# I Am a Title in Markdown\n\nHello, world\n\n* One Thing\n* Another Thing\n* A Third Thing\n',

View File

@ -241,7 +241,12 @@ function createMediaFileFromAsset({
return mediaFile;
}
export function persistMedia(file: File, opts: MediaOptions = {}, currentFolder?: string) {
export function persistMedia(
file: File,
opts: MediaOptions = {},
targetFolder?: string,
currentFolder?: string,
) {
const { field } = opts;
return async (dispatch: ThunkDispatch<RootState, {}, AnyAction>, getState: () => RootState) => {
const state = getState();
@ -287,7 +292,7 @@ export function persistMedia(file: File, opts: MediaOptions = {}, currentFolder?
try {
const entry = state.entryDraft.entry;
const collection = entry?.collection ? state.collections[entry.collection] : null;
const path = selectMediaFilePath(config, collection, entry, fileName, field, currentFolder);
const path = selectMediaFilePath(config, collection, entry, fileName, field, targetFolder);
const assetProxy = createAssetProxy({
file,
path,
@ -310,7 +315,7 @@ export function persistMedia(file: File, opts: MediaOptions = {}, currentFolder?
mediaFile = await backend.persistMedia(config, assetProxy);
}
return dispatch(mediaPersisted(mediaFile));
return dispatch(mediaPersisted(mediaFile, currentFolder));
} catch (error) {
console.error(error);
dispatch(
@ -487,10 +492,10 @@ export function mediaPersisting() {
return { type: MEDIA_PERSIST_REQUEST } as const;
}
export function mediaPersisted(file: ImplementationMediaFile) {
export function mediaPersisted(file: ImplementationMediaFile, currentFolder: string | undefined) {
return {
type: MEDIA_PERSIST_SUCCESS,
payload: { file },
payload: { file, currentFolder },
} as const;
}

View File

@ -2,10 +2,11 @@ import attempt from 'lodash/attempt';
import isError from 'lodash/isError';
import take from 'lodash/take';
import unset from 'lodash/unset';
import { extname } from 'path';
import { basename, dirname } from 'path';
import { v4 as uuid } from 'uuid';
import { Cursor, CURSOR_COMPATIBILITY_SYMBOL } from '@staticcms/core/lib/util';
import { isNotEmpty } from '@staticcms/core/lib/util/string.util';
import AuthenticationPage from './AuthenticationPage';
import type {
@ -20,7 +21,7 @@ import type {
} from '@staticcms/core/interface';
import type AssetProxy from '@staticcms/core/valueObjects/AssetProxy';
type RepoFile = { path: string; content: string | AssetProxy };
type RepoFile = { path: string; content: string | AssetProxy; isDirectory?: boolean };
type RepoTree = { [key: string]: RepoFile | RepoTree };
declare global {
@ -83,20 +84,36 @@ export function getFolderFiles(
depth: number,
files = [] as RepoFile[],
path = folder,
) {
includeFolders?: boolean,
): RepoFile[] {
if (depth <= 0) {
return files;
}
if (includeFolders) {
files.unshift({ isDirectory: true, content: '', path });
}
Object.keys(tree[folder] || {}).forEach(key => {
if (extname(key)) {
const parts = key.split('.');
const keyExtension = parts.length > 1 ? parts[parts.length - 1] : '';
if (isNotEmpty(keyExtension)) {
const file = (tree[folder] as RepoTree)[key] as RepoFile;
if (!extension || key.endsWith(`.${extension}`)) {
files.unshift({ content: file.content, path: `${path}/${key}` });
}
} else {
const subTree = tree[folder] as RepoTree;
return getFolderFiles(subTree, key, extension, depth - 1, files, `${path}/${key}`);
return getFolderFiles(
subTree,
key,
extension,
depth - 1,
files,
`${path}/${key}`,
includeFolders,
);
}
});
@ -222,18 +239,31 @@ export default class TestBackend implements BackendClass {
return Promise.resolve();
}
async getMedia(mediaFolder = this.mediaFolder): Promise<ImplementationMediaFile[]> {
async getMedia(
mediaFolder = this.mediaFolder,
folderSupport?: boolean,
): Promise<ImplementationMediaFile[]> {
if (!mediaFolder) {
return [];
}
const files = getFolderFiles(window.repoFiles, mediaFolder.split('/')[0], '', 100).filter(f =>
f.path.startsWith(mediaFolder),
);
const files = getFolderFiles(
window.repoFiles,
mediaFolder.split('/')[0],
'',
100,
undefined,
undefined,
folderSupport,
).filter(f => {
return dirname(f.path) === mediaFolder;
});
return files.map(f => ({
name: f.path,
name: basename(f.path),
id: f.path,
path: f.path,
displayURL: f.path,
isDirectory: f.isDirectory ?? false,
}));
}
@ -242,7 +272,7 @@ export default class TestBackend implements BackendClass {
id: path,
displayURL: path,
path,
name: path,
name: basename(path),
size: 1,
url: path,
};

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}

View File

@ -1,14 +1,14 @@
import { useEffect, useMemo, useState } from 'react';
import { dirname } from 'path';
import trim from 'lodash/trim';
import { basename, dirname } from 'path';
import { useEffect, useMemo, useState } from 'react';
import { selectMediaLibraryFiles } from '@staticcms/core/reducers/selectors/mediaLibrary';
import { selectEditingDraft } from '@staticcms/core/reducers/selectors/entryDraft';
import { useAppSelector } from '@staticcms/core/store/hooks';
import { currentBackend } from '@staticcms/core/backend';
import { selectCollection } from '@staticcms/core/reducers/selectors/collections';
import { selectConfig } from '@staticcms/core/reducers/selectors/config';
import { selectEditingDraft } from '@staticcms/core/reducers/selectors/entryDraft';
import { selectMediaLibraryFiles } from '@staticcms/core/reducers/selectors/mediaLibrary';
import { useAppSelector } from '@staticcms/core/store/hooks';
import { selectMediaFolder } from '../util/media.util';
import { currentBackend } from '@staticcms/core/backend';
import type { MediaField, MediaFile } from '@staticcms/core/interface';
@ -54,16 +54,35 @@ export default function useMediaFiles(field?: MediaField, currentFolder?: string
};
}, [currentFolder, config, entry]);
return useMemo(() => {
const files = useMemo(() => {
if (entry) {
const entryFiles = entry.mediaFiles ?? [];
if (config) {
const mediaFolder = selectMediaFolder(config, collection, entry, field, currentFolder);
const entryFolderFiles = entryFiles
.filter(f => {
if (f.name === '.gitkeep') {
const folder = dirname(f.path);
return dirname(folder) === mediaFolder;
}
return dirname(f.path) === mediaFolder;
})
.map(file => ({ key: file.id, ...file }));
.map(file => {
if (file.name === '.gitkeep') {
const folder = dirname(file.path);
return {
key: folder,
id: folder,
name: basename(folder),
path: folder,
isDirectory: true,
draft: true,
} as MediaFile;
}
return { key: file.id, ...file };
});
if (currentFolderMediaFiles) {
if (entryFiles.length > 0) {
const draftFiles = entryFolderFiles.filter(file => file.draft == true);
@ -77,4 +96,6 @@ export default function useMediaFiles(field?: MediaField, currentFolder?: string
return mediaLibraryFiles ?? [];
}, [collection, config, currentFolderMediaFiles, entry, field, mediaLibraryFiles, currentFolder]);
return useMemo(() => files.filter(file => file.name !== '.gitkeep'), [files]);
}

View File

@ -1,3 +1,7 @@
export default function classNames(...classes: (string | undefined | null | false)[]) {
return classes.filter(Boolean).join(' ');
const filteredClasses = classes.filter(Boolean) as string[];
return filteredClasses
.map(value => value.replace(/\n/g, ' ').replace(/[ ]+/g, ' ').trim())
.join(' ');
}

View File

@ -294,7 +294,7 @@ export function selectMediaFilePublicPath<EF extends BaseField>(
export function selectMediaFilePath(
config: Config,
collection: Collection | null,
collection: Collection | null | undefined,
entryMap: Entry | null | undefined,
mediaPath: string,
field: Field | undefined,

View File

@ -249,10 +249,12 @@ const en: LocalePhrasesRoot = {
chooseSelected: 'Choose selected',
},
folderSupport: {
onCreateTitle: 'Create new folder',
onCreateBody: 'Please enter a name for the new folder.',
goBackToHome: 'Go back to media folder.',
goBack: 'Go back to previous folder.',
newFolder: 'New folder',
createNewFolder: 'Create new folder',
enterFolderName: 'Enter folder name...',
home: 'Home',
up: 'Up',
upToFolder: 'Up to %{folder}',
},
},
ui: {

View File

@ -1,3 +1,4 @@
import { basename, dirname } from 'path';
import { v4 as uuid } from 'uuid';
import {
@ -208,13 +209,41 @@ function mediaLibrary(
};
case MEDIA_PERSIST_SUCCESS: {
const { file } = action.payload;
const { file, currentFolder } = action.payload;
const fileWithKey = { ...file, key: uuid() };
const files = state.files as MediaFile[];
const updatedFiles = [fileWithKey, ...files];
const dir = dirname(file.path);
if (!currentFolder || dir === currentFolder) {
const updatedFiles: MediaFile[] = [fileWithKey, ...files];
return {
...state,
files: updatedFiles,
isPersisting: false,
};
}
const folder = files.find(otherFile => otherFile.isDirectory && otherFile.path === dir);
if (!folder) {
const updatedFiles: MediaFile[] = [
{
name: basename(dir),
id: dir,
path: dir,
isDirectory: true,
},
...files,
];
return {
...state,
files: updatedFiles,
isPersisting: false,
};
}
return {
...state,
files: updatedFiles,
isPersisting: false,
};
}

View File

@ -107,6 +107,66 @@
dark:disabled:hover:bg-transparent;
}
.btn-contained-secondary {
@apply border
font-medium
text-gray-600
bg-white
border-gray-200/75
hover:text-gray-700
hover:bg-gray-100
hover:border-gray-200/50
disabled:text-gray-300/75
disabled:bg-white/80
disabled:border-gray-200/5
dark:text-gray-300
dark:bg-gray-800
dark:border-gray-600/60
dark:hover:bg-gray-700
dark:hover:text-white
dark:hover:border-gray-600/80
dark:disabled:text-gray-400/20
dark:disabled:bg-gray-700/20
dark:disabled:border-gray-600/20;
}
.btn-outlined-secondary {
@apply text-gray-900
bg-transparent
border
border-gray-200
hover:bg-gray-100
hover:text-gray-700
hover:border-gray-200/50
disabled:text-gray-300/75
disabled:border-gray-200/40
disabled:hover:bg-transparent
dark:bg-transparent
dark:text-gray-300
dark:border-gray-600/60
dark:hover:bg-gray-700
dark:hover:text-white
dark:hover:border-gray-600/80
dark:disabled:text-gray-400/20
dark:disabled:border-gray-600/20
dark:disabled:hover:bg-transparent;
}
.btn-text-secondary {
@apply bg-transparent
text-gray-900
hover:text-gray-700
hover:bg-gray-100
disabled:text-gray-300/75
disabled:hover:bg-transparent
dark:text-gray-300
dark:hover:text-white
dark:hover:bg-gray-700
dark:disabled:text-gray-400/20
dark:disabled:border-gray-600/20
dark:disabled:hover:bg-transparent;
}
.btn-contained-success {
@apply border
border-transparent
@ -238,11 +298,21 @@
--scrollbar-background: rgb(248 250 252);
}
.styled-scrollbars.styled-scrollbars-secondary {
--scrollbar-foreground: rgba(100, 116, 139, 0.25);
--scrollbar-background: rgb(255 255 255);
}
.dark .styled-scrollbars {
--scrollbar-foreground: rgba(30, 41, 59, 0.8);
--scrollbar-background: rgb(15 23 42);
}
.dark .styled-scrollbars.styled-scrollbars-secondary {
--scrollbar-foreground: rgba(47, 64, 93, 0.8);
--scrollbar-background: rgb(30 41 59);
}
.styled-scrollbars {
/* Foreground, Background */
scrollbar-color: var(--scrollbar-foreground) var(--scrollbar-background);

View File

@ -2,6 +2,7 @@ backend:
name: test-repo
site_url: 'https://staticcms.org/'
media_folder: assets/uploads
media_library_folder_support: true
locale: en
i18n:
# Required and can be one of multiple_folders, multiple_files or single_file

View File

@ -17,19 +17,17 @@
"lobby.jpg": {
content: "",
},
},
},
_posts: {
assets: {
uploads: {
"moby-dick.jpg": {
content: "",
'Other Pics': {
'moby-dick.jpg': {
content: '',
},
"lobby.jpg": {
content: "",
'lobby.jpg': {
content: '',
},
},
},
},
_posts: {
"2015-02-14-this-is-a-post.md": {
content:
"---\ntitle: This is a YAML front matter post\nimage: /assets/uploads/moby-dick.jpg\ndate: 2015-02-14T00:00:00.000Z\n---\n\n# I Am a Title in Markdown\n\nHello, world\n\n* One Thing\n* Another Thing\n* A Third Thing\n",