feat: folder creation (#696)
This commit is contained in:
parent
bc0331483a
commit
5384b5c7a2
BIN
packages/core/dev-test/assets/uploads/Other Pics/lobby.jpg
Normal file
BIN
packages/core/dev-test/assets/uploads/Other Pics/lobby.jpg
Normal file
Binary file not shown.
After Width: | Height: | Size: 808 KiB |
BIN
packages/core/dev-test/assets/uploads/Other Pics/moby-dick.jpg
Normal file
BIN
packages/core/dev-test/assets/uploads/Other Pics/moby-dick.jpg
Normal file
Binary file not shown.
After Width: | Height: | Size: 310 KiB |
@ -2,6 +2,7 @@ backend:
|
|||||||
name: test-repo
|
name: test-repo
|
||||||
site_url: 'https://example.com'
|
site_url: 'https://example.com'
|
||||||
media_folder: assets/uploads
|
media_folder: assets/uploads
|
||||||
|
media_library_folder_support: true
|
||||||
locale: en
|
locale: en
|
||||||
i18n:
|
i18n:
|
||||||
# Required and can be one of multiple_folders, multiple_files or single_file
|
# Required and can be one of multiple_folders, multiple_files or single_file
|
||||||
|
@ -14,11 +14,7 @@
|
|||||||
'lobby.jpg': {
|
'lobby.jpg': {
|
||||||
content: '',
|
content: '',
|
||||||
},
|
},
|
||||||
},
|
'Other Pics': {
|
||||||
},
|
|
||||||
_posts: {
|
|
||||||
assets: {
|
|
||||||
uploads: {
|
|
||||||
'moby-dick.jpg': {
|
'moby-dick.jpg': {
|
||||||
content: '',
|
content: '',
|
||||||
},
|
},
|
||||||
@ -27,6 +23,8 @@
|
|||||||
},
|
},
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
},
|
||||||
|
_posts: {
|
||||||
'2015-02-14-this-is-a-post.md': {
|
'2015-02-14-this-is-a-post.md': {
|
||||||
content:
|
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',
|
'---\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',
|
||||||
|
@ -241,7 +241,12 @@ function createMediaFileFromAsset({
|
|||||||
return mediaFile;
|
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;
|
const { field } = opts;
|
||||||
return async (dispatch: ThunkDispatch<RootState, {}, AnyAction>, getState: () => RootState) => {
|
return async (dispatch: ThunkDispatch<RootState, {}, AnyAction>, getState: () => RootState) => {
|
||||||
const state = getState();
|
const state = getState();
|
||||||
@ -287,7 +292,7 @@ export function persistMedia(file: File, opts: MediaOptions = {}, currentFolder?
|
|||||||
try {
|
try {
|
||||||
const entry = state.entryDraft.entry;
|
const entry = state.entryDraft.entry;
|
||||||
const collection = entry?.collection ? state.collections[entry.collection] : null;
|
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({
|
const assetProxy = createAssetProxy({
|
||||||
file,
|
file,
|
||||||
path,
|
path,
|
||||||
@ -310,7 +315,7 @@ export function persistMedia(file: File, opts: MediaOptions = {}, currentFolder?
|
|||||||
mediaFile = await backend.persistMedia(config, assetProxy);
|
mediaFile = await backend.persistMedia(config, assetProxy);
|
||||||
}
|
}
|
||||||
|
|
||||||
return dispatch(mediaPersisted(mediaFile));
|
return dispatch(mediaPersisted(mediaFile, currentFolder));
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error(error);
|
console.error(error);
|
||||||
dispatch(
|
dispatch(
|
||||||
@ -487,10 +492,10 @@ export function mediaPersisting() {
|
|||||||
return { type: MEDIA_PERSIST_REQUEST } as const;
|
return { type: MEDIA_PERSIST_REQUEST } as const;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function mediaPersisted(file: ImplementationMediaFile) {
|
export function mediaPersisted(file: ImplementationMediaFile, currentFolder: string | undefined) {
|
||||||
return {
|
return {
|
||||||
type: MEDIA_PERSIST_SUCCESS,
|
type: MEDIA_PERSIST_SUCCESS,
|
||||||
payload: { file },
|
payload: { file, currentFolder },
|
||||||
} as const;
|
} as const;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -2,10 +2,11 @@ import attempt from 'lodash/attempt';
|
|||||||
import isError from 'lodash/isError';
|
import isError from 'lodash/isError';
|
||||||
import take from 'lodash/take';
|
import take from 'lodash/take';
|
||||||
import unset from 'lodash/unset';
|
import unset from 'lodash/unset';
|
||||||
import { extname } from 'path';
|
import { basename, dirname } from 'path';
|
||||||
import { v4 as uuid } from 'uuid';
|
import { v4 as uuid } from 'uuid';
|
||||||
|
|
||||||
import { Cursor, CURSOR_COMPATIBILITY_SYMBOL } from '@staticcms/core/lib/util';
|
import { Cursor, CURSOR_COMPATIBILITY_SYMBOL } from '@staticcms/core/lib/util';
|
||||||
|
import { isNotEmpty } from '@staticcms/core/lib/util/string.util';
|
||||||
import AuthenticationPage from './AuthenticationPage';
|
import AuthenticationPage from './AuthenticationPage';
|
||||||
|
|
||||||
import type {
|
import type {
|
||||||
@ -20,7 +21,7 @@ import type {
|
|||||||
} from '@staticcms/core/interface';
|
} from '@staticcms/core/interface';
|
||||||
import type AssetProxy from '@staticcms/core/valueObjects/AssetProxy';
|
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 };
|
type RepoTree = { [key: string]: RepoFile | RepoTree };
|
||||||
|
|
||||||
declare global {
|
declare global {
|
||||||
@ -83,20 +84,36 @@ export function getFolderFiles(
|
|||||||
depth: number,
|
depth: number,
|
||||||
files = [] as RepoFile[],
|
files = [] as RepoFile[],
|
||||||
path = folder,
|
path = folder,
|
||||||
) {
|
includeFolders?: boolean,
|
||||||
|
): RepoFile[] {
|
||||||
if (depth <= 0) {
|
if (depth <= 0) {
|
||||||
return files;
|
return files;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (includeFolders) {
|
||||||
|
files.unshift({ isDirectory: true, content: '', path });
|
||||||
|
}
|
||||||
|
|
||||||
Object.keys(tree[folder] || {}).forEach(key => {
|
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;
|
const file = (tree[folder] as RepoTree)[key] as RepoFile;
|
||||||
if (!extension || key.endsWith(`.${extension}`)) {
|
if (!extension || key.endsWith(`.${extension}`)) {
|
||||||
files.unshift({ content: file.content, path: `${path}/${key}` });
|
files.unshift({ content: file.content, path: `${path}/${key}` });
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
const subTree = tree[folder] as RepoTree;
|
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();
|
return Promise.resolve();
|
||||||
}
|
}
|
||||||
|
|
||||||
async getMedia(mediaFolder = this.mediaFolder): Promise<ImplementationMediaFile[]> {
|
async getMedia(
|
||||||
|
mediaFolder = this.mediaFolder,
|
||||||
|
folderSupport?: boolean,
|
||||||
|
): Promise<ImplementationMediaFile[]> {
|
||||||
if (!mediaFolder) {
|
if (!mediaFolder) {
|
||||||
return [];
|
return [];
|
||||||
}
|
}
|
||||||
const files = getFolderFiles(window.repoFiles, mediaFolder.split('/')[0], '', 100).filter(f =>
|
const files = getFolderFiles(
|
||||||
f.path.startsWith(mediaFolder),
|
window.repoFiles,
|
||||||
);
|
mediaFolder.split('/')[0],
|
||||||
|
'',
|
||||||
|
100,
|
||||||
|
undefined,
|
||||||
|
undefined,
|
||||||
|
folderSupport,
|
||||||
|
).filter(f => {
|
||||||
|
return dirname(f.path) === mediaFolder;
|
||||||
|
});
|
||||||
|
|
||||||
return files.map(f => ({
|
return files.map(f => ({
|
||||||
name: f.path,
|
name: basename(f.path),
|
||||||
id: f.path,
|
id: f.path,
|
||||||
path: f.path,
|
path: f.path,
|
||||||
displayURL: f.path,
|
displayURL: f.path,
|
||||||
|
isDirectory: f.isDirectory ?? false,
|
||||||
}));
|
}));
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -242,7 +272,7 @@ export default class TestBackend implements BackendClass {
|
|||||||
id: path,
|
id: path,
|
||||||
displayURL: path,
|
displayURL: path,
|
||||||
path,
|
path,
|
||||||
name: path,
|
name: basename(path),
|
||||||
size: 1,
|
size: 1,
|
||||||
url: path,
|
url: path,
|
||||||
};
|
};
|
||||||
|
@ -9,6 +9,7 @@ import {
|
|||||||
selectFields,
|
selectFields,
|
||||||
selectTemplateName,
|
selectTemplateName,
|
||||||
} from '@staticcms/core/lib/util/collection.util';
|
} 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 { selectConfig } from '@staticcms/core/reducers/selectors/config';
|
||||||
import { selectTheme } from '@staticcms/core/reducers/selectors/globalUI';
|
import { selectTheme } from '@staticcms/core/reducers/selectors/globalUI';
|
||||||
import { useAppSelector } from '@staticcms/core/store/hooks';
|
import { useAppSelector } from '@staticcms/core/store/hooks';
|
||||||
@ -120,6 +121,8 @@ const EntryCard = ({
|
|||||||
value={value}
|
value={value}
|
||||||
theme={theme}
|
theme={theme}
|
||||||
/>
|
/>
|
||||||
|
) : isNullish(value) ? (
|
||||||
|
''
|
||||||
) : (
|
) : (
|
||||||
String(value)
|
String(value)
|
||||||
)}
|
)}
|
||||||
|
@ -8,7 +8,7 @@ import type { CSSProperties, FC, MouseEventHandler, ReactNode, Ref } from 'react
|
|||||||
|
|
||||||
export interface BaseBaseProps {
|
export interface BaseBaseProps {
|
||||||
variant?: 'contained' | 'outlined' | 'text';
|
variant?: 'contained' | 'outlined' | 'text';
|
||||||
color?: 'primary' | 'success' | 'error';
|
color?: 'primary' | 'secondary' | 'success' | 'error';
|
||||||
size?: 'medium' | 'small';
|
size?: 'medium' | 'small';
|
||||||
rounded?: boolean | 'no-padding';
|
rounded?: boolean | 'no-padding';
|
||||||
className?: string;
|
className?: string;
|
||||||
|
@ -8,16 +8,19 @@ const classes: Record<
|
|||||||
> = {
|
> = {
|
||||||
contained: {
|
contained: {
|
||||||
primary: 'btn-contained-primary',
|
primary: 'btn-contained-primary',
|
||||||
|
secondary: 'btn-contained-secondary',
|
||||||
success: 'btn-contained-success',
|
success: 'btn-contained-success',
|
||||||
error: 'btn-contained-error',
|
error: 'btn-contained-error',
|
||||||
},
|
},
|
||||||
outlined: {
|
outlined: {
|
||||||
primary: 'btn-outlined-primary',
|
primary: 'btn-outlined-primary',
|
||||||
|
secondary: 'btn-outlined-secondary',
|
||||||
success: 'btn-outlined-success',
|
success: 'btn-outlined-success',
|
||||||
error: 'btn-outlined-error',
|
error: 'btn-outlined-error',
|
||||||
},
|
},
|
||||||
text: {
|
text: {
|
||||||
primary: 'btn-text-primary',
|
primary: 'btn-text-primary',
|
||||||
|
secondary: 'btn-text-secondary',
|
||||||
success: 'btn-text-success',
|
success: 'btn-text-success',
|
||||||
error: 'btn-text-error',
|
error: 'btn-text-error',
|
||||||
},
|
},
|
||||||
|
@ -7,13 +7,16 @@ import classNames from '@staticcms/core/lib/util/classNames.util';
|
|||||||
import type { ChangeEventHandler, FC, MouseEventHandler, Ref } from 'react';
|
import type { ChangeEventHandler, FC, MouseEventHandler, Ref } from 'react';
|
||||||
|
|
||||||
export interface BaseTextFieldProps {
|
export interface BaseTextFieldProps {
|
||||||
|
id?: string;
|
||||||
readonly?: boolean;
|
readonly?: boolean;
|
||||||
disabled?: boolean;
|
disabled?: boolean;
|
||||||
'data-testid'?: string;
|
'data-testid'?: string;
|
||||||
onChange?: ChangeEventHandler<HTMLInputElement>;
|
onChange?: ChangeEventHandler<HTMLInputElement>;
|
||||||
onClick?: MouseEventHandler<HTMLInputElement>;
|
onClick?: MouseEventHandler<HTMLInputElement>;
|
||||||
cursor?: 'default' | 'pointer' | 'text';
|
cursor?: 'default' | 'pointer' | 'text';
|
||||||
|
variant?: 'borderless' | 'contained';
|
||||||
inputRef?: Ref<HTMLInputElement>;
|
inputRef?: Ref<HTMLInputElement>;
|
||||||
|
placeholder?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface NumberTextFieldProps extends BaseTextFieldProps {
|
export interface NumberTextFieldProps extends BaseTextFieldProps {
|
||||||
@ -36,6 +39,7 @@ const TextField: FC<TextFieldProps> = ({
|
|||||||
type,
|
type,
|
||||||
'data-testid': dataTestId,
|
'data-testid': dataTestId,
|
||||||
cursor = 'default',
|
cursor = 'default',
|
||||||
|
variant = 'borderless',
|
||||||
inputRef,
|
inputRef,
|
||||||
readonly,
|
readonly,
|
||||||
disabled = false,
|
disabled = false,
|
||||||
@ -66,17 +70,38 @@ const TextField: FC<TextFieldProps> = ({
|
|||||||
className: classNames(
|
className: classNames(
|
||||||
`
|
`
|
||||||
w-full
|
w-full
|
||||||
|
text-sm
|
||||||
|
`,
|
||||||
|
variant === 'borderless' &&
|
||||||
|
`
|
||||||
h-6
|
h-6
|
||||||
px-3
|
px-3
|
||||||
bg-transparent
|
bg-transparent
|
||||||
outline-none
|
outline-none
|
||||||
text-sm
|
|
||||||
font-medium
|
font-medium
|
||||||
text-gray-900
|
text-gray-900
|
||||||
disabled:text-gray-300
|
disabled:text-gray-300
|
||||||
dark:text-gray-100
|
dark:text-gray-100
|
||||||
dark:disabled:text-gray-500
|
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 === 'pointer' && 'cursor-pointer',
|
||||||
finalCursor === 'text' && 'cursor-text',
|
finalCursor === 'text' && 'cursor-text',
|
||||||
finalCursor === 'default' && 'cursor-default',
|
finalCursor === 'default' && 'cursor-default',
|
||||||
|
@ -44,7 +44,7 @@ const MediaLibraryModal: FC = () => {
|
|||||||
>
|
>
|
||||||
<CloseIcon className="w-5 h-5" />
|
<CloseIcon className="w-5 h-5" />
|
||||||
</IconButton>
|
</IconButton>
|
||||||
<MediaLibrary canInsert />
|
<MediaLibrary canInsert isDialog />
|
||||||
</Modal>
|
</Modal>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
@ -42,6 +42,7 @@ const CurrentMediaDetails: FC<CurrentMediaDetailsProps> = ({
|
|||||||
py-4
|
py-4
|
||||||
border-b
|
border-b
|
||||||
border-gray-200/75
|
border-gray-200/75
|
||||||
|
dark:border-slate-500/75
|
||||||
"
|
"
|
||||||
>
|
>
|
||||||
<Image
|
<Image
|
||||||
|
@ -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;
|
@ -1,13 +1,14 @@
|
|||||||
import { Photo as PhotoIcon } from '@styled-icons/material/Photo';
|
|
||||||
import { ArrowUpward as UpwardIcon } from '@styled-icons/material/ArrowUpward';
|
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 { 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 fuzzy from 'fuzzy';
|
||||||
import isEmpty from 'lodash/isEmpty';
|
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 trim from 'lodash/trim';
|
||||||
|
import { dirname, join } from 'path';
|
||||||
|
import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react';
|
||||||
|
import { translate } from 'react-polyglot';
|
||||||
|
|
||||||
import {
|
import {
|
||||||
closeMediaLibrary,
|
closeMediaLibrary,
|
||||||
@ -19,20 +20,23 @@ import {
|
|||||||
} from '@staticcms/core/actions/mediaLibrary';
|
} from '@staticcms/core/actions/mediaLibrary';
|
||||||
import useMediaFiles from '@staticcms/core/lib/hooks/useMediaFiles';
|
import useMediaFiles from '@staticcms/core/lib/hooks/useMediaFiles';
|
||||||
import { fileExtension } from '@staticcms/core/lib/util';
|
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 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 { 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 alert from '../../common/alert/Alert';
|
||||||
import Button from '../../common/button/Button';
|
import Button from '../../common/button/Button';
|
||||||
|
import IconButton from '../../common/button/IconButton';
|
||||||
import confirm from '../../common/confirm/Confirm';
|
import confirm from '../../common/confirm/Confirm';
|
||||||
import CurrentMediaDetails from './CurrentMediaDetails';
|
import CurrentMediaDetails from './CurrentMediaDetails';
|
||||||
import EmptyMessage from './EmptyMessage';
|
import EmptyMessage from './EmptyMessage';
|
||||||
import FileUploadButton from './FileUploadButton';
|
import FileUploadButton from './FileUploadButton';
|
||||||
|
import FolderCreationDialog from './FolderCreationDialog';
|
||||||
import MediaLibraryCardGrid from './MediaLibraryCardGrid';
|
import MediaLibraryCardGrid from './MediaLibraryCardGrid';
|
||||||
import MediaLibrarySearch from './MediaLibrarySearch';
|
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 { MediaFile, TranslatedProps } from '@staticcms/core/interface';
|
||||||
import type { ChangeEvent, FC, KeyboardEvent } from 'react';
|
import type { ChangeEvent, FC, KeyboardEvent } from 'react';
|
||||||
@ -56,9 +60,14 @@ const IMAGE_EXTENSIONS = [...IMAGE_EXTENSIONS_VIEWABLE];
|
|||||||
|
|
||||||
interface MediaLibraryProps {
|
interface MediaLibraryProps {
|
||||||
canInsert?: boolean;
|
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 [currentFolder, setCurrentFolder] = useState<string | undefined>(undefined);
|
||||||
const [selectedFile, setSelectedFile] = useState<MediaFile | null>(null);
|
const [selectedFile, setSelectedFile] = useState<MediaFile | null>(null);
|
||||||
const [query, setQuery] = useState<string | undefined>(undefined);
|
const [query, setQuery] = useState<string | undefined>(undefined);
|
||||||
@ -265,9 +274,13 @@ const MediaLibrary: FC<TranslatedProps<MediaLibraryProps>> = ({ canInsert = fals
|
|||||||
|
|
||||||
const handleOpenDirectory = useCallback(
|
const handleOpenDirectory = useCallback(
|
||||||
(dir: string) => {
|
(dir: string) => {
|
||||||
|
if (!config) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
const newDirectory = selectMediaFilePath(
|
const newDirectory = selectMediaFilePath(
|
||||||
config!,
|
config,
|
||||||
collection!,
|
collection,
|
||||||
entry,
|
entry,
|
||||||
dir,
|
dir,
|
||||||
field,
|
field,
|
||||||
@ -281,28 +294,72 @@ const MediaLibrary: FC<TranslatedProps<MediaLibraryProps>> = ({ canInsert = fals
|
|||||||
[dispatch, currentFolder, collection, config, entry, field],
|
[dispatch, currentFolder, collection, config, entry, field],
|
||||||
);
|
);
|
||||||
|
|
||||||
const handleGoBack = useCallback(
|
const mediaFolder = useMemo(() => {
|
||||||
(toHome?: boolean) => {
|
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);
|
setSelectedFile(null);
|
||||||
setQuery('');
|
setQuery('');
|
||||||
let newDirectory: string | undefined;
|
setCurrentFolder(folder);
|
||||||
if (toHome) {
|
dispatch(loadMedia({ currentFolder: folder }));
|
||||||
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],
|
[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(() => {
|
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
|
* Stores the public path of the file in the application store, where the
|
||||||
* editor field that launched the media library can retrieve it.
|
* 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);
|
const hasSelection = hasMedia && !isEmpty(selectedFile);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex flex-col w-full h-full">
|
<>
|
||||||
<CurrentMediaDetails
|
<div className="flex flex-col w-full h-full">
|
||||||
collection={collection}
|
<CurrentMediaDetails
|
||||||
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}
|
|
||||||
collection={collection}
|
collection={collection}
|
||||||
field={field}
|
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}
|
||||||
|
/>
|
||||||
|
</>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
@ -1,6 +1,6 @@
|
|||||||
import { Delete as DeleteIcon } from '@styled-icons/material/Delete';
|
import { Delete as DeleteIcon } from '@styled-icons/material/Delete';
|
||||||
import { Download as DownloadIcon } from '@styled-icons/material/Download';
|
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 React, { useCallback, useEffect, useMemo } from 'react';
|
||||||
import { translate } from 'react-polyglot';
|
import { translate } from 'react-polyglot';
|
||||||
|
|
||||||
@ -245,7 +245,7 @@ const MediaLibraryCard = <T extends MediaField, EF extends BaseField = UnknownFi
|
|||||||
text-5xl
|
text-5xl
|
||||||
"
|
"
|
||||||
>
|
>
|
||||||
<FolderIcon className="w-24 h-24" />
|
<FolderOpenIcon className="w-24 h-24" />
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<div
|
<div
|
||||||
|
@ -9,6 +9,9 @@ import {
|
|||||||
MEDIA_CARD_WIDTH,
|
MEDIA_CARD_WIDTH,
|
||||||
MEDIA_LIBRARY_PADDING,
|
MEDIA_LIBRARY_PADDING,
|
||||||
} from '@staticcms/core/constants/mediaLibrary';
|
} 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 MediaLibraryCard from './MediaLibraryCard';
|
||||||
|
|
||||||
import type {
|
import type {
|
||||||
@ -49,6 +52,7 @@ export interface MediaLibraryCardGridProps {
|
|||||||
displayURLs: MediaLibraryState['displayURLs'];
|
displayURLs: MediaLibraryState['displayURLs'];
|
||||||
collection?: Collection;
|
collection?: Collection;
|
||||||
field?: Field;
|
field?: Field;
|
||||||
|
isDialog: boolean;
|
||||||
onDelete: (file: MediaFile) => void;
|
onDelete: (file: MediaFile) => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -89,9 +93,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}`) +
|
|
||||||
MEDIA_LIBRARY_PADDING,
|
|
||||||
[style.top],
|
[style.top],
|
||||||
);
|
);
|
||||||
|
|
||||||
@ -134,7 +136,8 @@ const CardWrapper = ({
|
|||||||
};
|
};
|
||||||
|
|
||||||
const MediaLibraryCardGrid: FC<MediaLibraryCardGridProps> = props => {
|
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);
|
const [version, setVersion] = useState(0);
|
||||||
|
|
||||||
@ -154,7 +157,20 @@ const MediaLibraryCardGrid: FC<MediaLibraryCardGridProps> = props => {
|
|||||||
const rowCount = Math.ceil(mediaItems.length / columnCount);
|
const rowCount = Math.ceil(mediaItems.length / columnCount);
|
||||||
|
|
||||||
return (
|
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
|
<Grid
|
||||||
columnCount={columnCount}
|
columnCount={columnCount}
|
||||||
columnWidth={index =>
|
columnWidth={index =>
|
||||||
@ -163,20 +179,26 @@ const MediaLibraryCardGrid: FC<MediaLibraryCardGridProps> = props => {
|
|||||||
rowCount={rowCount}
|
rowCount={rowCount}
|
||||||
rowHeight={() => rowHeightWithGutter}
|
rowHeight={() => rowHeightWithGutter}
|
||||||
width={width}
|
width={width}
|
||||||
height={height}
|
height={
|
||||||
|
height - (!config?.media_library_folder_support ? MEDIA_LIBRARY_PADDING : 0)
|
||||||
|
}
|
||||||
itemData={
|
itemData={
|
||||||
{
|
{
|
||||||
...props,
|
...props,
|
||||||
columnCount,
|
columnCount,
|
||||||
} as CardGridItemData
|
} as CardGridItemData
|
||||||
}
|
}
|
||||||
className="
|
outerRef={scrollContainerRef}
|
||||||
px-5
|
className={classNames(
|
||||||
py-4
|
`
|
||||||
overflow-hidden
|
px-5
|
||||||
overflow-y-auto
|
pb-2
|
||||||
styled-scrollbars
|
overflow-hidden
|
||||||
"
|
overflow-y-auto
|
||||||
|
styled-scrollbars
|
||||||
|
`,
|
||||||
|
isDialog && 'styled-scrollbars-secondary',
|
||||||
|
)}
|
||||||
style={{ position: 'unset' }}
|
style={{ position: 'unset' }}
|
||||||
>
|
>
|
||||||
{CardWrapper}
|
{CardWrapper}
|
||||||
|
@ -1,14 +1,14 @@
|
|||||||
import { useEffect, useMemo, useState } from 'react';
|
|
||||||
import { dirname } from 'path';
|
|
||||||
import trim from 'lodash/trim';
|
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 { currentBackend } from '@staticcms/core/backend';
|
||||||
import { selectEditingDraft } from '@staticcms/core/reducers/selectors/entryDraft';
|
|
||||||
import { useAppSelector } from '@staticcms/core/store/hooks';
|
|
||||||
import { selectCollection } from '@staticcms/core/reducers/selectors/collections';
|
import { selectCollection } from '@staticcms/core/reducers/selectors/collections';
|
||||||
import { selectConfig } from '@staticcms/core/reducers/selectors/config';
|
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 { selectMediaFolder } from '../util/media.util';
|
||||||
import { currentBackend } from '@staticcms/core/backend';
|
|
||||||
|
|
||||||
import type { MediaField, MediaFile } from '@staticcms/core/interface';
|
import type { MediaField, MediaFile } from '@staticcms/core/interface';
|
||||||
|
|
||||||
@ -54,16 +54,35 @@ export default function useMediaFiles(field?: MediaField, currentFolder?: string
|
|||||||
};
|
};
|
||||||
}, [currentFolder, config, entry]);
|
}, [currentFolder, config, entry]);
|
||||||
|
|
||||||
return useMemo(() => {
|
const files = useMemo(() => {
|
||||||
if (entry) {
|
if (entry) {
|
||||||
const entryFiles = entry.mediaFiles ?? [];
|
const entryFiles = entry.mediaFiles ?? [];
|
||||||
if (config) {
|
if (config) {
|
||||||
const mediaFolder = selectMediaFolder(config, collection, entry, field, currentFolder);
|
const mediaFolder = selectMediaFolder(config, collection, entry, field, currentFolder);
|
||||||
const entryFolderFiles = entryFiles
|
const entryFolderFiles = entryFiles
|
||||||
.filter(f => {
|
.filter(f => {
|
||||||
|
if (f.name === '.gitkeep') {
|
||||||
|
const folder = dirname(f.path);
|
||||||
|
return dirname(folder) === mediaFolder;
|
||||||
|
}
|
||||||
|
|
||||||
return dirname(f.path) === 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 (currentFolderMediaFiles) {
|
||||||
if (entryFiles.length > 0) {
|
if (entryFiles.length > 0) {
|
||||||
const draftFiles = entryFolderFiles.filter(file => file.draft == true);
|
const draftFiles = entryFolderFiles.filter(file => file.draft == true);
|
||||||
@ -77,4 +96,6 @@ export default function useMediaFiles(field?: MediaField, currentFolder?: string
|
|||||||
|
|
||||||
return mediaLibraryFiles ?? [];
|
return mediaLibraryFiles ?? [];
|
||||||
}, [collection, config, currentFolderMediaFiles, entry, field, mediaLibraryFiles, currentFolder]);
|
}, [collection, config, currentFolderMediaFiles, entry, field, mediaLibraryFiles, currentFolder]);
|
||||||
|
|
||||||
|
return useMemo(() => files.filter(file => file.name !== '.gitkeep'), [files]);
|
||||||
}
|
}
|
||||||
|
@ -1,3 +1,7 @@
|
|||||||
export default function classNames(...classes: (string | undefined | null | false)[]) {
|
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(' ');
|
||||||
}
|
}
|
||||||
|
@ -294,7 +294,7 @@ export function selectMediaFilePublicPath<EF extends BaseField>(
|
|||||||
|
|
||||||
export function selectMediaFilePath(
|
export function selectMediaFilePath(
|
||||||
config: Config,
|
config: Config,
|
||||||
collection: Collection | null,
|
collection: Collection | null | undefined,
|
||||||
entryMap: Entry | null | undefined,
|
entryMap: Entry | null | undefined,
|
||||||
mediaPath: string,
|
mediaPath: string,
|
||||||
field: Field | undefined,
|
field: Field | undefined,
|
||||||
|
@ -249,10 +249,12 @@ const en: LocalePhrasesRoot = {
|
|||||||
chooseSelected: 'Choose selected',
|
chooseSelected: 'Choose selected',
|
||||||
},
|
},
|
||||||
folderSupport: {
|
folderSupport: {
|
||||||
onCreateTitle: 'Create new folder',
|
newFolder: 'New folder',
|
||||||
onCreateBody: 'Please enter a name for the new folder.',
|
createNewFolder: 'Create new folder',
|
||||||
goBackToHome: 'Go back to media folder.',
|
enterFolderName: 'Enter folder name...',
|
||||||
goBack: 'Go back to previous folder.',
|
home: 'Home',
|
||||||
|
up: 'Up',
|
||||||
|
upToFolder: 'Up to %{folder}',
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
ui: {
|
ui: {
|
||||||
|
@ -1,3 +1,4 @@
|
|||||||
|
import { basename, dirname } from 'path';
|
||||||
import { v4 as uuid } from 'uuid';
|
import { v4 as uuid } from 'uuid';
|
||||||
|
|
||||||
import {
|
import {
|
||||||
@ -208,13 +209,41 @@ function mediaLibrary(
|
|||||||
};
|
};
|
||||||
|
|
||||||
case MEDIA_PERSIST_SUCCESS: {
|
case MEDIA_PERSIST_SUCCESS: {
|
||||||
const { file } = action.payload;
|
const { file, currentFolder } = action.payload;
|
||||||
const fileWithKey = { ...file, key: uuid() };
|
const fileWithKey = { ...file, key: uuid() };
|
||||||
const files = state.files as MediaFile[];
|
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 {
|
return {
|
||||||
...state,
|
...state,
|
||||||
files: updatedFiles,
|
|
||||||
isPersisting: false,
|
isPersisting: false,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
@ -107,6 +107,66 @@
|
|||||||
dark:disabled:hover:bg-transparent;
|
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 {
|
.btn-contained-success {
|
||||||
@apply border
|
@apply border
|
||||||
border-transparent
|
border-transparent
|
||||||
@ -238,11 +298,21 @@
|
|||||||
--scrollbar-background: rgb(248 250 252);
|
--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 {
|
.dark .styled-scrollbars {
|
||||||
--scrollbar-foreground: rgba(30, 41, 59, 0.8);
|
--scrollbar-foreground: rgba(30, 41, 59, 0.8);
|
||||||
--scrollbar-background: rgb(15 23 42);
|
--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 {
|
.styled-scrollbars {
|
||||||
/* Foreground, Background */
|
/* Foreground, Background */
|
||||||
scrollbar-color: var(--scrollbar-foreground) var(--scrollbar-background);
|
scrollbar-color: var(--scrollbar-foreground) var(--scrollbar-background);
|
||||||
|
@ -2,6 +2,7 @@ backend:
|
|||||||
name: test-repo
|
name: test-repo
|
||||||
site_url: 'https://staticcms.org/'
|
site_url: 'https://staticcms.org/'
|
||||||
media_folder: assets/uploads
|
media_folder: assets/uploads
|
||||||
|
media_library_folder_support: true
|
||||||
locale: en
|
locale: en
|
||||||
i18n:
|
i18n:
|
||||||
# Required and can be one of multiple_folders, multiple_files or single_file
|
# Required and can be one of multiple_folders, multiple_files or single_file
|
||||||
|
@ -17,19 +17,17 @@
|
|||||||
"lobby.jpg": {
|
"lobby.jpg": {
|
||||||
content: "",
|
content: "",
|
||||||
},
|
},
|
||||||
},
|
'Other Pics': {
|
||||||
},
|
'moby-dick.jpg': {
|
||||||
_posts: {
|
content: '',
|
||||||
assets: {
|
|
||||||
uploads: {
|
|
||||||
"moby-dick.jpg": {
|
|
||||||
content: "",
|
|
||||||
},
|
},
|
||||||
"lobby.jpg": {
|
'lobby.jpg': {
|
||||||
content: "",
|
content: '',
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
},
|
||||||
|
_posts: {
|
||||||
"2015-02-14-this-is-a-post.md": {
|
"2015-02-14-this-is-a-post.md": {
|
||||||
content:
|
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",
|
"---\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",
|
||||||
|
Loading…
x
Reference in New Issue
Block a user