refactor: monorepo setup with lerna (#243)
This commit is contained in:
committed by
GitHub
parent
dac29fbf3c
commit
504d95c34f
132
packages/core/src/actions/auth.ts
Normal file
132
packages/core/src/actions/auth.ts
Normal file
@ -0,0 +1,132 @@
|
||||
import { currentBackend } from '../backend';
|
||||
import { addSnackbar } from '../store/slices/snackbars';
|
||||
|
||||
import type { AnyAction } from 'redux';
|
||||
import type { ThunkDispatch } from 'redux-thunk';
|
||||
import type { Credentials, User } from '../interface';
|
||||
import type { RootState } from '../store';
|
||||
|
||||
export const AUTH_REQUEST = 'AUTH_REQUEST';
|
||||
export const AUTH_SUCCESS = 'AUTH_SUCCESS';
|
||||
export const AUTH_FAILURE = 'AUTH_FAILURE';
|
||||
export const AUTH_REQUEST_DONE = 'AUTH_REQUEST_DONE';
|
||||
export const LOGOUT = 'LOGOUT';
|
||||
|
||||
export function authenticating() {
|
||||
return {
|
||||
type: AUTH_REQUEST,
|
||||
} as const;
|
||||
}
|
||||
|
||||
export function authenticate(userData: User) {
|
||||
return {
|
||||
type: AUTH_SUCCESS,
|
||||
payload: userData,
|
||||
} as const;
|
||||
}
|
||||
|
||||
export function authError(error: Error) {
|
||||
return {
|
||||
type: AUTH_FAILURE,
|
||||
error: 'Failed to authenticate',
|
||||
payload: error,
|
||||
} as const;
|
||||
}
|
||||
|
||||
export function doneAuthenticating() {
|
||||
return {
|
||||
type: AUTH_REQUEST_DONE,
|
||||
} as const;
|
||||
}
|
||||
|
||||
export function logout() {
|
||||
return {
|
||||
type: LOGOUT,
|
||||
} as const;
|
||||
}
|
||||
|
||||
// Check if user data token is cached and is valid
|
||||
export function authenticateUser() {
|
||||
return (dispatch: ThunkDispatch<RootState, {}, AnyAction>, getState: () => RootState) => {
|
||||
const state = getState();
|
||||
if (!state.config.config) {
|
||||
return;
|
||||
}
|
||||
|
||||
const backend = currentBackend(state.config.config);
|
||||
|
||||
dispatch(authenticating());
|
||||
return Promise.resolve(backend.currentUser())
|
||||
.then(user => {
|
||||
if (user) {
|
||||
dispatch(authenticate(user));
|
||||
} else {
|
||||
dispatch(doneAuthenticating());
|
||||
}
|
||||
})
|
||||
.catch((error: unknown) => {
|
||||
console.error(error);
|
||||
if (error instanceof Error) {
|
||||
dispatch(authError(error));
|
||||
}
|
||||
dispatch(logoutUser());
|
||||
});
|
||||
};
|
||||
}
|
||||
|
||||
export function loginUser(credentials: Credentials) {
|
||||
return (dispatch: ThunkDispatch<RootState, {}, AnyAction>, getState: () => RootState) => {
|
||||
const state = getState();
|
||||
if (!state.config.config) {
|
||||
return;
|
||||
}
|
||||
|
||||
const backend = currentBackend(state.config.config);
|
||||
|
||||
dispatch(authenticating());
|
||||
return backend
|
||||
.authenticate(credentials)
|
||||
.then(user => {
|
||||
dispatch(authenticate(user));
|
||||
})
|
||||
.catch((error: unknown) => {
|
||||
console.error(error);
|
||||
if (error instanceof Error) {
|
||||
dispatch(
|
||||
addSnackbar({
|
||||
type: 'warning',
|
||||
message: {
|
||||
key: 'ui.toast.onFailToAuth',
|
||||
options: {
|
||||
details: error.message,
|
||||
},
|
||||
},
|
||||
}),
|
||||
);
|
||||
dispatch(authError(error));
|
||||
}
|
||||
});
|
||||
};
|
||||
}
|
||||
|
||||
export function logoutUser() {
|
||||
return (dispatch: ThunkDispatch<RootState, {}, AnyAction>, getState: () => RootState) => {
|
||||
const state = getState();
|
||||
if (!state.config.config) {
|
||||
return;
|
||||
}
|
||||
|
||||
const backend = currentBackend(state.config.config);
|
||||
Promise.resolve(backend.logout()).then(() => {
|
||||
dispatch(logout());
|
||||
});
|
||||
};
|
||||
}
|
||||
|
||||
export type AuthAction = ReturnType<
|
||||
| typeof authenticating
|
||||
| typeof authenticate
|
||||
| typeof authError
|
||||
| typeof doneAuthenticating
|
||||
| typeof logout
|
||||
>;
|
18
packages/core/src/actions/collections.ts
Normal file
18
packages/core/src/actions/collections.ts
Normal file
@ -0,0 +1,18 @@
|
||||
import { history } from '../routing/history';
|
||||
import { getCollectionUrl, getNewEntryUrl } from '../lib/urlHelper';
|
||||
|
||||
export function searchCollections(query: string, collection?: string) {
|
||||
if (collection) {
|
||||
history.push(`/collections/${collection}/search/${query}`);
|
||||
} else {
|
||||
history.push(`/search/${query}`);
|
||||
}
|
||||
}
|
||||
|
||||
export function showCollection(collectionName: string) {
|
||||
history.push(getCollectionUrl(collectionName));
|
||||
}
|
||||
|
||||
export function createNewEntry(collectionName: string) {
|
||||
history.push(getNewEntryUrl(collectionName));
|
||||
}
|
404
packages/core/src/actions/config.ts
Normal file
404
packages/core/src/actions/config.ts
Normal file
@ -0,0 +1,404 @@
|
||||
import deepmerge from 'deepmerge';
|
||||
import { produce } from 'immer';
|
||||
import trim from 'lodash/trim';
|
||||
import trimStart from 'lodash/trimStart';
|
||||
import yaml from 'yaml';
|
||||
|
||||
import { resolveBackend } from '../backend';
|
||||
import validateConfig from '../constants/configSchema';
|
||||
import { I18N, I18N_FIELD, I18N_STRUCTURE } from '../lib/i18n';
|
||||
import { selectDefaultSortableFields } from '../lib/util/collection.util';
|
||||
|
||||
import type { AnyAction } from 'redux';
|
||||
import type { ThunkDispatch } from 'redux-thunk';
|
||||
import type {
|
||||
BaseField,
|
||||
Config,
|
||||
Field,
|
||||
I18nInfo,
|
||||
ListField,
|
||||
LocalBackend,
|
||||
ObjectField,
|
||||
UnknownField,
|
||||
} from '../interface';
|
||||
import type { RootState } from '../store';
|
||||
|
||||
export const CONFIG_REQUEST = 'CONFIG_REQUEST';
|
||||
export const CONFIG_SUCCESS = 'CONFIG_SUCCESS';
|
||||
export const CONFIG_FAILURE = 'CONFIG_FAILURE';
|
||||
|
||||
function isObjectField<F extends BaseField = UnknownField>(field: Field<F>): field is ObjectField {
|
||||
return 'fields' in (field as ObjectField);
|
||||
}
|
||||
|
||||
function isFieldList<F extends BaseField = UnknownField>(field: Field<F>): field is ListField {
|
||||
return 'types' in (field as ListField) || 'field' in (field as ListField);
|
||||
}
|
||||
|
||||
function traverseFieldsJS<F extends Field>(
|
||||
fields: F[],
|
||||
updater: <T extends Field>(field: T) => T,
|
||||
): F[] {
|
||||
return fields.map(field => {
|
||||
const newField = updater(field);
|
||||
if (isObjectField(newField)) {
|
||||
return { ...newField, fields: traverseFieldsJS(newField.fields, updater) };
|
||||
} else if (isFieldList(newField) && newField.types) {
|
||||
return { ...newField, types: traverseFieldsJS(newField.types, updater) };
|
||||
}
|
||||
|
||||
return newField;
|
||||
});
|
||||
}
|
||||
|
||||
function getConfigUrl() {
|
||||
const validTypes: { [type: string]: string } = {
|
||||
'text/yaml': 'yaml',
|
||||
'application/x-yaml': 'yaml',
|
||||
};
|
||||
const configLinkEl = document.querySelector<HTMLLinkElement>('link[rel="cms-config-url"]');
|
||||
if (configLinkEl && validTypes[configLinkEl.type] && configLinkEl.href) {
|
||||
console.info(`Using config file path: "${configLinkEl.href}"`);
|
||||
return configLinkEl.href;
|
||||
}
|
||||
return 'config.yml';
|
||||
}
|
||||
|
||||
function setDefaultPublicFolderForField<T extends Field>(field: T) {
|
||||
if ('media_folder' in field && !('public_folder' in field)) {
|
||||
return { ...field, public_folder: field.media_folder };
|
||||
}
|
||||
return field;
|
||||
}
|
||||
|
||||
function setI18nField<T extends Field>(field: T) {
|
||||
if (field[I18N] === true) {
|
||||
return { ...field, [I18N]: I18N_FIELD.TRANSLATE };
|
||||
} else if (field[I18N] === false || !field[I18N]) {
|
||||
return { ...field, [I18N]: I18N_FIELD.NONE };
|
||||
}
|
||||
return field;
|
||||
}
|
||||
|
||||
function getI18nDefaults(collectionOrFileI18n: boolean | I18nInfo, defaultI18n: I18nInfo) {
|
||||
if (typeof collectionOrFileI18n === 'boolean') {
|
||||
return defaultI18n;
|
||||
} else {
|
||||
const locales = collectionOrFileI18n.locales || defaultI18n.locales;
|
||||
const defaultLocale = collectionOrFileI18n.defaultLocale || locales[0];
|
||||
const mergedI18n: I18nInfo = deepmerge(defaultI18n, collectionOrFileI18n);
|
||||
mergedI18n.locales = locales;
|
||||
mergedI18n.defaultLocale = defaultLocale;
|
||||
throwOnMissingDefaultLocale(mergedI18n);
|
||||
return mergedI18n;
|
||||
}
|
||||
}
|
||||
|
||||
function setI18nDefaultsForFields(collectionOrFileFields: Field[], hasI18n: boolean) {
|
||||
if (hasI18n) {
|
||||
return traverseFieldsJS(collectionOrFileFields, setI18nField);
|
||||
} else {
|
||||
return traverseFieldsJS(collectionOrFileFields, field => {
|
||||
const newField = { ...field };
|
||||
delete newField[I18N];
|
||||
return newField;
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
function throwOnInvalidFileCollectionStructure(i18n?: I18nInfo) {
|
||||
if (i18n && i18n.structure !== I18N_STRUCTURE.SINGLE_FILE) {
|
||||
throw new Error(
|
||||
`i18n configuration for files collections is limited to ${I18N_STRUCTURE.SINGLE_FILE} structure`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
function throwOnMissingDefaultLocale(i18n?: I18nInfo) {
|
||||
if (i18n && i18n.defaultLocale && !i18n.locales.includes(i18n.defaultLocale)) {
|
||||
throw new Error(
|
||||
`i18n locales '${i18n.locales.join(', ')}' are missing the default locale ${
|
||||
i18n.defaultLocale
|
||||
}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
export function applyDefaults(originalConfig: Config) {
|
||||
return produce(originalConfig, config => {
|
||||
config.slug = config.slug || {};
|
||||
config.collections = config.collections || [];
|
||||
|
||||
// Use `site_url` as default `display_url`.
|
||||
if (!config.display_url && config.site_url) {
|
||||
config.display_url = config.site_url;
|
||||
}
|
||||
|
||||
// Use media_folder as default public_folder.
|
||||
const defaultPublicFolder = `/${trimStart(config.media_folder, '/')}`;
|
||||
if (!('public_folder' in config)) {
|
||||
config.public_folder = defaultPublicFolder;
|
||||
}
|
||||
|
||||
// default values for the slug config
|
||||
if (!('encoding' in config.slug)) {
|
||||
config.slug.encoding = 'unicode';
|
||||
}
|
||||
|
||||
if (!('clean_accents' in config.slug)) {
|
||||
config.slug.clean_accents = false;
|
||||
}
|
||||
|
||||
if (!('sanitize_replacement' in config.slug)) {
|
||||
config.slug.sanitize_replacement = '-';
|
||||
}
|
||||
|
||||
const i18n = config[I18N];
|
||||
|
||||
if (i18n) {
|
||||
i18n.defaultLocale = i18n.defaultLocale || i18n.locales[0];
|
||||
}
|
||||
|
||||
throwOnMissingDefaultLocale(i18n);
|
||||
|
||||
const backend = resolveBackend(config);
|
||||
|
||||
for (const collection of config.collections) {
|
||||
let collectionI18n = collection[I18N];
|
||||
|
||||
if (config.editor && !collection.editor) {
|
||||
collection.editor = { preview: config.editor.preview, frame: config.editor.frame };
|
||||
}
|
||||
|
||||
if (i18n && collectionI18n) {
|
||||
collectionI18n = getI18nDefaults(collectionI18n, i18n);
|
||||
collection[I18N] = collectionI18n;
|
||||
} else {
|
||||
collectionI18n = undefined;
|
||||
delete collection[I18N];
|
||||
}
|
||||
|
||||
if ('fields' in collection && collection.fields) {
|
||||
collection.fields = setI18nDefaultsForFields(collection.fields, Boolean(collectionI18n));
|
||||
}
|
||||
|
||||
const { view_filters, view_groups } = collection;
|
||||
|
||||
if ('folder' in collection && collection.folder) {
|
||||
if (collection.path && !collection.media_folder) {
|
||||
// default value for media folder when using the path config
|
||||
collection.media_folder = '';
|
||||
}
|
||||
|
||||
if ('media_folder' in collection && !('public_folder' in collection)) {
|
||||
collection.public_folder = collection.media_folder;
|
||||
}
|
||||
|
||||
if ('fields' in collection && collection.fields) {
|
||||
collection.fields = traverseFieldsJS(collection.fields, setDefaultPublicFolderForField);
|
||||
}
|
||||
|
||||
collection.folder = trim(collection.folder, '/');
|
||||
}
|
||||
|
||||
if ('files' in collection && collection.files) {
|
||||
throwOnInvalidFileCollectionStructure(collectionI18n);
|
||||
|
||||
for (const file of collection.files) {
|
||||
file.file = trimStart(file.file, '/');
|
||||
|
||||
if ('media_folder' in file && !('public_folder' in file)) {
|
||||
file.public_folder = file.media_folder;
|
||||
}
|
||||
|
||||
if (file.fields) {
|
||||
file.fields = traverseFieldsJS(file.fields, setDefaultPublicFolderForField);
|
||||
}
|
||||
|
||||
let fileI18n = file[I18N];
|
||||
|
||||
if (fileI18n && collectionI18n) {
|
||||
fileI18n = getI18nDefaults(fileI18n, collectionI18n);
|
||||
file[I18N] = fileI18n;
|
||||
} else {
|
||||
fileI18n = undefined;
|
||||
delete file[I18N];
|
||||
}
|
||||
|
||||
throwOnInvalidFileCollectionStructure(fileI18n);
|
||||
|
||||
if (file.fields) {
|
||||
file.fields = setI18nDefaultsForFields(file.fields, Boolean(fileI18n));
|
||||
}
|
||||
|
||||
if (collection.editor && !file.editor) {
|
||||
file.editor = { preview: collection.editor.preview, frame: collection.editor.frame };
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (!collection.sortable_fields) {
|
||||
collection.sortable_fields = {
|
||||
fields: selectDefaultSortableFields(collection, backend),
|
||||
};
|
||||
}
|
||||
|
||||
collection.view_filters = (view_filters || []).map(filter => {
|
||||
return {
|
||||
...filter,
|
||||
id: `${filter.field}__${filter.pattern}`,
|
||||
};
|
||||
});
|
||||
|
||||
collection.view_groups = (view_groups || []).map(group => {
|
||||
return {
|
||||
...group,
|
||||
id: `${group.field}__${group.pattern}`,
|
||||
};
|
||||
});
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
export function parseConfig(data: string) {
|
||||
const config = yaml.parse(data, { maxAliasCount: -1, prettyErrors: true, merge: true });
|
||||
if (
|
||||
typeof window !== 'undefined' &&
|
||||
typeof window.CMS_ENV === 'string' &&
|
||||
config[window.CMS_ENV]
|
||||
) {
|
||||
const configKeys = Object.keys(config[window.CMS_ENV]) as ReadonlyArray<keyof Config>;
|
||||
for (const key of configKeys) {
|
||||
config[key] = config[window.CMS_ENV][key] as Config[keyof Config];
|
||||
}
|
||||
}
|
||||
return config as Config;
|
||||
}
|
||||
|
||||
async function getConfigYaml(file: string): Promise<Config> {
|
||||
const response = await fetch(file, { credentials: 'same-origin' }).catch(error => error as Error);
|
||||
if (response instanceof Error || response.status !== 200) {
|
||||
const message = response instanceof Error ? response.message : response.status;
|
||||
throw new Error(`Failed to load config.yml (${message})`);
|
||||
}
|
||||
const contentType = response.headers.get('Content-Type') ?? 'Not-Found';
|
||||
const isYaml = contentType.indexOf('yaml') !== -1;
|
||||
if (!isYaml) {
|
||||
console.info(`Response for ${file} was not yaml. (Content-Type: ${contentType})`);
|
||||
}
|
||||
return parseConfig(await response.text());
|
||||
}
|
||||
|
||||
export function configLoaded(config: Config) {
|
||||
return {
|
||||
type: CONFIG_SUCCESS,
|
||||
payload: config,
|
||||
} as const;
|
||||
}
|
||||
|
||||
export function configLoading() {
|
||||
return {
|
||||
type: CONFIG_REQUEST,
|
||||
} as const;
|
||||
}
|
||||
|
||||
export function configFailed(err: Error) {
|
||||
return {
|
||||
type: CONFIG_FAILURE,
|
||||
error: 'Error loading config',
|
||||
payload: err,
|
||||
} as const;
|
||||
}
|
||||
|
||||
export async function detectProxyServer(localBackend?: boolean | LocalBackend) {
|
||||
const allowedHosts = [
|
||||
'localhost',
|
||||
'127.0.0.1',
|
||||
...(typeof localBackend === 'boolean' ? [] : localBackend?.allowed_hosts || []),
|
||||
];
|
||||
|
||||
if (!allowedHosts.includes(location.hostname) || !localBackend) {
|
||||
return {};
|
||||
}
|
||||
|
||||
const defaultUrl = 'http://localhost:8081/api/v1';
|
||||
const proxyUrl =
|
||||
localBackend === true
|
||||
? defaultUrl
|
||||
: localBackend.url || defaultUrl.replace('localhost', location.hostname);
|
||||
|
||||
try {
|
||||
console.info(`Looking for Static CMS Proxy Server at '${proxyUrl}'`);
|
||||
const res = await fetch(`${proxyUrl}`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ action: 'info' }),
|
||||
});
|
||||
const { repo, type } = (await res.json()) as {
|
||||
repo?: string;
|
||||
type?: string;
|
||||
};
|
||||
if (typeof repo === 'string' && typeof type === 'string') {
|
||||
console.info(`Detected Static CMS Proxy Server at '${proxyUrl}' with repo: '${repo}'`);
|
||||
return { proxyUrl, type };
|
||||
} else {
|
||||
console.info(`Static CMS Proxy Server not detected at '${proxyUrl}'`);
|
||||
return {};
|
||||
}
|
||||
} catch {
|
||||
console.info(`Static CMS Proxy Server not detected at '${proxyUrl}'`);
|
||||
return {};
|
||||
}
|
||||
}
|
||||
|
||||
export async function handleLocalBackend(originalConfig: Config) {
|
||||
if (!originalConfig.local_backend) {
|
||||
return originalConfig;
|
||||
}
|
||||
|
||||
const { proxyUrl } = await detectProxyServer(originalConfig.local_backend);
|
||||
|
||||
if (!proxyUrl) {
|
||||
return originalConfig;
|
||||
}
|
||||
|
||||
return produce(originalConfig, config => {
|
||||
config.backend.name = 'proxy';
|
||||
config.backend.proxy_url = proxyUrl;
|
||||
});
|
||||
}
|
||||
|
||||
export function loadConfig(manualConfig: Config | undefined, onLoad: (config: Config) => unknown) {
|
||||
if (window.CMS_CONFIG) {
|
||||
return configLoaded(window.CMS_CONFIG);
|
||||
}
|
||||
return async (dispatch: ThunkDispatch<RootState, {}, AnyAction>) => {
|
||||
dispatch(configLoading());
|
||||
|
||||
try {
|
||||
const configUrl = getConfigUrl();
|
||||
const mergedConfig = manualConfig ? manualConfig : await getConfigYaml(configUrl);
|
||||
|
||||
validateConfig(mergedConfig);
|
||||
|
||||
const withLocalBackend = await handleLocalBackend(mergedConfig);
|
||||
const config = applyDefaults(withLocalBackend);
|
||||
|
||||
dispatch(configLoaded(config));
|
||||
|
||||
if (typeof onLoad === 'function') {
|
||||
onLoad(config);
|
||||
}
|
||||
} catch (error: unknown) {
|
||||
console.error(error);
|
||||
if (error instanceof Error) {
|
||||
dispatch(configFailed(error));
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
export type ConfigAction = ReturnType<
|
||||
typeof configLoading | typeof configLoaded | typeof configFailed
|
||||
>;
|
1140
packages/core/src/actions/entries.ts
Normal file
1140
packages/core/src/actions/entries.ts
Normal file
File diff suppressed because it is too large
Load Diff
155
packages/core/src/actions/media.ts
Normal file
155
packages/core/src/actions/media.ts
Normal file
@ -0,0 +1,155 @@
|
||||
import { isAbsolutePath } from '../lib/util';
|
||||
import { selectMediaFilePath } from '../lib/util/media.util';
|
||||
import { selectMediaFileByPath } from '../reducers/mediaLibrary';
|
||||
import { createAssetProxy } from '../valueObjects/AssetProxy';
|
||||
import { getMediaDisplayURL, getMediaFile, waitForMediaLibraryToLoad } from './mediaLibrary';
|
||||
|
||||
import type { AnyAction } from 'redux';
|
||||
import type { ThunkDispatch } from 'redux-thunk';
|
||||
import type { BaseField, Collection, Entry, Field, UnknownField } from '../interface';
|
||||
import type { RootState } from '../store';
|
||||
import type AssetProxy from '../valueObjects/AssetProxy';
|
||||
|
||||
export const ADD_ASSETS = 'ADD_ASSETS';
|
||||
export const ADD_ASSET = 'ADD_ASSET';
|
||||
export const REMOVE_ASSET = 'REMOVE_ASSET';
|
||||
|
||||
export const LOAD_ASSET_REQUEST = 'LOAD_ASSET_REQUEST';
|
||||
export const LOAD_ASSET_SUCCESS = 'LOAD_ASSET_SUCCESS';
|
||||
export const LOAD_ASSET_FAILURE = 'LOAD_ASSET_FAILURE';
|
||||
|
||||
export function addAssets(assets: AssetProxy[]) {
|
||||
return { type: ADD_ASSETS, payload: assets } as const;
|
||||
}
|
||||
|
||||
export function addAsset(assetProxy: AssetProxy) {
|
||||
return { type: ADD_ASSET, payload: assetProxy } as const;
|
||||
}
|
||||
|
||||
export function removeAsset(path: string) {
|
||||
return { type: REMOVE_ASSET, payload: path } as const;
|
||||
}
|
||||
|
||||
export function loadAssetRequest(path: string) {
|
||||
return { type: LOAD_ASSET_REQUEST, payload: { path } } as const;
|
||||
}
|
||||
|
||||
export function loadAssetSuccess(path: string) {
|
||||
return { type: LOAD_ASSET_SUCCESS, payload: { path } } as const;
|
||||
}
|
||||
|
||||
export function loadAssetFailure(path: string, error: Error) {
|
||||
return { type: LOAD_ASSET_FAILURE, payload: { path, error } } as const;
|
||||
}
|
||||
|
||||
export const emptyAsset = createAssetProxy({
|
||||
path: 'empty.svg',
|
||||
file: new File([`<svg xmlns="http://www.w3.org/2000/svg"></svg>`], 'empty.svg', {
|
||||
type: 'image/svg+xml',
|
||||
}),
|
||||
});
|
||||
|
||||
async function loadAsset(
|
||||
resolvedPath: string,
|
||||
dispatch: ThunkDispatch<RootState, {}, AnyAction>,
|
||||
getState: () => RootState,
|
||||
): Promise<AssetProxy> {
|
||||
try {
|
||||
dispatch(loadAssetRequest(resolvedPath));
|
||||
// load asset url from backend
|
||||
await waitForMediaLibraryToLoad(dispatch, getState());
|
||||
const file = selectMediaFileByPath(getState(), resolvedPath);
|
||||
|
||||
let asset: AssetProxy;
|
||||
if (file) {
|
||||
const url = await getMediaDisplayURL(dispatch, getState(), file);
|
||||
asset = createAssetProxy({ path: resolvedPath, url: url || resolvedPath });
|
||||
dispatch(addAsset(asset));
|
||||
} else {
|
||||
const { url } = await getMediaFile(getState(), resolvedPath);
|
||||
asset = createAssetProxy({ path: resolvedPath, url });
|
||||
dispatch(addAsset(asset));
|
||||
}
|
||||
dispatch(loadAssetSuccess(resolvedPath));
|
||||
return asset;
|
||||
} catch (error: unknown) {
|
||||
console.error(error);
|
||||
if (error instanceof Error) {
|
||||
dispatch(loadAssetFailure(resolvedPath, error));
|
||||
}
|
||||
return emptyAsset;
|
||||
}
|
||||
}
|
||||
|
||||
const promiseCache: Record<string, Promise<AssetProxy>> = {};
|
||||
|
||||
export function getAsset<F extends BaseField = UnknownField>(
|
||||
collection: Collection<F> | null | undefined,
|
||||
entry: Entry | null | undefined,
|
||||
path: string,
|
||||
field?: F,
|
||||
) {
|
||||
return (
|
||||
dispatch: ThunkDispatch<RootState, {}, AnyAction>,
|
||||
getState: () => RootState,
|
||||
): Promise<AssetProxy> => {
|
||||
if (!collection || !entry || !path) {
|
||||
return Promise.resolve(emptyAsset);
|
||||
}
|
||||
|
||||
const state = getState();
|
||||
if (!state.config.config) {
|
||||
return Promise.resolve(emptyAsset);
|
||||
}
|
||||
|
||||
const resolvedPath = selectMediaFilePath(
|
||||
state.config.config,
|
||||
collection as Collection,
|
||||
entry,
|
||||
path,
|
||||
field as Field,
|
||||
);
|
||||
let { asset, isLoading, error } = state.medias[resolvedPath] || {};
|
||||
if (isLoading) {
|
||||
return promiseCache[resolvedPath];
|
||||
}
|
||||
|
||||
if (asset) {
|
||||
// There is already an AssetProxy in memory for this path. Use it.
|
||||
return Promise.resolve(asset);
|
||||
}
|
||||
|
||||
const p = new Promise<AssetProxy>(resolve => {
|
||||
if (isAbsolutePath(resolvedPath)) {
|
||||
// asset path is a public url so we can just use it as is
|
||||
asset = createAssetProxy({ path: resolvedPath, url: path });
|
||||
dispatch(addAsset(asset));
|
||||
resolve(asset);
|
||||
} else {
|
||||
if (error) {
|
||||
// on load error default back to original path
|
||||
asset = createAssetProxy({ path: resolvedPath, url: path });
|
||||
dispatch(addAsset(asset));
|
||||
resolve(asset);
|
||||
} else {
|
||||
loadAsset(resolvedPath, dispatch, getState).then(asset => {
|
||||
resolve(asset);
|
||||
});
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
promiseCache[resolvedPath] = p;
|
||||
|
||||
return p;
|
||||
};
|
||||
}
|
||||
|
||||
export type MediasAction = ReturnType<
|
||||
| typeof addAssets
|
||||
| typeof addAsset
|
||||
| typeof removeAsset
|
||||
| typeof loadAssetRequest
|
||||
| typeof loadAssetSuccess
|
||||
| typeof loadAssetFailure
|
||||
>;
|
559
packages/core/src/actions/mediaLibrary.ts
Normal file
559
packages/core/src/actions/mediaLibrary.ts
Normal file
@ -0,0 +1,559 @@
|
||||
import { currentBackend } from '../backend';
|
||||
import confirm from '../components/UI/Confirm';
|
||||
import { sanitizeSlug } from '../lib/urlHelper';
|
||||
import { basename, getBlobSHA } from '../lib/util';
|
||||
import { selectMediaFilePath, selectMediaFilePublicPath } from '../lib/util/media.util';
|
||||
import { selectEditingDraft } from '../reducers/entries';
|
||||
import { selectMediaDisplayURL, selectMediaFiles } from '../reducers/mediaLibrary';
|
||||
import { addSnackbar } from '../store/slices/snackbars';
|
||||
import { createAssetProxy } from '../valueObjects/AssetProxy';
|
||||
import { addDraftEntryMediaFile, removeDraftEntryMediaFile } from './entries';
|
||||
import { addAsset, removeAsset } from './media';
|
||||
import { waitUntilWithTimeout } from './waitUntil';
|
||||
|
||||
import type { AnyAction } from 'redux';
|
||||
import type { ThunkDispatch } from 'redux-thunk';
|
||||
import type {
|
||||
BaseField,
|
||||
DisplayURLState,
|
||||
Field,
|
||||
ImplementationMediaFile,
|
||||
MediaFile,
|
||||
MediaLibraryInstance,
|
||||
UnknownField,
|
||||
} from '../interface';
|
||||
import type { RootState } from '../store';
|
||||
import type AssetProxy from '../valueObjects/AssetProxy';
|
||||
|
||||
export const MEDIA_LIBRARY_OPEN = 'MEDIA_LIBRARY_OPEN';
|
||||
export const MEDIA_LIBRARY_CLOSE = 'MEDIA_LIBRARY_CLOSE';
|
||||
export const MEDIA_LIBRARY_CREATE = 'MEDIA_LIBRARY_CREATE';
|
||||
export const MEDIA_INSERT = 'MEDIA_INSERT';
|
||||
export const MEDIA_REMOVE_INSERTED = 'MEDIA_REMOVE_INSERTED';
|
||||
export const MEDIA_LOAD_REQUEST = 'MEDIA_LOAD_REQUEST';
|
||||
export const MEDIA_LOAD_SUCCESS = 'MEDIA_LOAD_SUCCESS';
|
||||
export const MEDIA_LOAD_FAILURE = 'MEDIA_LOAD_FAILURE';
|
||||
export const MEDIA_PERSIST_REQUEST = 'MEDIA_PERSIST_REQUEST';
|
||||
export const MEDIA_PERSIST_SUCCESS = 'MEDIA_PERSIST_SUCCESS';
|
||||
export const MEDIA_PERSIST_FAILURE = 'MEDIA_PERSIST_FAILURE';
|
||||
export const MEDIA_DELETE_REQUEST = 'MEDIA_DELETE_REQUEST';
|
||||
export const MEDIA_DELETE_SUCCESS = 'MEDIA_DELETE_SUCCESS';
|
||||
export const MEDIA_DELETE_FAILURE = 'MEDIA_DELETE_FAILURE';
|
||||
export const MEDIA_DISPLAY_URL_REQUEST = 'MEDIA_DISPLAY_URL_REQUEST';
|
||||
export const MEDIA_DISPLAY_URL_SUCCESS = 'MEDIA_DISPLAY_URL_SUCCESS';
|
||||
export const MEDIA_DISPLAY_URL_FAILURE = 'MEDIA_DISPLAY_URL_FAILURE';
|
||||
|
||||
export function createMediaLibrary(instance: MediaLibraryInstance) {
|
||||
const api = {
|
||||
show: instance.show || (() => undefined),
|
||||
hide: instance.hide || (() => undefined),
|
||||
onClearControl: instance.onClearControl || (() => undefined),
|
||||
onRemoveControl: instance.onRemoveControl || (() => undefined),
|
||||
enableStandalone: instance.enableStandalone || (() => undefined),
|
||||
};
|
||||
return { type: MEDIA_LIBRARY_CREATE, payload: api } as const;
|
||||
}
|
||||
|
||||
export function clearMediaControl(id: string) {
|
||||
return (_dispatch: ThunkDispatch<RootState, {}, AnyAction>, getState: () => RootState) => {
|
||||
const state = getState();
|
||||
const mediaLibrary = state.mediaLibrary.externalLibrary;
|
||||
if (mediaLibrary) {
|
||||
mediaLibrary.onClearControl?.({ id });
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
export function removeMediaControl(id: string) {
|
||||
return (_dispatch: ThunkDispatch<RootState, {}, AnyAction>, getState: () => RootState) => {
|
||||
const state = getState();
|
||||
const mediaLibrary = state.mediaLibrary.externalLibrary;
|
||||
if (mediaLibrary) {
|
||||
mediaLibrary.onRemoveControl?.({ id });
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
export function openMediaLibrary<F extends BaseField = UnknownField>(
|
||||
payload: {
|
||||
controlID?: string;
|
||||
forImage?: boolean;
|
||||
value?: string | string[];
|
||||
allowMultiple?: boolean;
|
||||
replaceIndex?: number;
|
||||
config?: Record<string, unknown>;
|
||||
field?: F;
|
||||
} = {},
|
||||
) {
|
||||
return (dispatch: ThunkDispatch<RootState, {}, AnyAction>, getState: () => RootState) => {
|
||||
const state = getState();
|
||||
const mediaLibrary = state.mediaLibrary.externalLibrary;
|
||||
const { controlID, value, config = {}, allowMultiple, forImage, replaceIndex, field } = payload;
|
||||
if (mediaLibrary) {
|
||||
mediaLibrary.show({ id: controlID, value, config, allowMultiple, imagesOnly: forImage });
|
||||
}
|
||||
dispatch(
|
||||
mediaLibraryOpened({
|
||||
controlID,
|
||||
forImage,
|
||||
value,
|
||||
allowMultiple,
|
||||
replaceIndex,
|
||||
config,
|
||||
field: field as Field,
|
||||
}),
|
||||
);
|
||||
};
|
||||
}
|
||||
|
||||
export function closeMediaLibrary() {
|
||||
return (dispatch: ThunkDispatch<RootState, {}, AnyAction>, getState: () => RootState) => {
|
||||
const state = getState();
|
||||
const mediaLibrary = state.mediaLibrary.externalLibrary;
|
||||
if (mediaLibrary) {
|
||||
mediaLibrary.hide?.();
|
||||
}
|
||||
dispatch(mediaLibraryClosed());
|
||||
};
|
||||
}
|
||||
|
||||
export function insertMedia(mediaPath: string | string[], field: Field | undefined) {
|
||||
return (dispatch: ThunkDispatch<RootState, {}, AnyAction>, getState: () => RootState) => {
|
||||
const state = getState();
|
||||
const config = state.config.config;
|
||||
const entry = state.entryDraft.entry;
|
||||
const collectionName = state.entryDraft.entry?.collection;
|
||||
if (!collectionName || !config) {
|
||||
return;
|
||||
}
|
||||
|
||||
const collection = state.collections[collectionName];
|
||||
if (Array.isArray(mediaPath)) {
|
||||
mediaPath = mediaPath.map(path =>
|
||||
selectMediaFilePublicPath(config, collection, path, entry, field),
|
||||
);
|
||||
} else {
|
||||
mediaPath = selectMediaFilePublicPath(config, collection, mediaPath as string, entry, field);
|
||||
}
|
||||
dispatch(mediaInserted(mediaPath));
|
||||
};
|
||||
}
|
||||
|
||||
export function removeInsertedMedia(controlID: string) {
|
||||
return { type: MEDIA_REMOVE_INSERTED, payload: { controlID } } as const;
|
||||
}
|
||||
|
||||
export function loadMedia(opts: { delay?: number; query?: string; page?: number } = {}) {
|
||||
const { delay = 0, page = 1 } = opts;
|
||||
return async (dispatch: ThunkDispatch<RootState, {}, AnyAction>, getState: () => RootState) => {
|
||||
const state = getState();
|
||||
const config = state.config.config;
|
||||
if (!config) {
|
||||
return;
|
||||
}
|
||||
|
||||
const backend = currentBackend(config);
|
||||
dispatch(mediaLoading(page));
|
||||
|
||||
function loadFunction() {
|
||||
return backend
|
||||
.getMedia()
|
||||
.then(files => dispatch(mediaLoaded(files)))
|
||||
.catch((error: { status?: number }) => {
|
||||
console.error(error);
|
||||
if (error.status === 404) {
|
||||
console.info('This 404 was expected and handled appropriately.');
|
||||
dispatch(mediaLoaded([]));
|
||||
} else {
|
||||
dispatch(mediaLoadFailed());
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
if (delay > 0) {
|
||||
return new Promise(resolve => {
|
||||
setTimeout(() => resolve(loadFunction()), delay);
|
||||
});
|
||||
} else {
|
||||
return loadFunction();
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
function createMediaFileFromAsset({
|
||||
id,
|
||||
file,
|
||||
assetProxy,
|
||||
draft,
|
||||
}: {
|
||||
id: string;
|
||||
file: File;
|
||||
assetProxy: AssetProxy;
|
||||
draft: boolean;
|
||||
}): ImplementationMediaFile {
|
||||
const mediaFile = {
|
||||
id,
|
||||
name: basename(assetProxy.path),
|
||||
displayURL: assetProxy.url,
|
||||
draft,
|
||||
file,
|
||||
size: file.size,
|
||||
url: assetProxy.url,
|
||||
path: assetProxy.path,
|
||||
field: assetProxy.field,
|
||||
};
|
||||
return mediaFile;
|
||||
}
|
||||
|
||||
export function persistMedia(file: File, opts: MediaOptions = {}) {
|
||||
const { field } = opts;
|
||||
return async (dispatch: ThunkDispatch<RootState, {}, AnyAction>, getState: () => RootState) => {
|
||||
const state = getState();
|
||||
const config = state.config.config;
|
||||
if (!config) {
|
||||
return;
|
||||
}
|
||||
|
||||
const backend = currentBackend(config);
|
||||
const files: MediaFile[] = selectMediaFiles(state, field);
|
||||
const fileName = sanitizeSlug(file.name.toLowerCase(), config.slug);
|
||||
const existingFile = files.find(existingFile => existingFile.name.toLowerCase() === fileName);
|
||||
|
||||
const editingDraft = selectEditingDraft(state.entryDraft);
|
||||
|
||||
/**
|
||||
* Check for existing files of the same name before persisting. If no asset
|
||||
* store integration is used, files are being stored in Git, so we can
|
||||
* expect file names to be unique. If an asset store is in use, file names
|
||||
* may not be unique, so we forego this check.
|
||||
*/
|
||||
if (existingFile) {
|
||||
if (
|
||||
!(await confirm({
|
||||
title: 'mediaLibrary.mediaLibrary.alreadyExistsTitle',
|
||||
body: {
|
||||
key: 'mediaLibrary.mediaLibrary.alreadyExistsBody',
|
||||
options: { filename: existingFile.name },
|
||||
},
|
||||
color: 'error',
|
||||
}))
|
||||
) {
|
||||
return;
|
||||
} else {
|
||||
await dispatch(deleteMedia(existingFile));
|
||||
}
|
||||
}
|
||||
|
||||
if (!editingDraft) {
|
||||
dispatch(mediaPersisting());
|
||||
}
|
||||
|
||||
try {
|
||||
const entry = state.entryDraft.entry;
|
||||
const collection = entry?.collection ? state.collections[entry.collection] : null;
|
||||
const path = selectMediaFilePath(config, collection, entry, fileName, field);
|
||||
const assetProxy = createAssetProxy({
|
||||
file,
|
||||
path,
|
||||
field,
|
||||
});
|
||||
|
||||
dispatch(addAsset(assetProxy));
|
||||
|
||||
let mediaFile: ImplementationMediaFile;
|
||||
if (editingDraft) {
|
||||
const id = await getBlobSHA(file);
|
||||
mediaFile = createMediaFileFromAsset({
|
||||
id,
|
||||
file,
|
||||
assetProxy,
|
||||
draft: Boolean(editingDraft),
|
||||
});
|
||||
return dispatch(addDraftEntryMediaFile(mediaFile));
|
||||
} else {
|
||||
mediaFile = await backend.persistMedia(config, assetProxy);
|
||||
}
|
||||
|
||||
return dispatch(mediaPersisted(mediaFile));
|
||||
} catch (error) {
|
||||
console.error(error);
|
||||
dispatch(
|
||||
addSnackbar({
|
||||
type: 'error',
|
||||
message: {
|
||||
key: 'ui.toast.onFailToPersistMedia',
|
||||
options: {
|
||||
details: error,
|
||||
},
|
||||
},
|
||||
}),
|
||||
);
|
||||
return dispatch(mediaPersistFailed());
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
export function deleteMedia(file: MediaFile) {
|
||||
return async (dispatch: ThunkDispatch<RootState, {}, AnyAction>, getState: () => RootState) => {
|
||||
const state = getState();
|
||||
const config = state.config.config;
|
||||
if (!config) {
|
||||
return;
|
||||
}
|
||||
|
||||
const backend = currentBackend(config);
|
||||
|
||||
try {
|
||||
if (file.draft) {
|
||||
dispatch(removeAsset(file.path));
|
||||
dispatch(removeDraftEntryMediaFile({ id: file.id }));
|
||||
} else {
|
||||
const editingDraft = selectEditingDraft(state.entryDraft);
|
||||
|
||||
dispatch(mediaDeleting());
|
||||
dispatch(removeAsset(file.path));
|
||||
|
||||
await backend.deleteMedia(config, file.path);
|
||||
|
||||
dispatch(mediaDeleted(file));
|
||||
if (editingDraft) {
|
||||
dispatch(removeDraftEntryMediaFile({ id: file.id }));
|
||||
}
|
||||
}
|
||||
} catch (error: unknown) {
|
||||
console.error(error);
|
||||
|
||||
if (error instanceof Error) {
|
||||
dispatch(
|
||||
addSnackbar({
|
||||
type: 'error',
|
||||
message: {
|
||||
key: 'ui.toast.onFailToDeleteMedia',
|
||||
options: {
|
||||
details: error.message,
|
||||
},
|
||||
},
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
return dispatch(mediaDeleteFailed());
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
export async function getMediaFile(state: RootState, path: string) {
|
||||
const config = state.config.config;
|
||||
if (!config) {
|
||||
return { url: '' };
|
||||
}
|
||||
|
||||
const backend = currentBackend(config);
|
||||
const { url } = await backend.getMediaFile(path);
|
||||
return { url };
|
||||
}
|
||||
|
||||
export function loadMediaDisplayURL(file: MediaFile) {
|
||||
return async (dispatch: ThunkDispatch<RootState, {}, AnyAction>, getState: () => RootState) => {
|
||||
const { displayURL, id } = file;
|
||||
const state = getState();
|
||||
const config = state.config.config;
|
||||
if (!config) {
|
||||
return Promise.reject();
|
||||
}
|
||||
|
||||
const displayURLState: DisplayURLState = selectMediaDisplayURL(state, id);
|
||||
if (
|
||||
!id ||
|
||||
!displayURL ||
|
||||
displayURLState.url ||
|
||||
displayURLState.isFetching ||
|
||||
displayURLState.err
|
||||
) {
|
||||
return Promise.resolve();
|
||||
}
|
||||
|
||||
if (typeof displayURL === 'string') {
|
||||
dispatch(mediaDisplayURLRequest(id));
|
||||
dispatch(mediaDisplayURLSuccess(id, displayURL));
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const backend = currentBackend(config);
|
||||
dispatch(mediaDisplayURLRequest(id));
|
||||
const newURL = await backend.getMediaDisplayURL(displayURL);
|
||||
if (newURL) {
|
||||
dispatch(mediaDisplayURLSuccess(id, newURL));
|
||||
} else {
|
||||
throw new Error('No display URL was returned!');
|
||||
}
|
||||
} catch (error: unknown) {
|
||||
console.error(error);
|
||||
|
||||
if (error instanceof Error) {
|
||||
dispatch(mediaDisplayURLFailure(id, error));
|
||||
}
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
function mediaLibraryOpened(payload: {
|
||||
controlID?: string;
|
||||
forImage?: boolean;
|
||||
value?: string | string[];
|
||||
replaceIndex?: number;
|
||||
allowMultiple?: boolean;
|
||||
config?: Record<string, unknown>;
|
||||
field?: Field;
|
||||
}) {
|
||||
return { type: MEDIA_LIBRARY_OPEN, payload } as const;
|
||||
}
|
||||
|
||||
function mediaLibraryClosed() {
|
||||
return { type: MEDIA_LIBRARY_CLOSE } as const;
|
||||
}
|
||||
|
||||
function mediaInserted(mediaPath: string | string[]) {
|
||||
return { type: MEDIA_INSERT, payload: { mediaPath } } as const;
|
||||
}
|
||||
|
||||
export function mediaLoading(page: number) {
|
||||
return {
|
||||
type: MEDIA_LOAD_REQUEST,
|
||||
payload: { page },
|
||||
} as const;
|
||||
}
|
||||
|
||||
export interface MediaOptions {
|
||||
field?: Field;
|
||||
page?: number;
|
||||
canPaginate?: boolean;
|
||||
dynamicSearch?: boolean;
|
||||
dynamicSearchQuery?: string;
|
||||
}
|
||||
|
||||
export function mediaLoaded(files: ImplementationMediaFile[], opts: MediaOptions = {}) {
|
||||
return {
|
||||
type: MEDIA_LOAD_SUCCESS,
|
||||
payload: { files, ...opts },
|
||||
} as const;
|
||||
}
|
||||
|
||||
export function mediaLoadFailed() {
|
||||
return { type: MEDIA_LOAD_FAILURE } as const;
|
||||
}
|
||||
|
||||
export function mediaPersisting() {
|
||||
return { type: MEDIA_PERSIST_REQUEST } as const;
|
||||
}
|
||||
|
||||
export function mediaPersisted(file: ImplementationMediaFile) {
|
||||
return {
|
||||
type: MEDIA_PERSIST_SUCCESS,
|
||||
payload: { file },
|
||||
} as const;
|
||||
}
|
||||
|
||||
export function mediaPersistFailed() {
|
||||
return { type: MEDIA_PERSIST_FAILURE } as const;
|
||||
}
|
||||
|
||||
export function mediaDeleting() {
|
||||
return { type: MEDIA_DELETE_REQUEST } as const;
|
||||
}
|
||||
|
||||
export function mediaDeleted(file: MediaFile) {
|
||||
return {
|
||||
type: MEDIA_DELETE_SUCCESS,
|
||||
payload: { file },
|
||||
} as const;
|
||||
}
|
||||
|
||||
export function mediaDeleteFailed() {
|
||||
return { type: MEDIA_DELETE_FAILURE } as const;
|
||||
}
|
||||
|
||||
export function mediaDisplayURLRequest(key: string) {
|
||||
return { type: MEDIA_DISPLAY_URL_REQUEST, payload: { key } } as const;
|
||||
}
|
||||
|
||||
export function mediaDisplayURLSuccess(key: string, url: string) {
|
||||
return {
|
||||
type: MEDIA_DISPLAY_URL_SUCCESS,
|
||||
payload: { key, url },
|
||||
} as const;
|
||||
}
|
||||
|
||||
export function mediaDisplayURLFailure(key: string, err: Error) {
|
||||
return {
|
||||
type: MEDIA_DISPLAY_URL_FAILURE,
|
||||
payload: { key, err },
|
||||
} as const;
|
||||
}
|
||||
|
||||
export async function waitForMediaLibraryToLoad(
|
||||
dispatch: ThunkDispatch<RootState, {}, AnyAction>,
|
||||
state: RootState,
|
||||
) {
|
||||
if (state.mediaLibrary.isLoading !== false && !state.mediaLibrary.externalLibrary) {
|
||||
await waitUntilWithTimeout(dispatch, resolve => ({
|
||||
predicate: ({ type }) => type === MEDIA_LOAD_SUCCESS || type === MEDIA_LOAD_FAILURE,
|
||||
run: () => resolve(),
|
||||
}));
|
||||
}
|
||||
}
|
||||
|
||||
export async function getMediaDisplayURL(
|
||||
dispatch: ThunkDispatch<RootState, {}, AnyAction>,
|
||||
state: RootState,
|
||||
file: MediaFile,
|
||||
) {
|
||||
const displayURLState: DisplayURLState = selectMediaDisplayURL(state, file.id);
|
||||
|
||||
let url: string | null | undefined;
|
||||
if (displayURLState.url) {
|
||||
// url was already loaded
|
||||
url = displayURLState.url;
|
||||
} else if (displayURLState.err) {
|
||||
// url loading had an error
|
||||
url = null;
|
||||
} else {
|
||||
const key = file.id;
|
||||
const promise = waitUntilWithTimeout<string>(dispatch, resolve => ({
|
||||
predicate: ({ type, payload }) =>
|
||||
(type === MEDIA_DISPLAY_URL_SUCCESS || type === MEDIA_DISPLAY_URL_FAILURE) &&
|
||||
payload.key === key,
|
||||
run: (_dispatch, _getState, action) => resolve(action.payload.url),
|
||||
}));
|
||||
|
||||
if (!displayURLState.isFetching) {
|
||||
// load display url
|
||||
dispatch(loadMediaDisplayURL(file));
|
||||
}
|
||||
|
||||
url = await promise;
|
||||
}
|
||||
|
||||
return url;
|
||||
}
|
||||
|
||||
export type MediaLibraryAction = ReturnType<
|
||||
| typeof createMediaLibrary
|
||||
| typeof mediaLibraryOpened
|
||||
| typeof mediaLibraryClosed
|
||||
| typeof mediaInserted
|
||||
| typeof removeInsertedMedia
|
||||
| typeof mediaLoading
|
||||
| typeof mediaLoaded
|
||||
| typeof mediaLoadFailed
|
||||
| typeof mediaPersisting
|
||||
| typeof mediaPersisted
|
||||
| typeof mediaPersistFailed
|
||||
| typeof mediaDeleting
|
||||
| typeof mediaDeleted
|
||||
| typeof mediaDeleteFailed
|
||||
| typeof mediaDisplayURLRequest
|
||||
| typeof mediaDisplayURLSuccess
|
||||
| typeof mediaDisplayURLFailure
|
||||
>;
|
32
packages/core/src/actions/scroll.ts
Normal file
32
packages/core/src/actions/scroll.ts
Normal file
@ -0,0 +1,32 @@
|
||||
import type { AnyAction } from 'redux';
|
||||
import type { ThunkDispatch } from 'redux-thunk';
|
||||
import type { RootState } from '../store';
|
||||
|
||||
export const SCROLL_SYNC_ENABLED = 'cms.scroll-sync-enabled';
|
||||
|
||||
export const TOGGLE_SCROLL = 'TOGGLE_SCROLL';
|
||||
export const SET_SCROLL = 'SET_SCROLL';
|
||||
|
||||
export function togglingScroll() {
|
||||
return {
|
||||
type: TOGGLE_SCROLL,
|
||||
} as const;
|
||||
}
|
||||
|
||||
export function loadScroll() {
|
||||
return {
|
||||
type: SET_SCROLL,
|
||||
payload: localStorage.getItem(SCROLL_SYNC_ENABLED) !== 'false',
|
||||
} as const;
|
||||
}
|
||||
|
||||
export function toggleScroll() {
|
||||
return async (
|
||||
dispatch: ThunkDispatch<RootState, undefined, AnyAction>,
|
||||
_getState: () => RootState,
|
||||
) => {
|
||||
return dispatch(togglingScroll());
|
||||
};
|
||||
}
|
||||
|
||||
export type ScrollAction = ReturnType<typeof togglingScroll | typeof loadScroll>;
|
187
packages/core/src/actions/search.ts
Normal file
187
packages/core/src/actions/search.ts
Normal file
@ -0,0 +1,187 @@
|
||||
import isEqual from 'lodash/isEqual';
|
||||
|
||||
import { currentBackend } from '../backend';
|
||||
|
||||
import type { AnyAction } from 'redux';
|
||||
import type { ThunkDispatch } from 'redux-thunk';
|
||||
import type { Entry, SearchQueryResponse } from '../interface';
|
||||
import type { RootState } from '../store';
|
||||
|
||||
/*
|
||||
* Constant Declarations
|
||||
*/
|
||||
export const SEARCH_ENTRIES_REQUEST = 'SEARCH_ENTRIES_REQUEST';
|
||||
export const SEARCH_ENTRIES_SUCCESS = 'SEARCH_ENTRIES_SUCCESS';
|
||||
export const SEARCH_ENTRIES_FAILURE = 'SEARCH_ENTRIES_FAILURE';
|
||||
|
||||
export const QUERY_REQUEST = 'QUERY_REQUEST';
|
||||
export const QUERY_SUCCESS = 'QUERY_SUCCESS';
|
||||
export const QUERY_FAILURE = 'QUERY_FAILURE';
|
||||
|
||||
export const SEARCH_CLEAR = 'SEARCH_CLEAR';
|
||||
|
||||
/*
|
||||
* Simple Action Creators (Internal)
|
||||
* We still need to export them for tests
|
||||
*/
|
||||
export function searchingEntries(searchTerm: string, searchCollections: string[], page: number) {
|
||||
return {
|
||||
type: SEARCH_ENTRIES_REQUEST,
|
||||
payload: { searchTerm, searchCollections, page },
|
||||
} as const;
|
||||
}
|
||||
|
||||
export function searchSuccess(entries: Entry[], page: number) {
|
||||
return {
|
||||
type: SEARCH_ENTRIES_SUCCESS,
|
||||
payload: {
|
||||
entries,
|
||||
page,
|
||||
},
|
||||
} as const;
|
||||
}
|
||||
|
||||
export function searchFailure(error: Error) {
|
||||
return {
|
||||
type: SEARCH_ENTRIES_FAILURE,
|
||||
payload: { error },
|
||||
} as const;
|
||||
}
|
||||
|
||||
export function querying(searchTerm: string) {
|
||||
return {
|
||||
type: QUERY_REQUEST,
|
||||
payload: {
|
||||
searchTerm,
|
||||
},
|
||||
} as const;
|
||||
}
|
||||
|
||||
export function querySuccess(namespace: string, hits: Entry[]) {
|
||||
return {
|
||||
type: QUERY_SUCCESS,
|
||||
payload: {
|
||||
namespace,
|
||||
hits,
|
||||
},
|
||||
} as const;
|
||||
}
|
||||
|
||||
export function queryFailure(error: Error) {
|
||||
return {
|
||||
type: QUERY_FAILURE,
|
||||
payload: { error },
|
||||
} as const;
|
||||
}
|
||||
|
||||
/*
|
||||
* Exported simple Action Creators
|
||||
*/
|
||||
|
||||
export function clearSearch() {
|
||||
return { type: SEARCH_CLEAR } as const;
|
||||
}
|
||||
|
||||
/*
|
||||
* Exported Thunk Action Creators
|
||||
*/
|
||||
|
||||
// SearchEntries will search for complete entries in all collections.
|
||||
export function searchEntries(searchTerm: string, searchCollections: string[], page = 0) {
|
||||
return async (
|
||||
dispatch: ThunkDispatch<RootState, undefined, AnyAction>,
|
||||
getState: () => RootState,
|
||||
) => {
|
||||
const state = getState();
|
||||
const { search } = state;
|
||||
const configState = state.config;
|
||||
if (!configState.config) {
|
||||
return;
|
||||
}
|
||||
|
||||
const backend = currentBackend(configState.config);
|
||||
const allCollections = searchCollections || Object.keys(state.collections);
|
||||
|
||||
// avoid duplicate searches
|
||||
if (
|
||||
search.isFetching &&
|
||||
search.term === searchTerm &&
|
||||
isEqual(allCollections, search.collections)
|
||||
) {
|
||||
return;
|
||||
}
|
||||
|
||||
dispatch(searchingEntries(searchTerm, allCollections, page));
|
||||
|
||||
try {
|
||||
const response = await backend.search(
|
||||
Object.entries(state.collections)
|
||||
.filter(([key, _value]) => allCollections.indexOf(key) !== -1)
|
||||
.map(([_key, value]) => value),
|
||||
searchTerm,
|
||||
);
|
||||
|
||||
return dispatch(searchSuccess(response.entries, page));
|
||||
} catch (error: unknown) {
|
||||
console.error(error);
|
||||
if (error instanceof Error) {
|
||||
return dispatch(searchFailure(error));
|
||||
}
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
// Instead of searching for complete entries, query will search for specific fields
|
||||
// in specific collections and return raw data (no entries).
|
||||
export function query(
|
||||
namespace: string,
|
||||
collectionName: string,
|
||||
searchFields: string[],
|
||||
searchTerm: string,
|
||||
file?: string,
|
||||
limit?: number,
|
||||
) {
|
||||
return async (dispatch: ThunkDispatch<RootState, {}, AnyAction>, getState: () => RootState) => {
|
||||
dispatch(querying(searchTerm));
|
||||
|
||||
const state = getState();
|
||||
const configState = state.config;
|
||||
if (!configState.config) {
|
||||
return dispatch(queryFailure(new Error('Config not found')));
|
||||
}
|
||||
|
||||
const backend = currentBackend(configState.config);
|
||||
const collection = Object.values(state.collections).find(
|
||||
collection => collection.name === collectionName,
|
||||
);
|
||||
if (!collection) {
|
||||
return dispatch(queryFailure(new Error('Collection not found')));
|
||||
}
|
||||
|
||||
try {
|
||||
const response: SearchQueryResponse = await backend.query(
|
||||
collection,
|
||||
searchFields,
|
||||
searchTerm,
|
||||
file,
|
||||
limit,
|
||||
);
|
||||
return dispatch(querySuccess(namespace, response.hits));
|
||||
} catch (error: unknown) {
|
||||
console.error(error);
|
||||
if (error instanceof Error) {
|
||||
return dispatch(queryFailure(error));
|
||||
}
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
export type SearchAction = ReturnType<
|
||||
| typeof searchingEntries
|
||||
| typeof searchSuccess
|
||||
| typeof searchFailure
|
||||
| typeof querying
|
||||
| typeof querySuccess
|
||||
| typeof queryFailure
|
||||
| typeof clearSearch
|
||||
>;
|
101
packages/core/src/actions/status.ts
Normal file
101
packages/core/src/actions/status.ts
Normal file
@ -0,0 +1,101 @@
|
||||
import { currentBackend } from '../backend';
|
||||
import { addSnackbar, removeSnackbarById } from '../store/slices/snackbars';
|
||||
|
||||
import type { AnyAction } from 'redux';
|
||||
import type { ThunkDispatch } from 'redux-thunk';
|
||||
import type { RootState } from '../store';
|
||||
|
||||
export const STATUS_REQUEST = 'STATUS_REQUEST';
|
||||
export const STATUS_SUCCESS = 'STATUS_SUCCESS';
|
||||
export const STATUS_FAILURE = 'STATUS_FAILURE';
|
||||
|
||||
export function statusRequest() {
|
||||
return {
|
||||
type: STATUS_REQUEST,
|
||||
} as const;
|
||||
}
|
||||
|
||||
export function statusSuccess(status: {
|
||||
auth: { status: boolean };
|
||||
api: { status: boolean; statusPage: string };
|
||||
}) {
|
||||
return {
|
||||
type: STATUS_SUCCESS,
|
||||
payload: { status },
|
||||
} as const;
|
||||
}
|
||||
|
||||
export function statusFailure(error: Error) {
|
||||
return {
|
||||
type: STATUS_FAILURE,
|
||||
payload: { error },
|
||||
} as const;
|
||||
}
|
||||
|
||||
export function checkBackendStatus() {
|
||||
return async (dispatch: ThunkDispatch<RootState, {}, AnyAction>, getState: () => RootState) => {
|
||||
try {
|
||||
const state = getState();
|
||||
const config = state.config.config;
|
||||
if (state.status.isFetching || !config) {
|
||||
return;
|
||||
}
|
||||
|
||||
dispatch(statusRequest());
|
||||
const backend = currentBackend(config);
|
||||
const status = await backend.status();
|
||||
|
||||
const backendDownKey = 'ui.toast.onBackendDown';
|
||||
const previousBackendDownNotifs = state.snackbar.messages.filter(
|
||||
n => typeof n.message !== 'string' && n.message.key === backendDownKey,
|
||||
);
|
||||
|
||||
if (status.api.status === false) {
|
||||
if (previousBackendDownNotifs.length === 0) {
|
||||
dispatch(
|
||||
addSnackbar({
|
||||
type: 'error',
|
||||
message: {
|
||||
key: 'ui.toast.onBackendDown',
|
||||
options: { details: status.api.statusPage },
|
||||
},
|
||||
}),
|
||||
);
|
||||
}
|
||||
return dispatch(statusSuccess(status));
|
||||
} else if (status.api.status === true && previousBackendDownNotifs.length > 0) {
|
||||
// If backend is up, clear all the danger messages
|
||||
previousBackendDownNotifs.forEach(notif => {
|
||||
dispatch(removeSnackbarById(notif.id));
|
||||
});
|
||||
}
|
||||
|
||||
const authError = status.auth.status === false;
|
||||
if (authError) {
|
||||
const key = 'ui.toast.onLoggedOut';
|
||||
const existingNotification = state.snackbar.messages.find(
|
||||
n => typeof n.message !== 'string' && n.message.key === key,
|
||||
);
|
||||
if (!existingNotification) {
|
||||
dispatch(
|
||||
addSnackbar({
|
||||
type: 'error',
|
||||
message: { key: 'ui.toast.onLoggedOut' },
|
||||
}),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
dispatch(statusSuccess(status));
|
||||
} catch (error: unknown) {
|
||||
console.error(error);
|
||||
if (error instanceof Error) {
|
||||
dispatch(statusFailure(error));
|
||||
}
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
export type StatusAction = ReturnType<
|
||||
typeof statusRequest | typeof statusSuccess | typeof statusFailure
|
||||
>;
|
49
packages/core/src/actions/waitUntil.ts
Normal file
49
packages/core/src/actions/waitUntil.ts
Normal file
@ -0,0 +1,49 @@
|
||||
import { WAIT_UNTIL_ACTION } from '../store/middleware/waitUntilAction';
|
||||
|
||||
import type { AnyAction } from 'redux';
|
||||
import type { ThunkDispatch } from 'redux-thunk';
|
||||
import type { RootState } from '../store';
|
||||
import type { WaitActionArgs } from '../store/middleware/waitUntilAction';
|
||||
|
||||
export function waitUntil({ predicate, run }: WaitActionArgs) {
|
||||
return {
|
||||
type: WAIT_UNTIL_ACTION,
|
||||
predicate,
|
||||
run,
|
||||
};
|
||||
}
|
||||
|
||||
export async function waitUntilWithTimeout<T>(
|
||||
dispatch: ThunkDispatch<RootState, {}, AnyAction>,
|
||||
waitActionArgs: (resolve: (value?: T) => void) => WaitActionArgs,
|
||||
timeout = 30000,
|
||||
): Promise<T | null | undefined> {
|
||||
let waitDone = false;
|
||||
|
||||
const waitPromise = new Promise<T | undefined>(resolve => {
|
||||
dispatch(waitUntil(waitActionArgs(resolve)));
|
||||
});
|
||||
|
||||
const timeoutPromise = new Promise<T | null>(resolve => {
|
||||
setTimeout(() => {
|
||||
if (waitDone) {
|
||||
resolve(null);
|
||||
} else {
|
||||
console.warn('Wait Action timed out');
|
||||
resolve(null);
|
||||
}
|
||||
}, timeout);
|
||||
});
|
||||
|
||||
const result = await Promise.race([
|
||||
waitPromise
|
||||
.then(result => {
|
||||
waitDone = true;
|
||||
return result;
|
||||
})
|
||||
.catch(null),
|
||||
timeoutPromise,
|
||||
]);
|
||||
|
||||
return result;
|
||||
}
|
Reference in New Issue
Block a user