feat: folder support in media library (#687)
This commit is contained in:
parent
49507d0b17
commit
e6d3c1535a
@ -77,6 +77,7 @@ export function getAsset<T extends MediaField, EF extends BaseField = UnknownFie
|
|||||||
entry: Entry | null | undefined,
|
entry: Entry | null | undefined,
|
||||||
path: string,
|
path: string,
|
||||||
field?: T,
|
field?: T,
|
||||||
|
currentFolder?: string,
|
||||||
) {
|
) {
|
||||||
return (
|
return (
|
||||||
dispatch: ThunkDispatch<RootState, {}, AnyAction>,
|
dispatch: ThunkDispatch<RootState, {}, AnyAction>,
|
||||||
@ -93,6 +94,7 @@ export function getAsset<T extends MediaField, EF extends BaseField = UnknownFie
|
|||||||
entry,
|
entry,
|
||||||
path,
|
path,
|
||||||
field as Field,
|
field as Field,
|
||||||
|
currentFolder,
|
||||||
);
|
);
|
||||||
|
|
||||||
const { asset, isLoading } = state.medias[resolvedPath] || {};
|
const { asset, isLoading } = state.medias[resolvedPath] || {};
|
||||||
|
@ -139,7 +139,12 @@ export function closeMediaLibrary() {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
export function insertMedia(mediaPath: string | string[], field: Field | undefined, alt?: string) {
|
export function insertMedia(
|
||||||
|
mediaPath: string | string[],
|
||||||
|
field: Field | undefined,
|
||||||
|
alt?: string,
|
||||||
|
currentFolder?: string,
|
||||||
|
) {
|
||||||
return (dispatch: ThunkDispatch<RootState, {}, AnyAction>, getState: () => RootState) => {
|
return (dispatch: ThunkDispatch<RootState, {}, AnyAction>, getState: () => RootState) => {
|
||||||
const state = getState();
|
const state = getState();
|
||||||
const config = state.config.config;
|
const config = state.config.config;
|
||||||
@ -152,10 +157,17 @@ export function insertMedia(mediaPath: string | string[], field: Field | undefin
|
|||||||
const collection = state.collections[collectionName];
|
const collection = state.collections[collectionName];
|
||||||
if (Array.isArray(mediaPath)) {
|
if (Array.isArray(mediaPath)) {
|
||||||
mediaPath = mediaPath.map(path =>
|
mediaPath = mediaPath.map(path =>
|
||||||
selectMediaFilePublicPath(config, collection, path, entry, field),
|
selectMediaFilePublicPath(config, collection, path, entry, field, currentFolder),
|
||||||
);
|
);
|
||||||
} else {
|
} else {
|
||||||
mediaPath = selectMediaFilePublicPath(config, collection, mediaPath as string, entry, field);
|
mediaPath = selectMediaFilePublicPath(
|
||||||
|
config,
|
||||||
|
collection,
|
||||||
|
mediaPath as string,
|
||||||
|
entry,
|
||||||
|
field,
|
||||||
|
currentFolder,
|
||||||
|
);
|
||||||
}
|
}
|
||||||
dispatch(mediaInserted(mediaPath, alt));
|
dispatch(mediaInserted(mediaPath, alt));
|
||||||
};
|
};
|
||||||
@ -165,8 +177,10 @@ export function removeInsertedMedia(controlID: string) {
|
|||||||
return { type: MEDIA_REMOVE_INSERTED, payload: { controlID } } as const;
|
return { type: MEDIA_REMOVE_INSERTED, payload: { controlID } } as const;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function loadMedia(opts: { delay?: number; query?: string; page?: number } = {}) {
|
export function loadMedia(
|
||||||
const { delay = 0, page = 1 } = opts;
|
opts: { delay?: number; query?: string; page?: number; currentFolder?: string } = {},
|
||||||
|
) {
|
||||||
|
const { delay = 0, page = 1, currentFolder } = opts;
|
||||||
return async (dispatch: ThunkDispatch<RootState, {}, AnyAction>, getState: () => RootState) => {
|
return async (dispatch: ThunkDispatch<RootState, {}, AnyAction>, getState: () => RootState) => {
|
||||||
const state = getState();
|
const state = getState();
|
||||||
const config = state.config.config;
|
const config = state.config.config;
|
||||||
@ -179,7 +193,7 @@ export function loadMedia(opts: { delay?: number; query?: string; page?: number
|
|||||||
|
|
||||||
function loadFunction() {
|
function loadFunction() {
|
||||||
return backend
|
return backend
|
||||||
.getMedia()
|
.getMedia(currentFolder, config?.media_library_folder_support ?? false)
|
||||||
.then(files => dispatch(mediaLoaded(files)))
|
.then(files => dispatch(mediaLoaded(files)))
|
||||||
.catch((error: { status?: number }) => {
|
.catch((error: { status?: number }) => {
|
||||||
console.error(error);
|
console.error(error);
|
||||||
@ -227,7 +241,7 @@ function createMediaFileFromAsset({
|
|||||||
return mediaFile;
|
return mediaFile;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function persistMedia(file: File, opts: MediaOptions = {}) {
|
export function persistMedia(file: File, opts: MediaOptions = {}, 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();
|
||||||
@ -273,7 +287,7 @@ export function persistMedia(file: File, opts: MediaOptions = {}) {
|
|||||||
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);
|
const path = selectMediaFilePath(config, collection, entry, fileName, field, currentFolder);
|
||||||
const assetProxy = createAssetProxy({
|
const assetProxy = createAssetProxy({
|
||||||
file,
|
file,
|
||||||
path,
|
path,
|
||||||
|
@ -251,6 +251,7 @@ export interface MediaFile {
|
|||||||
queryOrder?: unknown;
|
queryOrder?: unknown;
|
||||||
isViewableImage?: boolean;
|
isViewableImage?: boolean;
|
||||||
type?: string;
|
type?: string;
|
||||||
|
isDirectory?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
interface BackupEntry {
|
interface BackupEntry {
|
||||||
@ -749,8 +750,8 @@ export class Backend<EF extends BaseField = UnknownField, BC extends BackendClas
|
|||||||
return entryValue;
|
return entryValue;
|
||||||
}
|
}
|
||||||
|
|
||||||
getMedia(folder?: string | undefined, mediaPath?: string | undefined) {
|
getMedia(folder?: string | undefined, folderSupport?: boolean, mediaPath?: string | undefined) {
|
||||||
return this.implementation.getMedia(folder, mediaPath);
|
return this.implementation.getMedia(folder, folderSupport, mediaPath);
|
||||||
}
|
}
|
||||||
|
|
||||||
getMediaFile(path: string) {
|
getMediaFile(path: string) {
|
||||||
@ -804,7 +805,11 @@ export class Backend<EF extends BaseField = UnknownField, BC extends BackendClas
|
|||||||
entry,
|
entry,
|
||||||
undefined,
|
undefined,
|
||||||
);
|
);
|
||||||
return this.implementation.getMedia(folder, mediaPath);
|
return this.implementation.getMedia(
|
||||||
|
folder,
|
||||||
|
configState.config?.media_library_folder_support ?? false,
|
||||||
|
mediaPath,
|
||||||
|
);
|
||||||
}),
|
}),
|
||||||
);
|
);
|
||||||
entry.mediaFiles = entry.mediaFiles.concat(...files);
|
entry.mediaFiles = entry.mediaFiles.concat(...files);
|
||||||
|
@ -188,7 +188,8 @@ export default class API {
|
|||||||
// doesn't.)
|
// doesn't.)
|
||||||
...(file.commit && file.commit.hash ? { id: this.getFileId(file.commit.hash, file.path) } : {}),
|
...(file.commit && file.commit.hash ? { id: this.getFileId(file.commit.hash, file.path) } : {}),
|
||||||
});
|
});
|
||||||
processFiles = (files: BitBucketFile[]) => files.filter(this.isFile).map(this.processFile);
|
processFiles = (files: BitBucketFile[], folderSupport?: boolean) =>
|
||||||
|
files.filter(file => (!folderSupport ? this.isFile(file) : true)).map(this.processFile);
|
||||||
|
|
||||||
readFile = async (
|
readFile = async (
|
||||||
path: string,
|
path: string,
|
||||||
@ -294,7 +295,7 @@ export default class API {
|
|||||||
})),
|
})),
|
||||||
])((cursor.data?.links as Record<string, unknown>)[action]);
|
])((cursor.data?.links as Record<string, unknown>)[action]);
|
||||||
|
|
||||||
listAllFiles = async (path: string, depth: number, branch: string) => {
|
listAllFiles = async (path: string, depth: number, branch: string, folderSupport?: boolean) => {
|
||||||
const { cursor: initialCursor, entries: initialEntries } = await this.listFiles(
|
const { cursor: initialCursor, entries: initialEntries } = await this.listFiles(
|
||||||
path,
|
path,
|
||||||
depth,
|
depth,
|
||||||
@ -311,7 +312,7 @@ export default class API {
|
|||||||
entries.push(...newEntries);
|
entries.push(...newEntries);
|
||||||
currentCursor = newCursor;
|
currentCursor = newCursor;
|
||||||
}
|
}
|
||||||
return this.processFiles(entries);
|
return this.processFiles(entries, folderSupport);
|
||||||
};
|
};
|
||||||
|
|
||||||
async uploadFiles(
|
async uploadFiles(
|
||||||
|
@ -351,12 +351,18 @@ export default class BitbucketBackend implements BackendClass {
|
|||||||
}));
|
}));
|
||||||
}
|
}
|
||||||
|
|
||||||
async getMedia(mediaFolder = this.mediaFolder) {
|
async getMedia(mediaFolder = this.mediaFolder, folderSupport?: boolean) {
|
||||||
if (!mediaFolder) {
|
if (!mediaFolder) {
|
||||||
return [];
|
return [];
|
||||||
}
|
}
|
||||||
return this.api!.listAllFiles(mediaFolder, 1, this.branch).then(files =>
|
return this.api!.listAllFiles(mediaFolder, 1, this.branch, folderSupport).then(files =>
|
||||||
files.map(({ id, name, path }) => ({ id, name, path, displayURL: { id, path } })),
|
files.map(({ id, name, path, type }) => ({
|
||||||
|
id,
|
||||||
|
name,
|
||||||
|
path,
|
||||||
|
displayURL: { id, path },
|
||||||
|
isDirectory: type === 'commit_directory',
|
||||||
|
})),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -391,8 +391,8 @@ export default class GitGateway implements BackendClass {
|
|||||||
return client.enabled && client.matchPath(path);
|
return client.enabled && client.matchPath(path);
|
||||||
}
|
}
|
||||||
|
|
||||||
getMedia(mediaFolder = this.mediaFolder) {
|
getMedia(mediaFolder = this.mediaFolder, folderSupport?: boolean) {
|
||||||
return this.backend!.getMedia(mediaFolder);
|
return this.backend!.getMedia(mediaFolder, folderSupport);
|
||||||
}
|
}
|
||||||
|
|
||||||
// this method memoizes this._getLargeMediaClient so that there can
|
// this method memoizes this._getLargeMediaClient so that there can
|
||||||
|
@ -323,6 +323,7 @@ export default class API {
|
|||||||
async listFiles(
|
async listFiles(
|
||||||
path: string,
|
path: string,
|
||||||
{ repoURL = this.repoURL, branch = this.branch, depth = 1 } = {},
|
{ repoURL = this.repoURL, branch = this.branch, depth = 1 } = {},
|
||||||
|
folderSupport?: boolean,
|
||||||
): Promise<{ type: string; id: string; name: string; path: string; size: number }[]> {
|
): Promise<{ type: string; id: string; name: string; path: string; size: number }[]> {
|
||||||
const folder = trim(path, '/');
|
const folder = trim(path, '/');
|
||||||
try {
|
try {
|
||||||
@ -336,10 +337,11 @@ export default class API {
|
|||||||
);
|
);
|
||||||
return (
|
return (
|
||||||
result.tree
|
result.tree
|
||||||
// filter only files and up to the required depth
|
// filter only files and/or folders up to the required depth
|
||||||
.filter(
|
.filter(
|
||||||
file =>
|
file =>
|
||||||
file.type === 'blob' && decodeURIComponent(file.path).split('/').length <= depth,
|
(!folderSupport ? file.type === 'blob' : true) &&
|
||||||
|
decodeURIComponent(file.path).split('/').length <= depth,
|
||||||
)
|
)
|
||||||
.map(file => ({
|
.map(file => ({
|
||||||
type: file.type,
|
type: file.type,
|
||||||
|
@ -281,5 +281,49 @@ describe('gitea API', () => {
|
|||||||
params: { recursive: 1 },
|
params: { recursive: 1 },
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
it('should get files and folders', async () => {
|
||||||
|
const api = new API({ branch: 'master', repo: 'owner/repo' });
|
||||||
|
|
||||||
|
const tree = [
|
||||||
|
{
|
||||||
|
path: 'image.png',
|
||||||
|
type: 'blob',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
path: 'dir1',
|
||||||
|
type: 'tree',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
path: 'dir1/nested-image.png',
|
||||||
|
type: 'blob',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
path: 'dir1/dir2',
|
||||||
|
type: 'tree',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
path: 'dir1/dir2/nested-image.png',
|
||||||
|
type: 'blob',
|
||||||
|
},
|
||||||
|
];
|
||||||
|
api.request = jest.fn().mockResolvedValue({ tree });
|
||||||
|
|
||||||
|
await expect(api.listFiles('media', {}, true)).resolves.toEqual([
|
||||||
|
{
|
||||||
|
path: 'media/image.png',
|
||||||
|
type: 'blob',
|
||||||
|
name: 'image.png',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
path: 'media/dir1',
|
||||||
|
type: 'tree',
|
||||||
|
name: 'dir1',
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
expect(api.request).toHaveBeenCalledTimes(1);
|
||||||
|
expect(api.request).toHaveBeenCalledWith('/repos/owner/repo/git/trees/master:media', {
|
||||||
|
params: {},
|
||||||
|
});
|
||||||
|
});
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
@ -285,15 +285,13 @@ export default class Gitea implements BackendClass {
|
|||||||
.catch(() => ({ file: { path, id: null }, data: '' }));
|
.catch(() => ({ file: { path, id: null }, data: '' }));
|
||||||
}
|
}
|
||||||
|
|
||||||
async getMedia(mediaFolder = this.mediaFolder) {
|
async getMedia(mediaFolder = this.mediaFolder, folderSupport?: boolean) {
|
||||||
if (!mediaFolder) {
|
if (!mediaFolder) {
|
||||||
return [];
|
return [];
|
||||||
}
|
}
|
||||||
return this.api!.listFiles(mediaFolder).then(files =>
|
return this.api!.listFiles(mediaFolder, undefined, folderSupport).then(files =>
|
||||||
files.map(({ id, name, size, path }) => {
|
files.map(({ id, name, size, path, type }) => {
|
||||||
// load media using getMediaDisplayURL to avoid token expiration with Gitlab raw content urls
|
return { id, name, size, displayURL: { id, path }, path, isDirectory: type === 'tree' };
|
||||||
// for private repositories
|
|
||||||
return { id, name, size, displayURL: { id, path }, path };
|
|
||||||
}),
|
}),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
@ -338,6 +338,7 @@ export default class API {
|
|||||||
async listFiles(
|
async listFiles(
|
||||||
path: string,
|
path: string,
|
||||||
{ repoURL = this.repoURL, branch = this.branch, depth = 1 } = {},
|
{ repoURL = this.repoURL, branch = this.branch, depth = 1 } = {},
|
||||||
|
folderSupport?: boolean,
|
||||||
): Promise<{ type: string; id: string; name: string; path: string; size: number }[]> {
|
): Promise<{ type: string; id: string; name: string; path: string; size: number }[]> {
|
||||||
const folder = trim(path, '/');
|
const folder = trim(path, '/');
|
||||||
try {
|
try {
|
||||||
@ -351,8 +352,12 @@ export default class API {
|
|||||||
);
|
);
|
||||||
return (
|
return (
|
||||||
result.tree
|
result.tree
|
||||||
// filter only files and up to the required depth
|
// filter only files and/or folders up to the required depth
|
||||||
.filter(file => file.type === 'blob' && file.path.split('/').length <= depth)
|
.filter(
|
||||||
|
file =>
|
||||||
|
(!folderSupport ? file.type === 'blob' : true) &&
|
||||||
|
file.path.split('/').length <= depth,
|
||||||
|
)
|
||||||
.map(file => ({
|
.map(file => ({
|
||||||
type: file.type,
|
type: file.type,
|
||||||
id: file.sha,
|
id: file.sha,
|
||||||
|
@ -314,5 +314,49 @@ describe('github API', () => {
|
|||||||
params: { recursive: 1 },
|
params: { recursive: 1 },
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
it('should get files and folders', async () => {
|
||||||
|
const api = new API({ branch: 'master', repo: 'owner/repo' });
|
||||||
|
|
||||||
|
const tree = [
|
||||||
|
{
|
||||||
|
path: 'image.png',
|
||||||
|
type: 'blob',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
path: 'dir1',
|
||||||
|
type: 'tree',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
path: 'dir1/nested-image.png',
|
||||||
|
type: 'blob',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
path: 'dir1/dir2',
|
||||||
|
type: 'tree',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
path: 'dir1/dir2/nested-image.png',
|
||||||
|
type: 'blob',
|
||||||
|
},
|
||||||
|
];
|
||||||
|
api.request = jest.fn().mockResolvedValue({ tree });
|
||||||
|
|
||||||
|
await expect(api.listFiles('media', {}, true)).resolves.toEqual([
|
||||||
|
{
|
||||||
|
path: 'media/image.png',
|
||||||
|
type: 'blob',
|
||||||
|
name: 'image.png',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
path: 'media/dir1',
|
||||||
|
type: 'tree',
|
||||||
|
name: 'dir1',
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
expect(api.request).toHaveBeenCalledTimes(1);
|
||||||
|
expect(api.request).toHaveBeenCalledWith('/repos/owner/repo/git/trees/master:media', {
|
||||||
|
params: {},
|
||||||
|
});
|
||||||
|
});
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
@ -314,15 +314,15 @@ export default class GitHub implements BackendClass {
|
|||||||
.catch(() => ({ file: { path, id: null }, data: '' }));
|
.catch(() => ({ file: { path, id: null }, data: '' }));
|
||||||
}
|
}
|
||||||
|
|
||||||
async getMedia(mediaFolder = this.mediaFolder) {
|
async getMedia(mediaFolder = this.mediaFolder, folderSupport?: boolean) {
|
||||||
if (!mediaFolder) {
|
if (!mediaFolder) {
|
||||||
return [];
|
return [];
|
||||||
}
|
}
|
||||||
return this.api!.listFiles(mediaFolder).then(files =>
|
return this.api!.listFiles(mediaFolder, undefined, folderSupport).then(files =>
|
||||||
files.map(({ id, name, size, path }) => {
|
files.map(({ id, name, size, path, type }) => {
|
||||||
// load media using getMediaDisplayURL to avoid token expiration with GitHub raw content urls
|
// load media using getMediaDisplayURL to avoid token expiration with GitHub raw content urls
|
||||||
// for private repositories
|
// for private repositories
|
||||||
return { id, name, size, displayURL: { id, path }, path };
|
return { id, name, size, displayURL: { id, path }, path, isDirectory: type == 'tree' };
|
||||||
}),
|
}),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
@ -318,7 +318,12 @@ export default class API {
|
|||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
|
||||||
listAllFiles = async (path: string, recursive = false, branch = this.branch) => {
|
listAllFiles = async (
|
||||||
|
path: string,
|
||||||
|
folderSupport?: boolean,
|
||||||
|
recursive = false,
|
||||||
|
branch = this.branch,
|
||||||
|
) => {
|
||||||
const entries = [];
|
const entries = [];
|
||||||
// eslint-disable-next-line prefer-const
|
// eslint-disable-next-line prefer-const
|
||||||
let { cursor, entries: initialEntries } = await this.fetchCursorAndEntries({
|
let { cursor, entries: initialEntries } = await this.fetchCursorAndEntries({
|
||||||
@ -333,7 +338,7 @@ export default class API {
|
|||||||
entries.push(...newEntries);
|
entries.push(...newEntries);
|
||||||
cursor = newCursor;
|
cursor = newCursor;
|
||||||
}
|
}
|
||||||
return entries.filter(({ type }) => type === 'blob');
|
return entries.filter(({ type }) => (!folderSupport ? type === 'blob' : true));
|
||||||
};
|
};
|
||||||
|
|
||||||
toBase64 = (str: string) => Promise.resolve(Base64.encode(str));
|
toBase64 = (str: string) => Promise.resolve(Base64.encode(str));
|
||||||
@ -421,7 +426,7 @@ export default class API {
|
|||||||
for (const item of items.filter(i => i.oldPath && i.action === CommitAction.MOVE)) {
|
for (const item of items.filter(i => i.oldPath && i.action === CommitAction.MOVE)) {
|
||||||
const sourceDir = dirname(item.oldPath as string);
|
const sourceDir = dirname(item.oldPath as string);
|
||||||
const destDir = dirname(item.path);
|
const destDir = dirname(item.path);
|
||||||
const children = await this.listAllFiles(sourceDir, true, branch);
|
const children = await this.listAllFiles(sourceDir, undefined, true, branch);
|
||||||
children
|
children
|
||||||
.filter(f => f.path !== item.oldPath)
|
.filter(f => f.path !== item.oldPath)
|
||||||
.forEach(file => {
|
.forEach(file => {
|
||||||
|
@ -172,7 +172,7 @@ export default class GitLab implements BackendClass {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async listAllFiles(folder: string, extension: string, depth: number) {
|
async listAllFiles(folder: string, extension: string, depth: number) {
|
||||||
const files = await this.api!.listAllFiles(folder, depth > 1);
|
const files = await this.api!.listAllFiles(folder, undefined, depth > 1);
|
||||||
const filtered = files.filter(file => this.filterFile(folder, file, extension, depth));
|
const filtered = files.filter(file => this.filterFile(folder, file, extension, depth));
|
||||||
return filtered;
|
return filtered;
|
||||||
}
|
}
|
||||||
@ -217,13 +217,13 @@ export default class GitLab implements BackendClass {
|
|||||||
}));
|
}));
|
||||||
}
|
}
|
||||||
|
|
||||||
async getMedia(mediaFolder = this.mediaFolder) {
|
async getMedia(mediaFolder = this.mediaFolder, folderSupport?: boolean) {
|
||||||
if (!mediaFolder) {
|
if (!mediaFolder) {
|
||||||
return [];
|
return [];
|
||||||
}
|
}
|
||||||
return this.api!.listAllFiles(mediaFolder).then(files =>
|
return this.api!.listAllFiles(mediaFolder, folderSupport).then(files =>
|
||||||
files.map(({ id, name, path }) => {
|
files.map(({ id, name, path, type }) => {
|
||||||
return { id, name, path, displayURL: { id, name, path } };
|
return { id, name, path, displayURL: { id, name, path }, isDirectory: type === 'tree' };
|
||||||
}),
|
}),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
@ -146,17 +146,23 @@ export default class ProxyBackend implements BackendClass {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
async getMedia(mediaFolder = this.mediaFolder, publicFolder = this.publicFolder) {
|
async getMedia(
|
||||||
const files: { path: string; url: string }[] = await this.request({
|
mediaFolder = this.mediaFolder,
|
||||||
|
folderSupport?: boolean,
|
||||||
|
publicFolder = this.publicFolder,
|
||||||
|
) {
|
||||||
|
const files: { path: string; url: string; isDirectory: boolean }[] = await this.request({
|
||||||
action: 'getMedia',
|
action: 'getMedia',
|
||||||
params: { branch: this.branch, mediaFolder, publicFolder },
|
params: { branch: this.branch, mediaFolder, publicFolder },
|
||||||
});
|
});
|
||||||
|
|
||||||
return files.map(({ url, path }) => {
|
const filteredFiles = folderSupport ? files : files.filter(f => !f.isDirectory);
|
||||||
|
|
||||||
|
return filteredFiles.map(({ url, path, isDirectory }) => {
|
||||||
const id = url;
|
const id = url;
|
||||||
const name = basename(path);
|
const name = basename(path);
|
||||||
|
|
||||||
return { id, name, displayURL: { id, path: url }, path };
|
return { id, name, displayURL: { id, path: url }, path, isDirectory };
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -1,8 +1,13 @@
|
|||||||
import { Photo as PhotoIcon } from '@styled-icons/material/Photo';
|
import { Photo as PhotoIcon } from '@styled-icons/material/Photo';
|
||||||
|
import { ArrowUpward as UpwardIcon } from '@styled-icons/material/ArrowUpward';
|
||||||
|
import { Home as HomeIcon } from '@styled-icons/material/Home';
|
||||||
|
import { CreateNewFolder as NewFolderIcon } from '@styled-icons/material/CreateNewFolder';
|
||||||
import fuzzy from 'fuzzy';
|
import fuzzy from 'fuzzy';
|
||||||
import isEmpty from 'lodash/isEmpty';
|
import isEmpty from 'lodash/isEmpty';
|
||||||
import React, { useCallback, useEffect, useRef, useState } from 'react';
|
import React, { useCallback, useEffect, useRef, useState } from 'react';
|
||||||
import { translate } from 'react-polyglot';
|
import { translate } from 'react-polyglot';
|
||||||
|
import { dirname } from 'path';
|
||||||
|
import trim from 'lodash/trim';
|
||||||
|
|
||||||
import {
|
import {
|
||||||
closeMediaLibrary,
|
closeMediaLibrary,
|
||||||
@ -25,6 +30,9 @@ import EmptyMessage from './EmptyMessage';
|
|||||||
import FileUploadButton from './FileUploadButton';
|
import FileUploadButton from './FileUploadButton';
|
||||||
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';
|
||||||
@ -51,6 +59,7 @@ interface MediaLibraryProps {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const MediaLibrary: FC<TranslatedProps<MediaLibraryProps>> = ({ canInsert = false, t }) => {
|
const MediaLibrary: FC<TranslatedProps<MediaLibraryProps>> = ({ canInsert = false, t }) => {
|
||||||
|
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);
|
||||||
|
|
||||||
@ -74,17 +83,20 @@ const MediaLibrary: FC<TranslatedProps<MediaLibraryProps>> = ({ canInsert = fals
|
|||||||
insertOptions,
|
insertOptions,
|
||||||
} = useAppSelector(selectMediaLibraryState);
|
} = useAppSelector(selectMediaLibraryState);
|
||||||
|
|
||||||
|
const config = useAppSelector(selectConfig);
|
||||||
|
const entry = useAppSelector(selectEditingDraft);
|
||||||
|
|
||||||
const [url, setUrl] = useState<string | string[] | undefined>(initialValue ?? '');
|
const [url, setUrl] = useState<string | string[] | undefined>(initialValue ?? '');
|
||||||
|
|
||||||
const [alt, setAlt] = useState<string | undefined>(initialAlt);
|
const [alt, setAlt] = useState<string | undefined>(initialAlt);
|
||||||
|
|
||||||
const [prevIsVisible, setPrevIsVisible] = useState(false);
|
const [prevIsVisible, setPrevIsVisible] = useState(false);
|
||||||
|
|
||||||
const files = useMediaFiles(field);
|
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!prevIsVisible && isVisible) {
|
if (!prevIsVisible && isVisible) {
|
||||||
setSelectedFile(null);
|
setSelectedFile(null);
|
||||||
setQuery('');
|
setQuery('');
|
||||||
|
setCurrentFolder(undefined);
|
||||||
dispatch(loadMedia());
|
dispatch(loadMedia());
|
||||||
} else if (prevIsVisible && !isVisible) {
|
} else if (prevIsVisible && !isVisible) {
|
||||||
window.dispatchEvent(new MediaLibraryCloseEvent());
|
window.dispatchEvent(new MediaLibraryCloseEvent());
|
||||||
@ -93,6 +105,8 @@ const MediaLibrary: FC<TranslatedProps<MediaLibraryProps>> = ({ canInsert = fals
|
|||||||
setPrevIsVisible(isVisible);
|
setPrevIsVisible(isVisible);
|
||||||
}, [isVisible, dispatch, prevIsVisible]);
|
}, [isVisible, dispatch, prevIsVisible]);
|
||||||
|
|
||||||
|
const files = useMediaFiles(field, currentFolder);
|
||||||
|
|
||||||
const loadDisplayURL = useCallback(
|
const loadDisplayURL = useCallback(
|
||||||
(file: MediaFile) => {
|
(file: MediaFile) => {
|
||||||
dispatch(loadMediaDisplayURL(file));
|
dispatch(loadMediaDisplayURL(file));
|
||||||
@ -106,7 +120,7 @@ const MediaLibrary: FC<TranslatedProps<MediaLibraryProps>> = ({ canInsert = fals
|
|||||||
const filterImages = useCallback((files: MediaFile[]) => {
|
const filterImages = useCallback((files: MediaFile[]) => {
|
||||||
return files.filter(file => {
|
return files.filter(file => {
|
||||||
const ext = fileExtension(file.name).toLowerCase();
|
const ext = fileExtension(file.name).toLowerCase();
|
||||||
return IMAGE_EXTENSIONS.includes(ext);
|
return IMAGE_EXTENSIONS.includes(ext) || file.isDirectory;
|
||||||
});
|
});
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
@ -116,7 +130,7 @@ const MediaLibrary: FC<TranslatedProps<MediaLibraryProps>> = ({ canInsert = fals
|
|||||||
const toTableData = useCallback((files: MediaFile[]) => {
|
const toTableData = useCallback((files: MediaFile[]) => {
|
||||||
const tableData =
|
const tableData =
|
||||||
files &&
|
files &&
|
||||||
files.map(({ key, name, id, size, path, queryOrder, displayURL, draft }) => {
|
files.map(({ key, name, id, size, path, queryOrder, displayURL, draft, isDirectory }) => {
|
||||||
const ext = fileExtension(name).toLowerCase();
|
const ext = fileExtension(name).toLowerCase();
|
||||||
return {
|
return {
|
||||||
key,
|
key,
|
||||||
@ -130,6 +144,7 @@ const MediaLibrary: FC<TranslatedProps<MediaLibraryProps>> = ({ canInsert = fals
|
|||||||
draft,
|
draft,
|
||||||
isImage: IMAGE_EXTENSIONS.includes(ext),
|
isImage: IMAGE_EXTENSIONS.includes(ext),
|
||||||
isViewableImage: IMAGE_EXTENSIONS_VIEWABLE.includes(ext),
|
isViewableImage: IMAGE_EXTENSIONS_VIEWABLE.includes(ext),
|
||||||
|
isDirectory,
|
||||||
};
|
};
|
||||||
});
|
});
|
||||||
|
|
||||||
@ -157,7 +172,7 @@ const MediaLibrary: FC<TranslatedProps<MediaLibraryProps>> = ({ canInsert = fals
|
|||||||
*/
|
*/
|
||||||
const handleAssetSelect = useCallback(
|
const handleAssetSelect = useCallback(
|
||||||
(asset: MediaFile) => {
|
(asset: MediaFile) => {
|
||||||
if (!canInsert || selectedFile?.key === asset.key) {
|
if (!canInsert || selectedFile?.key === asset.key || asset.isDirectory) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -214,7 +229,7 @@ const MediaLibrary: FC<TranslatedProps<MediaLibraryProps>> = ({ canInsert = fals
|
|||||||
},
|
},
|
||||||
});
|
});
|
||||||
} else {
|
} else {
|
||||||
await dispatch(persistMedia(file, { field }));
|
await dispatch(persistMedia(file, { field }, currentFolder));
|
||||||
|
|
||||||
setSelectedFile(files[0] as unknown as MediaFile);
|
setSelectedFile(files[0] as unknown as MediaFile);
|
||||||
|
|
||||||
@ -225,15 +240,15 @@ const MediaLibrary: FC<TranslatedProps<MediaLibraryProps>> = ({ canInsert = fals
|
|||||||
event.target.value = '';
|
event.target.value = '';
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
[mediaConfig.max_file_size, field, dispatch],
|
[mediaConfig.max_file_size, field, dispatch, currentFolder],
|
||||||
);
|
);
|
||||||
|
|
||||||
const handleURLChange = useCallback(
|
const handleURLChange = useCallback(
|
||||||
(url: string) => {
|
(url: string) => {
|
||||||
setUrl(url);
|
setUrl(url);
|
||||||
dispatch(insertMedia(url, field, alt));
|
dispatch(insertMedia(url, field, alt, currentFolder));
|
||||||
},
|
},
|
||||||
[alt, dispatch, field],
|
[alt, dispatch, field, currentFolder],
|
||||||
);
|
);
|
||||||
|
|
||||||
const handleAltChange = useCallback(
|
const handleAltChange = useCallback(
|
||||||
@ -243,11 +258,51 @@ const MediaLibrary: FC<TranslatedProps<MediaLibraryProps>> = ({ canInsert = fals
|
|||||||
}
|
}
|
||||||
|
|
||||||
setAlt(alt);
|
setAlt(alt);
|
||||||
dispatch(insertMedia((url ?? selectedFile?.path) as string, field, alt));
|
dispatch(insertMedia((url ?? selectedFile?.path) as string, field, alt, currentFolder));
|
||||||
},
|
},
|
||||||
[dispatch, field, selectedFile?.path, url],
|
[dispatch, field, selectedFile?.path, url, currentFolder],
|
||||||
);
|
);
|
||||||
|
|
||||||
|
const handleOpenDirectory = useCallback(
|
||||||
|
(dir: string) => {
|
||||||
|
const newDirectory = selectMediaFilePath(
|
||||||
|
config!,
|
||||||
|
collection!,
|
||||||
|
entry,
|
||||||
|
dir,
|
||||||
|
field,
|
||||||
|
currentFolder,
|
||||||
|
);
|
||||||
|
setSelectedFile(null);
|
||||||
|
setQuery('');
|
||||||
|
setCurrentFolder(newDirectory);
|
||||||
|
dispatch(loadMedia({ currentFolder: newDirectory }));
|
||||||
|
},
|
||||||
|
[dispatch, currentFolder, collection, config, entry, field],
|
||||||
|
);
|
||||||
|
|
||||||
|
const handleGoBack = useCallback(
|
||||||
|
(toHome?: boolean) => {
|
||||||
|
setSelectedFile(null);
|
||||||
|
setQuery('');
|
||||||
|
let newDirectory: string | undefined;
|
||||||
|
if (toHome) {
|
||||||
|
setCurrentFolder(undefined);
|
||||||
|
} else {
|
||||||
|
const mediaFolder = trim(selectMediaFolder(config!, collection, entry, field), '/');
|
||||||
|
const dir = dirname(currentFolder!);
|
||||||
|
newDirectory = dir.includes(mediaFolder) && trim(dir, '/') != mediaFolder ? dir : undefined;
|
||||||
|
setCurrentFolder(newDirectory);
|
||||||
|
}
|
||||||
|
dispatch(loadMedia({ currentFolder: newDirectory }));
|
||||||
|
},
|
||||||
|
[dispatch, config, collection, entry, field, currentFolder],
|
||||||
|
);
|
||||||
|
|
||||||
|
const handleCreateFolder = useCallback(() => {
|
||||||
|
console.log('[createFolder]');
|
||||||
|
}, []);
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Stores the public path of the file in the application store, where the
|
* 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.
|
||||||
@ -259,12 +314,12 @@ const MediaLibrary: FC<TranslatedProps<MediaLibraryProps>> = ({ canInsert = fals
|
|||||||
|
|
||||||
const { path } = selectedFile;
|
const { path } = selectedFile;
|
||||||
setUrl(path);
|
setUrl(path);
|
||||||
dispatch(insertMedia(path, field, alt));
|
dispatch(insertMedia(path, field, alt, currentFolder));
|
||||||
|
|
||||||
if (!insertOptions?.chooseUrl && !insertOptions?.showAlt) {
|
if (!insertOptions?.chooseUrl && !insertOptions?.showAlt) {
|
||||||
handleClose();
|
handleClose();
|
||||||
}
|
}
|
||||||
}, [selectedFile, dispatch, field, alt, insertOptions, handleClose]);
|
}, [selectedFile, dispatch, field, alt, insertOptions, handleClose, currentFolder]);
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Removes the selected file from the backend.
|
* Removes the selected file from the backend.
|
||||||
@ -364,7 +419,7 @@ const MediaLibrary: FC<TranslatedProps<MediaLibraryProps>> = ({ canInsert = fals
|
|||||||
onUrlChange={handleURLChange}
|
onUrlChange={handleURLChange}
|
||||||
onAltChange={handleAltChange}
|
onAltChange={handleAltChange}
|
||||||
/>
|
/>
|
||||||
<div className="flex items-center px-5 pt-4">
|
<div className="flex items-center px-5 pt-4 mb-4">
|
||||||
<div className="flex flex-grow gap-4 mr-8">
|
<div className="flex flex-grow gap-4 mr-8">
|
||||||
<h2
|
<h2
|
||||||
className="
|
className="
|
||||||
@ -382,6 +437,24 @@ const MediaLibrary: FC<TranslatedProps<MediaLibraryProps>> = ({ canInsert = fals
|
|||||||
</div>
|
</div>
|
||||||
{t('app.header.media')}
|
{t('app.header.media')}
|
||||||
</h2>
|
</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
|
<MediaLibrarySearch
|
||||||
value={query}
|
value={query}
|
||||||
onChange={handleSearchChange}
|
onChange={handleSearchChange}
|
||||||
@ -391,6 +464,14 @@ const MediaLibrary: FC<TranslatedProps<MediaLibraryProps>> = ({ canInsert = fals
|
|||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex gap-3 items-center relative z-20">
|
<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} />
|
<FileUploadButton imagesOnly={forImage} onChange={handlePersist} />
|
||||||
{canInsert ? (
|
{canInsert ? (
|
||||||
<Button
|
<Button
|
||||||
@ -416,6 +497,8 @@ const MediaLibrary: FC<TranslatedProps<MediaLibraryProps>> = ({ canInsert = fals
|
|||||||
onAssetSelect={handleAssetSelect}
|
onAssetSelect={handleAssetSelect}
|
||||||
canLoadMore={hasNextPage}
|
canLoadMore={hasNextPage}
|
||||||
onLoadMore={handleLoadMore}
|
onLoadMore={handleLoadMore}
|
||||||
|
onDirectoryOpen={handleOpenDirectory}
|
||||||
|
currentFolder={currentFolder}
|
||||||
isPaginating={isPaginating}
|
isPaginating={isPaginating}
|
||||||
paginatingMessage={t('mediaLibrary.mediaLibraryModal.loading')}
|
paginatingMessage={t('mediaLibrary.mediaLibraryModal.loading')}
|
||||||
cardDraftText={t('mediaLibrary.mediaLibraryCard.draft')}
|
cardDraftText={t('mediaLibrary.mediaLibraryCard.draft')}
|
||||||
|
@ -1,5 +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 React, { useCallback, useEffect, useMemo } from 'react';
|
import React, { useCallback, useEffect, useMemo } from 'react';
|
||||||
import { translate } from 'react-polyglot';
|
import { translate } from 'react-polyglot';
|
||||||
|
|
||||||
@ -30,9 +31,12 @@ interface MediaLibraryCardProps<T extends MediaField, EF extends BaseField = Unk
|
|||||||
type?: string;
|
type?: string;
|
||||||
isViewableImage: boolean;
|
isViewableImage: boolean;
|
||||||
isDraft?: boolean;
|
isDraft?: boolean;
|
||||||
|
isDirectory?: boolean;
|
||||||
collection?: Collection<EF>;
|
collection?: Collection<EF>;
|
||||||
field?: T;
|
field?: T;
|
||||||
|
currentFolder?: string;
|
||||||
onSelect: () => void;
|
onSelect: () => void;
|
||||||
|
onDirectoryOpen: () => void;
|
||||||
loadDisplayURL: () => void;
|
loadDisplayURL: () => void;
|
||||||
onDelete: () => void;
|
onDelete: () => void;
|
||||||
}
|
}
|
||||||
@ -45,15 +49,18 @@ const MediaLibraryCard = <T extends MediaField, EF extends BaseField = UnknownFi
|
|||||||
type,
|
type,
|
||||||
isViewableImage,
|
isViewableImage,
|
||||||
isDraft,
|
isDraft,
|
||||||
|
isDirectory,
|
||||||
collection,
|
collection,
|
||||||
field,
|
field,
|
||||||
|
currentFolder,
|
||||||
onSelect,
|
onSelect,
|
||||||
|
onDirectoryOpen,
|
||||||
loadDisplayURL,
|
loadDisplayURL,
|
||||||
onDelete,
|
onDelete,
|
||||||
t,
|
t,
|
||||||
}: TranslatedProps<MediaLibraryCardProps<T, EF>>) => {
|
}: TranslatedProps<MediaLibraryCardProps<T, EF>>) => {
|
||||||
const entry = useAppSelector(selectEditingDraft);
|
const entry = useAppSelector(selectEditingDraft);
|
||||||
const url = useMediaAsset(displayURL.url, collection, field, entry);
|
const url = useMediaAsset(displayURL.url, collection, field, entry, currentFolder);
|
||||||
|
|
||||||
const handleDownload = useCallback(() => {
|
const handleDownload = useCallback(() => {
|
||||||
const url = displayURL.url;
|
const url = displayURL.url;
|
||||||
@ -109,6 +116,7 @@ const MediaLibraryCard = <T extends MediaField, EF extends BaseField = UnknownFi
|
|||||||
>
|
>
|
||||||
<div
|
<div
|
||||||
onClick={onSelect}
|
onClick={onSelect}
|
||||||
|
onDoubleClick={isDirectory ? onDirectoryOpen : undefined}
|
||||||
data-testid={`media-card-${displayURL.url}`}
|
data-testid={`media-card-${displayURL.url}`}
|
||||||
className="
|
className="
|
||||||
w-media-card
|
w-media-card
|
||||||
@ -168,6 +176,7 @@ const MediaLibraryCard = <T extends MediaField, EF extends BaseField = UnknownFi
|
|||||||
z-20
|
z-20
|
||||||
"
|
"
|
||||||
>
|
>
|
||||||
|
{!isDirectory ? (
|
||||||
<div
|
<div
|
||||||
className="
|
className="
|
||||||
absolute
|
absolute
|
||||||
@ -209,6 +218,7 @@ const MediaLibraryCard = <T extends MediaField, EF extends BaseField = UnknownFi
|
|||||||
<DeleteIcon className="w-5 h-5" />
|
<DeleteIcon className="w-5 h-5" />
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
|
) : null}
|
||||||
</div>
|
</div>
|
||||||
<div className="relative">
|
<div className="relative">
|
||||||
{isDraft ? (
|
{isDraft ? (
|
||||||
@ -218,6 +228,25 @@ const MediaLibraryCard = <T extends MediaField, EF extends BaseField = UnknownFi
|
|||||||
) : null}
|
) : null}
|
||||||
{url && isViewableImage ? (
|
{url && isViewableImage ? (
|
||||||
<Image src={url} className="w-media-card h-media-card-image rounded-md" />
|
<Image src={url} className="w-media-card h-media-card-image rounded-md" />
|
||||||
|
) : isDirectory ? (
|
||||||
|
<div
|
||||||
|
data-testid="card-file-icon"
|
||||||
|
className="
|
||||||
|
w-media-card
|
||||||
|
h-media-card-image
|
||||||
|
bg-gray-500
|
||||||
|
dark:bg-slate-700
|
||||||
|
text-gray-200
|
||||||
|
dark:text-slate-400
|
||||||
|
font-bold
|
||||||
|
flex
|
||||||
|
items-center
|
||||||
|
justify-center
|
||||||
|
text-5xl
|
||||||
|
"
|
||||||
|
>
|
||||||
|
<FolderIcon className="w-24 h-24" />
|
||||||
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<div
|
<div
|
||||||
data-testid="card-file-icon"
|
data-testid="card-file-icon"
|
||||||
|
@ -29,6 +29,7 @@ export interface MediaLibraryCardItem {
|
|||||||
type: string;
|
type: string;
|
||||||
draft: boolean;
|
draft: boolean;
|
||||||
isViewableImage?: boolean;
|
isViewableImage?: boolean;
|
||||||
|
isDirectory?: boolean;
|
||||||
url?: string;
|
url?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -39,6 +40,8 @@ export interface MediaLibraryCardGridProps {
|
|||||||
onAssetSelect: (asset: MediaFile) => void;
|
onAssetSelect: (asset: MediaFile) => void;
|
||||||
canLoadMore?: boolean;
|
canLoadMore?: boolean;
|
||||||
onLoadMore: () => void;
|
onLoadMore: () => void;
|
||||||
|
onDirectoryOpen: (dir: string) => void;
|
||||||
|
currentFolder?: string;
|
||||||
isPaginating?: boolean;
|
isPaginating?: boolean;
|
||||||
paginatingMessage?: string;
|
paginatingMessage?: string;
|
||||||
cardDraftText: string;
|
cardDraftText: string;
|
||||||
@ -62,6 +65,8 @@ const CardWrapper = ({
|
|||||||
mediaItems,
|
mediaItems,
|
||||||
isSelectedFile,
|
isSelectedFile,
|
||||||
onAssetSelect,
|
onAssetSelect,
|
||||||
|
onDirectoryOpen,
|
||||||
|
currentFolder,
|
||||||
cardDraftText,
|
cardDraftText,
|
||||||
displayURLs,
|
displayURLs,
|
||||||
loadDisplayURL,
|
loadDisplayURL,
|
||||||
@ -111,12 +116,15 @@ const CardWrapper = ({
|
|||||||
isSelected={isSelectedFile(file)}
|
isSelected={isSelectedFile(file)}
|
||||||
text={file.name}
|
text={file.name}
|
||||||
onSelect={() => onAssetSelect(file)}
|
onSelect={() => onAssetSelect(file)}
|
||||||
|
onDirectoryOpen={() => onDirectoryOpen(file.path)}
|
||||||
|
currentFolder={currentFolder}
|
||||||
isDraft={file.draft}
|
isDraft={file.draft}
|
||||||
draftText={cardDraftText}
|
draftText={cardDraftText}
|
||||||
displayURL={displayURLs[file.id] ?? (file.url ? { url: file.url } : {})}
|
displayURL={displayURLs[file.id] ?? (file.url ? { url: file.url } : {})}
|
||||||
loadDisplayURL={() => loadDisplayURL(file)}
|
loadDisplayURL={() => loadDisplayURL(file)}
|
||||||
type={file.type}
|
type={file.type}
|
||||||
isViewableImage={file.isViewableImage ?? false}
|
isViewableImage={file.isViewableImage ?? false}
|
||||||
|
isDirectory={file.isDirectory ?? false}
|
||||||
collection={collection}
|
collection={collection}
|
||||||
field={field}
|
field={field}
|
||||||
onDelete={() => onDelete(file)}
|
onDelete={() => onDelete(file)}
|
||||||
|
@ -490,7 +490,11 @@ export abstract class BackendClass {
|
|||||||
abstract entriesByFiles(files: ImplementationFile[]): Promise<ImplementationEntry[]>;
|
abstract entriesByFiles(files: ImplementationFile[]): Promise<ImplementationEntry[]>;
|
||||||
|
|
||||||
abstract getMediaDisplayURL(displayURL: DisplayURL): Promise<string>;
|
abstract getMediaDisplayURL(displayURL: DisplayURL): Promise<string>;
|
||||||
abstract getMedia(folder?: string, mediaPath?: string): Promise<ImplementationMediaFile[]>;
|
abstract getMedia(
|
||||||
|
folder?: string,
|
||||||
|
folderSupport?: boolean,
|
||||||
|
mediaPath?: string,
|
||||||
|
): Promise<ImplementationMediaFile[]>;
|
||||||
abstract getMediaFile(path: string): Promise<ImplementationMediaFile>;
|
abstract getMediaFile(path: string): Promise<ImplementationMediaFile>;
|
||||||
|
|
||||||
abstract persistEntry(entry: BackendEntry, opts: PersistOptions): Promise<void>;
|
abstract persistEntry(entry: BackendEntry, opts: PersistOptions): Promise<void>;
|
||||||
@ -813,6 +817,7 @@ export interface Config<EF extends BaseField = UnknownField> {
|
|||||||
local_backend?: boolean | LocalBackend;
|
local_backend?: boolean | LocalBackend;
|
||||||
editor?: EditorConfig;
|
editor?: EditorConfig;
|
||||||
search?: boolean;
|
search?: boolean;
|
||||||
|
media_library_folder_support?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface InitOptions<EF extends BaseField = UnknownField> {
|
export interface InitOptions<EF extends BaseField = UnknownField> {
|
||||||
|
@ -18,6 +18,7 @@ export default function useIsMediaAsset<T extends MediaField, EF extends BaseFie
|
|||||||
collection: Collection<EF>,
|
collection: Collection<EF>,
|
||||||
field: T,
|
field: T,
|
||||||
entry: Entry,
|
entry: Entry,
|
||||||
|
currentFolder?: string,
|
||||||
): boolean {
|
): boolean {
|
||||||
const dispatch = useAppDispatch();
|
const dispatch = useAppDispatch();
|
||||||
const [exists, setExists] = useState(false);
|
const [exists, setExists] = useState(false);
|
||||||
@ -29,14 +30,16 @@ export default function useIsMediaAsset<T extends MediaField, EF extends BaseFie
|
|||||||
}
|
}
|
||||||
|
|
||||||
const checkMediaExistence = async () => {
|
const checkMediaExistence = async () => {
|
||||||
const asset = await dispatch(getAsset<T, EF>(collection, entry, debouncedUrl, field));
|
const asset = await dispatch(
|
||||||
|
getAsset<T, EF>(collection, entry, debouncedUrl, field, currentFolder),
|
||||||
|
);
|
||||||
setExists(
|
setExists(
|
||||||
Boolean(asset && asset !== emptyAsset && isNotEmpty(asset.toString()) && asset.fileObj),
|
Boolean(asset && asset !== emptyAsset && isNotEmpty(asset.toString()) && asset.fileObj),
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
checkMediaExistence();
|
checkMediaExistence();
|
||||||
}, [collection, dispatch, entry, field, debouncedUrl]);
|
}, [collection, dispatch, entry, field, debouncedUrl, currentFolder]);
|
||||||
|
|
||||||
return exists;
|
return exists;
|
||||||
}
|
}
|
||||||
|
@ -18,6 +18,7 @@ export default function useMediaAsset<T extends MediaField, EF extends BaseField
|
|||||||
collection?: Collection<EF>,
|
collection?: Collection<EF>,
|
||||||
field?: T,
|
field?: T,
|
||||||
entry?: Entry,
|
entry?: Entry,
|
||||||
|
currentFolder?: string,
|
||||||
): string {
|
): string {
|
||||||
const isAbsolute = useMemo(
|
const isAbsolute = useMemo(
|
||||||
() => (isNotEmpty(url) ? /^(?:[a-z+]+:)?\/\//g.test(url) : false),
|
() => (isNotEmpty(url) ? /^(?:[a-z+]+:)?\/\//g.test(url) : false),
|
||||||
@ -34,7 +35,9 @@ export default function useMediaAsset<T extends MediaField, EF extends BaseField
|
|||||||
}
|
}
|
||||||
|
|
||||||
const fetchMedia = async () => {
|
const fetchMedia = async () => {
|
||||||
const asset = await dispatch(getAsset<T, EF>(collection, entry, debouncedUrl, field));
|
const asset = await dispatch(
|
||||||
|
getAsset<T, EF>(collection, entry, debouncedUrl, field, currentFolder),
|
||||||
|
);
|
||||||
if (asset !== emptyAsset) {
|
if (asset !== emptyAsset) {
|
||||||
setAssetSource(asset?.toString() ?? '');
|
setAssetSource(asset?.toString() ?? '');
|
||||||
}
|
}
|
||||||
|
@ -1,5 +1,6 @@
|
|||||||
import { useEffect, useMemo, useState } from 'react';
|
import { useEffect, useMemo, useState } from 'react';
|
||||||
import { dirname } from 'path';
|
import { dirname } from 'path';
|
||||||
|
import trim from 'lodash/trim';
|
||||||
|
|
||||||
import { selectMediaLibraryFiles } from '@staticcms/core/reducers/selectors/mediaLibrary';
|
import { selectMediaLibraryFiles } from '@staticcms/core/reducers/selectors/mediaLibrary';
|
||||||
import { selectEditingDraft } from '@staticcms/core/reducers/selectors/entryDraft';
|
import { selectEditingDraft } from '@staticcms/core/reducers/selectors/entryDraft';
|
||||||
@ -24,7 +25,7 @@ export default function useMediaFiles(field?: MediaField, currentFolder?: string
|
|||||||
const collection = useAppSelector(collectionSelector);
|
const collection = useAppSelector(collectionSelector);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!currentFolder || !config) {
|
if (!currentFolder || !config || !entry) {
|
||||||
setCurrentFolderMediaFiles(null);
|
setCurrentFolderMediaFiles(null);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@ -33,7 +34,13 @@ export default function useMediaFiles(field?: MediaField, currentFolder?: string
|
|||||||
|
|
||||||
const getMediaFiles = async () => {
|
const getMediaFiles = async () => {
|
||||||
const backend = currentBackend(config);
|
const backend = currentBackend(config);
|
||||||
const files = await backend.getMedia(currentFolder);
|
const files = await backend.getMedia(
|
||||||
|
currentFolder,
|
||||||
|
config.media_library_folder_support ?? false,
|
||||||
|
config.public_folder
|
||||||
|
? trim(currentFolder, '/').replace(trim(config.media_folder!), config.public_folder)
|
||||||
|
: currentFolder,
|
||||||
|
);
|
||||||
|
|
||||||
if (alive) {
|
if (alive) {
|
||||||
setCurrentFolderMediaFiles(files);
|
setCurrentFolderMediaFiles(files);
|
||||||
@ -45,23 +52,29 @@ export default function useMediaFiles(field?: MediaField, currentFolder?: string
|
|||||||
return () => {
|
return () => {
|
||||||
alive = false;
|
alive = false;
|
||||||
};
|
};
|
||||||
}, [currentFolder, config]);
|
}, [currentFolder, config, entry]);
|
||||||
|
|
||||||
return useMemo(() => {
|
return useMemo(() => {
|
||||||
if (currentFolderMediaFiles) {
|
|
||||||
return currentFolderMediaFiles;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (entry) {
|
if (entry) {
|
||||||
const entryFiles = entry.mediaFiles ?? [];
|
const entryFiles = entry.mediaFiles ?? [];
|
||||||
if (config) {
|
if (config) {
|
||||||
const mediaFolder = selectMediaFolder(config, collection, entry, field);
|
const mediaFolder = selectMediaFolder(config, collection, entry, field, currentFolder);
|
||||||
return entryFiles
|
const entryFolderFiles = entryFiles
|
||||||
.filter(f => dirname(f.path) === mediaFolder)
|
.filter(f => {
|
||||||
|
return dirname(f.path) === mediaFolder;
|
||||||
|
})
|
||||||
.map(file => ({ key: file.id, ...file }));
|
.map(file => ({ key: file.id, ...file }));
|
||||||
|
if (currentFolderMediaFiles) {
|
||||||
|
if (entryFiles.length > 0) {
|
||||||
|
const draftFiles = entryFolderFiles.filter(file => file.draft == true);
|
||||||
|
currentFolderMediaFiles.unshift(...draftFiles);
|
||||||
|
}
|
||||||
|
return currentFolderMediaFiles.map(file => ({ key: file.id, ...file }));
|
||||||
|
}
|
||||||
|
return entryFolderFiles;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return mediaLibraryFiles ?? [];
|
return mediaLibraryFiles ?? [];
|
||||||
}, [collection, config, currentFolderMediaFiles, entry, field, mediaLibraryFiles]);
|
}, [collection, config, currentFolderMediaFiles, entry, field, mediaLibraryFiles, currentFolder]);
|
||||||
}
|
}
|
||||||
|
@ -234,14 +234,15 @@ export function selectMediaFolder<EF extends BaseField>(
|
|||||||
collection: Collection<EF> | undefined | null,
|
collection: Collection<EF> | undefined | null,
|
||||||
entryMap: Entry | null | undefined,
|
entryMap: Entry | null | undefined,
|
||||||
field: MediaField | undefined,
|
field: MediaField | undefined,
|
||||||
|
currentFolder?: string,
|
||||||
) {
|
) {
|
||||||
const name = 'media_folder';
|
let mediaFolder = config['media_folder'] ?? '';
|
||||||
let mediaFolder = config[name];
|
|
||||||
|
|
||||||
if (hasCustomFolder(name, collection, entryMap?.slug, field)) {
|
if (currentFolder) {
|
||||||
const folder = evaluateFolder(name, config, collection!, entryMap, field);
|
mediaFolder = currentFolder;
|
||||||
|
} else if (hasCustomFolder('media_folder', collection, entryMap?.slug, field)) {
|
||||||
|
const folder = evaluateFolder('media_folder', config, collection!, entryMap, field);
|
||||||
if (folder.startsWith('/')) {
|
if (folder.startsWith('/')) {
|
||||||
// return absolute paths as is
|
|
||||||
mediaFolder = join(folder);
|
mediaFolder = join(folder);
|
||||||
} else {
|
} else {
|
||||||
const entryPath = entryMap?.path;
|
const entryPath = entryMap?.path;
|
||||||
@ -260,25 +261,35 @@ export function selectMediaFilePublicPath<EF extends BaseField>(
|
|||||||
mediaPath: string,
|
mediaPath: string,
|
||||||
entryMap: Entry | undefined,
|
entryMap: Entry | undefined,
|
||||||
field: Field<EF> | undefined,
|
field: Field<EF> | undefined,
|
||||||
|
currentFolder?: string,
|
||||||
) {
|
) {
|
||||||
if (isAbsolutePath(mediaPath)) {
|
if (isAbsolutePath(mediaPath)) {
|
||||||
return mediaPath;
|
return mediaPath;
|
||||||
}
|
}
|
||||||
|
|
||||||
const name = 'public_folder';
|
let publicFolder = config['public_folder']!;
|
||||||
let publicFolder = config[name]!;
|
let selectedPublicFolder = publicFolder;
|
||||||
|
|
||||||
const customFolder = hasCustomFolder(name, collection, entryMap?.slug, field);
|
const customPublicFolder = hasCustomFolder('public_folder', collection, entryMap?.slug, field);
|
||||||
|
|
||||||
if (customFolder) {
|
if (customPublicFolder) {
|
||||||
publicFolder = evaluateFolder(name, config, collection!, entryMap, field);
|
publicFolder = evaluateFolder('public_folder', config, collection!, entryMap, field);
|
||||||
|
selectedPublicFolder = publicFolder;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (isAbsolutePath(publicFolder)) {
|
if (currentFolder) {
|
||||||
return joinUrlPath(publicFolder, basename(mediaPath));
|
const customMediaFolder = hasCustomFolder('media_folder', collection, entryMap?.slug, field);
|
||||||
|
const mediaFolder = customMediaFolder
|
||||||
|
? evaluateFolder('media_folder', config, collection!, entryMap, field)
|
||||||
|
: config['media_folder'];
|
||||||
|
selectedPublicFolder = trim(currentFolder, '/').replace(trim(mediaFolder!, '/'), publicFolder);
|
||||||
}
|
}
|
||||||
|
|
||||||
return join(publicFolder, basename(mediaPath));
|
if (isAbsolutePath(selectedPublicFolder)) {
|
||||||
|
return joinUrlPath(selectedPublicFolder, basename(mediaPath));
|
||||||
|
}
|
||||||
|
|
||||||
|
return join(selectedPublicFolder, basename(mediaPath));
|
||||||
}
|
}
|
||||||
|
|
||||||
export function selectMediaFilePath(
|
export function selectMediaFilePath(
|
||||||
@ -287,12 +298,34 @@ export function selectMediaFilePath(
|
|||||||
entryMap: Entry | null | undefined,
|
entryMap: Entry | null | undefined,
|
||||||
mediaPath: string,
|
mediaPath: string,
|
||||||
field: Field | undefined,
|
field: Field | undefined,
|
||||||
|
currentFolder?: string,
|
||||||
) {
|
) {
|
||||||
if (isAbsolutePath(mediaPath)) {
|
if (isAbsolutePath(mediaPath)) {
|
||||||
return mediaPath;
|
return mediaPath;
|
||||||
}
|
}
|
||||||
|
|
||||||
const mediaFolder = selectMediaFolder(config, collection, entryMap, field);
|
let mediaFolder = selectMediaFolder(config, collection, entryMap, field, currentFolder);
|
||||||
|
|
||||||
|
if (!currentFolder) {
|
||||||
|
let publicFolder = trim(config['public_folder'] ?? mediaFolder, '/');
|
||||||
|
const mediaPathDir = trim(dirname(mediaPath), '/');
|
||||||
|
|
||||||
|
if (hasCustomFolder('public_folder', collection, entryMap?.slug, field)) {
|
||||||
|
publicFolder = trim(
|
||||||
|
evaluateFolder('public_folder', config, collection!, entryMap, field),
|
||||||
|
'/',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if (mediaPathDir.includes(publicFolder) && mediaPathDir != mediaFolder) {
|
||||||
|
mediaFolder = selectMediaFolder(
|
||||||
|
config,
|
||||||
|
collection,
|
||||||
|
entryMap,
|
||||||
|
field,
|
||||||
|
mediaPathDir.replace(publicFolder, mediaFolder),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
return join(mediaFolder, basename(mediaPath));
|
return join(mediaFolder, basename(mediaPath));
|
||||||
}
|
}
|
||||||
|
@ -248,6 +248,12 @@ const en: LocalePhrasesRoot = {
|
|||||||
deleteSelected: 'Delete selected',
|
deleteSelected: 'Delete selected',
|
||||||
chooseSelected: 'Choose selected',
|
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.',
|
||||||
|
},
|
||||||
},
|
},
|
||||||
ui: {
|
ui: {
|
||||||
common: {
|
common: {
|
||||||
|
Loading…
x
Reference in New Issue
Block a user