Feature/website overhaul (#49)
* Reorganize repo * Overhaul website design and rewrite in NextJS and Typescript * Delete website-publish.yml
This commit is contained in:
committed by
GitHub
parent
3674ee5bd8
commit
421ecf17e6
125
core/src/actions/auth.ts
Normal file
125
core/src/actions/auth.ts
Normal file
@ -0,0 +1,125 @@
|
||||
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: 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: Error) => {
|
||||
console.error(error);
|
||||
dispatch(
|
||||
addSnackbar({
|
||||
type: 'warning',
|
||||
message: {
|
||||
key: 'ui.toast.onFailToAuth',
|
||||
message: 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
core/src/actions/collections.ts
Normal file
18
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));
|
||||
}
|
475
core/src/actions/config.ts
Normal file
475
core/src/actions/config.ts
Normal file
@ -0,0 +1,475 @@
|
||||
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 { FILES, FOLDER } from '../constants/collectionTypes';
|
||||
import { validateConfig } from '../constants/configSchema';
|
||||
import { I18N, I18N_FIELD, I18N_STRUCTURE } from '../lib/i18n';
|
||||
import { selectDefaultSortableFields } from '../lib/util/collection.util';
|
||||
import { getIntegrations, selectIntegration } from '../reducers/integrations';
|
||||
|
||||
import type { AnyAction } from 'redux';
|
||||
import type { ThunkDispatch } from 'redux-thunk';
|
||||
import type {
|
||||
Collection,
|
||||
Config,
|
||||
Field,
|
||||
BaseField,
|
||||
ListField,
|
||||
ObjectField,
|
||||
I18nInfo,
|
||||
LocalBackend,
|
||||
} 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(field: Field): field is BaseField & ObjectField {
|
||||
return 'fields' in (field as ObjectField);
|
||||
}
|
||||
|
||||
function isFieldList(field: Field): field is BaseField & 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;
|
||||
}
|
||||
|
||||
// Mapping between existing camelCase and its snake_case counterpart
|
||||
const WIDGET_KEY_MAP = {
|
||||
dateFormat: 'date_format',
|
||||
timeFormat: 'time_format',
|
||||
pickerUtc: 'picker_utc',
|
||||
editorComponents: 'editor_components',
|
||||
valueType: 'value_type',
|
||||
valueField: 'value_field',
|
||||
searchFields: 'search_fields',
|
||||
displayFields: 'display_fields',
|
||||
optionsLength: 'options_length',
|
||||
} as const;
|
||||
|
||||
function setSnakeCaseConfig<T extends Field>(field: T) {
|
||||
const deprecatedKeys = Object.keys(WIDGET_KEY_MAP).filter(
|
||||
camel => camel in field,
|
||||
) as ReadonlyArray<keyof typeof WIDGET_KEY_MAP>;
|
||||
|
||||
const snakeValues = deprecatedKeys.map(camel => {
|
||||
const snake = WIDGET_KEY_MAP[camel];
|
||||
console.warn(
|
||||
`Field ${field.name} is using a deprecated configuration '${camel}'. Please use '${snake}'`,
|
||||
);
|
||||
return { [snake]: (field as unknown as Record<string, unknown>)[camel] };
|
||||
});
|
||||
|
||||
return Object.assign({}, field, ...snakeValues) as T;
|
||||
}
|
||||
|
||||
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
|
||||
}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
function hasIntegration(config: Config, collection: Collection) {
|
||||
const integrations = getIntegrations(config);
|
||||
const integration = selectIntegration(integrations, collection.name, 'listEntries');
|
||||
return !!integration;
|
||||
}
|
||||
|
||||
export function normalizeConfig(config: Config) {
|
||||
const { collections = [] } = config;
|
||||
|
||||
const normalizedCollections = collections.map(collection => {
|
||||
const { fields, files } = collection;
|
||||
|
||||
let normalizedCollection = collection;
|
||||
if (fields) {
|
||||
const normalizedFields = traverseFieldsJS(fields, setSnakeCaseConfig);
|
||||
normalizedCollection = { ...normalizedCollection, fields: normalizedFields };
|
||||
}
|
||||
|
||||
if (files) {
|
||||
const normalizedFiles = files.map(file => {
|
||||
const normalizedFileFields = traverseFieldsJS(file.fields, setSnakeCaseConfig);
|
||||
return { ...file, fields: normalizedFileFields };
|
||||
});
|
||||
normalizedCollection = { ...normalizedCollection, files: normalizedFiles };
|
||||
}
|
||||
|
||||
return normalizedCollection;
|
||||
});
|
||||
|
||||
return { ...config, collections: normalizedCollections };
|
||||
}
|
||||
|
||||
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 (i18n && collectionI18n) {
|
||||
collectionI18n = getI18nDefaults(collectionI18n, i18n);
|
||||
collection[I18N] = collectionI18n;
|
||||
} else {
|
||||
collectionI18n = undefined;
|
||||
delete collection[I18N];
|
||||
}
|
||||
|
||||
if (collection.fields) {
|
||||
collection.fields = setI18nDefaultsForFields(collection.fields, Boolean(collectionI18n));
|
||||
}
|
||||
|
||||
const { folder, files, view_filters, view_groups } = collection;
|
||||
|
||||
if (folder) {
|
||||
collection.type = 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 (collection.fields) {
|
||||
collection.fields = traverseFieldsJS(collection.fields, setDefaultPublicFolderForField);
|
||||
}
|
||||
|
||||
collection.folder = trim(folder, '/');
|
||||
}
|
||||
|
||||
if (files) {
|
||||
collection.type = FILES;
|
||||
|
||||
throwOnInvalidFileCollectionStructure(collectionI18n);
|
||||
|
||||
delete collection.nested;
|
||||
|
||||
for (const file of 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.sortable_fields) {
|
||||
collection.sortable_fields = {
|
||||
fields: selectDefaultSortableFields(
|
||||
collection,
|
||||
backend,
|
||||
hasIntegration(config, collection),
|
||||
),
|
||||
};
|
||||
}
|
||||
|
||||
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}`,
|
||||
};
|
||||
});
|
||||
|
||||
if (config.editor && !collection.editor) {
|
||||
collection.editor = { preview: config.editor.preview };
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
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: () => 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 normalizedConfig = normalizeConfig(withLocalBackend);
|
||||
|
||||
const config = applyDefaults(normalizedConfig);
|
||||
|
||||
dispatch(configLoaded(config));
|
||||
|
||||
if (typeof onLoad === 'function') {
|
||||
onLoad();
|
||||
}
|
||||
} 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
|
||||
>;
|
1150
core/src/actions/entries.ts
Normal file
1150
core/src/actions/entries.ts
Normal file
File diff suppressed because it is too large
Load Diff
128
core/src/actions/media.ts
Normal file
128
core/src/actions/media.ts
Normal file
@ -0,0 +1,128 @@
|
||||
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 { Field, Collection, Entry } 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 function loadAsset(resolvedPath: string) {
|
||||
return async (dispatch: ThunkDispatch<RootState, {}, AnyAction>, getState: () => RootState) => {
|
||||
try {
|
||||
dispatch(loadAssetRequest(resolvedPath));
|
||||
// load asset url from backend
|
||||
await waitForMediaLibraryToLoad(dispatch, getState());
|
||||
const file = selectMediaFileByPath(getState(), resolvedPath);
|
||||
|
||||
if (file) {
|
||||
const url = await getMediaDisplayURL(dispatch, getState(), file);
|
||||
const asset = createAssetProxy({ path: resolvedPath, url: url || resolvedPath });
|
||||
dispatch(addAsset(asset));
|
||||
} else {
|
||||
const { url } = await getMediaFile(getState(), resolvedPath);
|
||||
const asset = createAssetProxy({ path: resolvedPath, url });
|
||||
dispatch(addAsset(asset));
|
||||
}
|
||||
dispatch(loadAssetSuccess(resolvedPath));
|
||||
} catch (error: unknown) {
|
||||
console.error(error);
|
||||
if (error instanceof Error) {
|
||||
dispatch(loadAssetFailure(resolvedPath, error));
|
||||
}
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
const emptyAsset = createAssetProxy({
|
||||
path: 'empty.svg',
|
||||
file: new File([`<svg xmlns="http://www.w3.org/2000/svg"></svg>`], 'empty.svg', {
|
||||
type: 'image/svg+xml',
|
||||
}),
|
||||
});
|
||||
|
||||
export function getAsset(collection: Collection, entry: Entry, path: string, field?: Field) {
|
||||
return (dispatch: ThunkDispatch<RootState, {}, AnyAction>, getState: () => RootState) => {
|
||||
if (!path) {
|
||||
return emptyAsset;
|
||||
}
|
||||
|
||||
const state = getState();
|
||||
if (!state.config.config) {
|
||||
return emptyAsset;
|
||||
}
|
||||
|
||||
const resolvedPath = selectMediaFilePath(state.config.config, collection, entry, path, field);
|
||||
|
||||
let { asset, isLoading, error } = state.medias[resolvedPath] || {};
|
||||
if (isLoading) {
|
||||
return emptyAsset;
|
||||
}
|
||||
|
||||
if (asset) {
|
||||
// There is already an AssetProxy in memory for this path. Use it.
|
||||
return asset;
|
||||
}
|
||||
|
||||
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));
|
||||
} else {
|
||||
if (error) {
|
||||
// on load error default back to original path
|
||||
asset = createAssetProxy({ path: resolvedPath, url: path });
|
||||
dispatch(addAsset(asset));
|
||||
} else {
|
||||
dispatch(loadAsset(resolvedPath));
|
||||
asset = emptyAsset;
|
||||
}
|
||||
}
|
||||
|
||||
return asset;
|
||||
};
|
||||
}
|
||||
|
||||
export type MediasAction = ReturnType<
|
||||
| typeof addAssets
|
||||
| typeof addAsset
|
||||
| typeof removeAsset
|
||||
| typeof loadAssetRequest
|
||||
| typeof loadAssetSuccess
|
||||
| typeof loadAssetFailure
|
||||
>;
|
652
core/src/actions/mediaLibrary.ts
Normal file
652
core/src/actions/mediaLibrary.ts
Normal file
@ -0,0 +1,652 @@
|
||||
import { currentBackend } from '../backend';
|
||||
import confirm from '../components/UI/Confirm';
|
||||
import { getMediaIntegrationProvider } from '../integrations';
|
||||
import { sanitizeSlug } from '../lib/urlHelper';
|
||||
import { basename, getBlobSHA } from '../lib/util';
|
||||
import { selectIntegration } from '../reducers';
|
||||
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 { selectMediaFilePath, selectMediaFilePublicPath } from '../lib/util/media.util';
|
||||
|
||||
import type { AnyAction } from 'redux';
|
||||
import type { ThunkDispatch } from 'redux-thunk';
|
||||
import type {
|
||||
Field,
|
||||
DisplayURLState,
|
||||
ImplementationMediaFile,
|
||||
MediaFile,
|
||||
MediaLibraryInstance,
|
||||
} 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(
|
||||
payload: {
|
||||
controlID?: string;
|
||||
forImage?: boolean;
|
||||
privateUpload?: boolean;
|
||||
value?: string | string[];
|
||||
allowMultiple?: boolean;
|
||||
replaceIndex?: number;
|
||||
config?: Record<string, unknown>;
|
||||
field?: Field;
|
||||
} = {},
|
||||
) {
|
||||
return (dispatch: ThunkDispatch<RootState, {}, AnyAction>, getState: () => RootState) => {
|
||||
const state = getState();
|
||||
const mediaLibrary = state.mediaLibrary.externalLibrary;
|
||||
if (mediaLibrary) {
|
||||
const { controlID: id, value, config = {}, allowMultiple, forImage } = payload;
|
||||
mediaLibrary.show({ id, value, config, allowMultiple, imagesOnly: forImage });
|
||||
}
|
||||
dispatch(mediaLibraryOpened(payload));
|
||||
};
|
||||
}
|
||||
|
||||
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; privateUpload?: boolean } = {},
|
||||
) {
|
||||
const { delay = 0, query = '', page = 1, privateUpload = false } = 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 integration = selectIntegration(state, null, 'assetStore');
|
||||
if (integration) {
|
||||
const provider = getMediaIntegrationProvider(
|
||||
state.integrations,
|
||||
backend.getToken,
|
||||
integration,
|
||||
);
|
||||
if (!provider) {
|
||||
throw new Error('Provider not found');
|
||||
}
|
||||
|
||||
dispatch(mediaLoading(page));
|
||||
try {
|
||||
const files = await provider.retrieve(query, page, privateUpload);
|
||||
const mediaLoadedOpts = {
|
||||
page,
|
||||
canPaginate: true,
|
||||
dynamicSearch: true,
|
||||
dynamicSearchQuery: query,
|
||||
privateUpload,
|
||||
};
|
||||
return dispatch(mediaLoaded(files, mediaLoadedOpts));
|
||||
} catch (error) {
|
||||
return dispatch(mediaLoadFailed({ privateUpload }));
|
||||
}
|
||||
}
|
||||
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 { privateUpload, 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 integration = selectIntegration(state, null, 'assetStore');
|
||||
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 (!integration && 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, { privateUpload }));
|
||||
}
|
||||
}
|
||||
|
||||
if (integration || !editingDraft) {
|
||||
dispatch(mediaPersisting());
|
||||
}
|
||||
|
||||
try {
|
||||
let assetProxy: AssetProxy;
|
||||
if (integration) {
|
||||
try {
|
||||
const provider = getMediaIntegrationProvider(
|
||||
state.integrations,
|
||||
backend.getToken,
|
||||
integration,
|
||||
);
|
||||
if (!provider) {
|
||||
throw new Error('Provider not found');
|
||||
}
|
||||
|
||||
const response = await provider.upload(file, privateUpload);
|
||||
assetProxy = createAssetProxy({
|
||||
url: response.asset.url,
|
||||
path: response.asset.url,
|
||||
});
|
||||
} catch (error) {
|
||||
assetProxy = createAssetProxy({
|
||||
file,
|
||||
path: fileName,
|
||||
});
|
||||
}
|
||||
} else if (privateUpload) {
|
||||
throw new Error('The Private Upload option is only available for Asset Store Integration');
|
||||
} else {
|
||||
const entry = state.entryDraft.entry;
|
||||
if (!entry?.collection) {
|
||||
return;
|
||||
}
|
||||
|
||||
const collection = state.collections[entry?.collection];
|
||||
const path = selectMediaFilePath(config, collection, entry, fileName, field);
|
||||
assetProxy = createAssetProxy({
|
||||
file,
|
||||
path,
|
||||
field,
|
||||
});
|
||||
}
|
||||
|
||||
dispatch(addAsset(assetProxy));
|
||||
|
||||
let mediaFile: ImplementationMediaFile;
|
||||
if (integration) {
|
||||
const id = await getBlobSHA(file);
|
||||
// integration assets are persisted immediately, thus draft is false
|
||||
mediaFile = createMediaFileFromAsset({ id, file, assetProxy, draft: false });
|
||||
} else 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, { privateUpload }));
|
||||
} catch (error) {
|
||||
console.error(error);
|
||||
dispatch(
|
||||
addSnackbar({
|
||||
type: 'error',
|
||||
message: {
|
||||
key: 'ui.toast.onFailToPersistMedia',
|
||||
details: error,
|
||||
},
|
||||
}),
|
||||
);
|
||||
return dispatch(mediaPersistFailed({ privateUpload }));
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
export function deleteMedia(file: MediaFile, opts: MediaOptions = {}) {
|
||||
const { privateUpload } = 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 integration = selectIntegration(state, null, 'assetStore');
|
||||
if (integration) {
|
||||
const provider = getMediaIntegrationProvider(
|
||||
state.integrations,
|
||||
backend.getToken,
|
||||
integration,
|
||||
);
|
||||
if (!provider) {
|
||||
throw new Error('Provider not found');
|
||||
}
|
||||
|
||||
dispatch(mediaDeleting());
|
||||
|
||||
try {
|
||||
await provider.delete(file.id);
|
||||
return dispatch(mediaDeleted(file, { privateUpload }));
|
||||
} catch (error: unknown) {
|
||||
console.error(error);
|
||||
|
||||
if (error instanceof Error) {
|
||||
dispatch(
|
||||
addSnackbar({
|
||||
type: 'error',
|
||||
message: {
|
||||
key: 'ui.toast.onFailToDeleteMedia',
|
||||
details: error.message,
|
||||
},
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
return dispatch(mediaDeleteFailed({ privateUpload }));
|
||||
}
|
||||
}
|
||||
|
||||
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',
|
||||
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;
|
||||
privateUpload?: 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 {
|
||||
privateUpload?: boolean;
|
||||
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(opts: MediaOptions = {}) {
|
||||
const { privateUpload } = opts;
|
||||
return { type: MEDIA_LOAD_FAILURE, payload: { privateUpload } } as const;
|
||||
}
|
||||
|
||||
export function mediaPersisting() {
|
||||
return { type: MEDIA_PERSIST_REQUEST } as const;
|
||||
}
|
||||
|
||||
export function mediaPersisted(file: ImplementationMediaFile, opts: MediaOptions = {}) {
|
||||
const { privateUpload } = opts;
|
||||
return {
|
||||
type: MEDIA_PERSIST_SUCCESS,
|
||||
payload: { file, privateUpload },
|
||||
} as const;
|
||||
}
|
||||
|
||||
export function mediaPersistFailed(opts: MediaOptions = {}) {
|
||||
const { privateUpload } = opts;
|
||||
return { type: MEDIA_PERSIST_FAILURE, payload: { privateUpload } } as const;
|
||||
}
|
||||
|
||||
export function mediaDeleting() {
|
||||
return { type: MEDIA_DELETE_REQUEST } as const;
|
||||
}
|
||||
|
||||
export function mediaDeleted(file: MediaFile, opts: MediaOptions = {}) {
|
||||
const { privateUpload } = opts;
|
||||
return {
|
||||
type: MEDIA_DELETE_SUCCESS,
|
||||
payload: { file, privateUpload },
|
||||
} as const;
|
||||
}
|
||||
|
||||
export function mediaDeleteFailed(opts: MediaOptions = {}) {
|
||||
const { privateUpload } = opts;
|
||||
return { type: MEDIA_DELETE_FAILURE, payload: { privateUpload } } 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
core/src/actions/scroll.ts
Normal file
32
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>;
|
209
core/src/actions/search.ts
Normal file
209
core/src/actions/search.ts
Normal file
@ -0,0 +1,209 @@
|
||||
import isEqual from 'lodash/isEqual';
|
||||
|
||||
import { currentBackend } from '../backend';
|
||||
import { getSearchIntegrationProvider } from '../integrations';
|
||||
import { selectIntegration } from '../reducers';
|
||||
|
||||
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);
|
||||
const collections = allCollections.filter(collection =>
|
||||
selectIntegration(state, collection, 'search'),
|
||||
);
|
||||
const integration = selectIntegration(state, collections[0], 'search');
|
||||
|
||||
// avoid duplicate searches
|
||||
if (
|
||||
search.isFetching &&
|
||||
search.term === searchTerm &&
|
||||
isEqual(allCollections, search.collections) &&
|
||||
// if an integration doesn't exist, 'page' is not used
|
||||
(search.page === page || !integration)
|
||||
) {
|
||||
return;
|
||||
}
|
||||
|
||||
dispatch(searchingEntries(searchTerm, allCollections, page));
|
||||
|
||||
const searchPromise = integration
|
||||
? getSearchIntegrationProvider(state.integrations, backend.getToken, integration)?.search(
|
||||
collections,
|
||||
searchTerm,
|
||||
page,
|
||||
)
|
||||
: backend.search(
|
||||
Object.entries(state.collections)
|
||||
.filter(([key, _value]) => allCollections.indexOf(key) !== -1)
|
||||
.map(([_key, value]) => value),
|
||||
searchTerm,
|
||||
);
|
||||
|
||||
try {
|
||||
const response = await searchPromise;
|
||||
if (!response) {
|
||||
return dispatch(searchFailure(new Error(`No integration found for name "${integration}"`)));
|
||||
}
|
||||
|
||||
return dispatch(searchSuccess(response.entries, response.pagination));
|
||||
} 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 integration = selectIntegration(state, collectionName, 'search');
|
||||
const collection = Object.values(state.collections).find(
|
||||
collection => collection.name === collectionName,
|
||||
);
|
||||
if (!collection) {
|
||||
return dispatch(queryFailure(new Error('Collection not found')));
|
||||
}
|
||||
|
||||
const queryPromise = integration
|
||||
? getSearchIntegrationProvider(state.integrations, backend.getToken, integration)?.searchBy(
|
||||
JSON.stringify(searchFields.map(f => `data.${f}`)),
|
||||
collectionName,
|
||||
searchTerm,
|
||||
)
|
||||
: backend.query(collection, searchFields, searchTerm, file, limit);
|
||||
|
||||
try {
|
||||
const response: SearchQueryResponse = await queryPromise;
|
||||
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
|
||||
>;
|
98
core/src/actions/status.ts
Normal file
98
core/src/actions/status.ts
Normal file
@ -0,0 +1,98 @@
|
||||
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', 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
core/src/actions/waitUntil.ts
Normal file
49
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;
|
||||
}
|
1027
core/src/backend.ts
Normal file
1027
core/src/backend.ts
Normal file
File diff suppressed because it is too large
Load Diff
425
core/src/backends/azure/API.ts
Normal file
425
core/src/backends/azure/API.ts
Normal file
@ -0,0 +1,425 @@
|
||||
import { Base64 } from 'js-base64';
|
||||
import partial from 'lodash/partial';
|
||||
import result from 'lodash/result';
|
||||
import trim from 'lodash/trim';
|
||||
import trimStart from 'lodash/trimStart';
|
||||
import { basename, dirname } from 'path';
|
||||
|
||||
import {
|
||||
APIError,
|
||||
localForage,
|
||||
readFile,
|
||||
readFileMetadata,
|
||||
requestWithBackoff,
|
||||
responseParser,
|
||||
unsentRequest,
|
||||
} from '../../lib/util';
|
||||
|
||||
import type { DataFile, PersistOptions } from '../../interface';
|
||||
import type { ApiRequest } from '../../lib/util';
|
||||
import type { ApiRequestObject } from '../../lib/util/API';
|
||||
import type AssetProxy from '../../valueObjects/AssetProxy';
|
||||
|
||||
export const API_NAME = 'Azure DevOps';
|
||||
|
||||
const API_VERSION = 'api-version';
|
||||
|
||||
type AzureUser = {
|
||||
coreAttributes?: {
|
||||
Avatar?: { value?: { value?: string } };
|
||||
DisplayName?: { value?: string };
|
||||
EmailAddress?: { value?: string };
|
||||
};
|
||||
};
|
||||
|
||||
type AzureGitItem = {
|
||||
objectId: string;
|
||||
gitObjectType: AzureObjectType;
|
||||
path: string;
|
||||
};
|
||||
|
||||
// This does not match Azure documentation, but it is what comes back from some calls
|
||||
// PullRequest as an example is documented as returning PullRequest[], but it actually
|
||||
// returns that inside of this value prop in the json
|
||||
interface AzureArray<T> {
|
||||
value: T[];
|
||||
}
|
||||
|
||||
enum AzureCommitChangeType {
|
||||
ADD = 'add',
|
||||
DELETE = 'delete',
|
||||
RENAME = 'rename',
|
||||
EDIT = 'edit',
|
||||
}
|
||||
|
||||
enum AzureItemContentType {
|
||||
BASE64 = 'base64encoded',
|
||||
}
|
||||
|
||||
enum AzureObjectType {
|
||||
BLOB = 'blob',
|
||||
TREE = 'tree',
|
||||
}
|
||||
|
||||
type AzureRef = {
|
||||
name: string;
|
||||
objectId: string;
|
||||
};
|
||||
|
||||
type AzureCommit = {
|
||||
author: {
|
||||
date: string;
|
||||
email: string;
|
||||
name: string;
|
||||
};
|
||||
};
|
||||
|
||||
function getChangeItem(item: AzureCommitItem) {
|
||||
switch (item.action) {
|
||||
case AzureCommitChangeType.ADD:
|
||||
return {
|
||||
changeType: AzureCommitChangeType.ADD,
|
||||
item: { path: item.path },
|
||||
newContent: {
|
||||
content: item.base64Content,
|
||||
contentType: AzureItemContentType.BASE64,
|
||||
},
|
||||
};
|
||||
case AzureCommitChangeType.EDIT:
|
||||
return {
|
||||
changeType: AzureCommitChangeType.EDIT,
|
||||
item: { path: item.path },
|
||||
newContent: {
|
||||
content: item.base64Content,
|
||||
contentType: AzureItemContentType.BASE64,
|
||||
},
|
||||
};
|
||||
case AzureCommitChangeType.DELETE:
|
||||
return {
|
||||
changeType: AzureCommitChangeType.DELETE,
|
||||
item: { path: item.path },
|
||||
};
|
||||
case AzureCommitChangeType.RENAME:
|
||||
return {
|
||||
changeType: AzureCommitChangeType.RENAME,
|
||||
item: { path: item.path },
|
||||
sourceServerItem: item.oldPath,
|
||||
};
|
||||
default:
|
||||
return {};
|
||||
}
|
||||
}
|
||||
|
||||
type AzureCommitItem = {
|
||||
action: AzureCommitChangeType;
|
||||
base64Content?: string;
|
||||
text?: string;
|
||||
path: string;
|
||||
oldPath?: string;
|
||||
};
|
||||
|
||||
interface AzureApiConfig {
|
||||
apiRoot: string;
|
||||
repo: { org: string; project: string; repoName: string };
|
||||
branch: string;
|
||||
apiVersion: string;
|
||||
}
|
||||
|
||||
export default class API {
|
||||
apiVersion: string;
|
||||
token: string;
|
||||
branch: string;
|
||||
endpointUrl: string;
|
||||
|
||||
constructor(config: AzureApiConfig, token: string) {
|
||||
const { repo } = config;
|
||||
const apiRoot = trim(config.apiRoot, '/');
|
||||
this.endpointUrl = `${apiRoot}/${repo.org}/${repo.project}/_apis/git/repositories/${repo.repoName}`;
|
||||
this.token = token;
|
||||
this.branch = config.branch;
|
||||
this.apiVersion = config.apiVersion;
|
||||
}
|
||||
|
||||
withHeaders = (req: ApiRequest) => {
|
||||
const withHeaders = unsentRequest.withHeaders(
|
||||
{
|
||||
Authorization: `Bearer ${this.token}`,
|
||||
'Content-Type': 'application/json; charset=utf-8',
|
||||
},
|
||||
req,
|
||||
);
|
||||
return withHeaders;
|
||||
};
|
||||
|
||||
withAzureFeatures = (req: ApiRequestObject) => {
|
||||
if (API_VERSION in (req.params ?? {})) {
|
||||
return req;
|
||||
}
|
||||
|
||||
const withParams = unsentRequest.withParams(
|
||||
{
|
||||
[API_VERSION]: `${this.apiVersion}`,
|
||||
},
|
||||
req,
|
||||
);
|
||||
|
||||
return withParams;
|
||||
};
|
||||
|
||||
buildRequest = (req: ApiRequest) => {
|
||||
const withHeaders = this.withHeaders(req);
|
||||
const withAzureFeatures = this.withAzureFeatures(withHeaders);
|
||||
if ('cache' in withAzureFeatures) {
|
||||
return withAzureFeatures;
|
||||
} else {
|
||||
const withNoCache = unsentRequest.withNoCache(withAzureFeatures);
|
||||
return withNoCache;
|
||||
}
|
||||
};
|
||||
|
||||
request = (req: ApiRequest): Promise<Response> => {
|
||||
try {
|
||||
return requestWithBackoff(this, req);
|
||||
} catch (error: unknown) {
|
||||
if (error instanceof Error) {
|
||||
throw new APIError(error.message, null, API_NAME);
|
||||
}
|
||||
|
||||
throw new APIError('Unknown api error', null, API_NAME);
|
||||
}
|
||||
};
|
||||
|
||||
responseToJSON = responseParser({ format: 'json', apiName: API_NAME });
|
||||
responseToBlob = responseParser({ format: 'blob', apiName: API_NAME });
|
||||
responseToText = responseParser({ format: 'text', apiName: API_NAME });
|
||||
|
||||
requestJSON = <T>(req: ApiRequest) => this.request(req).then(this.responseToJSON) as Promise<T>;
|
||||
requestText = (req: ApiRequest) => this.request(req).then(this.responseToText) as Promise<string>;
|
||||
|
||||
toBase64 = (str: string) => Promise.resolve(Base64.encode(str));
|
||||
fromBase64 = (str: string) => Base64.decode(str);
|
||||
|
||||
branchToRef = (branch: string): string => `refs/heads/${branch}`;
|
||||
refToBranch = (ref: string): string => ref.slice('refs/heads/'.length);
|
||||
|
||||
user = async () => {
|
||||
const result = await this.requestJSON<AzureUser>({
|
||||
url: 'https://app.vssps.visualstudio.com/_apis/profile/profiles/me',
|
||||
params: { [API_VERSION]: '6.1-preview.2' },
|
||||
});
|
||||
|
||||
const name = result.coreAttributes?.DisplayName?.value;
|
||||
const email = result.coreAttributes?.EmailAddress?.value;
|
||||
const url = result.coreAttributes?.Avatar?.value?.value;
|
||||
const user = {
|
||||
name: name || email || '',
|
||||
avatar_url: `data:image/png;base64,${url}`,
|
||||
email,
|
||||
};
|
||||
return user;
|
||||
};
|
||||
|
||||
async readFileMetadata(
|
||||
path: string,
|
||||
sha: string | null | undefined,
|
||||
{ branch = this.branch } = {},
|
||||
) {
|
||||
const fetchFileMetadata = async () => {
|
||||
try {
|
||||
const { value } = await this.requestJSON<AzureArray<AzureCommit>>({
|
||||
url: `${this.endpointUrl}/commits/`,
|
||||
params: {
|
||||
'searchCriteria.itemPath': path,
|
||||
'searchCriteria.itemVersion.version': branch,
|
||||
'searchCriteria.$top': '1',
|
||||
},
|
||||
});
|
||||
const [commit] = value;
|
||||
|
||||
return {
|
||||
author: commit.author.name || commit.author.email,
|
||||
updatedOn: commit.author.date,
|
||||
};
|
||||
} catch (error) {
|
||||
return { author: '', updatedOn: '' };
|
||||
}
|
||||
};
|
||||
|
||||
const fileMetadata = await readFileMetadata(sha, fetchFileMetadata, localForage);
|
||||
return fileMetadata;
|
||||
}
|
||||
|
||||
readFile = (
|
||||
path: string,
|
||||
sha?: string | null,
|
||||
{ parseText = true, branch = this.branch } = {},
|
||||
) => {
|
||||
const fetchContent = () => {
|
||||
return this.request({
|
||||
url: `${this.endpointUrl}/items/`,
|
||||
params: { version: branch, path },
|
||||
cache: 'no-store',
|
||||
}).then<Blob | string>(parseText ? this.responseToText : this.responseToBlob);
|
||||
};
|
||||
|
||||
return readFile(sha, fetchContent, localForage, parseText);
|
||||
};
|
||||
|
||||
listFiles = async (path: string, recursive: boolean, branch = this.branch) => {
|
||||
try {
|
||||
const { value: items } = await this.requestJSON<AzureArray<AzureGitItem>>({
|
||||
url: `${this.endpointUrl}/items/`,
|
||||
params: {
|
||||
version: branch,
|
||||
scopePath: path,
|
||||
recursionLevel: recursive ? 'full' : 'oneLevel',
|
||||
},
|
||||
});
|
||||
|
||||
const files = items
|
||||
.filter(item => item.gitObjectType === AzureObjectType.BLOB)
|
||||
.map(file => ({
|
||||
id: file.objectId,
|
||||
path: trimStart(file.path, '/'),
|
||||
name: basename(file.path),
|
||||
}));
|
||||
return files;
|
||||
} catch (err: any) {
|
||||
if (err && err.status === 404) {
|
||||
console.info('This 404 was expected and handled appropriately.');
|
||||
return [];
|
||||
} else {
|
||||
throw err;
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
async getRef(branch: string = this.branch) {
|
||||
const { value: refs } = await this.requestJSON<AzureArray<AzureRef>>({
|
||||
url: `${this.endpointUrl}/refs`,
|
||||
params: {
|
||||
$top: '1', // There's only one ref, so keep the payload small
|
||||
filter: 'heads/' + branch,
|
||||
},
|
||||
});
|
||||
|
||||
return refs.find(b => b.name == this.branchToRef(branch))!;
|
||||
}
|
||||
|
||||
async uploadAndCommit(
|
||||
items: AzureCommitItem[],
|
||||
comment: string,
|
||||
branch: string,
|
||||
newBranch: boolean,
|
||||
) {
|
||||
const ref = await this.getRef(newBranch ? this.branch : branch);
|
||||
|
||||
const refUpdate = [
|
||||
{
|
||||
name: this.branchToRef(branch),
|
||||
oldObjectId: ref.objectId,
|
||||
},
|
||||
];
|
||||
|
||||
const changes = items.map(item => getChangeItem(item));
|
||||
const commits = [{ comment, changes }];
|
||||
const push = {
|
||||
refUpdates: refUpdate,
|
||||
commits,
|
||||
};
|
||||
|
||||
return this.requestJSON({
|
||||
url: `${this.endpointUrl}/pushes`,
|
||||
method: 'POST',
|
||||
body: JSON.stringify(push),
|
||||
});
|
||||
}
|
||||
|
||||
async getCommitItems(files: { path: string; newPath?: string }[], branch: string) {
|
||||
const items = await Promise.all(
|
||||
files.map(async file => {
|
||||
const [base64Content, fileExists] = await Promise.all([
|
||||
result(file, 'toBase64', partial(this.toBase64, (file as DataFile).raw)),
|
||||
this.isFileExists(file.path, branch),
|
||||
]);
|
||||
|
||||
const path = file.newPath || file.path;
|
||||
const oldPath = file.path;
|
||||
const renameOrEdit =
|
||||
path !== oldPath ? AzureCommitChangeType.RENAME : AzureCommitChangeType.EDIT;
|
||||
|
||||
const action = fileExists ? renameOrEdit : AzureCommitChangeType.ADD;
|
||||
return {
|
||||
action,
|
||||
base64Content,
|
||||
path,
|
||||
oldPath,
|
||||
} as AzureCommitItem;
|
||||
}),
|
||||
);
|
||||
|
||||
// move children
|
||||
for (const item of items.filter(i => i.oldPath && i.action === AzureCommitChangeType.RENAME)) {
|
||||
const sourceDir = dirname(item.oldPath as string);
|
||||
const destDir = dirname(item.path);
|
||||
const children = await this.listFiles(sourceDir, true, branch);
|
||||
children
|
||||
.filter(file => file.path !== item.oldPath)
|
||||
.forEach(file => {
|
||||
items.push({
|
||||
action: AzureCommitChangeType.RENAME,
|
||||
path: file.path.replace(sourceDir, destDir),
|
||||
oldPath: file.path,
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
return items;
|
||||
}
|
||||
|
||||
async persistFiles(dataFiles: DataFile[], mediaFiles: AssetProxy[], options: PersistOptions) {
|
||||
const files = [...dataFiles, ...mediaFiles];
|
||||
const items = await this.getCommitItems(files, this.branch);
|
||||
|
||||
return this.uploadAndCommit(items, options.commitMessage, this.branch, true);
|
||||
}
|
||||
|
||||
async deleteFiles(paths: string[], comment: string) {
|
||||
const ref = await this.getRef(this.branch);
|
||||
const refUpdate = {
|
||||
name: ref.name,
|
||||
oldObjectId: ref.objectId,
|
||||
};
|
||||
|
||||
const changes = paths.map(path =>
|
||||
getChangeItem({ action: AzureCommitChangeType.DELETE, path }),
|
||||
);
|
||||
const commits = [{ comment, changes }];
|
||||
const push = {
|
||||
refUpdates: [refUpdate],
|
||||
commits,
|
||||
};
|
||||
|
||||
return this.requestJSON({
|
||||
url: `${this.endpointUrl}/pushes`,
|
||||
method: 'POST',
|
||||
body: JSON.stringify(push),
|
||||
});
|
||||
}
|
||||
|
||||
async isFileExists(path: string, branch: string) {
|
||||
try {
|
||||
await this.requestText({
|
||||
url: `${this.endpointUrl}/items/`,
|
||||
params: { version: branch, path },
|
||||
cache: 'no-store',
|
||||
});
|
||||
return true;
|
||||
} catch (error) {
|
||||
if (error instanceof APIError && error.status === 404) {
|
||||
return false;
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
}
|
80
core/src/backends/azure/AuthenticationPage.tsx
Normal file
80
core/src/backends/azure/AuthenticationPage.tsx
Normal file
@ -0,0 +1,80 @@
|
||||
import React, { useCallback, useEffect, useMemo, useState } from 'react';
|
||||
|
||||
import alert from '../../components/UI/Alert';
|
||||
import AuthenticationPage from '../../components/UI/AuthenticationPage';
|
||||
import Icon from '../../components/UI/Icon';
|
||||
import { ImplicitAuthenticator } from '../../lib/auth';
|
||||
|
||||
import type { MouseEvent } from 'react';
|
||||
import type { AuthenticationPageProps, TranslatedProps } from '../../interface';
|
||||
|
||||
const AzureAuthenticationPage = ({
|
||||
inProgress = false,
|
||||
config,
|
||||
clearHash,
|
||||
onLogin,
|
||||
t,
|
||||
}: TranslatedProps<AuthenticationPageProps>) => {
|
||||
const [loginError, setLoginError] = useState<string | null>(null);
|
||||
|
||||
const auth = useMemo(
|
||||
() =>
|
||||
new ImplicitAuthenticator({
|
||||
base_url: `https://login.microsoftonline.com/${config.backend.tenant_id}`,
|
||||
auth_endpoint: 'oauth2/authorize',
|
||||
app_id: config.backend.app_id,
|
||||
clearHash,
|
||||
}),
|
||||
[clearHash, config.backend.app_id, config.backend.tenant_id],
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
// Complete implicit authentication if we were redirected back to from the provider.
|
||||
auth.completeAuth((err, data) => {
|
||||
if (err) {
|
||||
alert({
|
||||
title: 'auth.errors.authTitle',
|
||||
body: { key: 'auth.errors.authBody', options: { details: err } },
|
||||
});
|
||||
return;
|
||||
} else if (data) {
|
||||
onLogin(data);
|
||||
}
|
||||
});
|
||||
}, [auth, onLogin]);
|
||||
|
||||
const handleLogin = useCallback(
|
||||
(e: MouseEvent<HTMLButtonElement>) => {
|
||||
e.preventDefault();
|
||||
auth.authenticate(
|
||||
{
|
||||
scope: 'vso.code_full,user.read',
|
||||
resource: '499b84ac-1321-427f-aa17-267ca6975798',
|
||||
prompt: 'select_account',
|
||||
},
|
||||
(err, data) => {
|
||||
if (err) {
|
||||
setLoginError(err.toString());
|
||||
} else if (data) {
|
||||
onLogin(data);
|
||||
}
|
||||
},
|
||||
);
|
||||
},
|
||||
[auth, onLogin],
|
||||
);
|
||||
|
||||
return (
|
||||
<AuthenticationPage
|
||||
onLogin={handleLogin}
|
||||
loginDisabled={inProgress}
|
||||
loginErrorMessage={loginError}
|
||||
logoUrl={config.logo_url}
|
||||
icon={<Icon type="azure" />}
|
||||
buttonContent={inProgress ? t('auth.loggingIn') : t('auth.loginWithAzure')}
|
||||
t={t}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
export default AzureAuthenticationPage;
|
265
core/src/backends/azure/implementation.ts
Normal file
265
core/src/backends/azure/implementation.ts
Normal file
@ -0,0 +1,265 @@
|
||||
import trim from 'lodash/trim';
|
||||
import trimStart from 'lodash/trimStart';
|
||||
import semaphore from 'semaphore';
|
||||
|
||||
import { BackendClass } from '../../interface';
|
||||
import {
|
||||
asyncLock,
|
||||
basename,
|
||||
entriesByFiles,
|
||||
entriesByFolder,
|
||||
filterByExtension,
|
||||
getBlobSHA,
|
||||
getMediaAsBlob,
|
||||
getMediaDisplayURL,
|
||||
} from '../../lib/util';
|
||||
import API, { API_NAME } from './API';
|
||||
import AuthenticationPage from './AuthenticationPage';
|
||||
|
||||
import type { Semaphore } from 'semaphore';
|
||||
import type {
|
||||
BackendEntry,
|
||||
BackendInitializerOptions,
|
||||
Config,
|
||||
Credentials,
|
||||
DisplayURL,
|
||||
ImplementationEntry,
|
||||
ImplementationFile,
|
||||
ImplementationMediaFile,
|
||||
PersistOptions,
|
||||
User,
|
||||
} from '../../interface';
|
||||
import type { AsyncLock, Cursor } from '../../lib/util';
|
||||
import type AssetProxy from '../../valueObjects/AssetProxy';
|
||||
|
||||
const MAX_CONCURRENT_DOWNLOADS = 10;
|
||||
|
||||
function parseAzureRepo(config: Config) {
|
||||
const { repo } = config.backend;
|
||||
|
||||
if (typeof repo !== 'string') {
|
||||
throw new Error('The Azure backend needs a "repo" in the backend configuration.');
|
||||
}
|
||||
|
||||
const parts = repo.split('/');
|
||||
if (parts.length !== 3) {
|
||||
throw new Error('The Azure backend must be in a the format of {org}/{project}/{repo}');
|
||||
}
|
||||
|
||||
const [org, project, repoName] = parts;
|
||||
return {
|
||||
org,
|
||||
project,
|
||||
repoName,
|
||||
};
|
||||
}
|
||||
|
||||
export default class Azure extends BackendClass {
|
||||
lock: AsyncLock;
|
||||
api?: API;
|
||||
options: BackendInitializerOptions;
|
||||
repo: {
|
||||
org: string;
|
||||
project: string;
|
||||
repoName: string;
|
||||
};
|
||||
branch: string;
|
||||
apiRoot: string;
|
||||
apiVersion: string;
|
||||
token: string | null;
|
||||
mediaFolder: string;
|
||||
|
||||
_mediaDisplayURLSem?: Semaphore;
|
||||
|
||||
constructor(config: Config, options: BackendInitializerOptions) {
|
||||
super(config, options);
|
||||
this.options = {
|
||||
...options,
|
||||
};
|
||||
|
||||
this.repo = parseAzureRepo(config);
|
||||
this.branch = config.backend.branch || 'main';
|
||||
this.apiRoot = config.backend.api_root || 'https://dev.azure.com';
|
||||
this.apiVersion = config.backend.api_version || '6.1-preview';
|
||||
this.token = '';
|
||||
this.mediaFolder = trim(config.media_folder, '/');
|
||||
this.lock = asyncLock();
|
||||
}
|
||||
|
||||
isGitBackend() {
|
||||
return true;
|
||||
}
|
||||
|
||||
async status(): Promise<{
|
||||
auth: { status: boolean };
|
||||
api: { status: boolean; statusPage: string };
|
||||
}> {
|
||||
const auth =
|
||||
(await this.api!.user()
|
||||
.then(user => !!user)
|
||||
.catch(e => {
|
||||
console.warn('Failed getting Azure user', e);
|
||||
return false;
|
||||
})) || false;
|
||||
|
||||
return { auth: { status: auth }, api: { status: true, statusPage: '' } };
|
||||
}
|
||||
|
||||
authComponent() {
|
||||
return AuthenticationPage;
|
||||
}
|
||||
|
||||
restoreUser(user: User) {
|
||||
return this.authenticate(user);
|
||||
}
|
||||
|
||||
async authenticate(state: Credentials) {
|
||||
this.token = state.token as string;
|
||||
this.api = new API(
|
||||
{
|
||||
apiRoot: this.apiRoot,
|
||||
apiVersion: this.apiVersion,
|
||||
repo: this.repo,
|
||||
branch: this.branch,
|
||||
},
|
||||
this.token,
|
||||
);
|
||||
|
||||
const user = await this.api.user();
|
||||
return { token: state.token as string, ...user };
|
||||
}
|
||||
|
||||
/**
|
||||
* Log the user out by forgetting their access token.
|
||||
* TODO: *Actual* logout by redirecting to:
|
||||
* https://login.microsoftonline.com/{tenantId}/oauth2/logout?client_id={clientId}&post_logout_redirect_uri={baseUrl}
|
||||
*/
|
||||
logout() {
|
||||
this.token = null;
|
||||
return;
|
||||
}
|
||||
|
||||
getToken() {
|
||||
return Promise.resolve(this.token);
|
||||
}
|
||||
|
||||
async entriesByFolder(folder: string, extension: string, depth: number) {
|
||||
const listFiles = async () => {
|
||||
const files = await this.api!.listFiles(folder, depth > 1);
|
||||
const filtered = files.filter(file => filterByExtension({ path: file.path }, extension));
|
||||
return filtered.map(file => ({
|
||||
id: file.id,
|
||||
path: file.path,
|
||||
}));
|
||||
};
|
||||
|
||||
const entries = await entriesByFolder(
|
||||
listFiles,
|
||||
this.api!.readFile.bind(this.api!),
|
||||
this.api!.readFileMetadata.bind(this.api),
|
||||
API_NAME,
|
||||
);
|
||||
return entries;
|
||||
}
|
||||
|
||||
entriesByFiles(files: ImplementationFile[]) {
|
||||
return entriesByFiles(
|
||||
files,
|
||||
this.api!.readFile.bind(this.api!),
|
||||
this.api!.readFileMetadata.bind(this.api),
|
||||
API_NAME,
|
||||
);
|
||||
}
|
||||
|
||||
async getEntry(path: string) {
|
||||
const data = (await this.api!.readFile(path)) as string;
|
||||
return {
|
||||
file: { path },
|
||||
data,
|
||||
};
|
||||
}
|
||||
|
||||
async getMedia() {
|
||||
const files = await this.api!.listFiles(this.mediaFolder, false);
|
||||
const mediaFiles = await Promise.all(
|
||||
files.map(async ({ id, path, name }) => {
|
||||
const blobUrl = await this.getMediaDisplayURL({ id, path });
|
||||
return { id, name, displayURL: blobUrl, path };
|
||||
}),
|
||||
);
|
||||
return mediaFiles;
|
||||
}
|
||||
|
||||
getMediaDisplayURL(displayURL: DisplayURL) {
|
||||
this._mediaDisplayURLSem = this._mediaDisplayURLSem || semaphore(MAX_CONCURRENT_DOWNLOADS);
|
||||
return getMediaDisplayURL(
|
||||
displayURL,
|
||||
this.api!.readFile.bind(this.api!),
|
||||
this._mediaDisplayURLSem,
|
||||
);
|
||||
}
|
||||
|
||||
async getMediaFile(path: string) {
|
||||
const name = basename(path);
|
||||
const blob = await getMediaAsBlob(path, null, this.api!.readFile.bind(this.api!));
|
||||
const fileObj = new File([blob], name);
|
||||
const url = URL.createObjectURL(fileObj);
|
||||
const id = await getBlobSHA(blob);
|
||||
|
||||
return {
|
||||
id,
|
||||
displayURL: url,
|
||||
path,
|
||||
name,
|
||||
size: fileObj.size,
|
||||
file: fileObj,
|
||||
url,
|
||||
};
|
||||
}
|
||||
|
||||
async persistEntry(entry: BackendEntry, options: PersistOptions): Promise<void> {
|
||||
const mediaFiles: AssetProxy[] = entry.assets;
|
||||
await this.api!.persistFiles(entry.dataFiles, mediaFiles, options);
|
||||
}
|
||||
|
||||
async persistMedia(
|
||||
mediaFile: AssetProxy,
|
||||
options: PersistOptions,
|
||||
): Promise<ImplementationMediaFile> {
|
||||
const fileObj = mediaFile.fileObj as File;
|
||||
|
||||
const [id] = await Promise.all([
|
||||
getBlobSHA(fileObj),
|
||||
this.api!.persistFiles([], [mediaFile], options),
|
||||
]);
|
||||
|
||||
const { path } = mediaFile;
|
||||
const url = URL.createObjectURL(fileObj);
|
||||
|
||||
return {
|
||||
displayURL: url,
|
||||
path: trimStart(path, '/'),
|
||||
name: fileObj!.name,
|
||||
size: fileObj!.size,
|
||||
file: fileObj,
|
||||
url,
|
||||
id: id as string,
|
||||
};
|
||||
}
|
||||
|
||||
async deleteFiles(paths: string[], commitMessage: string) {
|
||||
await this.api!.deleteFiles(paths, commitMessage);
|
||||
}
|
||||
|
||||
traverseCursor(): Promise<{ entries: ImplementationEntry[]; cursor: Cursor }> {
|
||||
throw new Error('Not supported');
|
||||
}
|
||||
|
||||
allEntriesByFolder(
|
||||
_folder: string,
|
||||
_extension: string,
|
||||
_depth: number,
|
||||
): Promise<ImplementationEntry[]> {
|
||||
throw new Error('Not supported');
|
||||
}
|
||||
}
|
3
core/src/backends/azure/index.ts
Normal file
3
core/src/backends/azure/index.ts
Normal file
@ -0,0 +1,3 @@
|
||||
export { default as AzureBackend } from './implementation';
|
||||
export { default as API } from './API';
|
||||
export { default as AuthenticationPage } from './AuthenticationPage';
|
462
core/src/backends/bitbucket/API.ts
Normal file
462
core/src/backends/bitbucket/API.ts
Normal file
@ -0,0 +1,462 @@
|
||||
import flow from 'lodash/flow';
|
||||
import get from 'lodash/get';
|
||||
import { dirname } from 'path';
|
||||
import { parse } from 'what-the-diff';
|
||||
|
||||
import {
|
||||
APIError,
|
||||
basename,
|
||||
Cursor,
|
||||
localForage,
|
||||
readFile,
|
||||
readFileMetadata,
|
||||
requestWithBackoff,
|
||||
responseParser,
|
||||
then,
|
||||
throwOnConflictingBranches,
|
||||
unsentRequest,
|
||||
} from '../../lib/util';
|
||||
|
||||
import type { DataFile, PersistOptions } from '../../interface';
|
||||
import type { ApiRequest, FetchError } from '../../lib/util';
|
||||
import type AssetProxy from '../../valueObjects/AssetProxy';
|
||||
|
||||
interface Config {
|
||||
apiRoot?: string;
|
||||
token?: string;
|
||||
branch?: string;
|
||||
repo?: string;
|
||||
requestFunction?: (req: ApiRequest) => Promise<Response>;
|
||||
hasWriteAccess?: () => Promise<boolean>;
|
||||
}
|
||||
|
||||
interface CommitAuthor {
|
||||
name: string;
|
||||
email: string;
|
||||
}
|
||||
|
||||
type BitBucketFile = {
|
||||
id: string;
|
||||
type: string;
|
||||
path: string;
|
||||
commit?: { hash: string };
|
||||
};
|
||||
|
||||
type BitBucketSrcResult = {
|
||||
size: number;
|
||||
page: number;
|
||||
pagelen: number;
|
||||
next: string;
|
||||
previous: string;
|
||||
values: BitBucketFile[];
|
||||
};
|
||||
|
||||
type BitBucketUser = {
|
||||
username: string;
|
||||
display_name: string;
|
||||
nickname: string;
|
||||
links: {
|
||||
avatar: {
|
||||
href: string;
|
||||
};
|
||||
};
|
||||
};
|
||||
|
||||
type BitBucketBranch = {
|
||||
name: string;
|
||||
target: { hash: string };
|
||||
};
|
||||
|
||||
type BitBucketCommit = {
|
||||
hash: string;
|
||||
author: {
|
||||
raw: string;
|
||||
user: {
|
||||
display_name: string;
|
||||
nickname: string;
|
||||
};
|
||||
};
|
||||
date: string;
|
||||
};
|
||||
|
||||
export const API_NAME = 'Bitbucket';
|
||||
|
||||
function replace404WithEmptyResponse(err: FetchError) {
|
||||
if (err && err.status === 404) {
|
||||
console.info('This 404 was expected and handled appropriately.');
|
||||
return { size: 0, values: [] as BitBucketFile[] } as BitBucketSrcResult;
|
||||
} else {
|
||||
return Promise.reject(err);
|
||||
}
|
||||
}
|
||||
|
||||
export default class API {
|
||||
apiRoot: string;
|
||||
branch: string;
|
||||
repo: string;
|
||||
requestFunction: (req: ApiRequest) => Promise<Response>;
|
||||
repoURL: string;
|
||||
commitAuthor?: CommitAuthor;
|
||||
|
||||
constructor(config: Config) {
|
||||
this.apiRoot = config.apiRoot || 'https://api.bitbucket.org/2.0';
|
||||
this.branch = config.branch || 'main';
|
||||
this.repo = config.repo || '';
|
||||
this.requestFunction = config.requestFunction || unsentRequest.performRequest;
|
||||
// Allow overriding this.hasWriteAccess
|
||||
this.hasWriteAccess = config.hasWriteAccess || this.hasWriteAccess;
|
||||
this.repoURL = this.repo ? `/repositories/${this.repo}` : '';
|
||||
}
|
||||
|
||||
buildRequest = (req: ApiRequest) => {
|
||||
const withRoot = unsentRequest.withRoot(this.apiRoot)(req);
|
||||
if ('cache' in withRoot) {
|
||||
return withRoot;
|
||||
} else {
|
||||
const withNoCache = unsentRequest.withNoCache(withRoot);
|
||||
return withNoCache;
|
||||
}
|
||||
};
|
||||
|
||||
request = (req: ApiRequest): Promise<Response> => {
|
||||
try {
|
||||
return requestWithBackoff(this, req);
|
||||
} catch (error: unknown) {
|
||||
if (error instanceof Error) {
|
||||
throw new APIError(error.message, null, API_NAME);
|
||||
}
|
||||
|
||||
throw new APIError('Unknown api error', null, API_NAME);
|
||||
}
|
||||
};
|
||||
|
||||
responseToJSON = responseParser({ format: 'json', apiName: API_NAME });
|
||||
responseToBlob = responseParser({ format: 'blob', apiName: API_NAME });
|
||||
responseToText = responseParser({ format: 'text', apiName: API_NAME });
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
requestJSON = (req: ApiRequest) => this.request(req).then(this.responseToJSON) as Promise<any>;
|
||||
requestText = (req: ApiRequest) => this.request(req).then(this.responseToText) as Promise<string>;
|
||||
|
||||
user = () => this.requestJSON('/user') as Promise<BitBucketUser>;
|
||||
|
||||
hasWriteAccess = async () => {
|
||||
const response = await this.request(this.repoURL);
|
||||
if (response.status === 404) {
|
||||
throw Error('Repo not found');
|
||||
}
|
||||
return response.ok;
|
||||
};
|
||||
|
||||
getBranch = async (branchName: string) => {
|
||||
const branch: BitBucketBranch = await this.requestJSON(
|
||||
`${this.repoURL}/refs/branches/${branchName}`,
|
||||
);
|
||||
|
||||
return branch;
|
||||
};
|
||||
|
||||
branchCommitSha = async (branch: string) => {
|
||||
const {
|
||||
target: { hash: branchSha },
|
||||
}: BitBucketBranch = await this.getBranch(branch);
|
||||
|
||||
return branchSha;
|
||||
};
|
||||
|
||||
defaultBranchCommitSha = () => {
|
||||
return this.branchCommitSha(this.branch);
|
||||
};
|
||||
|
||||
isFile = ({ type }: BitBucketFile) => type === 'commit_file';
|
||||
|
||||
getFileId = (commitHash: string, path: string) => {
|
||||
return `${commitHash}/${path}`;
|
||||
};
|
||||
|
||||
processFile = (file: BitBucketFile) => ({
|
||||
id: file.id,
|
||||
type: file.type,
|
||||
path: file.path,
|
||||
name: basename(file.path),
|
||||
|
||||
// BitBucket does not return file SHAs, but it does give us the
|
||||
// commit SHA. Since the commit SHA will change if any files do,
|
||||
// we can construct an ID using the commit SHA and the file path
|
||||
// that will help with caching (though not as well as a normal
|
||||
// SHA, since it will change even if the individual file itself
|
||||
// doesn't.)
|
||||
...(file.commit && file.commit.hash ? { id: this.getFileId(file.commit.hash, file.path) } : {}),
|
||||
});
|
||||
processFiles = (files: BitBucketFile[]) => files.filter(this.isFile).map(this.processFile);
|
||||
|
||||
readFile = async (
|
||||
path: string,
|
||||
sha?: string | null,
|
||||
{ parseText = true, branch = this.branch, head = '' } = {},
|
||||
): Promise<string | Blob> => {
|
||||
const fetchContent = async () => {
|
||||
const node = head ? head : await this.branchCommitSha(branch);
|
||||
const content = await this.request({
|
||||
url: `${this.repoURL}/src/${node}/${path}`,
|
||||
cache: 'no-store',
|
||||
}).then<string | Blob>(parseText ? this.responseToText : this.responseToBlob);
|
||||
return content;
|
||||
};
|
||||
const content = await readFile(sha, fetchContent, localForage, parseText);
|
||||
return content;
|
||||
};
|
||||
|
||||
async readFileMetadata(path: string, sha: string | null | undefined) {
|
||||
const fetchFileMetadata = async () => {
|
||||
try {
|
||||
const { values }: { values: BitBucketCommit[] } = await this.requestJSON({
|
||||
url: `${this.repoURL}/commits`,
|
||||
params: { path, include: this.branch },
|
||||
});
|
||||
const commit = values[0];
|
||||
return {
|
||||
author: commit.author.user
|
||||
? commit.author.user.display_name || commit.author.user.nickname
|
||||
: commit.author.raw,
|
||||
updatedOn: commit.date,
|
||||
};
|
||||
} catch (e) {
|
||||
return { author: '', updatedOn: '' };
|
||||
}
|
||||
};
|
||||
const fileMetadata = await readFileMetadata(sha, fetchFileMetadata, localForage);
|
||||
return fileMetadata;
|
||||
}
|
||||
|
||||
async isShaExistsInBranch(branch: string, sha: string) {
|
||||
const { values }: { values: BitBucketCommit[] } = await this.requestJSON({
|
||||
url: `${this.repoURL}/commits`,
|
||||
params: { include: branch, pagelen: '100' },
|
||||
}).catch(e => {
|
||||
console.info(`Failed getting commits for branch '${branch}'`, e);
|
||||
return [];
|
||||
});
|
||||
|
||||
return values.some(v => v.hash === sha);
|
||||
}
|
||||
|
||||
getEntriesAndCursor = (jsonResponse: BitBucketSrcResult) => {
|
||||
const {
|
||||
size: count,
|
||||
page,
|
||||
pagelen: pageSize,
|
||||
next,
|
||||
previous: prev,
|
||||
values: entries,
|
||||
} = jsonResponse;
|
||||
const pageCount = pageSize && count ? Math.ceil(count / pageSize) : undefined;
|
||||
return {
|
||||
entries,
|
||||
cursor: Cursor.create({
|
||||
actions: [...(next ? ['next'] : []), ...(prev ? ['prev'] : [])],
|
||||
meta: { page, count, pageSize, pageCount },
|
||||
data: { links: { next, prev } },
|
||||
}),
|
||||
};
|
||||
};
|
||||
|
||||
listFiles = async (path: string, depth = 1, pagelen: number, branch: string) => {
|
||||
const node = await this.branchCommitSha(branch);
|
||||
const result: BitBucketSrcResult = await this.requestJSON({
|
||||
url: `${this.repoURL}/src/${node}/${path}`,
|
||||
params: {
|
||||
max_depth: `${depth}`,
|
||||
pagelen: `${pagelen}`,
|
||||
},
|
||||
}).catch(replace404WithEmptyResponse);
|
||||
const { entries, cursor } = this.getEntriesAndCursor(result);
|
||||
|
||||
return { entries: this.processFiles(entries), cursor: cursor as Cursor };
|
||||
};
|
||||
|
||||
traverseCursor = async (
|
||||
cursor: Cursor,
|
||||
action: string,
|
||||
): Promise<{
|
||||
cursor: Cursor;
|
||||
entries: { path: string; name: string; type: string; id: string }[];
|
||||
}> =>
|
||||
flow([
|
||||
this.requestJSON,
|
||||
then(this.getEntriesAndCursor),
|
||||
then<
|
||||
{ cursor: Cursor; entries: BitBucketFile[] },
|
||||
{ cursor: Cursor; entries: BitBucketFile[] }
|
||||
>(({ cursor: newCursor, entries }) => ({
|
||||
cursor: newCursor,
|
||||
entries: this.processFiles(entries),
|
||||
})),
|
||||
])((cursor.data?.links as Record<string, unknown>)[action]);
|
||||
|
||||
listAllFiles = async (path: string, depth: number, branch: string) => {
|
||||
const { cursor: initialCursor, entries: initialEntries } = await this.listFiles(
|
||||
path,
|
||||
depth,
|
||||
100,
|
||||
branch,
|
||||
);
|
||||
const entries = [...initialEntries];
|
||||
let currentCursor = initialCursor;
|
||||
while (currentCursor && currentCursor.actions!.has('next')) {
|
||||
const { cursor: newCursor, entries: newEntries } = await this.traverseCursor(
|
||||
currentCursor,
|
||||
'next',
|
||||
);
|
||||
entries.push(...newEntries);
|
||||
currentCursor = newCursor;
|
||||
}
|
||||
return this.processFiles(entries);
|
||||
};
|
||||
|
||||
async uploadFiles(
|
||||
files: { path: string; newPath?: string; delete?: boolean }[],
|
||||
{
|
||||
commitMessage,
|
||||
branch,
|
||||
parentSha,
|
||||
}: { commitMessage: string; branch: string; parentSha?: string },
|
||||
) {
|
||||
const formData = new FormData();
|
||||
const toMove: { from: string; to: string; contentBlob: Blob }[] = [];
|
||||
files.forEach(file => {
|
||||
if (file.delete) {
|
||||
// delete the file
|
||||
formData.append('files', file.path);
|
||||
} else if (file.newPath) {
|
||||
const contentBlob = get(file, 'fileObj', new Blob([(file as DataFile).raw]));
|
||||
toMove.push({ from: file.path, to: file.newPath, contentBlob });
|
||||
} else {
|
||||
// add/modify the file
|
||||
const contentBlob = get(file, 'fileObj', new Blob([(file as DataFile).raw]));
|
||||
// Third param is filename header, in case path is `message`, `branch`, etc.
|
||||
formData.append(file.path, contentBlob, basename(file.path));
|
||||
}
|
||||
});
|
||||
for (const { from, to, contentBlob } of toMove) {
|
||||
const sourceDir = dirname(from);
|
||||
const destDir = dirname(to);
|
||||
const filesBranch = parentSha ? this.branch : branch;
|
||||
const files = await this.listAllFiles(sourceDir, 100, filesBranch);
|
||||
for (const file of files) {
|
||||
// to move a file in Bitbucket we need to delete the old path
|
||||
// and upload the file content to the new path
|
||||
// NOTE: this is very wasteful, and also the Bitbucket `diff` API
|
||||
// reports these files as deleted+added instead of renamed
|
||||
// delete current path
|
||||
formData.append('files', file.path);
|
||||
// create in new path
|
||||
const content =
|
||||
file.path === from
|
||||
? contentBlob
|
||||
: await this.readFile(file.path, null, {
|
||||
branch: filesBranch,
|
||||
parseText: false,
|
||||
});
|
||||
formData.append(file.path.replace(sourceDir, destDir), content, basename(file.path));
|
||||
}
|
||||
}
|
||||
|
||||
if (commitMessage) {
|
||||
formData.append('message', commitMessage);
|
||||
}
|
||||
if (this.commitAuthor) {
|
||||
const { name, email } = this.commitAuthor;
|
||||
formData.append('author', `${name} <${email}>`);
|
||||
}
|
||||
|
||||
formData.append('branch', branch);
|
||||
|
||||
if (parentSha) {
|
||||
formData.append('parents', parentSha);
|
||||
}
|
||||
|
||||
try {
|
||||
await this.requestText({
|
||||
url: `${this.repoURL}/src`,
|
||||
method: 'POST',
|
||||
body: formData,
|
||||
});
|
||||
} catch (error: unknown) {
|
||||
if (error instanceof Error) {
|
||||
const message = error.message || '';
|
||||
// very descriptive message from Bitbucket
|
||||
if (parentSha && message.includes('Something went wrong')) {
|
||||
await throwOnConflictingBranches(branch, name => this.getBranch(name), API_NAME);
|
||||
}
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
|
||||
return files;
|
||||
}
|
||||
|
||||
async persistFiles(
|
||||
dataFiles: DataFile[],
|
||||
mediaFiles: (
|
||||
| {
|
||||
fileObj: File;
|
||||
size: number;
|
||||
sha: string;
|
||||
raw: string;
|
||||
path: string;
|
||||
}
|
||||
| AssetProxy
|
||||
)[],
|
||||
options: PersistOptions,
|
||||
) {
|
||||
const files = [...dataFiles, ...mediaFiles];
|
||||
return this.uploadFiles(files, { commitMessage: options.commitMessage, branch: this.branch });
|
||||
}
|
||||
|
||||
async getDifferences(source: string, destination: string = this.branch) {
|
||||
if (source === destination) {
|
||||
return [];
|
||||
}
|
||||
const rawDiff = await this.requestText({
|
||||
url: `${this.repoURL}/diff/${source}..${destination}`,
|
||||
params: {
|
||||
binary: 'false',
|
||||
},
|
||||
});
|
||||
|
||||
const diffs = parse(rawDiff).map(d => {
|
||||
const oldPath = d.oldPath?.replace(/b\//, '') || '';
|
||||
const newPath = d.newPath?.replace(/b\//, '') || '';
|
||||
const path = newPath || (oldPath as string);
|
||||
return {
|
||||
oldPath,
|
||||
newPath,
|
||||
status: d.status,
|
||||
newFile: d.status === 'added',
|
||||
path,
|
||||
binary: d.binary || /.svg$/.test(path),
|
||||
};
|
||||
});
|
||||
return diffs;
|
||||
}
|
||||
|
||||
deleteFiles = (paths: string[], message: string) => {
|
||||
const body = new FormData();
|
||||
paths.forEach(path => {
|
||||
body.append('files', path);
|
||||
});
|
||||
body.append('branch', this.branch);
|
||||
if (message) {
|
||||
body.append('message', message);
|
||||
}
|
||||
if (this.commitAuthor) {
|
||||
const { name, email } = this.commitAuthor;
|
||||
body.append('author', `${name} <${email}>`);
|
||||
}
|
||||
|
||||
return this.request(
|
||||
unsentRequest.withBody(body, unsentRequest.withMethod('POST', `${this.repoURL}/src`)),
|
||||
);
|
||||
};
|
||||
}
|
96
core/src/backends/bitbucket/AuthenticationPage.tsx
Normal file
96
core/src/backends/bitbucket/AuthenticationPage.tsx
Normal file
@ -0,0 +1,96 @@
|
||||
import { styled } from '@mui/material/styles';
|
||||
import React, { useCallback, useMemo, useState } from 'react';
|
||||
|
||||
import AuthenticationPage from '../../components/UI/AuthenticationPage';
|
||||
import Icon from '../../components/UI/Icon';
|
||||
import { ImplicitAuthenticator, NetlifyAuthenticator } from '../../lib/auth';
|
||||
|
||||
import type { MouseEvent } from 'react';
|
||||
import type { AuthenticationPageProps, TranslatedProps } from '../../interface';
|
||||
|
||||
const LoginButtonIcon = styled(Icon)`
|
||||
margin-right: 18px;
|
||||
`;
|
||||
|
||||
const BitbucketAuthenticationPage = ({
|
||||
inProgress = false,
|
||||
config,
|
||||
base_url,
|
||||
siteId,
|
||||
authEndpoint,
|
||||
clearHash,
|
||||
onLogin,
|
||||
t,
|
||||
}: TranslatedProps<AuthenticationPageProps>) => {
|
||||
const [loginError, setLoginError] = useState<string | null>(null);
|
||||
|
||||
const [auth, authSettings] = useMemo(() => {
|
||||
const { auth_type: authType = '' } = config.backend;
|
||||
|
||||
if (authType === 'implicit') {
|
||||
const {
|
||||
base_url = 'https://bitbucket.org',
|
||||
auth_endpoint = 'site/oauth2/authorize',
|
||||
app_id = '',
|
||||
} = config.backend;
|
||||
|
||||
const implicityAuth = new ImplicitAuthenticator({
|
||||
base_url,
|
||||
auth_endpoint,
|
||||
app_id,
|
||||
clearHash,
|
||||
});
|
||||
|
||||
// Complete implicit authentication if we were redirected back to from the provider.
|
||||
implicityAuth.completeAuth((err, data) => {
|
||||
if (err) {
|
||||
setLoginError(err.toString());
|
||||
return;
|
||||
} else if (data) {
|
||||
onLogin(data);
|
||||
}
|
||||
});
|
||||
|
||||
return [implicityAuth, { scope: 'repository:write' }];
|
||||
} else {
|
||||
return [
|
||||
new NetlifyAuthenticator({
|
||||
base_url,
|
||||
site_id:
|
||||
document.location.host.split(':')[0] === 'localhost' ? 'cms.netlify.com' : siteId,
|
||||
auth_endpoint: authEndpoint,
|
||||
}),
|
||||
{ provider: 'bitbucket', scope: 'repo' },
|
||||
] as const;
|
||||
}
|
||||
}, [authEndpoint, base_url, clearHash, config.backend, onLogin, siteId]);
|
||||
|
||||
const handleLogin = useCallback(
|
||||
(e: MouseEvent<HTMLButtonElement>) => {
|
||||
e.preventDefault();
|
||||
auth.authenticate(authSettings, (err, data) => {
|
||||
if (err) {
|
||||
setLoginError(err.toString());
|
||||
} else if (data) {
|
||||
onLogin(data);
|
||||
}
|
||||
});
|
||||
},
|
||||
[auth, authSettings, onLogin],
|
||||
);
|
||||
|
||||
return (
|
||||
<AuthenticationPage
|
||||
onLogin={handleLogin}
|
||||
loginDisabled={inProgress}
|
||||
loginErrorMessage={loginError}
|
||||
logoUrl={config.logo_url}
|
||||
siteUrl={config.site_url}
|
||||
icon={<LoginButtonIcon type="bitbucket" />}
|
||||
buttonContent={inProgress ? t('auth.loggingIn') : t('auth.loginWithBitbucket')}
|
||||
t={t}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
export default BitbucketAuthenticationPage;
|
103
core/src/backends/bitbucket/git-lfs-client.ts
Normal file
103
core/src/backends/bitbucket/git-lfs-client.ts
Normal file
@ -0,0 +1,103 @@
|
||||
import minimatch from 'minimatch';
|
||||
|
||||
import { unsentRequest } from '../../lib/util';
|
||||
|
||||
import type { ApiRequest, PointerFile } from '../../lib/util';
|
||||
|
||||
type MakeAuthorizedRequest = (req: ApiRequest) => Promise<Response>;
|
||||
|
||||
interface LfsBatchAction {
|
||||
href: string;
|
||||
header?: { [key: string]: string };
|
||||
expires_in?: number;
|
||||
expires_at?: string;
|
||||
}
|
||||
|
||||
interface LfsBatchObject {
|
||||
oid: string;
|
||||
size: number;
|
||||
}
|
||||
|
||||
interface LfsBatchObjectUpload extends LfsBatchObject {
|
||||
actions?: {
|
||||
upload: LfsBatchAction;
|
||||
verify?: LfsBatchAction;
|
||||
};
|
||||
}
|
||||
|
||||
interface LfsBatchObjectError extends LfsBatchObject {
|
||||
error: {
|
||||
code: number;
|
||||
message: string;
|
||||
};
|
||||
}
|
||||
|
||||
interface LfsBatchUploadResponse {
|
||||
transfer?: string;
|
||||
objects: (LfsBatchObjectUpload | LfsBatchObjectError)[];
|
||||
}
|
||||
|
||||
export class GitLfsClient {
|
||||
private static defaultContentHeaders = {
|
||||
Accept: 'application/vnd.git-lfs+json',
|
||||
['Content-Type']: 'application/vnd.git-lfs+json',
|
||||
};
|
||||
|
||||
constructor(
|
||||
public enabled: boolean,
|
||||
public rootURL: string,
|
||||
public patterns: string[],
|
||||
private makeAuthorizedRequest: MakeAuthorizedRequest,
|
||||
) {}
|
||||
|
||||
matchPath(path: string) {
|
||||
return this.patterns.some(pattern => minimatch(path, pattern, { matchBase: true }));
|
||||
}
|
||||
|
||||
async uploadResource(pointer: PointerFile, resource: Blob): Promise<string> {
|
||||
const requests = await this.getResourceUploadRequests([pointer]);
|
||||
for (const request of requests) {
|
||||
await this.doUpload(request.actions!.upload, resource);
|
||||
if (request.actions!.verify) {
|
||||
await this.doVerify(request.actions!.verify, request);
|
||||
}
|
||||
}
|
||||
return pointer.sha;
|
||||
}
|
||||
|
||||
private async doUpload(upload: LfsBatchAction, resource: Blob) {
|
||||
await unsentRequest.fetchWithTimeout(decodeURI(upload.href), {
|
||||
method: 'PUT',
|
||||
body: resource,
|
||||
headers: upload.header,
|
||||
});
|
||||
}
|
||||
private async doVerify(verify: LfsBatchAction, object: LfsBatchObject) {
|
||||
this.makeAuthorizedRequest({
|
||||
url: decodeURI(verify.href),
|
||||
method: 'POST',
|
||||
headers: { ...GitLfsClient.defaultContentHeaders, ...verify.header },
|
||||
body: JSON.stringify({ oid: object.oid, size: object.size }),
|
||||
});
|
||||
}
|
||||
|
||||
private async getResourceUploadRequests(objects: PointerFile[]): Promise<LfsBatchObjectUpload[]> {
|
||||
const response = await this.makeAuthorizedRequest({
|
||||
url: `${this.rootURL}/objects/batch`,
|
||||
method: 'POST',
|
||||
headers: GitLfsClient.defaultContentHeaders,
|
||||
body: JSON.stringify({
|
||||
operation: 'upload',
|
||||
transfers: ['basic'],
|
||||
objects: objects.map(({ sha, ...rest }) => ({ ...rest, oid: sha })),
|
||||
}),
|
||||
});
|
||||
return ((await response.json()) as LfsBatchUploadResponse).objects.filter(object => {
|
||||
if ('error' in object) {
|
||||
console.error(object.error);
|
||||
return false;
|
||||
}
|
||||
return object.actions;
|
||||
});
|
||||
}
|
||||
}
|
541
core/src/backends/bitbucket/implementation.ts
Normal file
541
core/src/backends/bitbucket/implementation.ts
Normal file
@ -0,0 +1,541 @@
|
||||
import { stripIndent } from 'common-tags';
|
||||
import trimStart from 'lodash/trimStart';
|
||||
import semaphore from 'semaphore';
|
||||
|
||||
import { NetlifyAuthenticator } from '../../lib/auth';
|
||||
import {
|
||||
AccessTokenError,
|
||||
allEntriesByFolder,
|
||||
asyncLock,
|
||||
basename,
|
||||
blobToFileObj,
|
||||
CURSOR_COMPATIBILITY_SYMBOL,
|
||||
entriesByFiles,
|
||||
entriesByFolder,
|
||||
filterByExtension,
|
||||
getBlobSHA,
|
||||
getLargeMediaFilteredMediaFiles,
|
||||
getLargeMediaPatternsFromGitAttributesFile,
|
||||
getMediaAsBlob,
|
||||
getMediaDisplayURL,
|
||||
getPointerFileForMediaFileObj,
|
||||
localForage,
|
||||
runWithLock,
|
||||
unsentRequest,
|
||||
} from '../../lib/util';
|
||||
import API, { API_NAME } from './API';
|
||||
import AuthenticationPage from './AuthenticationPage';
|
||||
import { GitLfsClient } from './git-lfs-client';
|
||||
|
||||
import type { Semaphore } from 'semaphore';
|
||||
import type {
|
||||
BackendEntry,
|
||||
BackendClass,
|
||||
Config,
|
||||
Credentials,
|
||||
DisplayURL,
|
||||
ImplementationFile,
|
||||
PersistOptions,
|
||||
User,
|
||||
} from '../../interface';
|
||||
import type { ApiRequest, AsyncLock, Cursor, FetchError } from '../../lib/util';
|
||||
import type AssetProxy from '../../valueObjects/AssetProxy';
|
||||
|
||||
const MAX_CONCURRENT_DOWNLOADS = 10;
|
||||
|
||||
const STATUS_PAGE = 'https://bitbucket.status.atlassian.com';
|
||||
const BITBUCKET_STATUS_ENDPOINT = `${STATUS_PAGE}/api/v2/components.json`;
|
||||
const BITBUCKET_OPERATIONAL_UNITS = ['API', 'Authentication and user management', 'Git LFS'];
|
||||
type BitbucketStatusComponent = {
|
||||
id: string;
|
||||
name: string;
|
||||
status: string;
|
||||
};
|
||||
|
||||
// Implementation wrapper class
|
||||
export default class BitbucketBackend implements BackendClass {
|
||||
lock: AsyncLock;
|
||||
api: API | null;
|
||||
updateUserCredentials: (args: { token: string; refresh_token: string }) => Promise<null>;
|
||||
options: {
|
||||
proxied: boolean;
|
||||
API: API | null;
|
||||
updateUserCredentials: (args: { token: string; refresh_token: string }) => Promise<null>;
|
||||
};
|
||||
repo: string;
|
||||
branch: string;
|
||||
apiRoot: string;
|
||||
baseUrl: string;
|
||||
siteId: string;
|
||||
token: string | null;
|
||||
mediaFolder?: string;
|
||||
refreshToken?: string;
|
||||
refreshedTokenPromise?: Promise<string>;
|
||||
authenticator?: NetlifyAuthenticator;
|
||||
_mediaDisplayURLSem?: Semaphore;
|
||||
largeMediaURL: string;
|
||||
_largeMediaClientPromise?: Promise<GitLfsClient>;
|
||||
authType: string;
|
||||
|
||||
constructor(config: Config, options = {}) {
|
||||
this.options = {
|
||||
proxied: false,
|
||||
API: null,
|
||||
updateUserCredentials: async () => null,
|
||||
...options,
|
||||
};
|
||||
|
||||
if (
|
||||
!this.options.proxied &&
|
||||
(config.backend.repo === null || config.backend.repo === undefined)
|
||||
) {
|
||||
throw new Error('The BitBucket backend needs a "repo" in the backend configuration.');
|
||||
}
|
||||
|
||||
this.api = this.options.API || null;
|
||||
|
||||
this.updateUserCredentials = this.options.updateUserCredentials;
|
||||
|
||||
this.repo = config.backend.repo || '';
|
||||
this.branch = config.backend.branch || 'main';
|
||||
this.apiRoot = config.backend.api_root || 'https://api.bitbucket.org/2.0';
|
||||
this.baseUrl = config.base_url || '';
|
||||
this.siteId = config.site_id || '';
|
||||
this.largeMediaURL =
|
||||
config.backend.large_media_url || `https://bitbucket.org/${config.backend.repo}/info/lfs`;
|
||||
this.token = '';
|
||||
this.mediaFolder = config.media_folder;
|
||||
this.lock = asyncLock();
|
||||
this.authType = config.backend.auth_type || '';
|
||||
}
|
||||
|
||||
isGitBackend() {
|
||||
return true;
|
||||
}
|
||||
|
||||
async status() {
|
||||
const api = await fetch(BITBUCKET_STATUS_ENDPOINT)
|
||||
.then(res => res.json())
|
||||
.then(res => {
|
||||
return res['components']
|
||||
.filter((statusComponent: BitbucketStatusComponent) =>
|
||||
BITBUCKET_OPERATIONAL_UNITS.includes(statusComponent.name),
|
||||
)
|
||||
.every(
|
||||
(statusComponent: BitbucketStatusComponent) => statusComponent.status === 'operational',
|
||||
);
|
||||
})
|
||||
.catch(e => {
|
||||
console.warn('Failed getting BitBucket status', e);
|
||||
return true;
|
||||
});
|
||||
|
||||
let auth = false;
|
||||
// no need to check auth if api is down
|
||||
if (api) {
|
||||
auth =
|
||||
(await this.api
|
||||
?.user()
|
||||
.then(user => !!user)
|
||||
.catch(e => {
|
||||
console.warn('Failed getting Bitbucket user', e);
|
||||
return false;
|
||||
})) || false;
|
||||
}
|
||||
|
||||
return { auth: { status: auth }, api: { status: api, statusPage: STATUS_PAGE } };
|
||||
}
|
||||
|
||||
authComponent() {
|
||||
return AuthenticationPage;
|
||||
}
|
||||
|
||||
setUser(user: { token: string }) {
|
||||
this.token = user.token;
|
||||
this.api = new API({
|
||||
requestFunction: this.apiRequestFunction,
|
||||
branch: this.branch,
|
||||
repo: this.repo,
|
||||
});
|
||||
}
|
||||
|
||||
requestFunction = async (req: ApiRequest) => {
|
||||
const token = await this.getToken();
|
||||
const authorizedRequest = unsentRequest.withHeaders({ Authorization: `Bearer ${token}` }, req);
|
||||
return unsentRequest.performRequest(authorizedRequest);
|
||||
};
|
||||
|
||||
restoreUser(user: User) {
|
||||
return this.authenticate(user);
|
||||
}
|
||||
|
||||
async authenticate(state: Credentials) {
|
||||
this.token = state.token as string;
|
||||
this.refreshToken = state.refresh_token;
|
||||
this.api = new API({
|
||||
requestFunction: this.apiRequestFunction,
|
||||
branch: this.branch,
|
||||
repo: this.repo,
|
||||
apiRoot: this.apiRoot,
|
||||
});
|
||||
|
||||
const isCollab = await this.api.hasWriteAccess().catch(error => {
|
||||
error.message = stripIndent`
|
||||
Repo "${this.repo}" not found.
|
||||
|
||||
Please ensure the repo information is spelled correctly.
|
||||
|
||||
If the repo is private, make sure you're logged into a Bitbucket account with access.
|
||||
`;
|
||||
throw error;
|
||||
});
|
||||
|
||||
// Unauthorized user
|
||||
if (!isCollab) {
|
||||
throw new Error('Your BitBucket user account does not have access to this repo.');
|
||||
}
|
||||
|
||||
const user = await this.api.user();
|
||||
|
||||
// Authorized user
|
||||
return {
|
||||
...user,
|
||||
name: user.display_name,
|
||||
login: user.username,
|
||||
token: state.token,
|
||||
avatar_url: user.links.avatar.href,
|
||||
refresh_token: state.refresh_token,
|
||||
};
|
||||
}
|
||||
|
||||
getRefreshedAccessToken() {
|
||||
if (this.authType === 'implicit') {
|
||||
throw new AccessTokenError(`Can't refresh access token when using implicit auth`);
|
||||
}
|
||||
if (this.refreshedTokenPromise) {
|
||||
return this.refreshedTokenPromise;
|
||||
}
|
||||
|
||||
// instantiating a new Authenticator on each refresh isn't ideal,
|
||||
if (!this.authenticator) {
|
||||
const cfg = {
|
||||
base_url: this.baseUrl,
|
||||
site_id: this.siteId,
|
||||
};
|
||||
this.authenticator = new NetlifyAuthenticator(cfg);
|
||||
}
|
||||
|
||||
this.refreshedTokenPromise = this.authenticator!.refresh({
|
||||
provider: 'bitbucket',
|
||||
refresh_token: this.refreshToken as string,
|
||||
})?.then(({ token, refresh_token }: { token: string; refresh_token: string }) => {
|
||||
this.token = token;
|
||||
this.refreshToken = refresh_token;
|
||||
this.refreshedTokenPromise = undefined;
|
||||
|
||||
this.updateUserCredentials({ token, refresh_token });
|
||||
return token;
|
||||
});
|
||||
|
||||
return this.refreshedTokenPromise;
|
||||
}
|
||||
|
||||
logout() {
|
||||
this.token = null;
|
||||
return;
|
||||
}
|
||||
|
||||
getToken() {
|
||||
if (this.refreshedTokenPromise) {
|
||||
return this.refreshedTokenPromise;
|
||||
}
|
||||
|
||||
return Promise.resolve(this.token);
|
||||
}
|
||||
|
||||
apiRequestFunction = async (req: ApiRequest) => {
|
||||
const token = (
|
||||
this.refreshedTokenPromise ? await this.refreshedTokenPromise : this.token
|
||||
) as string;
|
||||
|
||||
const authorizedRequest = unsentRequest.withHeaders({ Authorization: `Bearer ${token}` }, req);
|
||||
const response: Response = await unsentRequest.performRequest(authorizedRequest);
|
||||
if (response.status === 401) {
|
||||
const json = await response.json().catch(() => null);
|
||||
if (json && json.type === 'error' && /^access token expired/i.test(json.error.message)) {
|
||||
const newToken = await this.getRefreshedAccessToken();
|
||||
const reqWithNewToken = unsentRequest.withHeaders(
|
||||
{
|
||||
Authorization: `Bearer ${newToken}`,
|
||||
},
|
||||
req,
|
||||
) as ApiRequest;
|
||||
return unsentRequest.performRequest(reqWithNewToken);
|
||||
}
|
||||
}
|
||||
return response;
|
||||
};
|
||||
|
||||
async entriesByFolder(folder: string, extension: string, depth: number) {
|
||||
let cursor: Cursor;
|
||||
|
||||
const listFiles = () =>
|
||||
this.api!.listFiles(folder, depth, 20, this.branch).then(({ entries, cursor: c }) => {
|
||||
cursor = c.mergeMeta({ extension });
|
||||
return entries.filter(e => filterByExtension(e, extension));
|
||||
});
|
||||
|
||||
const head = await this.api!.defaultBranchCommitSha();
|
||||
const readFile = (path: string, id: string | null | undefined) => {
|
||||
return this.api!.readFile(path, id, { head }) as Promise<string>;
|
||||
};
|
||||
|
||||
const files = await entriesByFolder(
|
||||
listFiles,
|
||||
readFile,
|
||||
this.api!.readFileMetadata.bind(this.api),
|
||||
API_NAME,
|
||||
);
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
|
||||
// @ts-ignore
|
||||
files[CURSOR_COMPATIBILITY_SYMBOL] = cursor;
|
||||
return files;
|
||||
}
|
||||
|
||||
async listAllFiles(folder: string, extension: string, depth: number) {
|
||||
const files = await this.api!.listAllFiles(folder, depth, this.branch);
|
||||
const filtered = files.filter(file => filterByExtension(file, extension));
|
||||
return filtered;
|
||||
}
|
||||
|
||||
async allEntriesByFolder(folder: string, extension: string, depth: number) {
|
||||
const head = await this.api!.defaultBranchCommitSha();
|
||||
|
||||
const readFile = (path: string, id: string | null | undefined) => {
|
||||
return this.api!.readFile(path, id, { head }) as Promise<string>;
|
||||
};
|
||||
|
||||
const files = await allEntriesByFolder({
|
||||
listAllFiles: () => this.listAllFiles(folder, extension, depth),
|
||||
readFile,
|
||||
readFileMetadata: this.api!.readFileMetadata.bind(this.api),
|
||||
apiName: API_NAME,
|
||||
branch: this.branch,
|
||||
localForage,
|
||||
folder,
|
||||
extension,
|
||||
depth,
|
||||
getDefaultBranch: () => Promise.resolve({ name: this.branch, sha: head }),
|
||||
isShaExistsInBranch: this.api!.isShaExistsInBranch.bind(this.api!),
|
||||
getDifferences: (source, destination) => this.api!.getDifferences(source, destination),
|
||||
getFileId: path => Promise.resolve(this.api!.getFileId(head, path)),
|
||||
filterFile: file => filterByExtension(file, extension),
|
||||
});
|
||||
return files;
|
||||
}
|
||||
|
||||
async entriesByFiles(files: ImplementationFile[]) {
|
||||
const head = await this.api!.defaultBranchCommitSha();
|
||||
const readFile = (path: string, id: string | null | undefined) => {
|
||||
return this.api!.readFile(path, id, { head }) as Promise<string>;
|
||||
};
|
||||
|
||||
return entriesByFiles(files, readFile, this.api!.readFileMetadata.bind(this.api), API_NAME);
|
||||
}
|
||||
|
||||
getEntry(path: string) {
|
||||
return this.api!.readFile(path).then(data => ({
|
||||
file: { path, id: null },
|
||||
data: data as string,
|
||||
}));
|
||||
}
|
||||
|
||||
async getMedia(mediaFolder = this.mediaFolder) {
|
||||
if (!mediaFolder) {
|
||||
return [];
|
||||
}
|
||||
return this.api!.listAllFiles(mediaFolder, 1, this.branch).then(files =>
|
||||
files.map(({ id, name, path }) => ({ id, name, path, displayURL: { id, path } })),
|
||||
);
|
||||
}
|
||||
|
||||
getLargeMediaClient() {
|
||||
if (!this._largeMediaClientPromise) {
|
||||
this._largeMediaClientPromise = (async (): Promise<GitLfsClient> => {
|
||||
const patterns = await this.api!.readFile('.gitattributes')
|
||||
.then(attributes => getLargeMediaPatternsFromGitAttributesFile(attributes as string))
|
||||
.catch((err: FetchError) => {
|
||||
if (err.status === 404) {
|
||||
console.info('This 404 was expected and handled appropriately.');
|
||||
} else {
|
||||
console.error(err);
|
||||
}
|
||||
return [];
|
||||
});
|
||||
|
||||
return new GitLfsClient(
|
||||
!!(this.largeMediaURL && patterns.length > 0),
|
||||
this.largeMediaURL,
|
||||
patterns,
|
||||
this.requestFunction,
|
||||
);
|
||||
})();
|
||||
}
|
||||
return this._largeMediaClientPromise;
|
||||
}
|
||||
|
||||
getMediaDisplayURL(displayURL: DisplayURL) {
|
||||
this._mediaDisplayURLSem = this._mediaDisplayURLSem || semaphore(MAX_CONCURRENT_DOWNLOADS);
|
||||
return getMediaDisplayURL(
|
||||
displayURL,
|
||||
this.api!.readFile.bind(this.api!),
|
||||
this._mediaDisplayURLSem,
|
||||
);
|
||||
}
|
||||
|
||||
async getMediaFile(path: string) {
|
||||
const name = basename(path);
|
||||
const blob = await getMediaAsBlob(path, null, this.api!.readFile.bind(this.api!));
|
||||
const fileObj = blobToFileObj(name, blob);
|
||||
const url = URL.createObjectURL(fileObj);
|
||||
const id = await getBlobSHA(fileObj);
|
||||
|
||||
return {
|
||||
id,
|
||||
displayURL: url,
|
||||
path,
|
||||
name,
|
||||
size: fileObj.size,
|
||||
file: fileObj,
|
||||
url,
|
||||
};
|
||||
}
|
||||
|
||||
async persistEntry(entry: BackendEntry, options: PersistOptions) {
|
||||
const client = await this.getLargeMediaClient();
|
||||
// persistEntry is a transactional operation
|
||||
return runWithLock(
|
||||
this.lock,
|
||||
async () =>
|
||||
this.api!.persistFiles(
|
||||
entry.dataFiles,
|
||||
client.enabled
|
||||
? await getLargeMediaFilteredMediaFiles(client, entry.assets)
|
||||
: entry.assets,
|
||||
options,
|
||||
),
|
||||
'Failed to acquire persist entry lock',
|
||||
);
|
||||
}
|
||||
|
||||
async persistMedia(
|
||||
mediaFile:
|
||||
| {
|
||||
fileObj: File;
|
||||
size: number;
|
||||
sha: string;
|
||||
raw: string;
|
||||
path: string;
|
||||
}
|
||||
| AssetProxy,
|
||||
options: PersistOptions,
|
||||
) {
|
||||
const { fileObj, path } = mediaFile;
|
||||
const displayURL = URL.createObjectURL(fileObj as Blob);
|
||||
const client = await this.getLargeMediaClient();
|
||||
const fixedPath = path.startsWith('/') ? path.slice(1) : path;
|
||||
if (!client.enabled || !client.matchPath(fixedPath)) {
|
||||
return this._persistMedia(mediaFile, options);
|
||||
}
|
||||
|
||||
const persistMediaArgument = await getPointerFileForMediaFileObj(client, fileObj as File, path);
|
||||
return {
|
||||
...(await this._persistMedia(persistMediaArgument, options)),
|
||||
displayURL,
|
||||
};
|
||||
}
|
||||
|
||||
async _persistMedia(
|
||||
mediaFile:
|
||||
| {
|
||||
fileObj: File;
|
||||
size: number;
|
||||
sha: string;
|
||||
raw: string;
|
||||
path: string;
|
||||
}
|
||||
| AssetProxy,
|
||||
options: PersistOptions,
|
||||
) {
|
||||
const fileObj = mediaFile.fileObj as File;
|
||||
|
||||
const [id] = await Promise.all([
|
||||
getBlobSHA(fileObj),
|
||||
this.api!.persistFiles([], [mediaFile], options),
|
||||
]);
|
||||
|
||||
const url = URL.createObjectURL(fileObj);
|
||||
|
||||
return {
|
||||
displayURL: url,
|
||||
path: trimStart(mediaFile.path, '/k'),
|
||||
name: fileObj!.name,
|
||||
size: fileObj!.size,
|
||||
id,
|
||||
file: fileObj,
|
||||
url,
|
||||
};
|
||||
}
|
||||
|
||||
deleteFiles(paths: string[], commitMessage: string) {
|
||||
return this.api!.deleteFiles(paths, commitMessage);
|
||||
}
|
||||
|
||||
traverseCursor(cursor: Cursor, action: string) {
|
||||
return this.api!.traverseCursor(cursor, action).then(async ({ entries, cursor: newCursor }) => {
|
||||
const extension = cursor.meta?.extension as string | undefined;
|
||||
if (extension) {
|
||||
entries = entries.filter(e => filterByExtension(e, extension));
|
||||
newCursor = newCursor.mergeMeta({ extension });
|
||||
}
|
||||
const head = await this.api!.defaultBranchCommitSha();
|
||||
const readFile = (path: string, id: string | null | undefined) => {
|
||||
return this.api!.readFile(path, id, { head }) as Promise<string>;
|
||||
};
|
||||
const entriesWithData = await entriesByFiles(
|
||||
entries,
|
||||
readFile,
|
||||
this.api!.readFileMetadata.bind(this.api)!,
|
||||
API_NAME,
|
||||
);
|
||||
|
||||
return {
|
||||
entries: entriesWithData,
|
||||
cursor: newCursor,
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
async loadMediaFile(path: string, id: string, { branch }: { branch: string }) {
|
||||
const readFile = async (
|
||||
path: string,
|
||||
id: string | null | undefined,
|
||||
{ parseText }: { parseText: boolean },
|
||||
) => {
|
||||
const content = await this.api!.readFile(path, id, { branch, parseText });
|
||||
return content;
|
||||
};
|
||||
const blob = await getMediaAsBlob(path, id, readFile);
|
||||
const name = basename(path);
|
||||
const fileObj = blobToFileObj(name, blob);
|
||||
return {
|
||||
id: path,
|
||||
displayURL: URL.createObjectURL(fileObj),
|
||||
path,
|
||||
name,
|
||||
size: fileObj.size,
|
||||
file: fileObj,
|
||||
};
|
||||
}
|
||||
}
|
3
core/src/backends/bitbucket/index.ts
Normal file
3
core/src/backends/bitbucket/index.ts
Normal file
@ -0,0 +1,3 @@
|
||||
export { default as BitbucketBackend } from './implementation';
|
||||
export { default as API } from './API';
|
||||
export { default as AuthenticationPage } from './AuthenticationPage';
|
225
core/src/backends/git-gateway/AuthenticationPage.tsx
Normal file
225
core/src/backends/git-gateway/AuthenticationPage.tsx
Normal file
@ -0,0 +1,225 @@
|
||||
import Button from '@mui/material/Button';
|
||||
import { styled } from '@mui/material/styles';
|
||||
import TextField from '@mui/material/TextField';
|
||||
import React, { useCallback, useEffect, useState } from 'react';
|
||||
|
||||
import AuthenticationPage from '../../components/UI/AuthenticationPage';
|
||||
import { colors } from '../../components/UI/styles';
|
||||
|
||||
import type { ChangeEvent, FormEvent } from 'react';
|
||||
import type { AuthenticationPageProps, TranslatedProps, User } from '../../interface';
|
||||
|
||||
const StyledAuthForm = styled('form')`
|
||||
width: 350px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 16px;
|
||||
`;
|
||||
|
||||
const ErrorMessage = styled('div')`
|
||||
color: ${colors.errorText};
|
||||
`;
|
||||
|
||||
function useNetlifyIdentifyEvent(eventName: 'login', callback: (login: User) => void): void;
|
||||
function useNetlifyIdentifyEvent(eventName: 'logout', callback: () => void): void;
|
||||
function useNetlifyIdentifyEvent(eventName: 'error', callback: (err: Error) => void): void;
|
||||
function useNetlifyIdentifyEvent(
|
||||
eventName: 'login' | 'logout' | 'error',
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
callback: (input?: any) => void,
|
||||
): void {
|
||||
useEffect(() => {
|
||||
window.netlifyIdentity?.on(eventName, callback);
|
||||
}, [callback, eventName]);
|
||||
}
|
||||
|
||||
export interface GitGatewayAuthenticationPageProps
|
||||
extends TranslatedProps<AuthenticationPageProps> {
|
||||
handleAuth: (email: string, password: string) => Promise<User | string>;
|
||||
}
|
||||
|
||||
const GitGatewayAuthenticationPage = ({
|
||||
inProgress = false,
|
||||
config,
|
||||
onLogin,
|
||||
handleAuth,
|
||||
t,
|
||||
}: GitGatewayAuthenticationPageProps) => {
|
||||
const [loggedIn, setLoggedIn] = useState(false);
|
||||
const [email, setEmail] = useState('');
|
||||
const [password, setPassword] = useState('');
|
||||
const [errors, setErrors] = useState<{
|
||||
identity?: string;
|
||||
server?: string;
|
||||
email?: string;
|
||||
password?: string;
|
||||
}>({});
|
||||
|
||||
useEffect(() => {
|
||||
if (!loggedIn && window.netlifyIdentity && window.netlifyIdentity.currentUser()) {
|
||||
onLogin(window.netlifyIdentity.currentUser());
|
||||
window.netlifyIdentity.close();
|
||||
}
|
||||
}, [loggedIn, onLogin]);
|
||||
|
||||
const handleIdentityLogin = useCallback(
|
||||
(user: User) => {
|
||||
onLogin(user);
|
||||
window.netlifyIdentity?.close();
|
||||
},
|
||||
[onLogin],
|
||||
);
|
||||
|
||||
useNetlifyIdentifyEvent('login', handleIdentityLogin);
|
||||
|
||||
const handleIdentityLogout = useCallback(() => {
|
||||
window.netlifyIdentity?.open();
|
||||
}, []);
|
||||
|
||||
useNetlifyIdentifyEvent('logout', handleIdentityLogout);
|
||||
|
||||
const handleIdentityError = useCallback(
|
||||
(err: Error) => {
|
||||
if (err?.message?.match(/^Failed to load settings from.+\.netlify\/identity$/)) {
|
||||
window.netlifyIdentity?.close();
|
||||
setErrors({ identity: t('auth.errors.identitySettings') });
|
||||
}
|
||||
},
|
||||
[t],
|
||||
);
|
||||
|
||||
useNetlifyIdentifyEvent('error', handleIdentityError);
|
||||
|
||||
const handleIdentity = useCallback(() => {
|
||||
const user = window.netlifyIdentity?.currentUser();
|
||||
if (user) {
|
||||
onLogin(user);
|
||||
} else {
|
||||
window.netlifyIdentity?.open();
|
||||
}
|
||||
}, [onLogin]);
|
||||
|
||||
const handleEmailChange = useCallback((event: ChangeEvent<HTMLInputElement>) => {
|
||||
setEmail(event.target.value);
|
||||
}, []);
|
||||
|
||||
const handlePasswordChange = useCallback((event: ChangeEvent<HTMLInputElement>) => {
|
||||
setPassword(event.target.value);
|
||||
}, []);
|
||||
|
||||
const handleLogin = useCallback(
|
||||
async (e: FormEvent<HTMLFormElement>) => {
|
||||
e.preventDefault();
|
||||
|
||||
const validationErrors: typeof errors = {};
|
||||
if (!email) {
|
||||
validationErrors.email = t('auth.errors.email');
|
||||
}
|
||||
if (!password) {
|
||||
validationErrors.password = t('auth.errors.password');
|
||||
}
|
||||
|
||||
if (Object.keys(validationErrors).length > 0) {
|
||||
setErrors(validationErrors);
|
||||
return;
|
||||
}
|
||||
|
||||
let response: User | string;
|
||||
try {
|
||||
response = await handleAuth(email, password);
|
||||
} catch (e: unknown) {
|
||||
if (e instanceof Error) {
|
||||
response = e.message;
|
||||
} else {
|
||||
response = 'Unknown authentication error';
|
||||
}
|
||||
}
|
||||
|
||||
if (typeof response === 'string') {
|
||||
setErrors({ server: response });
|
||||
setLoggedIn(false);
|
||||
return;
|
||||
}
|
||||
|
||||
onLogin(response);
|
||||
},
|
||||
[email, handleAuth, onLogin, password, t],
|
||||
);
|
||||
|
||||
if (window.netlifyIdentity) {
|
||||
if (errors.identity) {
|
||||
return (
|
||||
<AuthenticationPage
|
||||
logoUrl={config.logo_url}
|
||||
siteUrl={config.site_url}
|
||||
onLogin={handleIdentity}
|
||||
pageContent={
|
||||
<a
|
||||
href="https://docs.netlify.com/visitor-access/git-gateway/#setup-and-settings"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
>
|
||||
{errors.identity}
|
||||
</a>
|
||||
}
|
||||
t={t}
|
||||
/>
|
||||
);
|
||||
} else {
|
||||
return (
|
||||
<AuthenticationPage
|
||||
logoUrl={config.logo_url}
|
||||
siteUrl={config.site_url}
|
||||
onLogin={handleIdentity}
|
||||
buttonContent={t('auth.loginWithNetlifyIdentity')}
|
||||
t={t}
|
||||
/>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<AuthenticationPage
|
||||
logoUrl={config.logo_url}
|
||||
siteUrl={config.site_url}
|
||||
pageContent={
|
||||
<StyledAuthForm onSubmit={handleLogin}>
|
||||
{!errors.server ? null : <ErrorMessage>{String(errors.server)}</ErrorMessage>}
|
||||
<TextField
|
||||
type="text"
|
||||
name="email"
|
||||
label="Email"
|
||||
value={email}
|
||||
onChange={handleEmailChange}
|
||||
fullWidth
|
||||
variant="outlined"
|
||||
error={Boolean(errors.email)}
|
||||
helperText={errors.email ?? undefined}
|
||||
/>
|
||||
<TextField
|
||||
type="password"
|
||||
name="password"
|
||||
label="Password"
|
||||
value={password}
|
||||
onChange={handlePasswordChange}
|
||||
fullWidth
|
||||
variant="outlined"
|
||||
error={Boolean(errors.password)}
|
||||
helperText={errors.password ?? undefined}
|
||||
/>
|
||||
<Button
|
||||
variant="contained"
|
||||
type="submit"
|
||||
disabled={inProgress}
|
||||
sx={{ width: 120, alignSelf: 'center' }}
|
||||
>
|
||||
{inProgress ? t('auth.loggingIn') : t('auth.login')}
|
||||
</Button>
|
||||
</StyledAuthForm>
|
||||
}
|
||||
t={t}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
export default GitGatewayAuthenticationPage;
|
121
core/src/backends/git-gateway/GitHubAPI.ts
Normal file
121
core/src/backends/git-gateway/GitHubAPI.ts
Normal file
@ -0,0 +1,121 @@
|
||||
import { APIError } from '../../lib/util';
|
||||
import { API as GithubAPI } from '../github';
|
||||
|
||||
import type { FetchError } from '../../lib/util';
|
||||
import type { Config as GitHubConfig } from '../github/API';
|
||||
|
||||
type Config = GitHubConfig & {
|
||||
apiRoot: string;
|
||||
tokenPromise: () => Promise<string>;
|
||||
commitAuthor: { name: string };
|
||||
isLargeMedia: (filename: string) => Promise<boolean>;
|
||||
};
|
||||
|
||||
export default class API extends GithubAPI {
|
||||
tokenPromise: () => Promise<string>;
|
||||
commitAuthor: { name: string };
|
||||
isLargeMedia: (filename: string) => Promise<boolean>;
|
||||
|
||||
constructor(config: Config) {
|
||||
super(config);
|
||||
this.apiRoot = config.apiRoot;
|
||||
this.tokenPromise = config.tokenPromise;
|
||||
this.commitAuthor = config.commitAuthor;
|
||||
this.isLargeMedia = config.isLargeMedia;
|
||||
this.repoURL = '';
|
||||
this.originRepoURL = '';
|
||||
}
|
||||
|
||||
hasWriteAccess() {
|
||||
return this.getDefaultBranch()
|
||||
.then(() => true)
|
||||
.catch((error: FetchError) => {
|
||||
if (error.status === 401) {
|
||||
if (error.message === 'Bad credentials') {
|
||||
throw new APIError(
|
||||
'Git Gateway Error: Please ask your site administrator to reissue the Git Gateway token.',
|
||||
error.status,
|
||||
'Git Gateway',
|
||||
);
|
||||
} else {
|
||||
return false;
|
||||
}
|
||||
} else if (
|
||||
error.status === 404 &&
|
||||
(error.message === undefined || error.message === 'Unable to locate site configuration')
|
||||
) {
|
||||
throw new APIError(
|
||||
`Git Gateway Error: Please make sure Git Gateway is enabled on your site.`,
|
||||
error.status,
|
||||
'Git Gateway',
|
||||
);
|
||||
} else {
|
||||
console.error('Problem fetching repo data from Git Gateway');
|
||||
throw error;
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
requestHeaders(headers = {}) {
|
||||
return this.tokenPromise().then(jwtToken => {
|
||||
const baseHeader = {
|
||||
Authorization: `Bearer ${jwtToken}`,
|
||||
'Content-Type': 'application/json; charset=utf-8',
|
||||
...headers,
|
||||
};
|
||||
|
||||
return baseHeader;
|
||||
});
|
||||
}
|
||||
|
||||
handleRequestError(error: FetchError & { msg: string }, responseStatus: number) {
|
||||
throw new APIError(error.message || error.msg, responseStatus, 'Git Gateway');
|
||||
}
|
||||
|
||||
user() {
|
||||
return Promise.resolve({ login: '', ...this.commitAuthor });
|
||||
}
|
||||
|
||||
async getHeadReference(head: string) {
|
||||
if (!this.repoOwner) {
|
||||
// get the repo owner from the branch url
|
||||
// this is required for returning the full head reference, e.g. owner:head
|
||||
// when filtering pull requests based on the head
|
||||
const branch = await this.getDefaultBranch();
|
||||
const self = branch._links.self;
|
||||
const regex = new RegExp('https?://.+?/repos/(.+?)/');
|
||||
const owner = self.match(regex);
|
||||
this.repoOwner = owner ? owner[1] : '';
|
||||
}
|
||||
return super.getHeadReference(head);
|
||||
}
|
||||
|
||||
commit(message: string, changeTree: { parentSha?: string; sha: string }) {
|
||||
const commitParams: {
|
||||
message: string;
|
||||
tree: string;
|
||||
parents: string[];
|
||||
author?: { name: string; date: string };
|
||||
} = {
|
||||
message,
|
||||
tree: changeTree.sha,
|
||||
parents: changeTree.parentSha ? [changeTree.parentSha] : [],
|
||||
};
|
||||
|
||||
if (this.commitAuthor) {
|
||||
commitParams.author = {
|
||||
...this.commitAuthor,
|
||||
date: new Date().toISOString(),
|
||||
};
|
||||
}
|
||||
|
||||
return this.request('/git/commits', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify(commitParams),
|
||||
});
|
||||
}
|
||||
|
||||
nextUrlProcessor() {
|
||||
return (url: string) => url.replace(/^(?:[a-z]+:\/\/.+?\/.+?\/.+?\/)/, `${this.apiRoot}/`);
|
||||
}
|
||||
}
|
30
core/src/backends/git-gateway/GitLabAPI.ts
Normal file
30
core/src/backends/git-gateway/GitLabAPI.ts
Normal file
@ -0,0 +1,30 @@
|
||||
import { unsentRequest } from '../../lib/util';
|
||||
import { API as GitlabAPI } from '../gitlab';
|
||||
|
||||
import type { Config as GitLabConfig, CommitAuthor } from '../gitlab/API';
|
||||
import type { ApiRequest } from '../../lib/util';
|
||||
|
||||
type Config = GitLabConfig & { tokenPromise: () => Promise<string>; commitAuthor: CommitAuthor };
|
||||
|
||||
export default class API extends GitlabAPI {
|
||||
tokenPromise: () => Promise<string>;
|
||||
|
||||
constructor(config: Config) {
|
||||
super(config);
|
||||
this.tokenPromise = config.tokenPromise;
|
||||
this.commitAuthor = config.commitAuthor;
|
||||
this.repoURL = '';
|
||||
}
|
||||
|
||||
withAuthorizationHeaders = async (req: ApiRequest) => {
|
||||
const token = await this.tokenPromise();
|
||||
return unsentRequest.withHeaders(
|
||||
{
|
||||
Authorization: `Bearer ${token}`,
|
||||
},
|
||||
req,
|
||||
);
|
||||
};
|
||||
|
||||
hasWriteAccess = () => Promise.resolve(true);
|
||||
}
|
581
core/src/backends/git-gateway/implementation.tsx
Normal file
581
core/src/backends/git-gateway/implementation.tsx
Normal file
@ -0,0 +1,581 @@
|
||||
import React, { useCallback } from 'react';
|
||||
import GoTrue from 'gotrue-js';
|
||||
import ini from 'ini';
|
||||
import jwtDecode from 'jwt-decode';
|
||||
import get from 'lodash/get';
|
||||
import intersection from 'lodash/intersection';
|
||||
import pick from 'lodash/pick';
|
||||
|
||||
import {
|
||||
AccessTokenError,
|
||||
APIError,
|
||||
basename,
|
||||
entriesByFiles,
|
||||
getLargeMediaFilteredMediaFiles,
|
||||
getLargeMediaPatternsFromGitAttributesFile,
|
||||
getPointerFileForMediaFileObj,
|
||||
parsePointerFile,
|
||||
unsentRequest,
|
||||
} from '../../lib/util';
|
||||
import { API as BitBucketAPI, BitbucketBackend } from '../bitbucket';
|
||||
import { GitHubBackend } from '../github';
|
||||
import { GitLabBackend } from '../gitlab';
|
||||
import AuthenticationPage from './AuthenticationPage';
|
||||
import GitHubAPI from './GitHubAPI';
|
||||
import GitLabAPI from './GitLabAPI';
|
||||
import { getClient } from './netlify-lfs-client';
|
||||
|
||||
import type { ApiRequest, Cursor } from '../../lib/util';
|
||||
import type {
|
||||
Config,
|
||||
Credentials,
|
||||
DisplayURL,
|
||||
DisplayURLObject,
|
||||
BackendEntry,
|
||||
BackendClass,
|
||||
ImplementationFile,
|
||||
PersistOptions,
|
||||
User,
|
||||
TranslatedProps,
|
||||
AuthenticationPageProps,
|
||||
} from '../../interface';
|
||||
import type { Client } from './netlify-lfs-client';
|
||||
import type AssetProxy from '../../valueObjects/AssetProxy';
|
||||
|
||||
const STATUS_PAGE = 'https://www.netlifystatus.com';
|
||||
const GIT_GATEWAY_STATUS_ENDPOINT = `${STATUS_PAGE}/api/v2/components.json`;
|
||||
const GIT_GATEWAY_OPERATIONAL_UNITS = ['Git Gateway'];
|
||||
type GitGatewayStatus = {
|
||||
id: string;
|
||||
name: string;
|
||||
status: string;
|
||||
};
|
||||
|
||||
type NetlifyIdentity = {
|
||||
logout: () => void;
|
||||
currentUser: () => User;
|
||||
on: (
|
||||
eventName: 'init' | 'login' | 'logout' | 'error',
|
||||
callback: (input?: unknown) => void,
|
||||
) => void;
|
||||
init: () => void;
|
||||
store: { user: unknown; modal: { page: string }; saving: boolean };
|
||||
open: () => void;
|
||||
close: () => void;
|
||||
};
|
||||
|
||||
type AuthClient = {
|
||||
logout: () => void;
|
||||
currentUser: () => unknown;
|
||||
login?: (email: string, password: string, remember?: boolean) => Promise<User>;
|
||||
clearStore: () => void;
|
||||
};
|
||||
|
||||
declare global {
|
||||
interface Window {
|
||||
netlifyIdentity?: NetlifyIdentity;
|
||||
}
|
||||
}
|
||||
|
||||
const localHosts: Record<string, boolean> = {
|
||||
localhost: true,
|
||||
'127.0.0.1': true,
|
||||
'0.0.0.0': true,
|
||||
};
|
||||
const defaults = {
|
||||
identity: '/.netlify/identity',
|
||||
gateway: '/.netlify/git',
|
||||
largeMedia: '/.netlify/large-media',
|
||||
};
|
||||
|
||||
function getEndpoint(endpoint: string, netlifySiteURL: string | null) {
|
||||
if (
|
||||
localHosts[document.location.host.split(':').shift() as string] &&
|
||||
netlifySiteURL &&
|
||||
endpoint.match(/^\/\.netlify\//)
|
||||
) {
|
||||
const parts = [];
|
||||
if (netlifySiteURL) {
|
||||
parts.push(netlifySiteURL);
|
||||
if (!netlifySiteURL.match(/\/$/)) {
|
||||
parts.push('/');
|
||||
}
|
||||
}
|
||||
parts.push(endpoint.replace(/^\//, ''));
|
||||
return parts.join('');
|
||||
}
|
||||
return endpoint;
|
||||
}
|
||||
|
||||
// wait for identity widget to initialize
|
||||
// force init on timeout
|
||||
let initPromise = Promise.resolve() as Promise<unknown>;
|
||||
if (window.netlifyIdentity) {
|
||||
let initialized = false;
|
||||
initPromise = Promise.race([
|
||||
new Promise<void>(resolve => {
|
||||
window.netlifyIdentity?.on('init', () => {
|
||||
initialized = true;
|
||||
resolve();
|
||||
});
|
||||
}),
|
||||
new Promise(resolve => setTimeout(resolve, 2500)).then(() => {
|
||||
if (!initialized) {
|
||||
console.info('Manually initializing identity widget');
|
||||
window.netlifyIdentity?.init();
|
||||
}
|
||||
}),
|
||||
]);
|
||||
}
|
||||
|
||||
interface NetlifyUser extends Credentials {
|
||||
jwt: () => Promise<string>;
|
||||
email: string;
|
||||
user_metadata: { full_name: string; avatar_url: string };
|
||||
}
|
||||
|
||||
export default class GitGateway implements BackendClass {
|
||||
config: Config;
|
||||
api?: GitHubAPI | GitLabAPI | BitBucketAPI;
|
||||
branch: string;
|
||||
mediaFolder?: string;
|
||||
transformImages: boolean;
|
||||
gatewayUrl: string;
|
||||
netlifyLargeMediaURL: string;
|
||||
backendType: string | null;
|
||||
apiUrl: string;
|
||||
authClient?: AuthClient;
|
||||
backend: GitHubBackend | GitLabBackend | BitbucketBackend | null;
|
||||
acceptRoles?: string[];
|
||||
tokenPromise?: () => Promise<string>;
|
||||
_largeMediaClientPromise?: Promise<Client>;
|
||||
|
||||
options: {
|
||||
proxied: boolean;
|
||||
API: GitHubAPI | GitLabAPI | BitBucketAPI | null;
|
||||
};
|
||||
constructor(config: Config, options = {}) {
|
||||
this.options = {
|
||||
proxied: true,
|
||||
API: null,
|
||||
...options,
|
||||
};
|
||||
this.config = config;
|
||||
this.branch = config.backend.branch?.trim() || 'main';
|
||||
this.mediaFolder = config.media_folder;
|
||||
const { use_large_media_transforms_in_media_library: transformImages = true } = config.backend;
|
||||
this.transformImages = transformImages;
|
||||
|
||||
const netlifySiteURL = localStorage.getItem('netlifySiteURL');
|
||||
this.apiUrl = getEndpoint(config.backend.identity_url || defaults.identity, netlifySiteURL);
|
||||
this.gatewayUrl = getEndpoint(config.backend.gateway_url || defaults.gateway, netlifySiteURL);
|
||||
this.netlifyLargeMediaURL = getEndpoint(
|
||||
config.backend.large_media_url || defaults.largeMedia,
|
||||
netlifySiteURL,
|
||||
);
|
||||
const backendTypeRegex = /\/(github|gitlab|bitbucket)\/?$/;
|
||||
const backendTypeMatches = this.gatewayUrl.match(backendTypeRegex);
|
||||
if (backendTypeMatches) {
|
||||
this.backendType = backendTypeMatches[1];
|
||||
this.gatewayUrl = this.gatewayUrl.replace(backendTypeRegex, '');
|
||||
} else {
|
||||
this.backendType = null;
|
||||
}
|
||||
|
||||
this.backend = null;
|
||||
}
|
||||
|
||||
isGitBackend() {
|
||||
return true;
|
||||
}
|
||||
|
||||
async status() {
|
||||
const api = await fetch(GIT_GATEWAY_STATUS_ENDPOINT)
|
||||
.then(res => res.json())
|
||||
.then(res => {
|
||||
return res['components']
|
||||
.filter((statusComponent: GitGatewayStatus) =>
|
||||
GIT_GATEWAY_OPERATIONAL_UNITS.includes(statusComponent.name),
|
||||
)
|
||||
.every((statusComponent: GitGatewayStatus) => statusComponent.status === 'operational');
|
||||
})
|
||||
.catch(e => {
|
||||
console.warn('Failed getting Git Gateway status', e);
|
||||
return true;
|
||||
});
|
||||
|
||||
let auth = false;
|
||||
// no need to check auth if api is down
|
||||
if (api) {
|
||||
auth =
|
||||
(await this.tokenPromise?.()
|
||||
.then(token => !!token)
|
||||
.catch(e => {
|
||||
console.warn('Failed getting Identity token', e);
|
||||
return false;
|
||||
})) || false;
|
||||
}
|
||||
|
||||
return { auth: { status: auth }, api: { status: api, statusPage: STATUS_PAGE } };
|
||||
}
|
||||
|
||||
async getAuthClient() {
|
||||
if (this.authClient) {
|
||||
return this.authClient;
|
||||
}
|
||||
await initPromise;
|
||||
if (window.netlifyIdentity) {
|
||||
this.authClient = {
|
||||
logout: () => window.netlifyIdentity?.logout(),
|
||||
currentUser: () => window.netlifyIdentity?.currentUser(),
|
||||
clearStore: () => {
|
||||
const store = window.netlifyIdentity?.store;
|
||||
if (store) {
|
||||
store.user = null;
|
||||
store.modal.page = 'login';
|
||||
store.saving = false;
|
||||
}
|
||||
},
|
||||
};
|
||||
} else {
|
||||
const goTrue = new GoTrue({ APIUrl: this.apiUrl });
|
||||
this.authClient = {
|
||||
logout: () => {
|
||||
const user = goTrue.currentUser();
|
||||
if (user) {
|
||||
return user.logout();
|
||||
}
|
||||
},
|
||||
currentUser: () => goTrue.currentUser(),
|
||||
login: goTrue.login.bind(goTrue),
|
||||
clearStore: () => undefined,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
requestFunction = (req: ApiRequest) =>
|
||||
this.tokenPromise!()
|
||||
.then(
|
||||
token => unsentRequest.withHeaders({ Authorization: `Bearer ${token}` }, req) as ApiRequest,
|
||||
)
|
||||
.then(unsentRequest.performRequest);
|
||||
|
||||
authenticate(credentials: Credentials) {
|
||||
const user = credentials as NetlifyUser;
|
||||
this.tokenPromise = async () => {
|
||||
try {
|
||||
const func = user.jwt.bind(user);
|
||||
const token = await func();
|
||||
return token;
|
||||
} catch (error: unknown) {
|
||||
if (error instanceof Error) {
|
||||
throw new AccessTokenError(`Failed getting access token: ${error.message}`);
|
||||
}
|
||||
|
||||
throw new AccessTokenError('Failed getting access token');
|
||||
}
|
||||
};
|
||||
return this.tokenPromise!().then(async token => {
|
||||
if (!this.backendType) {
|
||||
const {
|
||||
github_enabled: githubEnabled,
|
||||
gitlab_enabled: gitlabEnabled,
|
||||
bitbucket_enabled: bitbucketEnabled,
|
||||
roles,
|
||||
} = await unsentRequest
|
||||
.fetchWithTimeout(`${this.gatewayUrl}/settings`, {
|
||||
headers: { Authorization: `Bearer ${token}` },
|
||||
})
|
||||
.then(async res => {
|
||||
const contentType = res.headers.get('Content-Type') || '';
|
||||
if (!contentType.includes('application/json') && !contentType.includes('text/json')) {
|
||||
throw new APIError(
|
||||
`Your Git Gateway backend is not returning valid settings. Please make sure it is enabled.`,
|
||||
res.status,
|
||||
'Git Gateway',
|
||||
);
|
||||
}
|
||||
const body = await res.json();
|
||||
|
||||
if (!res.ok) {
|
||||
throw new APIError(
|
||||
`Git Gateway Error: ${body.message ? body.message : body}`,
|
||||
res.status,
|
||||
'Git Gateway',
|
||||
);
|
||||
}
|
||||
|
||||
return body;
|
||||
});
|
||||
this.acceptRoles = roles;
|
||||
if (githubEnabled) {
|
||||
this.backendType = 'github';
|
||||
} else if (gitlabEnabled) {
|
||||
this.backendType = 'gitlab';
|
||||
} else if (bitbucketEnabled) {
|
||||
this.backendType = 'bitbucket';
|
||||
}
|
||||
}
|
||||
|
||||
if (this.acceptRoles && this.acceptRoles.length > 0) {
|
||||
const userRoles = get(jwtDecode(token), 'app_metadata.roles', []);
|
||||
const validRole = intersection(userRoles, this.acceptRoles).length > 0;
|
||||
if (!validRole) {
|
||||
throw new Error("You don't have sufficient permissions to access Static CMS");
|
||||
}
|
||||
}
|
||||
|
||||
const userData = {
|
||||
name: user.user_metadata.full_name || user.email.split('@').shift()!,
|
||||
email: user.email,
|
||||
avatar_url: user.user_metadata.avatar_url,
|
||||
metadata: user.user_metadata,
|
||||
};
|
||||
const apiConfig = {
|
||||
apiRoot: `${this.gatewayUrl}/${this.backendType}`,
|
||||
branch: this.branch,
|
||||
tokenPromise: this.tokenPromise!,
|
||||
commitAuthor: pick(userData, ['name', 'email']),
|
||||
isLargeMedia: (filename: string) => this.isLargeMediaFile(filename),
|
||||
};
|
||||
|
||||
if (this.backendType === 'github') {
|
||||
this.api = new GitHubAPI(apiConfig);
|
||||
this.backend = new GitHubBackend(this.config, { ...this.options, API: this.api });
|
||||
} else if (this.backendType === 'gitlab') {
|
||||
this.api = new GitLabAPI(apiConfig);
|
||||
this.backend = new GitLabBackend(this.config, { ...this.options, API: this.api });
|
||||
} else if (this.backendType === 'bitbucket') {
|
||||
this.api = new BitBucketAPI({
|
||||
...apiConfig,
|
||||
requestFunction: this.requestFunction,
|
||||
hasWriteAccess: async () => true,
|
||||
});
|
||||
this.backend = new BitbucketBackend(this.config, { ...this.options, API: this.api });
|
||||
}
|
||||
|
||||
if (!(await this.api!.hasWriteAccess())) {
|
||||
throw new Error("You don't have sufficient permissions to access Static CMS");
|
||||
}
|
||||
return { name: userData.name, login: userData.email } as User;
|
||||
});
|
||||
}
|
||||
async restoreUser() {
|
||||
const client = await this.getAuthClient();
|
||||
const user = client?.currentUser();
|
||||
if (!user) {
|
||||
return Promise.reject();
|
||||
}
|
||||
return this.authenticate(user as Credentials);
|
||||
}
|
||||
|
||||
authComponent() {
|
||||
const WrappedAuthenticationPage = (props: TranslatedProps<AuthenticationPageProps>) => {
|
||||
const handleAuth = useCallback(
|
||||
async (email: string, password: string): Promise<User | string> => {
|
||||
try {
|
||||
const authClient = await this.getAuthClient();
|
||||
if (!authClient) {
|
||||
return 'Auth client not started';
|
||||
}
|
||||
|
||||
if (!authClient.login) {
|
||||
return 'Auth client login function not found';
|
||||
}
|
||||
|
||||
return authClient.login(email, password, true);
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
} catch (error: any) {
|
||||
return error.description || error.msg || error;
|
||||
}
|
||||
},
|
||||
[],
|
||||
);
|
||||
|
||||
return <AuthenticationPage {...props} handleAuth={handleAuth} />;
|
||||
};
|
||||
WrappedAuthenticationPage.displayName = 'AuthenticationPage';
|
||||
return WrappedAuthenticationPage;
|
||||
}
|
||||
|
||||
async logout() {
|
||||
const client = await this.getAuthClient();
|
||||
try {
|
||||
client?.logout();
|
||||
} catch (e) {
|
||||
console.error(e);
|
||||
}
|
||||
}
|
||||
getToken() {
|
||||
return this.tokenPromise!();
|
||||
}
|
||||
|
||||
async entriesByFolder(folder: string, extension: string, depth: number) {
|
||||
return this.backend!.entriesByFolder(folder, extension, depth);
|
||||
}
|
||||
allEntriesByFolder(folder: string, extension: string, depth: number) {
|
||||
return this.backend!.allEntriesByFolder(folder, extension, depth);
|
||||
}
|
||||
entriesByFiles(files: ImplementationFile[]) {
|
||||
return this.backend!.entriesByFiles(files);
|
||||
}
|
||||
getEntry(path: string) {
|
||||
return this.backend!.getEntry(path);
|
||||
}
|
||||
|
||||
async isLargeMediaFile(path: string) {
|
||||
const client = await this.getLargeMediaClient();
|
||||
return client.enabled && client.matchPath(path);
|
||||
}
|
||||
|
||||
getMedia(mediaFolder = this.mediaFolder) {
|
||||
return this.backend!.getMedia(mediaFolder);
|
||||
}
|
||||
|
||||
// this method memoizes this._getLargeMediaClient so that there can
|
||||
// only be one client at a time
|
||||
getLargeMediaClient() {
|
||||
if (this._largeMediaClientPromise) {
|
||||
return this._largeMediaClientPromise;
|
||||
}
|
||||
this._largeMediaClientPromise = this._getLargeMediaClient();
|
||||
return this._largeMediaClientPromise;
|
||||
}
|
||||
_getLargeMediaClient() {
|
||||
const netlifyLargeMediaEnabledPromise = this.api!.readFile('.lfsconfig')
|
||||
.then(config => ini.decode<{ lfs: { url: string } }>(config as string))
|
||||
.then(({ lfs: { url } }) => new URL(url))
|
||||
.then(lfsURL => ({
|
||||
enabled: lfsURL.hostname.endsWith('netlify.com') || lfsURL.hostname.endsWith('netlify.app'),
|
||||
}))
|
||||
.catch((err: Error) => ({ enabled: false, err }));
|
||||
|
||||
const lfsPatternsPromise = this.api!.readFile('.gitattributes')
|
||||
.then(attributes => getLargeMediaPatternsFromGitAttributesFile(attributes as string))
|
||||
.then((patterns: string[]) => ({ err: null, patterns }))
|
||||
.catch((err: Error) => {
|
||||
if (err.message.includes('404')) {
|
||||
console.info('This 404 was expected and handled appropriately.');
|
||||
return { err: null, patterns: [] as string[] };
|
||||
} else {
|
||||
return { err, patterns: [] as string[] };
|
||||
}
|
||||
});
|
||||
|
||||
return Promise.all([netlifyLargeMediaEnabledPromise, lfsPatternsPromise]).then(
|
||||
([{ enabled: maybeEnabled }, { patterns, err: patternsErr }]) => {
|
||||
const enabled = maybeEnabled && !patternsErr;
|
||||
|
||||
// We expect LFS patterns to exist when the .lfsconfig states
|
||||
// that we're using Netlify Large Media
|
||||
if (maybeEnabled && patternsErr) {
|
||||
console.error(patternsErr);
|
||||
}
|
||||
|
||||
return getClient({
|
||||
enabled,
|
||||
rootURL: this.netlifyLargeMediaURL,
|
||||
makeAuthorizedRequest: this.requestFunction,
|
||||
patterns,
|
||||
transformImages: this.transformImages ? { nf_resize: 'fit', w: 560, h: 320 } : false,
|
||||
});
|
||||
},
|
||||
);
|
||||
}
|
||||
async getLargeMediaDisplayURL(
|
||||
{ path, id }: { path: string; id: string | null },
|
||||
branch = this.branch,
|
||||
) {
|
||||
const readFile = (
|
||||
path: string,
|
||||
id: string | null | undefined,
|
||||
{ parseText }: { parseText: boolean },
|
||||
) => this.api!.readFile(path, id, { branch, parseText });
|
||||
|
||||
const items = await entriesByFiles(
|
||||
[{ path, id }],
|
||||
readFile,
|
||||
this.api!.readFileMetadata.bind(this.api),
|
||||
'Git-Gateway',
|
||||
);
|
||||
const entry = items[0];
|
||||
const pointerFile = parsePointerFile(entry.data);
|
||||
if (!pointerFile.sha) {
|
||||
console.warn(`Failed parsing pointer file ${path}`);
|
||||
return { url: path, blob: new Blob() };
|
||||
}
|
||||
|
||||
const client = await this.getLargeMediaClient();
|
||||
const { url, blob } = await client.getDownloadURL(pointerFile);
|
||||
return { url, blob };
|
||||
}
|
||||
|
||||
async getMediaDisplayURL(displayURL: DisplayURL) {
|
||||
const { path, id } = displayURL as DisplayURLObject;
|
||||
const isLargeMedia = await this.isLargeMediaFile(path);
|
||||
if (isLargeMedia) {
|
||||
const { url } = await this.getLargeMediaDisplayURL({ path, id });
|
||||
return url;
|
||||
}
|
||||
if (typeof displayURL === 'string') {
|
||||
return displayURL;
|
||||
}
|
||||
|
||||
const url = await this.backend!.getMediaDisplayURL(displayURL);
|
||||
return url;
|
||||
}
|
||||
|
||||
async getMediaFile(path: string) {
|
||||
const isLargeMedia = await this.isLargeMediaFile(path);
|
||||
if (isLargeMedia) {
|
||||
const { url, blob } = await this.getLargeMediaDisplayURL({ path, id: null });
|
||||
const name = basename(path);
|
||||
return {
|
||||
id: url,
|
||||
name,
|
||||
path,
|
||||
url,
|
||||
displayURL: url,
|
||||
file: new File([blob], name),
|
||||
size: blob.size,
|
||||
};
|
||||
}
|
||||
return this.backend!.getMediaFile(path);
|
||||
}
|
||||
|
||||
async persistEntry(entry: BackendEntry, options: PersistOptions) {
|
||||
const client = await this.getLargeMediaClient();
|
||||
if (client.enabled) {
|
||||
const assets = (await getLargeMediaFilteredMediaFiles(client, entry.assets)) as any;
|
||||
return this.backend!.persistEntry({ ...entry, assets }, options);
|
||||
} else {
|
||||
return this.backend!.persistEntry(entry, options);
|
||||
}
|
||||
}
|
||||
|
||||
async persistMedia(mediaFile: AssetProxy, options: PersistOptions) {
|
||||
const { fileObj, path } = mediaFile;
|
||||
const displayURL = URL.createObjectURL(fileObj as Blob);
|
||||
const client = await this.getLargeMediaClient();
|
||||
const fixedPath = path.startsWith('/') ? path.slice(1) : path;
|
||||
const isLargeMedia = await this.isLargeMediaFile(fixedPath);
|
||||
if (isLargeMedia) {
|
||||
const persistMediaArgument = (await getPointerFileForMediaFileObj(
|
||||
client,
|
||||
fileObj as File,
|
||||
path,
|
||||
)) as any;
|
||||
return {
|
||||
...(await this.backend!.persistMedia(persistMediaArgument, options)),
|
||||
displayURL,
|
||||
};
|
||||
}
|
||||
return await this.backend!.persistMedia(mediaFile, options);
|
||||
}
|
||||
deleteFiles(paths: string[], commitMessage: string) {
|
||||
return this.backend!.deleteFiles(paths, commitMessage);
|
||||
}
|
||||
traverseCursor(cursor: Cursor, action: string) {
|
||||
return this.backend!.traverseCursor!(cursor, action);
|
||||
}
|
||||
}
|
2
core/src/backends/git-gateway/index.ts
Normal file
2
core/src/backends/git-gateway/index.ts
Normal file
@ -0,0 +1,2 @@
|
||||
export { default as GitGatewayBackend } from './implementation';
|
||||
export { default as AuthenticationPage } from './AuthenticationPage';
|
181
core/src/backends/git-gateway/netlify-lfs-client.ts
Normal file
181
core/src/backends/git-gateway/netlify-lfs-client.ts
Normal file
@ -0,0 +1,181 @@
|
||||
import { flow, fromPairs, map } from 'lodash/fp';
|
||||
import isPlainObject from 'lodash/isPlainObject';
|
||||
import isEmpty from 'lodash/isEmpty';
|
||||
import minimatch from 'minimatch';
|
||||
|
||||
import { unsentRequest } from '../../lib/util';
|
||||
|
||||
import type { ApiRequest, PointerFile } from '../../lib/util';
|
||||
|
||||
type MakeAuthorizedRequest = (req: ApiRequest) => Promise<Response>;
|
||||
|
||||
type ImageTransformations = { nf_resize: string; w: number; h: number };
|
||||
|
||||
type ClientConfig = {
|
||||
rootURL: string;
|
||||
makeAuthorizedRequest: MakeAuthorizedRequest;
|
||||
patterns: string[];
|
||||
enabled: boolean;
|
||||
transformImages: ImageTransformations | boolean;
|
||||
};
|
||||
|
||||
export function matchPath({ patterns }: ClientConfig, path: string) {
|
||||
return patterns.some(pattern => minimatch(path, pattern, { matchBase: true }));
|
||||
}
|
||||
|
||||
//
|
||||
// API interactions
|
||||
|
||||
const defaultContentHeaders = {
|
||||
Accept: 'application/vnd.git-lfs+json',
|
||||
['Content-Type']: 'application/vnd.git-lfs+json',
|
||||
};
|
||||
|
||||
async function resourceExists(
|
||||
{ rootURL, makeAuthorizedRequest }: ClientConfig,
|
||||
{ sha, size }: PointerFile,
|
||||
) {
|
||||
const response = await makeAuthorizedRequest({
|
||||
url: `${rootURL}/verify`,
|
||||
method: 'POST',
|
||||
headers: defaultContentHeaders,
|
||||
body: JSON.stringify({ oid: sha, size }),
|
||||
});
|
||||
if (response.ok) {
|
||||
return true;
|
||||
}
|
||||
if (response.status === 404) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// TODO: what kind of error to throw here? APIError doesn't seem to fit
|
||||
}
|
||||
|
||||
function getTransofrmationsParams(t: boolean | ImageTransformations) {
|
||||
if (isPlainObject(t) && !isEmpty(t)) {
|
||||
const { nf_resize: resize, w, h } = t as ImageTransformations;
|
||||
return `?nf_resize=${resize}&w=${w}&h=${h}`;
|
||||
}
|
||||
return '';
|
||||
}
|
||||
|
||||
async function getDownloadURL(
|
||||
{ rootURL, transformImages: t, makeAuthorizedRequest }: ClientConfig,
|
||||
{ sha }: PointerFile,
|
||||
) {
|
||||
try {
|
||||
const transformation = getTransofrmationsParams(t);
|
||||
const transformedPromise = makeAuthorizedRequest(`${rootURL}/origin/${sha}${transformation}`);
|
||||
const [transformed, original] = await Promise.all([
|
||||
transformedPromise,
|
||||
// if transformation is defined, we need to load the original so we have the correct meta data
|
||||
transformation ? makeAuthorizedRequest(`${rootURL}/origin/${sha}`) : transformedPromise,
|
||||
]);
|
||||
if (!transformed.ok) {
|
||||
const error = await transformed.json();
|
||||
throw new Error(
|
||||
`Failed getting large media for sha '${sha}': '${error.code} - ${error.msg}'`,
|
||||
);
|
||||
}
|
||||
|
||||
const transformedBlob = await transformed.blob();
|
||||
const url = URL.createObjectURL(transformedBlob);
|
||||
return { url, blob: transformation ? await original.blob() : transformedBlob };
|
||||
} catch (error) {
|
||||
console.error(error);
|
||||
return { url: '', blob: new Blob() };
|
||||
}
|
||||
}
|
||||
|
||||
function uploadOperation(objects: PointerFile[]) {
|
||||
return {
|
||||
operation: 'upload',
|
||||
transfers: ['basic'],
|
||||
objects: objects.map(({ sha, ...rest }) => ({ ...rest, oid: sha })),
|
||||
};
|
||||
}
|
||||
|
||||
async function getResourceUploadURLs(
|
||||
{
|
||||
rootURL,
|
||||
makeAuthorizedRequest,
|
||||
}: { rootURL: string; makeAuthorizedRequest: MakeAuthorizedRequest },
|
||||
pointerFiles: PointerFile[],
|
||||
) {
|
||||
const response = await makeAuthorizedRequest({
|
||||
url: `${rootURL}/objects/batch`,
|
||||
method: 'POST',
|
||||
headers: defaultContentHeaders,
|
||||
body: JSON.stringify(uploadOperation(pointerFiles)),
|
||||
});
|
||||
|
||||
const { objects } = await response.json();
|
||||
const uploadUrls = objects.map(
|
||||
(object: { error?: { message: string }; actions: { upload: { href: string } } }) => {
|
||||
if (object.error) {
|
||||
throw new Error(object.error.message);
|
||||
}
|
||||
return object.actions.upload.href;
|
||||
},
|
||||
);
|
||||
return uploadUrls;
|
||||
}
|
||||
|
||||
function uploadBlob(uploadURL: string, blob: Blob) {
|
||||
return unsentRequest.fetchWithTimeout(uploadURL, {
|
||||
method: 'PUT',
|
||||
body: blob,
|
||||
});
|
||||
}
|
||||
|
||||
async function uploadResource(
|
||||
clientConfig: ClientConfig,
|
||||
{ sha, size }: PointerFile,
|
||||
resource: Blob,
|
||||
) {
|
||||
const existingFile = await resourceExists(clientConfig, { sha, size });
|
||||
if (existingFile) {
|
||||
return sha;
|
||||
}
|
||||
const [uploadURL] = await getResourceUploadURLs(clientConfig, [{ sha, size }]);
|
||||
await uploadBlob(uploadURL, resource);
|
||||
return sha;
|
||||
}
|
||||
|
||||
//
|
||||
// Create Large Media client
|
||||
|
||||
function configureFn(config: ClientConfig, fn: Function) {
|
||||
return (...args: unknown[]) => fn(config, ...args);
|
||||
}
|
||||
|
||||
const clientFns: Record<string, Function> = {
|
||||
resourceExists,
|
||||
getResourceUploadURLs,
|
||||
getDownloadURL,
|
||||
uploadResource,
|
||||
matchPath,
|
||||
};
|
||||
|
||||
export type Client = {
|
||||
resourceExists: (pointer: PointerFile) => Promise<boolean | undefined>;
|
||||
getResourceUploadURLs: (objects: PointerFile[]) => Promise<string>;
|
||||
getDownloadURL: (pointer: PointerFile) => Promise<{ url: string; blob: Blob }>;
|
||||
uploadResource: (pointer: PointerFile, blob: Blob) => Promise<string>;
|
||||
matchPath: (path: string) => boolean;
|
||||
patterns: string[];
|
||||
enabled: boolean;
|
||||
};
|
||||
|
||||
export function getClient(clientConfig: ClientConfig) {
|
||||
return flow([
|
||||
Object.keys,
|
||||
map((key: string) => [key, configureFn(clientConfig, clientFns[key])]),
|
||||
fromPairs,
|
||||
configuredFns => ({
|
||||
...configuredFns,
|
||||
patterns: clientConfig.patterns,
|
||||
enabled: clientConfig.enabled,
|
||||
}),
|
||||
])(clientFns);
|
||||
}
|
542
core/src/backends/github/API.ts
Normal file
542
core/src/backends/github/API.ts
Normal file
@ -0,0 +1,542 @@
|
||||
import { Base64 } from 'js-base64';
|
||||
import initial from 'lodash/initial';
|
||||
import last from 'lodash/last';
|
||||
import partial from 'lodash/partial';
|
||||
import result from 'lodash/result';
|
||||
import trim from 'lodash/trim';
|
||||
import trimStart from 'lodash/trimStart';
|
||||
import { dirname } from 'path';
|
||||
|
||||
import {
|
||||
APIError,
|
||||
basename,
|
||||
generateContentKey,
|
||||
getAllResponses,
|
||||
localForage,
|
||||
parseContentKey,
|
||||
readFileMetadata,
|
||||
requestWithBackoff,
|
||||
unsentRequest,
|
||||
} from '../../lib/util';
|
||||
|
||||
import type { Octokit } from '@octokit/rest';
|
||||
import type { Semaphore } from 'semaphore';
|
||||
import type { DataFile, PersistOptions } from '../../interface';
|
||||
import type { ApiRequest, FetchError } from '../../lib/util';
|
||||
import type AssetProxy from '../../valueObjects/AssetProxy';
|
||||
|
||||
type GitHubUser = Octokit.UsersGetAuthenticatedResponse;
|
||||
type GitCreateTreeParamsTree = Octokit.GitCreateTreeParamsTree;
|
||||
type GitHubAuthor = Octokit.GitCreateCommitResponseAuthor;
|
||||
type GitHubCommitter = Octokit.GitCreateCommitResponseCommitter;
|
||||
|
||||
export const API_NAME = 'GitHub';
|
||||
|
||||
export interface Config {
|
||||
apiRoot?: string;
|
||||
token?: string;
|
||||
branch?: string;
|
||||
repo?: string;
|
||||
originRepo?: string;
|
||||
}
|
||||
|
||||
type Override<T, U> = Pick<T, Exclude<keyof T, keyof U>> & U;
|
||||
|
||||
type TreeEntry = Override<GitCreateTreeParamsTree, { sha: string | null }>;
|
||||
|
||||
interface MetaDataObjects {
|
||||
entry: { path: string; sha: string };
|
||||
files: MediaFile[];
|
||||
}
|
||||
|
||||
export interface Metadata {
|
||||
type: string;
|
||||
objects: MetaDataObjects;
|
||||
branch: string;
|
||||
status: string;
|
||||
collection: string;
|
||||
commitMessage: string;
|
||||
version?: string;
|
||||
user: string;
|
||||
title?: string;
|
||||
description?: string;
|
||||
timeStamp: string;
|
||||
}
|
||||
|
||||
export interface BlobArgs {
|
||||
sha: string;
|
||||
repoURL: string;
|
||||
parseText: boolean;
|
||||
}
|
||||
|
||||
type Param = string | number | undefined;
|
||||
|
||||
type Options = RequestInit & { params?: Record<string, Param | Record<string, Param> | string[]> };
|
||||
|
||||
type MediaFile = {
|
||||
sha: string;
|
||||
path: string;
|
||||
};
|
||||
|
||||
export type Diff = {
|
||||
path: string;
|
||||
newFile: boolean;
|
||||
sha: string;
|
||||
binary: boolean;
|
||||
};
|
||||
|
||||
export default class API {
|
||||
apiRoot: string;
|
||||
token: string;
|
||||
branch: string;
|
||||
repo: string;
|
||||
originRepo: string;
|
||||
repoOwner: string;
|
||||
repoName: string;
|
||||
originRepoOwner: string;
|
||||
originRepoName: string;
|
||||
repoURL: string;
|
||||
originRepoURL: string;
|
||||
|
||||
_userPromise?: Promise<GitHubUser>;
|
||||
_metadataSemaphore?: Semaphore;
|
||||
|
||||
commitAuthor?: {};
|
||||
|
||||
constructor(config: Config) {
|
||||
this.apiRoot = config.apiRoot || 'https://api.github.com';
|
||||
this.token = config.token || '';
|
||||
this.branch = config.branch || 'main';
|
||||
this.repo = config.repo || '';
|
||||
this.originRepo = config.originRepo || this.repo;
|
||||
this.repoURL = `/repos/${this.repo}`;
|
||||
this.originRepoURL = `/repos/${this.originRepo}`;
|
||||
|
||||
const [repoParts, originRepoParts] = [this.repo.split('/'), this.originRepo.split('/')];
|
||||
this.repoOwner = repoParts[0];
|
||||
this.repoName = repoParts[1];
|
||||
|
||||
this.originRepoOwner = originRepoParts[0];
|
||||
this.originRepoName = originRepoParts[1];
|
||||
}
|
||||
|
||||
static DEFAULT_COMMIT_MESSAGE = 'Automatically generated by Static CMS';
|
||||
|
||||
user(): Promise<{ name: string; login: string }> {
|
||||
if (!this._userPromise) {
|
||||
this._userPromise = this.getUser();
|
||||
}
|
||||
return this._userPromise;
|
||||
}
|
||||
|
||||
getUser() {
|
||||
return this.request('/user') as Promise<GitHubUser>;
|
||||
}
|
||||
|
||||
async hasWriteAccess() {
|
||||
try {
|
||||
const result: Octokit.ReposGetResponse = await this.request(this.repoURL);
|
||||
// update config repoOwner to avoid case sensitivity issues with GitHub
|
||||
this.repoOwner = result.owner.login;
|
||||
return result.permissions.push;
|
||||
} catch (error) {
|
||||
console.error('Problem fetching repo data from GitHub');
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
reset() {
|
||||
// no op
|
||||
}
|
||||
|
||||
requestHeaders(headers = {}) {
|
||||
const baseHeader: Record<string, string> = {
|
||||
'Content-Type': 'application/json; charset=utf-8',
|
||||
...headers,
|
||||
};
|
||||
|
||||
if (this.token) {
|
||||
baseHeader.Authorization = `token ${this.token}`;
|
||||
return Promise.resolve(baseHeader);
|
||||
}
|
||||
|
||||
return Promise.resolve(baseHeader);
|
||||
}
|
||||
|
||||
parseJsonResponse(response: Response) {
|
||||
return response.json().then(json => {
|
||||
if (!response.ok) {
|
||||
return Promise.reject(json);
|
||||
}
|
||||
|
||||
return json;
|
||||
});
|
||||
}
|
||||
|
||||
urlFor(path: string, options: Options) {
|
||||
const params = [];
|
||||
if (options.params) {
|
||||
for (const key in options.params) {
|
||||
params.push(`${key}=${encodeURIComponent(options.params[key] as string)}`);
|
||||
}
|
||||
}
|
||||
if (params.length) {
|
||||
path += `?${params.join('&')}`;
|
||||
}
|
||||
return this.apiRoot + path;
|
||||
}
|
||||
|
||||
parseResponse(response: Response) {
|
||||
const contentType = response.headers.get('Content-Type');
|
||||
if (contentType && contentType.match(/json/)) {
|
||||
return this.parseJsonResponse(response);
|
||||
}
|
||||
const textPromise = response.text().then(text => {
|
||||
if (!response.ok) {
|
||||
return Promise.reject(text);
|
||||
}
|
||||
return text;
|
||||
});
|
||||
return textPromise;
|
||||
}
|
||||
|
||||
handleRequestError(error: FetchError, responseStatus: number) {
|
||||
throw new APIError(error.message, responseStatus, API_NAME);
|
||||
}
|
||||
|
||||
buildRequest(req: ApiRequest) {
|
||||
return req;
|
||||
}
|
||||
|
||||
async request(
|
||||
path: string,
|
||||
options: Options = {},
|
||||
parser = (response: Response) => this.parseResponse(response),
|
||||
) {
|
||||
options = { cache: 'no-cache', ...options };
|
||||
const headers = await this.requestHeaders(options.headers || {});
|
||||
const url = this.urlFor(path, options);
|
||||
let responseStatus = 500;
|
||||
|
||||
try {
|
||||
const req = unsentRequest.fromFetchArguments(url, {
|
||||
...options,
|
||||
headers,
|
||||
}) as unknown as ApiRequest;
|
||||
const response = await requestWithBackoff(this, req);
|
||||
responseStatus = response.status;
|
||||
const parsedResponse = await parser(response);
|
||||
return parsedResponse;
|
||||
} catch (error: any) {
|
||||
return this.handleRequestError(error, responseStatus);
|
||||
}
|
||||
}
|
||||
|
||||
nextUrlProcessor() {
|
||||
return (url: string) => url;
|
||||
}
|
||||
|
||||
async requestAllPages<T>(url: string, options: Options = {}) {
|
||||
options = { cache: 'no-cache', ...options };
|
||||
const headers = await this.requestHeaders(options.headers || {});
|
||||
const processedURL = this.urlFor(url, options);
|
||||
const allResponses = await getAllResponses(
|
||||
processedURL,
|
||||
{ ...options, headers },
|
||||
'next',
|
||||
this.nextUrlProcessor(),
|
||||
);
|
||||
const pages: T[][] = await Promise.all(
|
||||
allResponses.map((res: Response) => this.parseResponse(res)),
|
||||
);
|
||||
return ([] as T[]).concat(...pages);
|
||||
}
|
||||
|
||||
generateContentKey(collectionName: string, slug: string) {
|
||||
return generateContentKey(collectionName, slug);
|
||||
}
|
||||
|
||||
parseContentKey(contentKey: string) {
|
||||
return parseContentKey(contentKey);
|
||||
}
|
||||
|
||||
async readFile(
|
||||
path: string,
|
||||
sha?: string | null,
|
||||
{
|
||||
branch = this.branch,
|
||||
repoURL = this.repoURL,
|
||||
parseText = true,
|
||||
}: {
|
||||
branch?: string;
|
||||
repoURL?: string;
|
||||
parseText?: boolean;
|
||||
} = {},
|
||||
) {
|
||||
if (!sha) {
|
||||
sha = await this.getFileSha(path, { repoURL, branch });
|
||||
}
|
||||
const content = await this.fetchBlobContent({ sha: sha as string, repoURL, parseText });
|
||||
return content;
|
||||
}
|
||||
|
||||
async readFileMetadata(path: string, sha: string | null | undefined) {
|
||||
const fetchFileMetadata = async () => {
|
||||
try {
|
||||
const result: Octokit.ReposListCommitsResponse = await this.request(
|
||||
`${this.originRepoURL}/commits`,
|
||||
{
|
||||
params: { path, sha: this.branch },
|
||||
},
|
||||
);
|
||||
const { commit } = result[0];
|
||||
return {
|
||||
author: commit.author.name || commit.author.email,
|
||||
updatedOn: commit.author.date,
|
||||
};
|
||||
} catch (e) {
|
||||
return { author: '', updatedOn: '' };
|
||||
}
|
||||
};
|
||||
const fileMetadata = await readFileMetadata(sha, fetchFileMetadata, localForage);
|
||||
return fileMetadata;
|
||||
}
|
||||
|
||||
async fetchBlobContent({ sha, repoURL, parseText }: BlobArgs) {
|
||||
const result: Octokit.GitGetBlobResponse = await this.request(`${repoURL}/git/blobs/${sha}`, {
|
||||
cache: 'force-cache',
|
||||
});
|
||||
|
||||
if (parseText) {
|
||||
// treat content as a utf-8 string
|
||||
const content = Base64.decode(result.content);
|
||||
return content;
|
||||
} else {
|
||||
// treat content as binary and convert to blob
|
||||
const content = Base64.atob(result.content);
|
||||
const byteArray = new Uint8Array(content.length);
|
||||
for (let i = 0; i < content.length; i++) {
|
||||
byteArray[i] = content.charCodeAt(i);
|
||||
}
|
||||
const blob = new Blob([byteArray]);
|
||||
return blob;
|
||||
}
|
||||
}
|
||||
|
||||
async listFiles(
|
||||
path: string,
|
||||
{ repoURL = this.repoURL, branch = this.branch, depth = 1 } = {},
|
||||
): Promise<{ type: string; id: string; name: string; path: string; size: number }[]> {
|
||||
const folder = trim(path, '/');
|
||||
try {
|
||||
const result: Octokit.GitGetTreeResponse = await this.request(
|
||||
`${repoURL}/git/trees/${branch}:${folder}`,
|
||||
{
|
||||
// GitHub API supports recursive=1 for getting the entire recursive tree
|
||||
// or omitting it to get the non-recursive tree
|
||||
params: depth > 1 ? { recursive: 1 } : {},
|
||||
},
|
||||
);
|
||||
return (
|
||||
result.tree
|
||||
// filter only files and up to the required depth
|
||||
.filter(file => file.type === 'blob' && file.path.split('/').length <= depth)
|
||||
.map(file => ({
|
||||
type: file.type,
|
||||
id: file.sha,
|
||||
name: basename(file.path),
|
||||
path: `${folder}/${file.path}`,
|
||||
size: file.size!,
|
||||
}))
|
||||
);
|
||||
} catch (err: any) {
|
||||
if (err && err.status === 404) {
|
||||
console.info('This 404 was expected and handled appropriately.');
|
||||
return [];
|
||||
} else {
|
||||
throw err;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async persistFiles(dataFiles: DataFile[], mediaFiles: AssetProxy[], options: PersistOptions) {
|
||||
const files: (DataFile | AssetProxy)[] = mediaFiles.concat(dataFiles as any);
|
||||
const uploadPromises = files.map(file => this.uploadBlob(file));
|
||||
await Promise.all(uploadPromises);
|
||||
|
||||
return this.getDefaultBranch()
|
||||
.then(branchData => this.updateTree(branchData.commit.sha, files as any))
|
||||
.then(changeTree => this.commit(options.commitMessage, changeTree))
|
||||
.then(response => this.patchBranch(this.branch, response.sha));
|
||||
}
|
||||
|
||||
async getFileSha(path: string, { repoURL = this.repoURL, branch = this.branch } = {}) {
|
||||
/**
|
||||
* We need to request the tree first to get the SHA. We use extended SHA-1
|
||||
* syntax (<rev>:<path>) to get a blob from a tree without having to recurse
|
||||
* through the tree.
|
||||
*/
|
||||
|
||||
const pathArray = path.split('/');
|
||||
const filename = last(pathArray);
|
||||
const directory = initial(pathArray).join('/');
|
||||
const fileDataPath = encodeURIComponent(directory);
|
||||
const fileDataURL = `${repoURL}/git/trees/${branch}:${fileDataPath}`;
|
||||
|
||||
const result: Octokit.GitGetTreeResponse = await this.request(fileDataURL);
|
||||
const file = result.tree.find(file => file.path === filename);
|
||||
if (file) {
|
||||
return file.sha;
|
||||
} else {
|
||||
throw new APIError('Not Found', 404, API_NAME);
|
||||
}
|
||||
}
|
||||
|
||||
async deleteFiles(paths: string[], message: string) {
|
||||
const branchData = await this.getDefaultBranch();
|
||||
const files = paths.map(path => ({ path, sha: null }));
|
||||
const changeTree = await this.updateTree(branchData.commit.sha, files);
|
||||
const commit = await this.commit(message, changeTree);
|
||||
await this.patchBranch(this.branch, commit.sha);
|
||||
}
|
||||
|
||||
async createRef(type: string, name: string, sha: string) {
|
||||
const result: Octokit.GitCreateRefResponse = await this.request(`${this.repoURL}/git/refs`, {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({ ref: `refs/${type}/${name}`, sha }),
|
||||
});
|
||||
return result;
|
||||
}
|
||||
|
||||
async patchRef(type: string, name: string, sha: string) {
|
||||
const result: Octokit.GitUpdateRefResponse = await this.request(
|
||||
`${this.repoURL}/git/refs/${type}/${encodeURIComponent(name)}`,
|
||||
{
|
||||
method: 'PATCH',
|
||||
body: JSON.stringify({ sha }),
|
||||
},
|
||||
);
|
||||
return result;
|
||||
}
|
||||
|
||||
deleteRef(type: string, name: string) {
|
||||
return this.request(`${this.repoURL}/git/refs/${type}/${encodeURIComponent(name)}`, {
|
||||
method: 'DELETE',
|
||||
});
|
||||
}
|
||||
|
||||
async getDefaultBranch() {
|
||||
const result: Octokit.ReposGetBranchResponse = await this.request(
|
||||
`${this.originRepoURL}/branches/${encodeURIComponent(this.branch)}`,
|
||||
);
|
||||
return result;
|
||||
}
|
||||
|
||||
patchBranch(branchName: string, sha: string) {
|
||||
return this.patchRef('heads', branchName, sha);
|
||||
}
|
||||
|
||||
async getHeadReference(head: string) {
|
||||
return `${this.repoOwner}:${head}`;
|
||||
}
|
||||
|
||||
toBase64(str: string) {
|
||||
return Promise.resolve(Base64.encode(str));
|
||||
}
|
||||
|
||||
async uploadBlob(item: { raw?: string; sha?: string; toBase64?: () => Promise<string> }) {
|
||||
const contentBase64 = await result(
|
||||
item,
|
||||
'toBase64',
|
||||
partial(this.toBase64, item.raw as string),
|
||||
);
|
||||
const response = await this.request(`${this.repoURL}/git/blobs`, {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({
|
||||
content: contentBase64,
|
||||
encoding: 'base64',
|
||||
}),
|
||||
});
|
||||
item.sha = response.sha;
|
||||
return item;
|
||||
}
|
||||
|
||||
async updateTree(
|
||||
baseSha: string,
|
||||
files: { path: string; sha: string | null; newPath?: string }[],
|
||||
branch = this.branch,
|
||||
) {
|
||||
const toMove: { from: string; to: string; sha: string }[] = [];
|
||||
const tree = files.reduce((acc, file) => {
|
||||
const entry = {
|
||||
path: trimStart(file.path, '/'),
|
||||
mode: '100644',
|
||||
type: 'blob',
|
||||
sha: file.sha,
|
||||
} as TreeEntry;
|
||||
|
||||
if (file.newPath) {
|
||||
toMove.push({ from: file.path, to: file.newPath, sha: file.sha as string });
|
||||
} else {
|
||||
acc.push(entry);
|
||||
}
|
||||
|
||||
return acc;
|
||||
}, [] as TreeEntry[]);
|
||||
|
||||
for (const { from, to, sha } of toMove) {
|
||||
const sourceDir = dirname(from);
|
||||
const destDir = dirname(to);
|
||||
const files = await this.listFiles(sourceDir, { branch, depth: 100 });
|
||||
for (const file of files) {
|
||||
// delete current path
|
||||
tree.push({
|
||||
path: file.path,
|
||||
mode: '100644',
|
||||
type: 'blob',
|
||||
sha: null,
|
||||
});
|
||||
// create in new path
|
||||
tree.push({
|
||||
path: file.path.replace(sourceDir, destDir),
|
||||
mode: '100644',
|
||||
type: 'blob',
|
||||
sha: file.path === from ? sha : file.id,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
const newTree = await this.createTree(baseSha, tree);
|
||||
return { ...newTree, parentSha: baseSha };
|
||||
}
|
||||
|
||||
async createTree(baseSha: string, tree: TreeEntry[]) {
|
||||
const result: Octokit.GitCreateTreeResponse = await this.request(`${this.repoURL}/git/trees`, {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({ base_tree: baseSha, tree }),
|
||||
});
|
||||
return result;
|
||||
}
|
||||
|
||||
commit(message: string, changeTree: { parentSha?: string; sha: string }) {
|
||||
const parents = changeTree.parentSha ? [changeTree.parentSha] : [];
|
||||
return this.createCommit(message, changeTree.sha, parents);
|
||||
}
|
||||
|
||||
async createCommit(
|
||||
message: string,
|
||||
treeSha: string,
|
||||
parents: string[],
|
||||
author?: GitHubAuthor,
|
||||
committer?: GitHubCommitter,
|
||||
) {
|
||||
const result: Octokit.GitCreateCommitResponse = await this.request(
|
||||
`${this.repoURL}/git/commits`,
|
||||
{
|
||||
method: 'POST',
|
||||
body: JSON.stringify({ message, tree: treeSha, parents, author, committer }),
|
||||
},
|
||||
);
|
||||
return result;
|
||||
}
|
||||
}
|
64
core/src/backends/github/AuthenticationPage.tsx
Normal file
64
core/src/backends/github/AuthenticationPage.tsx
Normal file
@ -0,0 +1,64 @@
|
||||
import { styled } from '@mui/material/styles';
|
||||
import React, { useCallback, useState } from 'react';
|
||||
|
||||
import AuthenticationPage from '../../components/UI/AuthenticationPage';
|
||||
import Icon from '../../components/UI/Icon';
|
||||
import { NetlifyAuthenticator } from '../../lib/auth';
|
||||
|
||||
import type { MouseEvent } from 'react';
|
||||
import type { AuthenticationPageProps, TranslatedProps } from '../../interface';
|
||||
|
||||
const LoginButtonIcon = styled(Icon)`
|
||||
margin-right: 18px;
|
||||
`;
|
||||
|
||||
const GitHubAuthenticationPage = ({
|
||||
inProgress = false,
|
||||
config,
|
||||
base_url,
|
||||
siteId,
|
||||
authEndpoint,
|
||||
onLogin,
|
||||
t,
|
||||
}: TranslatedProps<AuthenticationPageProps>) => {
|
||||
const [loginError, setLoginError] = useState<string | null>(null);
|
||||
|
||||
const handleLogin = useCallback(
|
||||
(e: MouseEvent<HTMLButtonElement>) => {
|
||||
e.preventDefault();
|
||||
const cfg = {
|
||||
base_url,
|
||||
site_id: document.location.host.split(':')[0] === 'localhost' ? 'cms.netlify.com' : siteId,
|
||||
auth_endpoint: authEndpoint,
|
||||
};
|
||||
const auth = new NetlifyAuthenticator(cfg);
|
||||
|
||||
const { auth_scope: authScope = '' } = config.backend;
|
||||
|
||||
const scope = authScope || 'repo';
|
||||
auth.authenticate({ provider: 'github', scope }, (err, data) => {
|
||||
if (err) {
|
||||
setLoginError(err.toString());
|
||||
} else if (data) {
|
||||
onLogin(data);
|
||||
}
|
||||
});
|
||||
},
|
||||
[authEndpoint, base_url, config.backend, onLogin, siteId],
|
||||
);
|
||||
|
||||
return (
|
||||
<AuthenticationPage
|
||||
onLogin={handleLogin}
|
||||
loginDisabled={inProgress}
|
||||
loginErrorMessage={loginError}
|
||||
logoUrl={config.logo_url}
|
||||
siteUrl={config.site_url}
|
||||
icon={<LoginButtonIcon type="github" />}
|
||||
buttonContent={t('auth.loginWithGitHub')}
|
||||
t={t}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
export default GitHubAuthenticationPage;
|
308
core/src/backends/github/GraphQLAPI.ts
Normal file
308
core/src/backends/github/GraphQLAPI.ts
Normal file
@ -0,0 +1,308 @@
|
||||
import { InMemoryCache, IntrospectionFragmentMatcher } from 'apollo-cache-inmemory';
|
||||
import { ApolloClient } from 'apollo-client';
|
||||
import { setContext } from 'apollo-link-context';
|
||||
import { createHttpLink } from 'apollo-link-http';
|
||||
import trim from 'lodash/trim';
|
||||
import trimStart from 'lodash/trimStart';
|
||||
|
||||
import { APIError, localForage, readFile, throwOnConflictingBranches } from '../../lib/util';
|
||||
import API, { API_NAME } from './API';
|
||||
import introspectionQueryResultData from './fragmentTypes';
|
||||
import * as mutations from './mutations';
|
||||
import * as queries from './queries';
|
||||
|
||||
import type { NormalizedCacheObject } from 'apollo-cache-inmemory';
|
||||
import type { MutationOptions, OperationVariables, QueryOptions } from 'apollo-client';
|
||||
import type { BlobArgs, Config } from './API';
|
||||
|
||||
const NO_CACHE = 'no-cache';
|
||||
const CACHE_FIRST = 'cache-first';
|
||||
|
||||
const fragmentMatcher = new IntrospectionFragmentMatcher({
|
||||
introspectionQueryResultData,
|
||||
});
|
||||
|
||||
interface TreeEntry {
|
||||
object?: {
|
||||
entries: TreeEntry[];
|
||||
};
|
||||
type: 'blob' | 'tree';
|
||||
name: string;
|
||||
sha: string;
|
||||
blob?: {
|
||||
size: number;
|
||||
};
|
||||
}
|
||||
|
||||
interface TreeFile {
|
||||
path: string;
|
||||
id: string;
|
||||
size: number;
|
||||
type: string;
|
||||
name: string;
|
||||
}
|
||||
|
||||
export default class GraphQLAPI extends API {
|
||||
client: ApolloClient<NormalizedCacheObject>;
|
||||
|
||||
constructor(config: Config) {
|
||||
super(config);
|
||||
|
||||
this.client = this.getApolloClient();
|
||||
}
|
||||
|
||||
getApolloClient() {
|
||||
const authLink = setContext((_, { headers }) => {
|
||||
return {
|
||||
headers: {
|
||||
'Content-Type': 'application/json; charset=utf-8',
|
||||
...headers,
|
||||
authorization: this.token ? `token ${this.token}` : '',
|
||||
},
|
||||
};
|
||||
});
|
||||
const httpLink = createHttpLink({ uri: `${this.apiRoot}/graphql` });
|
||||
return new ApolloClient({
|
||||
link: authLink.concat(httpLink),
|
||||
cache: new InMemoryCache({ fragmentMatcher }),
|
||||
defaultOptions: {
|
||||
watchQuery: {
|
||||
fetchPolicy: NO_CACHE,
|
||||
errorPolicy: 'ignore',
|
||||
},
|
||||
query: {
|
||||
fetchPolicy: NO_CACHE,
|
||||
errorPolicy: 'all',
|
||||
},
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
reset() {
|
||||
return this.client.resetStore();
|
||||
}
|
||||
|
||||
async getRepository(owner: string, name: string) {
|
||||
const { data } = await this.query({
|
||||
query: queries.repository,
|
||||
variables: { owner, name },
|
||||
fetchPolicy: CACHE_FIRST, // repository id doesn't change
|
||||
});
|
||||
return data.repository;
|
||||
}
|
||||
|
||||
query(options: QueryOptions<OperationVariables>) {
|
||||
return this.client.query(options).catch(error => {
|
||||
throw new APIError(error.message, 500, 'GitHub');
|
||||
});
|
||||
}
|
||||
|
||||
async mutate(options: MutationOptions<OperationVariables>) {
|
||||
try {
|
||||
const result = await this.client.mutate(options);
|
||||
return result;
|
||||
} catch (error: any) {
|
||||
const errors = error.graphQLErrors;
|
||||
if (Array.isArray(errors) && errors.some(e => e.message === 'Ref cannot be created.')) {
|
||||
const refName = options?.variables?.createRefInput?.name || '';
|
||||
const branchName = trimStart(refName, 'refs/heads/');
|
||||
if (branchName) {
|
||||
await throwOnConflictingBranches(branchName, name => this.getBranch(name), API_NAME);
|
||||
}
|
||||
}
|
||||
throw new APIError(error.message, 500, 'GitHub');
|
||||
}
|
||||
}
|
||||
|
||||
async hasWriteAccess() {
|
||||
const { repoOwner: owner, repoName: name } = this;
|
||||
try {
|
||||
const { data } = await this.query({
|
||||
query: queries.repoPermission,
|
||||
variables: { owner, name },
|
||||
fetchPolicy: CACHE_FIRST, // we can assume permission doesn't change often
|
||||
});
|
||||
// https://developer.github.com/v4/enum/repositorypermission/
|
||||
const { viewerPermission } = data.repository;
|
||||
return ['ADMIN', 'MAINTAIN', 'WRITE'].includes(viewerPermission);
|
||||
} catch (error: any) {
|
||||
console.error('Problem fetching repo data from GitHub');
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
async user() {
|
||||
const { data } = await this.query({
|
||||
query: queries.user,
|
||||
fetchPolicy: CACHE_FIRST, // we can assume user details don't change often
|
||||
});
|
||||
return data.viewer;
|
||||
}
|
||||
|
||||
async retrieveBlobObject(owner: string, name: string, expression: string, options = {}) {
|
||||
const { data } = await this.query({
|
||||
query: queries.blob,
|
||||
variables: { owner, name, expression },
|
||||
...options,
|
||||
});
|
||||
// https://developer.github.com/v4/object/blob/
|
||||
if (data.repository.object) {
|
||||
const { is_binary: isBinary, text } = data.repository.object;
|
||||
return { isNull: false, isBinary, text };
|
||||
} else {
|
||||
return { isNull: true };
|
||||
}
|
||||
}
|
||||
|
||||
getOwnerAndNameFromRepoUrl(repoURL: string) {
|
||||
let { repoOwner: owner, repoName: name } = this;
|
||||
|
||||
if (repoURL === this.originRepoURL) {
|
||||
({ originRepoOwner: owner, originRepoName: name } = this);
|
||||
}
|
||||
|
||||
return { owner, name };
|
||||
}
|
||||
|
||||
async readFile(
|
||||
path: string,
|
||||
sha?: string | null,
|
||||
{
|
||||
branch = this.branch,
|
||||
repoURL = this.repoURL,
|
||||
parseText = true,
|
||||
}: {
|
||||
branch?: string;
|
||||
repoURL?: string;
|
||||
parseText?: boolean;
|
||||
} = {},
|
||||
) {
|
||||
if (!sha) {
|
||||
sha = await this.getFileSha(path, { repoURL, branch });
|
||||
}
|
||||
const fetchContent = () => this.fetchBlobContent({ sha: sha as string, repoURL, parseText });
|
||||
const content = await readFile(sha, fetchContent, localForage, parseText);
|
||||
return content;
|
||||
}
|
||||
|
||||
async fetchBlobContent({ sha, repoURL, parseText }: BlobArgs) {
|
||||
if (!parseText) {
|
||||
return super.fetchBlobContent({ sha, repoURL, parseText });
|
||||
}
|
||||
const { owner, name } = this.getOwnerAndNameFromRepoUrl(repoURL);
|
||||
const { isNull, isBinary, text } = await this.retrieveBlobObject(
|
||||
owner,
|
||||
name,
|
||||
sha,
|
||||
{ fetchPolicy: CACHE_FIRST }, // blob sha is derived from file content
|
||||
);
|
||||
|
||||
if (isNull) {
|
||||
throw new APIError('Not Found', 404, 'GitHub');
|
||||
} else if (!isBinary) {
|
||||
return text;
|
||||
} else {
|
||||
return super.fetchBlobContent({ sha, repoURL, parseText });
|
||||
}
|
||||
}
|
||||
|
||||
getAllFiles(entries: TreeEntry[], path: string) {
|
||||
const allFiles: TreeFile[] = entries.reduce((acc, item) => {
|
||||
if (item.type === 'tree') {
|
||||
const entries = item.object?.entries || [];
|
||||
return [...acc, ...this.getAllFiles(entries, `${path}/${item.name}`)];
|
||||
} else if (item.type === 'blob') {
|
||||
return [
|
||||
...acc,
|
||||
{
|
||||
name: item.name,
|
||||
type: item.type,
|
||||
id: item.sha,
|
||||
path: `${path}/${item.name}`,
|
||||
size: item.blob ? item.blob.size : 0,
|
||||
},
|
||||
];
|
||||
}
|
||||
|
||||
return acc;
|
||||
}, [] as TreeFile[]);
|
||||
return allFiles;
|
||||
}
|
||||
|
||||
async listFiles(path: string, { repoURL = this.repoURL, branch = this.branch, depth = 1 } = {}) {
|
||||
const { owner, name } = this.getOwnerAndNameFromRepoUrl(repoURL);
|
||||
const folder = trim(path, '/');
|
||||
const { data } = await this.query({
|
||||
query: queries.files(depth),
|
||||
variables: { owner, name, expression: `${branch}:${folder}` },
|
||||
});
|
||||
|
||||
if (data.repository.object) {
|
||||
const allFiles = this.getAllFiles(data.repository.object.entries, folder);
|
||||
return allFiles;
|
||||
} else {
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
getBranchQualifiedName(branch: string) {
|
||||
return `refs/heads/${branch}`;
|
||||
}
|
||||
|
||||
getBranchQuery(branch: string, owner: string, name: string) {
|
||||
return {
|
||||
query: queries.branch,
|
||||
variables: {
|
||||
owner,
|
||||
name,
|
||||
qualifiedName: this.getBranchQualifiedName(branch),
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
async getDefaultBranch() {
|
||||
const { data } = await this.query({
|
||||
...this.getBranchQuery(this.branch, this.originRepoOwner, this.originRepoName),
|
||||
});
|
||||
return data.repository.branch;
|
||||
}
|
||||
|
||||
async getBranch(branch: string) {
|
||||
const { data } = await this.query({
|
||||
...this.getBranchQuery(branch, this.repoOwner, this.repoName),
|
||||
fetchPolicy: CACHE_FIRST,
|
||||
});
|
||||
if (!data.repository.branch) {
|
||||
throw new APIError('Branch not found', 404, API_NAME);
|
||||
}
|
||||
return data.repository.branch;
|
||||
}
|
||||
|
||||
async patchRef(type: string, name: string, sha: string) {
|
||||
if (type !== 'heads') {
|
||||
return super.patchRef(type, name, sha);
|
||||
}
|
||||
|
||||
const branch = await this.getBranch(name);
|
||||
const { data } = await this.mutate({
|
||||
mutation: mutations.updateBranch,
|
||||
variables: {
|
||||
input: { oid: sha, refId: branch.id },
|
||||
},
|
||||
});
|
||||
return data!.updateRef.branch;
|
||||
}
|
||||
|
||||
async getFileSha(path: string, { repoURL = this.repoURL, branch = this.branch } = {}) {
|
||||
const { owner, name } = this.getOwnerAndNameFromRepoUrl(repoURL);
|
||||
const { data } = await this.query({
|
||||
query: queries.fileSha,
|
||||
variables: { owner, name, expression: `${branch}:${path}` },
|
||||
});
|
||||
|
||||
if (data.repository.file) {
|
||||
return data.repository.file.sha;
|
||||
}
|
||||
throw new APIError('Not Found', 404, API_NAME);
|
||||
}
|
||||
}
|
572
core/src/backends/github/fragmentTypes.ts
Normal file
572
core/src/backends/github/fragmentTypes.ts
Normal file
@ -0,0 +1,572 @@
|
||||
export default {
|
||||
__schema: {
|
||||
types: [
|
||||
{
|
||||
kind: 'INTERFACE',
|
||||
name: 'Node',
|
||||
possibleTypes: [
|
||||
{ name: 'AddedToProjectEvent' },
|
||||
{ name: 'App' },
|
||||
{ name: 'AssignedEvent' },
|
||||
{ name: 'BaseRefChangedEvent' },
|
||||
{ name: 'BaseRefForcePushedEvent' },
|
||||
{ name: 'Blob' },
|
||||
{ name: 'Bot' },
|
||||
{ name: 'BranchProtectionRule' },
|
||||
{ name: 'ClosedEvent' },
|
||||
{ name: 'CodeOfConduct' },
|
||||
{ name: 'CommentDeletedEvent' },
|
||||
{ name: 'Commit' },
|
||||
{ name: 'CommitComment' },
|
||||
{ name: 'CommitCommentThread' },
|
||||
{ name: 'ConvertedNoteToIssueEvent' },
|
||||
{ name: 'CrossReferencedEvent' },
|
||||
{ name: 'DemilestonedEvent' },
|
||||
{ name: 'DeployKey' },
|
||||
{ name: 'DeployedEvent' },
|
||||
{ name: 'Deployment' },
|
||||
{ name: 'DeploymentEnvironmentChangedEvent' },
|
||||
{ name: 'DeploymentStatus' },
|
||||
{ name: 'ExternalIdentity' },
|
||||
{ name: 'Gist' },
|
||||
{ name: 'GistComment' },
|
||||
{ name: 'HeadRefDeletedEvent' },
|
||||
{ name: 'HeadRefForcePushedEvent' },
|
||||
{ name: 'HeadRefRestoredEvent' },
|
||||
{ name: 'Issue' },
|
||||
{ name: 'IssueComment' },
|
||||
{ name: 'Label' },
|
||||
{ name: 'LabeledEvent' },
|
||||
{ name: 'Language' },
|
||||
{ name: 'License' },
|
||||
{ name: 'LockedEvent' },
|
||||
{ name: 'Mannequin' },
|
||||
{ name: 'MarketplaceCategory' },
|
||||
{ name: 'MarketplaceListing' },
|
||||
{ name: 'MentionedEvent' },
|
||||
{ name: 'MergedEvent' },
|
||||
{ name: 'Milestone' },
|
||||
{ name: 'MilestonedEvent' },
|
||||
{ name: 'MovedColumnsInProjectEvent' },
|
||||
{ name: 'Organization' },
|
||||
{ name: 'OrganizationIdentityProvider' },
|
||||
{ name: 'OrganizationInvitation' },
|
||||
{ name: 'PinnedEvent' },
|
||||
{ name: 'Project' },
|
||||
{ name: 'ProjectCard' },
|
||||
{ name: 'ProjectColumn' },
|
||||
{ name: 'PublicKey' },
|
||||
{ name: 'PullRequest' },
|
||||
{ name: 'PullRequestCommit' },
|
||||
{ name: 'PullRequestCommitCommentThread' },
|
||||
{ name: 'PullRequestReview' },
|
||||
{ name: 'PullRequestReviewComment' },
|
||||
{ name: 'PullRequestReviewThread' },
|
||||
{ name: 'PushAllowance' },
|
||||
{ name: 'Reaction' },
|
||||
{ name: 'ReadyForReviewEvent' },
|
||||
{ name: 'Ref' },
|
||||
{ name: 'ReferencedEvent' },
|
||||
{ name: 'RegistryPackage' },
|
||||
{ name: 'RegistryPackageDependency' },
|
||||
{ name: 'RegistryPackageFile' },
|
||||
{ name: 'RegistryPackageTag' },
|
||||
{ name: 'RegistryPackageVersion' },
|
||||
{ name: 'Release' },
|
||||
{ name: 'ReleaseAsset' },
|
||||
{ name: 'RemovedFromProjectEvent' },
|
||||
{ name: 'RenamedTitleEvent' },
|
||||
{ name: 'ReopenedEvent' },
|
||||
{ name: 'Repository' },
|
||||
{ name: 'RepositoryInvitation' },
|
||||
{ name: 'RepositoryTopic' },
|
||||
{ name: 'ReviewDismissalAllowance' },
|
||||
{ name: 'ReviewDismissedEvent' },
|
||||
{ name: 'ReviewRequest' },
|
||||
{ name: 'ReviewRequestRemovedEvent' },
|
||||
{ name: 'ReviewRequestedEvent' },
|
||||
{ name: 'SavedReply' },
|
||||
{ name: 'SecurityAdvisory' },
|
||||
{ name: 'SponsorsListing' },
|
||||
{ name: 'Sponsorship' },
|
||||
{ name: 'Status' },
|
||||
{ name: 'StatusContext' },
|
||||
{ name: 'SubscribedEvent' },
|
||||
{ name: 'Tag' },
|
||||
{ name: 'Team' },
|
||||
{ name: 'Topic' },
|
||||
{ name: 'TransferredEvent' },
|
||||
{ name: 'Tree' },
|
||||
{ name: 'UnassignedEvent' },
|
||||
{ name: 'UnlabeledEvent' },
|
||||
{ name: 'UnlockedEvent' },
|
||||
{ name: 'UnpinnedEvent' },
|
||||
{ name: 'UnsubscribedEvent' },
|
||||
{ name: 'User' },
|
||||
{ name: 'UserBlockedEvent' },
|
||||
{ name: 'UserContentEdit' },
|
||||
{ name: 'UserStatus' },
|
||||
],
|
||||
},
|
||||
{
|
||||
kind: 'INTERFACE',
|
||||
name: 'UniformResourceLocatable',
|
||||
possibleTypes: [
|
||||
{ name: 'Bot' },
|
||||
{ name: 'ClosedEvent' },
|
||||
{ name: 'Commit' },
|
||||
{ name: 'CrossReferencedEvent' },
|
||||
{ name: 'Gist' },
|
||||
{ name: 'Issue' },
|
||||
{ name: 'Mannequin' },
|
||||
{ name: 'MergedEvent' },
|
||||
{ name: 'Milestone' },
|
||||
{ name: 'Organization' },
|
||||
{ name: 'PullRequest' },
|
||||
{ name: 'PullRequestCommit' },
|
||||
{ name: 'ReadyForReviewEvent' },
|
||||
{ name: 'Release' },
|
||||
{ name: 'Repository' },
|
||||
{ name: 'RepositoryTopic' },
|
||||
{ name: 'ReviewDismissedEvent' },
|
||||
{ name: 'User' },
|
||||
],
|
||||
},
|
||||
{
|
||||
kind: 'INTERFACE',
|
||||
name: 'Actor',
|
||||
possibleTypes: [
|
||||
{ name: 'Bot' },
|
||||
{ name: 'Mannequin' },
|
||||
{ name: 'Organization' },
|
||||
{ name: 'User' },
|
||||
],
|
||||
},
|
||||
{
|
||||
kind: 'INTERFACE',
|
||||
name: 'RegistryPackageOwner',
|
||||
possibleTypes: [{ name: 'Organization' }, { name: 'Repository' }, { name: 'User' }],
|
||||
},
|
||||
{
|
||||
kind: 'INTERFACE',
|
||||
name: 'ProjectOwner',
|
||||
possibleTypes: [{ name: 'Organization' }, { name: 'Repository' }, { name: 'User' }],
|
||||
},
|
||||
{
|
||||
kind: 'INTERFACE',
|
||||
name: 'Closable',
|
||||
possibleTypes: [
|
||||
{ name: 'Issue' },
|
||||
{ name: 'Milestone' },
|
||||
{ name: 'Project' },
|
||||
{ name: 'PullRequest' },
|
||||
],
|
||||
},
|
||||
{
|
||||
kind: 'INTERFACE',
|
||||
name: 'Updatable',
|
||||
possibleTypes: [
|
||||
{ name: 'CommitComment' },
|
||||
{ name: 'GistComment' },
|
||||
{ name: 'Issue' },
|
||||
{ name: 'IssueComment' },
|
||||
{ name: 'Project' },
|
||||
{ name: 'PullRequest' },
|
||||
{ name: 'PullRequestReview' },
|
||||
{ name: 'PullRequestReviewComment' },
|
||||
],
|
||||
},
|
||||
{
|
||||
kind: 'UNION',
|
||||
name: 'ProjectCardItem',
|
||||
possibleTypes: [{ name: 'Issue' }, { name: 'PullRequest' }],
|
||||
},
|
||||
{
|
||||
kind: 'INTERFACE',
|
||||
name: 'Assignable',
|
||||
possibleTypes: [{ name: 'Issue' }, { name: 'PullRequest' }],
|
||||
},
|
||||
{
|
||||
kind: 'INTERFACE',
|
||||
name: 'Comment',
|
||||
possibleTypes: [
|
||||
{ name: 'CommitComment' },
|
||||
{ name: 'GistComment' },
|
||||
{ name: 'Issue' },
|
||||
{ name: 'IssueComment' },
|
||||
{ name: 'PullRequest' },
|
||||
{ name: 'PullRequestReview' },
|
||||
{ name: 'PullRequestReviewComment' },
|
||||
],
|
||||
},
|
||||
{
|
||||
kind: 'INTERFACE',
|
||||
name: 'UpdatableComment',
|
||||
possibleTypes: [
|
||||
{ name: 'CommitComment' },
|
||||
{ name: 'GistComment' },
|
||||
{ name: 'Issue' },
|
||||
{ name: 'IssueComment' },
|
||||
{ name: 'PullRequest' },
|
||||
{ name: 'PullRequestReview' },
|
||||
{ name: 'PullRequestReviewComment' },
|
||||
],
|
||||
},
|
||||
{
|
||||
kind: 'INTERFACE',
|
||||
name: 'Labelable',
|
||||
possibleTypes: [{ name: 'Issue' }, { name: 'PullRequest' }],
|
||||
},
|
||||
{
|
||||
kind: 'INTERFACE',
|
||||
name: 'Lockable',
|
||||
possibleTypes: [{ name: 'Issue' }, { name: 'PullRequest' }],
|
||||
},
|
||||
{
|
||||
kind: 'INTERFACE',
|
||||
name: 'RegistryPackageSearch',
|
||||
possibleTypes: [{ name: 'Organization' }, { name: 'User' }],
|
||||
},
|
||||
{
|
||||
kind: 'INTERFACE',
|
||||
name: 'RepositoryOwner',
|
||||
possibleTypes: [{ name: 'Organization' }, { name: 'User' }],
|
||||
},
|
||||
{
|
||||
kind: 'INTERFACE',
|
||||
name: 'MemberStatusable',
|
||||
possibleTypes: [{ name: 'Organization' }, { name: 'Team' }],
|
||||
},
|
||||
{
|
||||
kind: 'INTERFACE',
|
||||
name: 'ProfileOwner',
|
||||
possibleTypes: [{ name: 'Organization' }, { name: 'User' }],
|
||||
},
|
||||
{
|
||||
kind: 'UNION',
|
||||
name: 'PinnableItem',
|
||||
possibleTypes: [{ name: 'Gist' }, { name: 'Repository' }],
|
||||
},
|
||||
{
|
||||
kind: 'INTERFACE',
|
||||
name: 'Starrable',
|
||||
possibleTypes: [{ name: 'Gist' }, { name: 'Repository' }, { name: 'Topic' }],
|
||||
},
|
||||
{ kind: 'INTERFACE', name: 'RepositoryInfo', possibleTypes: [{ name: 'Repository' }] },
|
||||
{
|
||||
kind: 'INTERFACE',
|
||||
name: 'GitObject',
|
||||
possibleTypes: [{ name: 'Blob' }, { name: 'Commit' }, { name: 'Tag' }, { name: 'Tree' }],
|
||||
},
|
||||
{
|
||||
kind: 'INTERFACE',
|
||||
name: 'RepositoryNode',
|
||||
possibleTypes: [
|
||||
{ name: 'CommitComment' },
|
||||
{ name: 'CommitCommentThread' },
|
||||
{ name: 'Issue' },
|
||||
{ name: 'IssueComment' },
|
||||
{ name: 'PullRequest' },
|
||||
{ name: 'PullRequestCommitCommentThread' },
|
||||
{ name: 'PullRequestReview' },
|
||||
{ name: 'PullRequestReviewComment' },
|
||||
],
|
||||
},
|
||||
{
|
||||
kind: 'INTERFACE',
|
||||
name: 'Subscribable',
|
||||
possibleTypes: [
|
||||
{ name: 'Commit' },
|
||||
{ name: 'Issue' },
|
||||
{ name: 'PullRequest' },
|
||||
{ name: 'Repository' },
|
||||
{ name: 'Team' },
|
||||
],
|
||||
},
|
||||
{
|
||||
kind: 'INTERFACE',
|
||||
name: 'Deletable',
|
||||
possibleTypes: [
|
||||
{ name: 'CommitComment' },
|
||||
{ name: 'GistComment' },
|
||||
{ name: 'IssueComment' },
|
||||
{ name: 'PullRequestReview' },
|
||||
{ name: 'PullRequestReviewComment' },
|
||||
],
|
||||
},
|
||||
{
|
||||
kind: 'INTERFACE',
|
||||
name: 'Reactable',
|
||||
possibleTypes: [
|
||||
{ name: 'CommitComment' },
|
||||
{ name: 'Issue' },
|
||||
{ name: 'IssueComment' },
|
||||
{ name: 'PullRequest' },
|
||||
{ name: 'PullRequestReview' },
|
||||
{ name: 'PullRequestReviewComment' },
|
||||
],
|
||||
},
|
||||
{
|
||||
kind: 'INTERFACE',
|
||||
name: 'GitSignature',
|
||||
possibleTypes: [
|
||||
{ name: 'GpgSignature' },
|
||||
{ name: 'SmimeSignature' },
|
||||
{ name: 'UnknownSignature' },
|
||||
],
|
||||
},
|
||||
{
|
||||
kind: 'UNION',
|
||||
name: 'RequestedReviewer',
|
||||
possibleTypes: [{ name: 'User' }, { name: 'Team' }, { name: 'Mannequin' }],
|
||||
},
|
||||
{
|
||||
kind: 'UNION',
|
||||
name: 'PullRequestTimelineItem',
|
||||
possibleTypes: [
|
||||
{ name: 'Commit' },
|
||||
{ name: 'CommitCommentThread' },
|
||||
{ name: 'PullRequestReview' },
|
||||
{ name: 'PullRequestReviewThread' },
|
||||
{ name: 'PullRequestReviewComment' },
|
||||
{ name: 'IssueComment' },
|
||||
{ name: 'ClosedEvent' },
|
||||
{ name: 'ReopenedEvent' },
|
||||
{ name: 'SubscribedEvent' },
|
||||
{ name: 'UnsubscribedEvent' },
|
||||
{ name: 'MergedEvent' },
|
||||
{ name: 'ReferencedEvent' },
|
||||
{ name: 'CrossReferencedEvent' },
|
||||
{ name: 'AssignedEvent' },
|
||||
{ name: 'UnassignedEvent' },
|
||||
{ name: 'LabeledEvent' },
|
||||
{ name: 'UnlabeledEvent' },
|
||||
{ name: 'MilestonedEvent' },
|
||||
{ name: 'DemilestonedEvent' },
|
||||
{ name: 'RenamedTitleEvent' },
|
||||
{ name: 'LockedEvent' },
|
||||
{ name: 'UnlockedEvent' },
|
||||
{ name: 'DeployedEvent' },
|
||||
{ name: 'DeploymentEnvironmentChangedEvent' },
|
||||
{ name: 'HeadRefDeletedEvent' },
|
||||
{ name: 'HeadRefRestoredEvent' },
|
||||
{ name: 'HeadRefForcePushedEvent' },
|
||||
{ name: 'BaseRefForcePushedEvent' },
|
||||
{ name: 'ReviewRequestedEvent' },
|
||||
{ name: 'ReviewRequestRemovedEvent' },
|
||||
{ name: 'ReviewDismissedEvent' },
|
||||
{ name: 'UserBlockedEvent' },
|
||||
],
|
||||
},
|
||||
{
|
||||
kind: 'UNION',
|
||||
name: 'Closer',
|
||||
possibleTypes: [{ name: 'Commit' }, { name: 'PullRequest' }],
|
||||
},
|
||||
{
|
||||
kind: 'UNION',
|
||||
name: 'ReferencedSubject',
|
||||
possibleTypes: [{ name: 'Issue' }, { name: 'PullRequest' }],
|
||||
},
|
||||
{
|
||||
kind: 'UNION',
|
||||
name: 'Assignee',
|
||||
possibleTypes: [
|
||||
{ name: 'Bot' },
|
||||
{ name: 'Mannequin' },
|
||||
{ name: 'Organization' },
|
||||
{ name: 'User' },
|
||||
],
|
||||
},
|
||||
{
|
||||
kind: 'UNION',
|
||||
name: 'MilestoneItem',
|
||||
possibleTypes: [{ name: 'Issue' }, { name: 'PullRequest' }],
|
||||
},
|
||||
{
|
||||
kind: 'UNION',
|
||||
name: 'RenamedTitleSubject',
|
||||
possibleTypes: [{ name: 'Issue' }, { name: 'PullRequest' }],
|
||||
},
|
||||
{
|
||||
kind: 'UNION',
|
||||
name: 'PullRequestTimelineItems',
|
||||
possibleTypes: [
|
||||
{ name: 'PullRequestCommit' },
|
||||
{ name: 'PullRequestCommitCommentThread' },
|
||||
{ name: 'PullRequestReview' },
|
||||
{ name: 'PullRequestReviewThread' },
|
||||
{ name: 'PullRequestRevisionMarker' },
|
||||
{ name: 'BaseRefChangedEvent' },
|
||||
{ name: 'BaseRefForcePushedEvent' },
|
||||
{ name: 'DeployedEvent' },
|
||||
{ name: 'DeploymentEnvironmentChangedEvent' },
|
||||
{ name: 'HeadRefDeletedEvent' },
|
||||
{ name: 'HeadRefForcePushedEvent' },
|
||||
{ name: 'HeadRefRestoredEvent' },
|
||||
{ name: 'MergedEvent' },
|
||||
{ name: 'ReviewDismissedEvent' },
|
||||
{ name: 'ReviewRequestedEvent' },
|
||||
{ name: 'ReviewRequestRemovedEvent' },
|
||||
{ name: 'ReadyForReviewEvent' },
|
||||
{ name: 'IssueComment' },
|
||||
{ name: 'CrossReferencedEvent' },
|
||||
{ name: 'AddedToProjectEvent' },
|
||||
{ name: 'AssignedEvent' },
|
||||
{ name: 'ClosedEvent' },
|
||||
{ name: 'CommentDeletedEvent' },
|
||||
{ name: 'ConvertedNoteToIssueEvent' },
|
||||
{ name: 'DemilestonedEvent' },
|
||||
{ name: 'LabeledEvent' },
|
||||
{ name: 'LockedEvent' },
|
||||
{ name: 'MentionedEvent' },
|
||||
{ name: 'MilestonedEvent' },
|
||||
{ name: 'MovedColumnsInProjectEvent' },
|
||||
{ name: 'PinnedEvent' },
|
||||
{ name: 'ReferencedEvent' },
|
||||
{ name: 'RemovedFromProjectEvent' },
|
||||
{ name: 'RenamedTitleEvent' },
|
||||
{ name: 'ReopenedEvent' },
|
||||
{ name: 'SubscribedEvent' },
|
||||
{ name: 'TransferredEvent' },
|
||||
{ name: 'UnassignedEvent' },
|
||||
{ name: 'UnlabeledEvent' },
|
||||
{ name: 'UnlockedEvent' },
|
||||
{ name: 'UserBlockedEvent' },
|
||||
{ name: 'UnpinnedEvent' },
|
||||
{ name: 'UnsubscribedEvent' },
|
||||
],
|
||||
},
|
||||
{
|
||||
kind: 'UNION',
|
||||
name: 'IssueOrPullRequest',
|
||||
possibleTypes: [{ name: 'Issue' }, { name: 'PullRequest' }],
|
||||
},
|
||||
{
|
||||
kind: 'UNION',
|
||||
name: 'IssueTimelineItem',
|
||||
possibleTypes: [
|
||||
{ name: 'Commit' },
|
||||
{ name: 'IssueComment' },
|
||||
{ name: 'CrossReferencedEvent' },
|
||||
{ name: 'ClosedEvent' },
|
||||
{ name: 'ReopenedEvent' },
|
||||
{ name: 'SubscribedEvent' },
|
||||
{ name: 'UnsubscribedEvent' },
|
||||
{ name: 'ReferencedEvent' },
|
||||
{ name: 'AssignedEvent' },
|
||||
{ name: 'UnassignedEvent' },
|
||||
{ name: 'LabeledEvent' },
|
||||
{ name: 'UnlabeledEvent' },
|
||||
{ name: 'UserBlockedEvent' },
|
||||
{ name: 'MilestonedEvent' },
|
||||
{ name: 'DemilestonedEvent' },
|
||||
{ name: 'RenamedTitleEvent' },
|
||||
{ name: 'LockedEvent' },
|
||||
{ name: 'UnlockedEvent' },
|
||||
{ name: 'TransferredEvent' },
|
||||
],
|
||||
},
|
||||
{
|
||||
kind: 'UNION',
|
||||
name: 'IssueTimelineItems',
|
||||
possibleTypes: [
|
||||
{ name: 'IssueComment' },
|
||||
{ name: 'CrossReferencedEvent' },
|
||||
{ name: 'AddedToProjectEvent' },
|
||||
{ name: 'AssignedEvent' },
|
||||
{ name: 'ClosedEvent' },
|
||||
{ name: 'CommentDeletedEvent' },
|
||||
{ name: 'ConvertedNoteToIssueEvent' },
|
||||
{ name: 'DemilestonedEvent' },
|
||||
{ name: 'LabeledEvent' },
|
||||
{ name: 'LockedEvent' },
|
||||
{ name: 'MentionedEvent' },
|
||||
{ name: 'MilestonedEvent' },
|
||||
{ name: 'MovedColumnsInProjectEvent' },
|
||||
{ name: 'PinnedEvent' },
|
||||
{ name: 'ReferencedEvent' },
|
||||
{ name: 'RemovedFromProjectEvent' },
|
||||
{ name: 'RenamedTitleEvent' },
|
||||
{ name: 'ReopenedEvent' },
|
||||
{ name: 'SubscribedEvent' },
|
||||
{ name: 'TransferredEvent' },
|
||||
{ name: 'UnassignedEvent' },
|
||||
{ name: 'UnlabeledEvent' },
|
||||
{ name: 'UnlockedEvent' },
|
||||
{ name: 'UserBlockedEvent' },
|
||||
{ name: 'UnpinnedEvent' },
|
||||
{ name: 'UnsubscribedEvent' },
|
||||
],
|
||||
},
|
||||
{
|
||||
kind: 'UNION',
|
||||
name: 'ReviewDismissalAllowanceActor',
|
||||
possibleTypes: [{ name: 'User' }, { name: 'Team' }],
|
||||
},
|
||||
{
|
||||
kind: 'UNION',
|
||||
name: 'PushAllowanceActor',
|
||||
possibleTypes: [{ name: 'User' }, { name: 'Team' }],
|
||||
},
|
||||
{
|
||||
kind: 'UNION',
|
||||
name: 'PermissionGranter',
|
||||
possibleTypes: [{ name: 'Organization' }, { name: 'Repository' }, { name: 'Team' }],
|
||||
},
|
||||
{ kind: 'INTERFACE', name: 'Sponsorable', possibleTypes: [{ name: 'User' }] },
|
||||
{
|
||||
kind: 'INTERFACE',
|
||||
name: 'Contribution',
|
||||
possibleTypes: [
|
||||
{ name: 'CreatedCommitContribution' },
|
||||
{ name: 'CreatedIssueContribution' },
|
||||
{ name: 'CreatedPullRequestContribution' },
|
||||
{ name: 'CreatedPullRequestReviewContribution' },
|
||||
{ name: 'CreatedRepositoryContribution' },
|
||||
{ name: 'JoinedGitHubContribution' },
|
||||
{ name: 'RestrictedContribution' },
|
||||
],
|
||||
},
|
||||
{
|
||||
kind: 'UNION',
|
||||
name: 'CreatedRepositoryOrRestrictedContribution',
|
||||
possibleTypes: [
|
||||
{ name: 'CreatedRepositoryContribution' },
|
||||
{ name: 'RestrictedContribution' },
|
||||
],
|
||||
},
|
||||
{
|
||||
kind: 'UNION',
|
||||
name: 'CreatedIssueOrRestrictedContribution',
|
||||
possibleTypes: [{ name: 'CreatedIssueContribution' }, { name: 'RestrictedContribution' }],
|
||||
},
|
||||
{
|
||||
kind: 'UNION',
|
||||
name: 'CreatedPullRequestOrRestrictedContribution',
|
||||
possibleTypes: [
|
||||
{ name: 'CreatedPullRequestContribution' },
|
||||
{ name: 'RestrictedContribution' },
|
||||
],
|
||||
},
|
||||
{
|
||||
kind: 'UNION',
|
||||
name: 'SearchResultItem',
|
||||
possibleTypes: [
|
||||
{ name: 'Issue' },
|
||||
{ name: 'PullRequest' },
|
||||
{ name: 'Repository' },
|
||||
{ name: 'User' },
|
||||
{ name: 'Organization' },
|
||||
{ name: 'MarketplaceListing' },
|
||||
{ name: 'App' },
|
||||
],
|
||||
},
|
||||
{
|
||||
kind: 'UNION',
|
||||
name: 'CollectionItemContent',
|
||||
possibleTypes: [{ name: 'Repository' }, { name: 'Organization' }, { name: 'User' }],
|
||||
},
|
||||
],
|
||||
},
|
||||
};
|
92
core/src/backends/github/fragments.ts
Normal file
92
core/src/backends/github/fragments.ts
Normal file
@ -0,0 +1,92 @@
|
||||
import { gql } from 'graphql-tag';
|
||||
|
||||
export const repository = gql`
|
||||
fragment RepositoryParts on Repository {
|
||||
id
|
||||
isFork
|
||||
}
|
||||
`;
|
||||
|
||||
export const blobWithText = gql`
|
||||
fragment BlobWithTextParts on Blob {
|
||||
id
|
||||
text
|
||||
is_binary: isBinary
|
||||
}
|
||||
`;
|
||||
|
||||
export const object = gql`
|
||||
fragment ObjectParts on GitObject {
|
||||
id
|
||||
sha: oid
|
||||
}
|
||||
`;
|
||||
|
||||
export const branch = gql`
|
||||
fragment BranchParts on Ref {
|
||||
commit: target {
|
||||
...ObjectParts
|
||||
}
|
||||
id
|
||||
name
|
||||
prefix
|
||||
repository {
|
||||
...RepositoryParts
|
||||
}
|
||||
}
|
||||
${object}
|
||||
${repository}
|
||||
`;
|
||||
|
||||
export const pullRequest = gql`
|
||||
fragment PullRequestParts on PullRequest {
|
||||
id
|
||||
baseRefName
|
||||
baseRefOid
|
||||
body
|
||||
headRefName
|
||||
headRefOid
|
||||
number
|
||||
state
|
||||
title
|
||||
merged_at: mergedAt
|
||||
updated_at: updatedAt
|
||||
user: author {
|
||||
login
|
||||
... on User {
|
||||
name
|
||||
}
|
||||
}
|
||||
repository {
|
||||
...RepositoryParts
|
||||
}
|
||||
labels(last: 100) {
|
||||
nodes {
|
||||
name
|
||||
}
|
||||
}
|
||||
}
|
||||
${repository}
|
||||
`;
|
||||
|
||||
export const treeEntry = gql`
|
||||
fragment TreeEntryParts on TreeEntry {
|
||||
path: name
|
||||
sha: oid
|
||||
type
|
||||
mode
|
||||
}
|
||||
`;
|
||||
|
||||
export const fileEntry = gql`
|
||||
fragment FileEntryParts on TreeEntry {
|
||||
name
|
||||
sha: oid
|
||||
type
|
||||
blob: object {
|
||||
... on Blob {
|
||||
size: byteSize
|
||||
}
|
||||
}
|
||||
}
|
||||
`;
|
436
core/src/backends/github/implementation.tsx
Normal file
436
core/src/backends/github/implementation.tsx
Normal file
@ -0,0 +1,436 @@
|
||||
import { stripIndent } from 'common-tags';
|
||||
import trimStart from 'lodash/trimStart';
|
||||
import semaphore from 'semaphore';
|
||||
|
||||
import {
|
||||
asyncLock,
|
||||
basename,
|
||||
blobToFileObj,
|
||||
Cursor,
|
||||
CURSOR_COMPATIBILITY_SYMBOL,
|
||||
entriesByFiles,
|
||||
entriesByFolder,
|
||||
filterByExtension,
|
||||
getBlobSHA,
|
||||
getMediaAsBlob,
|
||||
getMediaDisplayURL,
|
||||
runWithLock,
|
||||
unsentRequest,
|
||||
} from '../../lib/util';
|
||||
import API, { API_NAME } from './API';
|
||||
import AuthenticationPage from './AuthenticationPage';
|
||||
|
||||
import type { Octokit } from '@octokit/rest';
|
||||
import type { Semaphore } from 'semaphore';
|
||||
import type {
|
||||
BackendEntry,
|
||||
BackendClass,
|
||||
Config,
|
||||
Credentials,
|
||||
DisplayURL,
|
||||
ImplementationFile,
|
||||
PersistOptions,
|
||||
User,
|
||||
} from '../../interface';
|
||||
import type { AsyncLock } from '../../lib/util';
|
||||
import type AssetProxy from '../../valueObjects/AssetProxy';
|
||||
|
||||
type GitHubUser = Octokit.UsersGetAuthenticatedResponse;
|
||||
|
||||
const MAX_CONCURRENT_DOWNLOADS = 10;
|
||||
|
||||
type ApiFile = { id: string; type: string; name: string; path: string; size: number };
|
||||
|
||||
const { fetchWithTimeout: fetch } = unsentRequest;
|
||||
|
||||
const STATUS_PAGE = 'https://www.githubstatus.com';
|
||||
const GITHUB_STATUS_ENDPOINT = `${STATUS_PAGE}/api/v2/components.json`;
|
||||
const GITHUB_OPERATIONAL_UNITS = ['API Requests', 'Issues, Pull Requests, Projects'];
|
||||
type GitHubStatusComponent = {
|
||||
id: string;
|
||||
name: string;
|
||||
status: string;
|
||||
};
|
||||
|
||||
export default class GitHub implements BackendClass {
|
||||
lock: AsyncLock;
|
||||
api: API | null;
|
||||
options: {
|
||||
proxied: boolean;
|
||||
API: API | null;
|
||||
};
|
||||
originRepo: string;
|
||||
repo?: string;
|
||||
branch: string;
|
||||
apiRoot: string;
|
||||
mediaFolder?: string;
|
||||
token: string | null;
|
||||
_currentUserPromise?: Promise<GitHubUser>;
|
||||
_userIsOriginMaintainerPromises?: {
|
||||
[key: string]: Promise<boolean>;
|
||||
};
|
||||
_mediaDisplayURLSem?: Semaphore;
|
||||
|
||||
constructor(config: Config, options = {}) {
|
||||
this.options = {
|
||||
proxied: false,
|
||||
API: null,
|
||||
...options,
|
||||
};
|
||||
|
||||
if (
|
||||
!this.options.proxied &&
|
||||
(config.backend.repo === null || config.backend.repo === undefined)
|
||||
) {
|
||||
throw new Error('The GitHub backend needs a "repo" in the backend configuration.');
|
||||
}
|
||||
|
||||
this.api = this.options.API || null;
|
||||
this.repo = this.originRepo = config.backend.repo || '';
|
||||
this.branch = config.backend.branch?.trim() || 'main';
|
||||
this.apiRoot = config.backend.api_root || 'https://api.github.com';
|
||||
this.token = '';
|
||||
this.mediaFolder = config.media_folder;
|
||||
this.lock = asyncLock();
|
||||
}
|
||||
|
||||
isGitBackend() {
|
||||
return true;
|
||||
}
|
||||
|
||||
async status() {
|
||||
const api = await fetch(GITHUB_STATUS_ENDPOINT)
|
||||
.then(res => res.json())
|
||||
.then(res => {
|
||||
return res['components']
|
||||
.filter((statusComponent: GitHubStatusComponent) =>
|
||||
GITHUB_OPERATIONAL_UNITS.includes(statusComponent.name),
|
||||
)
|
||||
.every(
|
||||
(statusComponent: GitHubStatusComponent) => statusComponent.status === 'operational',
|
||||
);
|
||||
})
|
||||
.catch(e => {
|
||||
console.warn('Failed getting GitHub status', e);
|
||||
return true;
|
||||
});
|
||||
|
||||
let auth = false;
|
||||
// no need to check auth if api is down
|
||||
if (api) {
|
||||
auth =
|
||||
(await this.api
|
||||
?.getUser()
|
||||
.then(user => !!user)
|
||||
.catch(e => {
|
||||
console.warn('Failed getting GitHub user', e);
|
||||
return false;
|
||||
})) || false;
|
||||
}
|
||||
|
||||
return { auth: { status: auth }, api: { status: api, statusPage: STATUS_PAGE } };
|
||||
}
|
||||
|
||||
authComponent() {
|
||||
return AuthenticationPage;
|
||||
}
|
||||
|
||||
restoreUser(user: User) {
|
||||
return this.authenticate(user);
|
||||
}
|
||||
|
||||
async currentUser({ token }: { token: string }) {
|
||||
if (!this._currentUserPromise) {
|
||||
this._currentUserPromise = fetch(`${this.apiRoot}/user`, {
|
||||
headers: {
|
||||
Authorization: `token ${token}`,
|
||||
},
|
||||
}).then(res => res.json());
|
||||
}
|
||||
return this._currentUserPromise;
|
||||
}
|
||||
|
||||
async userIsOriginMaintainer({
|
||||
username: usernameArg,
|
||||
token,
|
||||
}: {
|
||||
username?: string;
|
||||
token: string;
|
||||
}) {
|
||||
const username = usernameArg || (await this.currentUser({ token })).login;
|
||||
this._userIsOriginMaintainerPromises = this._userIsOriginMaintainerPromises || {};
|
||||
if (!this._userIsOriginMaintainerPromises[username]) {
|
||||
this._userIsOriginMaintainerPromises[username] = fetch(
|
||||
`${this.apiRoot}/repos/${this.originRepo}/collaborators/${username}/permission`,
|
||||
{
|
||||
headers: {
|
||||
Authorization: `token ${token}`,
|
||||
},
|
||||
},
|
||||
)
|
||||
.then(res => res.json())
|
||||
.then(({ permission }) => permission === 'admin' || permission === 'write');
|
||||
}
|
||||
return this._userIsOriginMaintainerPromises[username];
|
||||
}
|
||||
|
||||
async authenticate(state: Credentials) {
|
||||
this.token = state.token as string;
|
||||
const apiCtor = API;
|
||||
this.api = new apiCtor({
|
||||
token: this.token,
|
||||
branch: this.branch,
|
||||
repo: this.repo,
|
||||
originRepo: this.originRepo,
|
||||
apiRoot: this.apiRoot,
|
||||
});
|
||||
const user = await this.api!.user();
|
||||
const isCollab = await this.api!.hasWriteAccess().catch(error => {
|
||||
error.message = stripIndent`
|
||||
Repo "${this.repo}" not found.
|
||||
|
||||
Please ensure the repo information is spelled correctly.
|
||||
|
||||
If the repo is private, make sure you're logged into a GitHub account with access.
|
||||
|
||||
If your repo is under an organization, ensure the organization has granted access to Netlify
|
||||
CMS.
|
||||
`;
|
||||
throw error;
|
||||
});
|
||||
|
||||
// Unauthorized user
|
||||
if (!isCollab) {
|
||||
throw new Error('Your GitHub user account does not have access to this repo.');
|
||||
}
|
||||
|
||||
// Authorized user
|
||||
return { ...user, token: state.token as string };
|
||||
}
|
||||
|
||||
logout() {
|
||||
this.token = null;
|
||||
if (this.api && this.api.reset && typeof this.api.reset === 'function') {
|
||||
return this.api.reset();
|
||||
}
|
||||
}
|
||||
|
||||
getToken() {
|
||||
return Promise.resolve(this.token);
|
||||
}
|
||||
|
||||
getCursorAndFiles = (files: ApiFile[], page: number) => {
|
||||
const pageSize = 20;
|
||||
const count = files.length;
|
||||
const pageCount = Math.ceil(files.length / pageSize);
|
||||
|
||||
const actions = [] as string[];
|
||||
if (page > 1) {
|
||||
actions.push('prev');
|
||||
actions.push('first');
|
||||
}
|
||||
if (page < pageCount) {
|
||||
actions.push('next');
|
||||
actions.push('last');
|
||||
}
|
||||
|
||||
const cursor = Cursor.create({
|
||||
actions,
|
||||
meta: { page, count, pageSize, pageCount },
|
||||
data: { files },
|
||||
});
|
||||
const pageFiles = files.slice((page - 1) * pageSize, page * pageSize);
|
||||
return { cursor, files: pageFiles };
|
||||
};
|
||||
|
||||
async entriesByFolder(folder: string, extension: string, depth: number) {
|
||||
const repoURL = this.api!.originRepoURL;
|
||||
|
||||
let cursor: Cursor;
|
||||
|
||||
const listFiles = () =>
|
||||
this.api!.listFiles(folder, {
|
||||
repoURL,
|
||||
depth,
|
||||
}).then(files => {
|
||||
const filtered = files.filter(file => filterByExtension(file, extension));
|
||||
const result = this.getCursorAndFiles(filtered, 1);
|
||||
cursor = result.cursor;
|
||||
return result.files;
|
||||
});
|
||||
|
||||
const readFile = (path: string, id: string | null | undefined) =>
|
||||
this.api!.readFile(path, id, { repoURL }) as Promise<string>;
|
||||
|
||||
const files = await entriesByFolder(
|
||||
listFiles,
|
||||
readFile,
|
||||
this.api!.readFileMetadata.bind(this.api),
|
||||
API_NAME,
|
||||
);
|
||||
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
|
||||
// @ts-ignore
|
||||
files[CURSOR_COMPATIBILITY_SYMBOL] = cursor;
|
||||
return files;
|
||||
}
|
||||
|
||||
async allEntriesByFolder(folder: string, extension: string, depth: number) {
|
||||
const repoURL = this.api!.originRepoURL;
|
||||
|
||||
const listFiles = () =>
|
||||
this.api!.listFiles(folder, {
|
||||
repoURL,
|
||||
depth,
|
||||
}).then(files => files.filter(file => filterByExtension(file, extension)));
|
||||
|
||||
const readFile = (path: string, id: string | null | undefined) => {
|
||||
return this.api!.readFile(path, id, { repoURL }) as Promise<string>;
|
||||
};
|
||||
|
||||
const files = await entriesByFolder(
|
||||
listFiles,
|
||||
readFile,
|
||||
this.api!.readFileMetadata.bind(this.api),
|
||||
API_NAME,
|
||||
);
|
||||
return files;
|
||||
}
|
||||
|
||||
entriesByFiles(files: ImplementationFile[]) {
|
||||
const repoURL = this.api!.repoURL;
|
||||
|
||||
const readFile = (path: string, id: string | null | undefined) =>
|
||||
this.api!.readFile(path, id, { repoURL }).catch(() => '') as Promise<string>;
|
||||
|
||||
return entriesByFiles(files, readFile, this.api!.readFileMetadata.bind(this.api), API_NAME);
|
||||
}
|
||||
|
||||
// Fetches a single entry.
|
||||
getEntry(path: string) {
|
||||
const repoURL = this.api!.originRepoURL;
|
||||
return this.api!.readFile(path, null, { repoURL })
|
||||
.then(data => ({
|
||||
file: { path, id: null },
|
||||
data: data as string,
|
||||
}))
|
||||
.catch(() => ({ file: { path, id: null }, data: '' }));
|
||||
}
|
||||
|
||||
async getMedia(mediaFolder = this.mediaFolder) {
|
||||
if (!mediaFolder) {
|
||||
return [];
|
||||
}
|
||||
return this.api!.listFiles(mediaFolder).then(files =>
|
||||
files.map(({ id, name, size, path }) => {
|
||||
// load media using getMediaDisplayURL to avoid token expiration with GitHub raw content urls
|
||||
// for private repositories
|
||||
return { id, name, size, displayURL: { id, path }, path };
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
async getMediaFile(path: string) {
|
||||
const blob = await getMediaAsBlob(path, null, this.api!.readFile.bind(this.api!));
|
||||
|
||||
const name = basename(path);
|
||||
const fileObj = blobToFileObj(name, blob);
|
||||
const url = URL.createObjectURL(fileObj);
|
||||
const id = await getBlobSHA(blob);
|
||||
|
||||
return {
|
||||
id,
|
||||
displayURL: url,
|
||||
path,
|
||||
name,
|
||||
size: fileObj.size,
|
||||
file: fileObj,
|
||||
url,
|
||||
};
|
||||
}
|
||||
|
||||
getMediaDisplayURL(displayURL: DisplayURL) {
|
||||
this._mediaDisplayURLSem = this._mediaDisplayURLSem || semaphore(MAX_CONCURRENT_DOWNLOADS);
|
||||
return getMediaDisplayURL(
|
||||
displayURL,
|
||||
this.api!.readFile.bind(this.api!),
|
||||
this._mediaDisplayURLSem,
|
||||
);
|
||||
}
|
||||
|
||||
persistEntry(entry: BackendEntry, options: PersistOptions) {
|
||||
// persistEntry is a transactional operation
|
||||
return runWithLock(
|
||||
this.lock,
|
||||
() => this.api!.persistFiles(entry.dataFiles, entry.assets, options),
|
||||
'Failed to acquire persist entry lock',
|
||||
);
|
||||
}
|
||||
|
||||
async persistMedia(mediaFile: AssetProxy, options: PersistOptions) {
|
||||
try {
|
||||
await this.api!.persistFiles([], [mediaFile], options);
|
||||
const { sha, path, fileObj } = mediaFile as AssetProxy & { sha: string };
|
||||
const displayURL = URL.createObjectURL(fileObj as Blob);
|
||||
return {
|
||||
id: sha,
|
||||
name: fileObj!.name,
|
||||
size: fileObj!.size,
|
||||
displayURL,
|
||||
path: trimStart(path, '/'),
|
||||
};
|
||||
} catch (error) {
|
||||
console.error(error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
deleteFiles(paths: string[], commitMessage: string) {
|
||||
return this.api!.deleteFiles(paths, commitMessage);
|
||||
}
|
||||
|
||||
async traverseCursor(cursor: Cursor, action: string) {
|
||||
const meta = cursor.meta;
|
||||
const files = (cursor.data?.files ?? []) as ApiFile[];
|
||||
|
||||
let result: { cursor: Cursor; files: ApiFile[] };
|
||||
switch (action) {
|
||||
case 'first': {
|
||||
result = this.getCursorAndFiles(files, 1);
|
||||
break;
|
||||
}
|
||||
case 'last': {
|
||||
result = this.getCursorAndFiles(files, (meta?.['pageCount'] as number) ?? 1);
|
||||
break;
|
||||
}
|
||||
case 'next': {
|
||||
result = this.getCursorAndFiles(files, (meta?.['page'] as number) + 1 ?? 1);
|
||||
break;
|
||||
}
|
||||
case 'prev': {
|
||||
result = this.getCursorAndFiles(files, (meta?.['page'] as number) - 1 ?? 1);
|
||||
break;
|
||||
}
|
||||
default: {
|
||||
result = this.getCursorAndFiles(files, 1);
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
const readFile = (path: string, id: string | null | undefined) =>
|
||||
this.api!.readFile(path, id, { repoURL: this.api!.originRepoURL }).catch(
|
||||
() => '',
|
||||
) as Promise<string>;
|
||||
|
||||
const entries = await entriesByFiles(
|
||||
result.files,
|
||||
readFile,
|
||||
this.api!.readFileMetadata.bind(this.api),
|
||||
API_NAME,
|
||||
);
|
||||
|
||||
return {
|
||||
entries,
|
||||
cursor: result.cursor,
|
||||
};
|
||||
}
|
||||
}
|
3
core/src/backends/github/index.ts
Normal file
3
core/src/backends/github/index.ts
Normal file
@ -0,0 +1,3 @@
|
||||
export { default as GitHubBackend } from './implementation';
|
||||
export { default as API } from './API';
|
||||
export { default as AuthenticationPage } from './AuthenticationPage';
|
15
core/src/backends/github/mutations.ts
Normal file
15
core/src/backends/github/mutations.ts
Normal file
@ -0,0 +1,15 @@
|
||||
import { gql } from 'graphql-tag';
|
||||
|
||||
import * as fragments from './fragments';
|
||||
|
||||
// updateRef only works for branches at the moment
|
||||
export const updateBranch = gql`
|
||||
mutation updateRef($input: UpdateRefInput!) {
|
||||
updateRef(input: $input) {
|
||||
branch: ref {
|
||||
...BranchParts
|
||||
}
|
||||
}
|
||||
}
|
||||
${fragments.branch}
|
||||
`;
|
152
core/src/backends/github/queries.ts
Normal file
152
core/src/backends/github/queries.ts
Normal file
@ -0,0 +1,152 @@
|
||||
import { gql } from 'graphql-tag';
|
||||
import { oneLine } from 'common-tags';
|
||||
|
||||
import * as fragments from './fragments';
|
||||
|
||||
export const repoPermission = gql`
|
||||
query repoPermission($owner: String!, $name: String!) {
|
||||
repository(owner: $owner, name: $name) {
|
||||
...RepositoryParts
|
||||
viewerPermission
|
||||
}
|
||||
}
|
||||
${fragments.repository}
|
||||
`;
|
||||
|
||||
export const user = gql`
|
||||
query {
|
||||
viewer {
|
||||
id
|
||||
avatar_url: avatarUrl
|
||||
name
|
||||
login
|
||||
}
|
||||
}
|
||||
`;
|
||||
|
||||
export const blob = gql`
|
||||
query blob($owner: String!, $name: String!, $expression: String!) {
|
||||
repository(owner: $owner, name: $name) {
|
||||
...RepositoryParts
|
||||
object(expression: $expression) {
|
||||
... on Blob {
|
||||
...BlobWithTextParts
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
${fragments.repository}
|
||||
${fragments.blobWithText}
|
||||
`;
|
||||
|
||||
export const statues = gql`
|
||||
query statues($owner: String!, $name: String!, $sha: GitObjectID!) {
|
||||
repository(owner: $owner, name: $name) {
|
||||
...RepositoryParts
|
||||
object(oid: $sha) {
|
||||
...ObjectParts
|
||||
... on Commit {
|
||||
status {
|
||||
id
|
||||
contexts {
|
||||
id
|
||||
context
|
||||
state
|
||||
target_url: targetUrl
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
${fragments.repository}
|
||||
${fragments.object}
|
||||
`;
|
||||
|
||||
function buildFilesQuery(depth = 1) {
|
||||
const PLACE_HOLDER = 'PLACE_HOLDER';
|
||||
let query = oneLine`
|
||||
...ObjectParts
|
||||
... on Tree {
|
||||
entries {
|
||||
...FileEntryParts
|
||||
${PLACE_HOLDER}
|
||||
}
|
||||
}
|
||||
`;
|
||||
|
||||
for (let i = 0; i < depth - 1; i++) {
|
||||
query = query.replace(
|
||||
PLACE_HOLDER,
|
||||
oneLine`
|
||||
object {
|
||||
... on Tree {
|
||||
entries {
|
||||
...FileEntryParts
|
||||
${PLACE_HOLDER}
|
||||
}
|
||||
}
|
||||
}
|
||||
`,
|
||||
);
|
||||
}
|
||||
|
||||
query = query.replace(PLACE_HOLDER, '');
|
||||
|
||||
return query;
|
||||
}
|
||||
|
||||
export function files(depth: number) {
|
||||
return gql`
|
||||
query files($owner: String!, $name: String!, $expression: String!) {
|
||||
repository(owner: $owner, name: $name) {
|
||||
...RepositoryParts
|
||||
object(expression: $expression) {
|
||||
${buildFilesQuery(depth)}
|
||||
}
|
||||
}
|
||||
}
|
||||
${fragments.repository}
|
||||
${fragments.object}
|
||||
${fragments.fileEntry}
|
||||
`;
|
||||
}
|
||||
|
||||
const branchQueryPart = `
|
||||
branch: ref(qualifiedName: $qualifiedName) {
|
||||
...BranchParts
|
||||
}
|
||||
`;
|
||||
|
||||
export const branch = gql`
|
||||
query branch($owner: String!, $name: String!, $qualifiedName: String!) {
|
||||
repository(owner: $owner, name: $name) {
|
||||
...RepositoryParts
|
||||
${branchQueryPart}
|
||||
}
|
||||
}
|
||||
${fragments.repository}
|
||||
${fragments.branch}
|
||||
`;
|
||||
|
||||
export const repository = gql`
|
||||
query repository($owner: String!, $name: String!) {
|
||||
repository(owner: $owner, name: $name) {
|
||||
...RepositoryParts
|
||||
}
|
||||
}
|
||||
${fragments.repository}
|
||||
`;
|
||||
|
||||
export const fileSha = gql`
|
||||
query fileSha($owner: String!, $name: String!, $expression: String!) {
|
||||
repository(owner: $owner, name: $name) {
|
||||
...RepositoryParts
|
||||
file: object(expression: $expression) {
|
||||
...ObjectParts
|
||||
}
|
||||
}
|
||||
}
|
||||
${fragments.repository}
|
||||
${fragments.object}
|
||||
`;
|
50
core/src/backends/github/scripts/createFragmentTypes.ts
Normal file
50
core/src/backends/github/scripts/createFragmentTypes.ts
Normal file
@ -0,0 +1,50 @@
|
||||
import fs from 'fs';
|
||||
import fetch from 'node-fetch';
|
||||
import path from 'path';
|
||||
|
||||
const API_HOST = process.env.GITHUB_HOST || 'https://api.github.com';
|
||||
const API_TOKEN = process.env.GITHUB_API_TOKEN;
|
||||
|
||||
if (!API_TOKEN) {
|
||||
throw new Error('Missing environment variable GITHUB_API_TOKEN');
|
||||
}
|
||||
|
||||
fetch(`${API_HOST}/graphql`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json', Authorization: `bearer ${API_TOKEN}` },
|
||||
body: JSON.stringify({
|
||||
variables: {},
|
||||
query: `
|
||||
{
|
||||
__schema {
|
||||
types {
|
||||
kind
|
||||
name
|
||||
possibleTypes {
|
||||
name
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
`,
|
||||
}),
|
||||
})
|
||||
.then(result => result.json())
|
||||
.then(result => {
|
||||
// here we're filtering out any type information unrelated to unions or interfaces
|
||||
const filteredData = result.data.__schema.types.filter(
|
||||
(type: { possibleTypes: string[] | null }) => type.possibleTypes !== null,
|
||||
);
|
||||
result.data.__schema.types = filteredData;
|
||||
fs.writeFile(
|
||||
path.join(__dirname, '..', 'src', 'fragmentTypes.js'),
|
||||
`module.exports = ${JSON.stringify(result.data)}`,
|
||||
err => {
|
||||
if (err) {
|
||||
console.error('Error writing fragmentTypes file', err);
|
||||
} else {
|
||||
console.info('Fragment types successfully extracted!');
|
||||
}
|
||||
},
|
||||
);
|
||||
});
|
540
core/src/backends/gitlab/API.ts
Normal file
540
core/src/backends/gitlab/API.ts
Normal file
@ -0,0 +1,540 @@
|
||||
import { Base64 } from 'js-base64';
|
||||
import partial from 'lodash/partial';
|
||||
import result from 'lodash/result';
|
||||
import trimStart from 'lodash/trimStart';
|
||||
import { dirname } from 'path';
|
||||
|
||||
import {
|
||||
APIError,
|
||||
Cursor,
|
||||
localForage,
|
||||
parseLinkHeader,
|
||||
readFile,
|
||||
readFileMetadata,
|
||||
requestWithBackoff,
|
||||
responseParser,
|
||||
throwOnConflictingBranches,
|
||||
unsentRequest,
|
||||
} from '../../lib/util';
|
||||
|
||||
import type { DataFile, PersistOptions } from '../../interface';
|
||||
import type { ApiRequest, FetchError } from '../../lib/util';
|
||||
import type AssetProxy from '../../valueObjects/AssetProxy';
|
||||
|
||||
export const API_NAME = 'GitLab';
|
||||
|
||||
export interface Config {
|
||||
apiRoot?: string;
|
||||
token?: string;
|
||||
branch?: string;
|
||||
repo?: string;
|
||||
}
|
||||
|
||||
export interface CommitAuthor {
|
||||
name: string;
|
||||
email: string;
|
||||
}
|
||||
|
||||
enum CommitAction {
|
||||
CREATE = 'create',
|
||||
DELETE = 'delete',
|
||||
MOVE = 'move',
|
||||
UPDATE = 'update',
|
||||
}
|
||||
|
||||
type CommitItem = {
|
||||
base64Content?: string;
|
||||
path: string;
|
||||
oldPath?: string;
|
||||
action: CommitAction;
|
||||
};
|
||||
|
||||
type FileEntry = { id: string; type: string; path: string; name: string };
|
||||
|
||||
interface CommitsParams {
|
||||
commit_message: string;
|
||||
branch: string;
|
||||
author_name?: string;
|
||||
author_email?: string;
|
||||
actions?: {
|
||||
action: string;
|
||||
file_path: string;
|
||||
previous_path?: string;
|
||||
content?: string;
|
||||
encoding?: string;
|
||||
}[];
|
||||
}
|
||||
|
||||
type GitLabCommitDiff = {
|
||||
diff: string;
|
||||
new_path: string;
|
||||
old_path: string;
|
||||
new_file: boolean;
|
||||
renamed_file: boolean;
|
||||
deleted_file: boolean;
|
||||
};
|
||||
|
||||
type GitLabRepo = {
|
||||
shared_with_groups: { group_access_level: number }[] | null;
|
||||
permissions: {
|
||||
project_access: { access_level: number } | null;
|
||||
group_access: { access_level: number } | null;
|
||||
};
|
||||
};
|
||||
|
||||
type GitLabBranch = {
|
||||
name: string;
|
||||
developers_can_push: boolean;
|
||||
developers_can_merge: boolean;
|
||||
commit: {
|
||||
id: string;
|
||||
};
|
||||
};
|
||||
|
||||
type GitLabCommitRef = {
|
||||
type: string;
|
||||
name: string;
|
||||
};
|
||||
|
||||
type GitLabCommit = {
|
||||
id: string;
|
||||
short_id: string;
|
||||
title: string;
|
||||
author_name: string;
|
||||
author_email: string;
|
||||
authored_date: string;
|
||||
committer_name: string;
|
||||
committer_email: string;
|
||||
committed_date: string;
|
||||
created_at: string;
|
||||
message: string;
|
||||
};
|
||||
|
||||
export function getMaxAccess(groups: { group_access_level: number }[]) {
|
||||
return groups.reduce((previous, current) => {
|
||||
if (current.group_access_level > previous.group_access_level) {
|
||||
return current;
|
||||
}
|
||||
return previous;
|
||||
}, groups[0]);
|
||||
}
|
||||
|
||||
export default class API {
|
||||
apiRoot: string;
|
||||
token: string | boolean;
|
||||
branch: string;
|
||||
repo: string;
|
||||
repoURL: string;
|
||||
commitAuthor?: CommitAuthor;
|
||||
|
||||
constructor(config: Config) {
|
||||
this.apiRoot = config.apiRoot || 'https://gitlab.com/api/v4';
|
||||
this.token = config.token || false;
|
||||
this.branch = config.branch || 'main';
|
||||
this.repo = config.repo || '';
|
||||
this.repoURL = `/projects/${encodeURIComponent(this.repo)}`;
|
||||
}
|
||||
|
||||
withAuthorizationHeaders = (req: ApiRequest) => {
|
||||
const withHeaders = unsentRequest.withHeaders(
|
||||
this.token ? { Authorization: `Bearer ${this.token}` } : {},
|
||||
req,
|
||||
);
|
||||
return Promise.resolve(withHeaders);
|
||||
};
|
||||
|
||||
buildRequest = async (req: ApiRequest) => {
|
||||
const withRoot: ApiRequest = unsentRequest.withRoot(this.apiRoot)(req);
|
||||
const withAuthorizationHeaders = await this.withAuthorizationHeaders(withRoot);
|
||||
|
||||
if ('cache' in withAuthorizationHeaders) {
|
||||
return withAuthorizationHeaders;
|
||||
} else {
|
||||
const withNoCache: ApiRequest = unsentRequest.withNoCache(withAuthorizationHeaders);
|
||||
return withNoCache;
|
||||
}
|
||||
};
|
||||
|
||||
request = async (req: ApiRequest): Promise<Response> => {
|
||||
try {
|
||||
return requestWithBackoff(this, req);
|
||||
} catch (error: unknown) {
|
||||
if (error instanceof Error) {
|
||||
throw new APIError(error.message, null, API_NAME);
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
};
|
||||
|
||||
responseToJSON = responseParser({ format: 'json', apiName: API_NAME });
|
||||
responseToBlob = responseParser({ format: 'blob', apiName: API_NAME });
|
||||
responseToText = responseParser({ format: 'text', apiName: API_NAME });
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
requestJSON = (req: ApiRequest) => this.request(req).then(this.responseToJSON) as Promise<any>;
|
||||
requestText = (req: ApiRequest) => this.request(req).then(this.responseToText) as Promise<string>;
|
||||
|
||||
user = () => this.requestJSON('/user');
|
||||
|
||||
WRITE_ACCESS = 30;
|
||||
MAINTAINER_ACCESS = 40;
|
||||
|
||||
hasWriteAccess = async () => {
|
||||
const { shared_with_groups: sharedWithGroups, permissions }: GitLabRepo =
|
||||
await this.requestJSON(this.repoURL);
|
||||
|
||||
const { project_access: projectAccess, group_access: groupAccess } = permissions;
|
||||
if (projectAccess && projectAccess.access_level >= this.WRITE_ACCESS) {
|
||||
return true;
|
||||
}
|
||||
if (groupAccess && groupAccess.access_level >= this.WRITE_ACCESS) {
|
||||
return true;
|
||||
}
|
||||
// check for group write permissions
|
||||
if (sharedWithGroups && sharedWithGroups.length > 0) {
|
||||
const maxAccess = getMaxAccess(sharedWithGroups);
|
||||
// maintainer access
|
||||
if (maxAccess.group_access_level >= this.MAINTAINER_ACCESS) {
|
||||
return true;
|
||||
}
|
||||
// developer access
|
||||
if (maxAccess.group_access_level >= this.WRITE_ACCESS) {
|
||||
// check permissions to merge and push
|
||||
try {
|
||||
const branch = await this.getDefaultBranch();
|
||||
if (branch.developers_can_merge && branch.developers_can_push) {
|
||||
return true;
|
||||
}
|
||||
} catch (e) {
|
||||
console.error('Failed getting default branch', e);
|
||||
}
|
||||
}
|
||||
}
|
||||
return false;
|
||||
};
|
||||
|
||||
readFile = async (
|
||||
path: string,
|
||||
sha?: string | null,
|
||||
{ parseText = true, branch = this.branch } = {},
|
||||
): Promise<string | Blob> => {
|
||||
const fetchContent = async () => {
|
||||
const content = await this.request({
|
||||
url: `${this.repoURL}/repository/files/${encodeURIComponent(path)}/raw`,
|
||||
params: { ref: branch },
|
||||
cache: 'no-store',
|
||||
}).then<Blob | string>(parseText ? this.responseToText : this.responseToBlob);
|
||||
return content;
|
||||
};
|
||||
|
||||
const content = await readFile(sha, fetchContent, localForage, parseText);
|
||||
return content;
|
||||
};
|
||||
|
||||
async readFileMetadata(path: string, sha: string | null | undefined) {
|
||||
const fetchFileMetadata = async () => {
|
||||
try {
|
||||
const result: GitLabCommit[] = await this.requestJSON({
|
||||
url: `${this.repoURL}/repository/commits`,
|
||||
params: { path, ref_name: this.branch },
|
||||
});
|
||||
const commit = result[0];
|
||||
return {
|
||||
author: commit.author_name || commit.author_email,
|
||||
updatedOn: commit.authored_date,
|
||||
};
|
||||
} catch (e) {
|
||||
return { author: '', updatedOn: '' };
|
||||
}
|
||||
};
|
||||
const fileMetadata = await readFileMetadata(sha, fetchFileMetadata, localForage);
|
||||
return fileMetadata;
|
||||
}
|
||||
|
||||
getCursorFromHeaders = (headers: Headers) => {
|
||||
const page = parseInt(headers.get('X-Page') as string, 10);
|
||||
const pageCount = parseInt(headers.get('X-Total-Pages') as string, 10);
|
||||
const pageSize = parseInt(headers.get('X-Per-Page') as string, 10);
|
||||
const count = parseInt(headers.get('X-Total') as string, 10);
|
||||
const links = parseLinkHeader(headers.get('Link'));
|
||||
const actions = Object.keys(links).flatMap(key =>
|
||||
(key === 'prev' && page > 1) ||
|
||||
(key === 'next' && page < pageCount) ||
|
||||
(key === 'first' && page > 1) ||
|
||||
(key === 'last' && page < pageCount)
|
||||
? [key]
|
||||
: [],
|
||||
);
|
||||
return Cursor.create({
|
||||
actions,
|
||||
meta: { page, count, pageSize, pageCount },
|
||||
data: { links },
|
||||
});
|
||||
};
|
||||
|
||||
getCursor = ({ headers }: { headers: Headers }) => this.getCursorFromHeaders(headers);
|
||||
|
||||
// Gets a cursor without retrieving the entries by using a HEAD request
|
||||
fetchCursor = (req: ApiRequest) =>
|
||||
this.request(unsentRequest.withMethod('HEAD', req)).then(value => this.getCursor(value));
|
||||
|
||||
fetchCursorAndEntries = (
|
||||
req: ApiRequest,
|
||||
): Promise<{
|
||||
entries: FileEntry[];
|
||||
cursor: Cursor;
|
||||
}> => {
|
||||
const request = this.request(unsentRequest.withMethod('GET', req));
|
||||
|
||||
return Promise.all([
|
||||
request.then(this.getCursor),
|
||||
request.then(this.responseToJSON).catch((e: FetchError) => {
|
||||
if (e.status === 404) {
|
||||
return [];
|
||||
} else {
|
||||
throw e;
|
||||
}
|
||||
}),
|
||||
]).then(([cursor, entries]) => ({ cursor, entries }));
|
||||
};
|
||||
|
||||
listFiles = async (path: string, recursive = false) => {
|
||||
const { entries, cursor } = await this.fetchCursorAndEntries({
|
||||
url: `${this.repoURL}/repository/tree`,
|
||||
params: { path, ref: this.branch, recursive: `${recursive}` },
|
||||
});
|
||||
return {
|
||||
files: entries.filter(({ type }) => type === 'blob'),
|
||||
cursor,
|
||||
};
|
||||
};
|
||||
|
||||
traverseCursor = async (cursor: Cursor, action: string) => {
|
||||
const link = (cursor.data?.links as Record<string, ApiRequest>)[action];
|
||||
const { entries, cursor: newCursor } = await this.fetchCursorAndEntries(link);
|
||||
return {
|
||||
entries: entries.filter(({ type }) => type === 'blob'),
|
||||
cursor: newCursor,
|
||||
};
|
||||
};
|
||||
|
||||
listAllFiles = async (path: string, recursive = false, branch = this.branch) => {
|
||||
const entries = [];
|
||||
// eslint-disable-next-line prefer-const
|
||||
let { cursor, entries: initialEntries } = await this.fetchCursorAndEntries({
|
||||
url: `${this.repoURL}/repository/tree`,
|
||||
// Get the maximum number of entries per page
|
||||
params: { path, ref: branch, per_page: '100', recursive: `${recursive}` },
|
||||
});
|
||||
entries.push(...initialEntries);
|
||||
while (cursor && cursor.actions!.has('next')) {
|
||||
const link = (cursor.data?.links as Record<string, ApiRequest>).next;
|
||||
const { cursor: newCursor, entries: newEntries } = await this.fetchCursorAndEntries(link);
|
||||
entries.push(...newEntries);
|
||||
cursor = newCursor;
|
||||
}
|
||||
return entries.filter(({ type }) => type === 'blob');
|
||||
};
|
||||
|
||||
toBase64 = (str: string) => Promise.resolve(Base64.encode(str));
|
||||
fromBase64 = (str: string) => Base64.decode(str);
|
||||
|
||||
async getBranch(branchName: string) {
|
||||
const branch: GitLabBranch = await this.requestJSON(
|
||||
`${this.repoURL}/repository/branches/${encodeURIComponent(branchName)}`,
|
||||
);
|
||||
return branch;
|
||||
}
|
||||
|
||||
async uploadAndCommit(
|
||||
items: CommitItem[],
|
||||
{ commitMessage = '', branch = this.branch, newBranch = false },
|
||||
) {
|
||||
const actions = items.map(item => ({
|
||||
action: item.action,
|
||||
file_path: item.path,
|
||||
...(item.oldPath ? { previous_path: item.oldPath } : {}),
|
||||
...(item.base64Content !== undefined
|
||||
? { content: item.base64Content, encoding: 'base64' }
|
||||
: {}),
|
||||
}));
|
||||
|
||||
const commitParams: CommitsParams = {
|
||||
branch,
|
||||
commit_message: commitMessage,
|
||||
actions,
|
||||
...(newBranch ? { start_branch: this.branch } : {}),
|
||||
};
|
||||
if (this.commitAuthor) {
|
||||
const { name, email } = this.commitAuthor;
|
||||
commitParams.author_name = name;
|
||||
commitParams.author_email = email;
|
||||
}
|
||||
|
||||
try {
|
||||
const result = await this.requestJSON({
|
||||
url: `${this.repoURL}/repository/commits`,
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json; charset=utf-8' },
|
||||
body: JSON.stringify(commitParams),
|
||||
});
|
||||
return result;
|
||||
} catch (error: unknown) {
|
||||
if (error instanceof Error) {
|
||||
const message = error.message || '';
|
||||
if (newBranch && message.includes(`Could not update ${branch}`)) {
|
||||
await throwOnConflictingBranches(branch, name => this.getBranch(name), API_NAME);
|
||||
}
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
async getCommitItems(files: { path: string; newPath?: string }[], branch: string) {
|
||||
const items: CommitItem[] = await Promise.all(
|
||||
files.map(async file => {
|
||||
const [base64Content, fileExists] = await Promise.all([
|
||||
result(file, 'toBase64', partial(this.toBase64, (file as DataFile).raw)),
|
||||
this.isFileExists(file.path, branch),
|
||||
]);
|
||||
|
||||
let action = CommitAction.CREATE;
|
||||
let path = trimStart(file.path, '/');
|
||||
let oldPath = undefined;
|
||||
if (fileExists) {
|
||||
oldPath = file.newPath && path;
|
||||
action =
|
||||
file.newPath && file.newPath !== oldPath ? CommitAction.MOVE : CommitAction.UPDATE;
|
||||
path = file.newPath ? trimStart(file.newPath, '/') : path;
|
||||
}
|
||||
|
||||
return {
|
||||
action,
|
||||
base64Content,
|
||||
path,
|
||||
oldPath,
|
||||
};
|
||||
}),
|
||||
);
|
||||
|
||||
// move children
|
||||
for (const item of items.filter(i => i.oldPath && i.action === CommitAction.MOVE)) {
|
||||
const sourceDir = dirname(item.oldPath as string);
|
||||
const destDir = dirname(item.path);
|
||||
const children = await this.listAllFiles(sourceDir, true, branch);
|
||||
children
|
||||
.filter(f => f.path !== item.oldPath)
|
||||
.forEach(file => {
|
||||
items.push({
|
||||
action: CommitAction.MOVE,
|
||||
path: file.path.replace(sourceDir, destDir),
|
||||
oldPath: file.path,
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
return items;
|
||||
}
|
||||
|
||||
async persistFiles(dataFiles: DataFile[], mediaFiles: AssetProxy[], options: PersistOptions) {
|
||||
const files = [...dataFiles, ...mediaFiles];
|
||||
const items = await this.getCommitItems(files, this.branch);
|
||||
return this.uploadAndCommit(items, {
|
||||
commitMessage: options.commitMessage,
|
||||
});
|
||||
}
|
||||
|
||||
deleteFiles = (paths: string[], commitMessage: string) => {
|
||||
const branch = this.branch;
|
||||
const commitParams: CommitsParams = { commit_message: commitMessage, branch };
|
||||
if (this.commitAuthor) {
|
||||
const { name, email } = this.commitAuthor;
|
||||
commitParams.author_name = name;
|
||||
commitParams.author_email = email;
|
||||
}
|
||||
|
||||
const items = paths.map(path => ({ path, action: CommitAction.DELETE }));
|
||||
return this.uploadAndCommit(items, {
|
||||
commitMessage,
|
||||
});
|
||||
};
|
||||
|
||||
async getFileId(path: string, branch: string) {
|
||||
const request = await this.request({
|
||||
method: 'HEAD',
|
||||
url: `${this.repoURL}/repository/files/${encodeURIComponent(path)}`,
|
||||
params: { ref: branch },
|
||||
});
|
||||
|
||||
const blobId = request.headers.get('X - Gitlab - Blob - Id') as string;
|
||||
return blobId;
|
||||
}
|
||||
|
||||
async isFileExists(path: string, branch: string) {
|
||||
const fileExists = await this.requestText({
|
||||
method: 'HEAD',
|
||||
url: `${this.repoURL}/repository/files/${encodeURIComponent(path)}`,
|
||||
params: { ref: branch },
|
||||
})
|
||||
.then(() => true)
|
||||
.catch(error => {
|
||||
if (error instanceof APIError && error.status === 404) {
|
||||
return false;
|
||||
}
|
||||
throw error;
|
||||
});
|
||||
|
||||
return fileExists;
|
||||
}
|
||||
|
||||
async getDifferences(to: string, from = this.branch) {
|
||||
if (to === from) {
|
||||
return [];
|
||||
}
|
||||
const result: { diffs: GitLabCommitDiff[] } = await this.requestJSON({
|
||||
url: `${this.repoURL}/repository/compare`,
|
||||
params: {
|
||||
from,
|
||||
to,
|
||||
},
|
||||
});
|
||||
|
||||
if (result.diffs.length >= 1000) {
|
||||
throw new APIError('Diff limit reached', null, API_NAME);
|
||||
}
|
||||
|
||||
return result.diffs.map(d => {
|
||||
let status = 'modified';
|
||||
if (d.new_file) {
|
||||
status = 'added';
|
||||
} else if (d.deleted_file) {
|
||||
status = 'deleted';
|
||||
} else if (d.renamed_file) {
|
||||
status = 'renamed';
|
||||
}
|
||||
return {
|
||||
status,
|
||||
oldPath: d.old_path,
|
||||
newPath: d.new_path,
|
||||
newFile: d.new_file,
|
||||
path: d.new_path || d.old_path,
|
||||
binary: d.diff.startsWith('Binary') || /.svg$/.test(d.new_path),
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
async getDefaultBranch() {
|
||||
const branch: GitLabBranch = await this.getBranch(this.branch);
|
||||
return branch;
|
||||
}
|
||||
|
||||
async isShaExistsInBranch(branch: string, sha: string) {
|
||||
const refs: GitLabCommitRef[] = await this.requestJSON({
|
||||
url: `${this.repoURL}/repository/commits/${sha}/refs`,
|
||||
params: {
|
||||
type: 'branch',
|
||||
},
|
||||
});
|
||||
return refs.some(r => r.name === branch);
|
||||
}
|
||||
}
|
99
core/src/backends/gitlab/AuthenticationPage.tsx
Normal file
99
core/src/backends/gitlab/AuthenticationPage.tsx
Normal file
@ -0,0 +1,99 @@
|
||||
import { styled } from '@mui/material/styles';
|
||||
import React, { useCallback, useMemo, useState } from 'react';
|
||||
|
||||
import AuthenticationPage from '../../components/UI/AuthenticationPage';
|
||||
import Icon from '../../components/UI/Icon';
|
||||
import { NetlifyAuthenticator, PkceAuthenticator } from '../../lib/auth';
|
||||
import { isNotEmpty } from '../../lib/util/string.util';
|
||||
|
||||
import type { MouseEvent } from 'react';
|
||||
import type {
|
||||
AuthenticationPageProps,
|
||||
AuthenticatorConfig,
|
||||
TranslatedProps,
|
||||
} from '../../interface';
|
||||
|
||||
const LoginButtonIcon = styled(Icon)`
|
||||
margin-right: 18px;
|
||||
`;
|
||||
|
||||
const clientSideAuthenticators = {
|
||||
pkce: (config: AuthenticatorConfig) => new PkceAuthenticator(config),
|
||||
} as const;
|
||||
|
||||
const GitLabAuthenticationPage = ({
|
||||
inProgress = false,
|
||||
config,
|
||||
siteId,
|
||||
authEndpoint,
|
||||
clearHash,
|
||||
onLogin,
|
||||
t,
|
||||
}: TranslatedProps<AuthenticationPageProps>) => {
|
||||
const [loginError, setLoginError] = useState<string | null>(null);
|
||||
|
||||
const auth = useMemo(() => {
|
||||
const {
|
||||
auth_type: authType = '',
|
||||
base_url = 'https://gitlab.com',
|
||||
auth_endpoint = 'oauth/authorize',
|
||||
app_id = '',
|
||||
} = config.backend;
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
if (isNotEmpty(authType) && authType in clientSideAuthenticators) {
|
||||
const clientSizeAuth = clientSideAuthenticators[
|
||||
authType as keyof typeof clientSideAuthenticators
|
||||
]({
|
||||
base_url,
|
||||
auth_endpoint,
|
||||
app_id,
|
||||
auth_token_endpoint: 'oauth/token',
|
||||
clearHash,
|
||||
});
|
||||
// Complete implicit authentication if we were redirected back to from the provider.
|
||||
clientSizeAuth.completeAuth((err, data) => {
|
||||
if (err) {
|
||||
setLoginError(err.toString());
|
||||
} else if (data) {
|
||||
onLogin(data);
|
||||
}
|
||||
});
|
||||
return clientSizeAuth;
|
||||
} else {
|
||||
return new NetlifyAuthenticator({
|
||||
base_url,
|
||||
site_id: document.location.host.split(':')[0] === 'localhost' ? 'cms.netlify.com' : siteId,
|
||||
auth_endpoint: authEndpoint,
|
||||
});
|
||||
}
|
||||
}, [authEndpoint, clearHash, config.backend, onLogin, siteId]);
|
||||
|
||||
const handleLogin = useCallback(
|
||||
(e: MouseEvent<HTMLButtonElement>) => {
|
||||
e.preventDefault();
|
||||
auth.authenticate({ provider: 'gitlab', scope: 'api' }, err => {
|
||||
if (err) {
|
||||
setLoginError(err.toString());
|
||||
return;
|
||||
}
|
||||
});
|
||||
},
|
||||
[auth],
|
||||
);
|
||||
|
||||
return (
|
||||
<AuthenticationPage
|
||||
onLogin={handleLogin}
|
||||
loginDisabled={inProgress}
|
||||
loginErrorMessage={loginError}
|
||||
logoUrl={config.logo_url}
|
||||
siteUrl={config.site_url}
|
||||
icon={<LoginButtonIcon type="gitlab" />}
|
||||
buttonContent={inProgress ? t('auth.loggingIn') : t('auth.loginWithGitLab')}
|
||||
t={t}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
export default GitLabAuthenticationPage;
|
316
core/src/backends/gitlab/implementation.ts
Normal file
316
core/src/backends/gitlab/implementation.ts
Normal file
@ -0,0 +1,316 @@
|
||||
import { stripIndent } from 'common-tags';
|
||||
import trim from 'lodash/trim';
|
||||
import trimStart from 'lodash/trimStart';
|
||||
import semaphore from 'semaphore';
|
||||
|
||||
import {
|
||||
allEntriesByFolder,
|
||||
asyncLock,
|
||||
basename,
|
||||
blobToFileObj,
|
||||
CURSOR_COMPATIBILITY_SYMBOL,
|
||||
entriesByFiles,
|
||||
entriesByFolder,
|
||||
filterByExtension,
|
||||
getBlobSHA,
|
||||
getMediaAsBlob,
|
||||
getMediaDisplayURL,
|
||||
localForage,
|
||||
runWithLock,
|
||||
} from '../../lib/util';
|
||||
import API, { API_NAME } from './API';
|
||||
import AuthenticationPage from './AuthenticationPage';
|
||||
|
||||
import type { Semaphore } from 'semaphore';
|
||||
import type { AsyncLock, Cursor } from '../../lib/util';
|
||||
import type {
|
||||
Config,
|
||||
Credentials,
|
||||
DisplayURL,
|
||||
BackendEntry,
|
||||
BackendClass,
|
||||
ImplementationFile,
|
||||
PersistOptions,
|
||||
User,
|
||||
} from '../../interface';
|
||||
import type AssetProxy from '../../valueObjects/AssetProxy';
|
||||
|
||||
const MAX_CONCURRENT_DOWNLOADS = 10;
|
||||
|
||||
export default class GitLab implements BackendClass {
|
||||
lock: AsyncLock;
|
||||
api: API | null;
|
||||
options: {
|
||||
proxied: boolean;
|
||||
API: API | null;
|
||||
};
|
||||
repo: string;
|
||||
branch: string;
|
||||
apiRoot: string;
|
||||
token: string | null;
|
||||
mediaFolder?: string;
|
||||
|
||||
_mediaDisplayURLSem?: Semaphore;
|
||||
|
||||
constructor(config: Config, options = {}) {
|
||||
this.options = {
|
||||
proxied: false,
|
||||
API: null,
|
||||
...options,
|
||||
};
|
||||
|
||||
if (
|
||||
!this.options.proxied &&
|
||||
(config.backend.repo === null || config.backend.repo === undefined)
|
||||
) {
|
||||
throw new Error('The GitLab backend needs a "repo" in the backend configuration.');
|
||||
}
|
||||
|
||||
this.api = this.options.API || null;
|
||||
|
||||
this.repo = config.backend.repo || '';
|
||||
this.branch = config.backend.branch || 'main';
|
||||
this.apiRoot = config.backend.api_root || 'https://gitlab.com/api/v4';
|
||||
this.token = '';
|
||||
this.mediaFolder = config.media_folder;
|
||||
this.lock = asyncLock();
|
||||
}
|
||||
|
||||
isGitBackend() {
|
||||
return true;
|
||||
}
|
||||
|
||||
async status() {
|
||||
const auth =
|
||||
(await this.api
|
||||
?.user()
|
||||
.then(user => !!user)
|
||||
.catch(e => {
|
||||
console.warn('Failed getting GitLab user', e);
|
||||
return false;
|
||||
})) || false;
|
||||
|
||||
return { auth: { status: auth }, api: { status: true, statusPage: '' } };
|
||||
}
|
||||
|
||||
authComponent() {
|
||||
return AuthenticationPage;
|
||||
}
|
||||
|
||||
restoreUser(user: User) {
|
||||
return this.authenticate(user);
|
||||
}
|
||||
|
||||
async authenticate(state: Credentials) {
|
||||
this.token = state.token as string;
|
||||
this.api = new API({
|
||||
token: this.token,
|
||||
branch: this.branch,
|
||||
repo: this.repo,
|
||||
apiRoot: this.apiRoot,
|
||||
});
|
||||
const user = await this.api.user();
|
||||
const isCollab = await this.api.hasWriteAccess().catch((error: Error) => {
|
||||
error.message = stripIndent`
|
||||
Repo "${this.repo}" not found.
|
||||
|
||||
Please ensure the repo information is spelled correctly.
|
||||
|
||||
If the repo is private, make sure you're logged into a GitLab account with access.
|
||||
`;
|
||||
throw error;
|
||||
});
|
||||
|
||||
// Unauthorized user
|
||||
if (!isCollab) {
|
||||
throw new Error('Your GitLab user account does not have access to this repo.');
|
||||
}
|
||||
|
||||
// Authorized user
|
||||
return { ...user, login: user.username, token: state.token as string };
|
||||
}
|
||||
|
||||
async logout() {
|
||||
this.token = null;
|
||||
return;
|
||||
}
|
||||
|
||||
getToken() {
|
||||
return Promise.resolve(this.token);
|
||||
}
|
||||
|
||||
filterFile(
|
||||
folder: string,
|
||||
file: { path: string; name: string },
|
||||
extension: string,
|
||||
depth: number,
|
||||
) {
|
||||
// gitlab paths include the root folder
|
||||
const fileFolder = trim(file.path.split(folder)[1] || '/', '/');
|
||||
return filterByExtension(file, extension) && fileFolder.split('/').length <= depth;
|
||||
}
|
||||
|
||||
async entriesByFolder(folder: string, extension: string, depth: number) {
|
||||
let cursor: Cursor;
|
||||
|
||||
const listFiles = () =>
|
||||
this.api!.listFiles(folder, depth > 1).then(({ files, cursor: c }) => {
|
||||
cursor = c.mergeMeta({ folder, extension, depth });
|
||||
return files.filter(file => this.filterFile(folder, file, extension, depth));
|
||||
});
|
||||
|
||||
const files = await entriesByFolder(
|
||||
listFiles,
|
||||
this.api!.readFile.bind(this.api!),
|
||||
this.api!.readFileMetadata.bind(this.api),
|
||||
API_NAME,
|
||||
);
|
||||
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
|
||||
// @ts-ignore
|
||||
files[CURSOR_COMPATIBILITY_SYMBOL] = cursor;
|
||||
return files;
|
||||
}
|
||||
|
||||
async listAllFiles(folder: string, extension: string, depth: number) {
|
||||
const files = await this.api!.listAllFiles(folder, depth > 1);
|
||||
const filtered = files.filter(file => this.filterFile(folder, file, extension, depth));
|
||||
return filtered;
|
||||
}
|
||||
|
||||
async allEntriesByFolder(folder: string, extension: string, depth: number) {
|
||||
const files = await allEntriesByFolder({
|
||||
listAllFiles: () => this.listAllFiles(folder, extension, depth),
|
||||
readFile: this.api!.readFile.bind(this.api!),
|
||||
readFileMetadata: this.api!.readFileMetadata.bind(this.api),
|
||||
apiName: API_NAME,
|
||||
branch: this.branch,
|
||||
localForage,
|
||||
folder,
|
||||
extension,
|
||||
depth,
|
||||
getDefaultBranch: () =>
|
||||
this.api!.getDefaultBranch().then(b => ({ name: b.name, sha: b.commit.id })),
|
||||
isShaExistsInBranch: this.api!.isShaExistsInBranch.bind(this.api!),
|
||||
getDifferences: (to, from) => this.api!.getDifferences(to, from),
|
||||
getFileId: path => this.api!.getFileId(path, this.branch),
|
||||
filterFile: file => this.filterFile(folder, file, extension, depth),
|
||||
customFetch: undefined,
|
||||
});
|
||||
|
||||
return files;
|
||||
}
|
||||
|
||||
entriesByFiles(files: ImplementationFile[]) {
|
||||
return entriesByFiles(
|
||||
files,
|
||||
this.api!.readFile.bind(this.api!),
|
||||
this.api!.readFileMetadata.bind(this.api),
|
||||
API_NAME,
|
||||
);
|
||||
}
|
||||
|
||||
// Fetches a single entry.
|
||||
getEntry(path: string) {
|
||||
return this.api!.readFile(path).then(data => ({
|
||||
file: { path, id: null },
|
||||
data: data as string,
|
||||
}));
|
||||
}
|
||||
|
||||
async getMedia(mediaFolder = this.mediaFolder) {
|
||||
if (!mediaFolder) {
|
||||
return [];
|
||||
}
|
||||
return this.api!.listAllFiles(mediaFolder).then(files =>
|
||||
files.map(({ id, name, path }) => {
|
||||
return { id, name, path, displayURL: { id, name, path } };
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
getMediaDisplayURL(displayURL: DisplayURL) {
|
||||
this._mediaDisplayURLSem = this._mediaDisplayURLSem || semaphore(MAX_CONCURRENT_DOWNLOADS);
|
||||
return getMediaDisplayURL(
|
||||
displayURL,
|
||||
this.api!.readFile.bind(this.api!),
|
||||
this._mediaDisplayURLSem,
|
||||
);
|
||||
}
|
||||
|
||||
async getMediaFile(path: string) {
|
||||
const name = basename(path);
|
||||
const blob = await getMediaAsBlob(path, null, this.api!.readFile.bind(this.api!));
|
||||
const fileObj = blobToFileObj(name, blob);
|
||||
const url = URL.createObjectURL(fileObj);
|
||||
const id = await getBlobSHA(blob);
|
||||
|
||||
return {
|
||||
id,
|
||||
displayURL: url,
|
||||
path,
|
||||
name,
|
||||
size: fileObj.size,
|
||||
file: fileObj,
|
||||
url,
|
||||
};
|
||||
}
|
||||
|
||||
async persistEntry(entry: BackendEntry, options: PersistOptions) {
|
||||
// persistEntry is a transactional operation
|
||||
return runWithLock(
|
||||
this.lock,
|
||||
() => this.api!.persistFiles(entry.dataFiles, entry.assets, options),
|
||||
'Failed to acquire persist entry lock',
|
||||
);
|
||||
}
|
||||
|
||||
async persistMedia(mediaFile: AssetProxy, options: PersistOptions) {
|
||||
const fileObj = mediaFile.fileObj as File;
|
||||
|
||||
const [id] = await Promise.all([
|
||||
getBlobSHA(fileObj),
|
||||
this.api!.persistFiles([], [mediaFile], options),
|
||||
]);
|
||||
|
||||
const { path } = mediaFile;
|
||||
const url = URL.createObjectURL(fileObj);
|
||||
|
||||
return {
|
||||
displayURL: url,
|
||||
path: trimStart(path, '/'),
|
||||
name: fileObj!.name,
|
||||
size: fileObj!.size,
|
||||
file: fileObj,
|
||||
url,
|
||||
id,
|
||||
};
|
||||
}
|
||||
|
||||
deleteFiles(paths: string[], commitMessage: string) {
|
||||
return this.api!.deleteFiles(paths, commitMessage);
|
||||
}
|
||||
|
||||
traverseCursor(cursor: Cursor, action: string) {
|
||||
return this.api!.traverseCursor(cursor, action).then(async ({ entries, cursor: newCursor }) => {
|
||||
const [folder, depth, extension] = [
|
||||
cursor.meta?.folder as string,
|
||||
cursor.meta?.depth as number,
|
||||
cursor.meta?.extension as string,
|
||||
];
|
||||
if (folder && depth && extension) {
|
||||
entries = entries.filter(f => this.filterFile(folder, f, extension, depth));
|
||||
newCursor = newCursor.mergeMeta({ folder, extension, depth });
|
||||
}
|
||||
const entriesWithData = await entriesByFiles(
|
||||
entries,
|
||||
this.api!.readFile.bind(this.api!),
|
||||
this.api!.readFileMetadata.bind(this.api)!,
|
||||
API_NAME,
|
||||
);
|
||||
return {
|
||||
entries: entriesWithData,
|
||||
cursor: newCursor,
|
||||
};
|
||||
});
|
||||
}
|
||||
}
|
3
core/src/backends/gitlab/index.ts
Normal file
3
core/src/backends/gitlab/index.ts
Normal file
@ -0,0 +1,3 @@
|
||||
export { default as GitLabBackend } from './implementation';
|
||||
export { default as API } from './API';
|
||||
export { default as AuthenticationPage } from './AuthenticationPage';
|
73
core/src/backends/gitlab/queries.ts
Normal file
73
core/src/backends/gitlab/queries.ts
Normal file
@ -0,0 +1,73 @@
|
||||
import { gql } from 'graphql-tag';
|
||||
import { oneLine } from 'common-tags';
|
||||
|
||||
export const files = gql`
|
||||
query files($repo: ID!, $branch: String!, $path: String!, $recursive: Boolean!, $cursor: String) {
|
||||
project(fullPath: $repo) {
|
||||
repository {
|
||||
tree(ref: $branch, path: $path, recursive: $recursive) {
|
||||
blobs(after: $cursor) {
|
||||
nodes {
|
||||
type
|
||||
id: sha
|
||||
path
|
||||
name
|
||||
}
|
||||
pageInfo {
|
||||
endCursor
|
||||
hasNextPage
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
`;
|
||||
|
||||
export const blobs = gql`
|
||||
query blobs($repo: ID!, $branch: String!, $paths: [String!]!) {
|
||||
project(fullPath: $repo) {
|
||||
repository {
|
||||
blobs(ref: $branch, paths: $paths) {
|
||||
nodes {
|
||||
id
|
||||
data: rawBlob
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
`;
|
||||
|
||||
export function lastCommits(paths: string[]) {
|
||||
const tree = paths
|
||||
.map(
|
||||
(path, index) => oneLine`
|
||||
tree${index}: tree(ref: $branch, path: "${path}") {
|
||||
lastCommit {
|
||||
authorName
|
||||
authoredDate
|
||||
author {
|
||||
id
|
||||
username
|
||||
name
|
||||
publicEmail
|
||||
}
|
||||
}
|
||||
}
|
||||
`,
|
||||
)
|
||||
.join('\n');
|
||||
|
||||
const query = gql`
|
||||
query lastCommits($repo: ID!, $branch: String!) {
|
||||
project(fullPath: $repo) {
|
||||
repository {
|
||||
${tree}
|
||||
}
|
||||
}
|
||||
}
|
||||
`;
|
||||
|
||||
return query;
|
||||
}
|
7
core/src/backends/index.tsx
Normal file
7
core/src/backends/index.tsx
Normal file
@ -0,0 +1,7 @@
|
||||
export { AzureBackend } from './azure';
|
||||
export { BitbucketBackend } from './bitbucket';
|
||||
export { GitGatewayBackend } from './git-gateway';
|
||||
export { GitHubBackend } from './github';
|
||||
export { GitLabBackend } from './gitlab';
|
||||
export { ProxyBackend } from './proxy';
|
||||
export { TestBackend } from './test';
|
48
core/src/backends/proxy/AuthenticationPage.tsx
Normal file
48
core/src/backends/proxy/AuthenticationPage.tsx
Normal file
@ -0,0 +1,48 @@
|
||||
import Button from '@mui/material/Button';
|
||||
import { styled } from '@mui/material/styles';
|
||||
import React, { useCallback } from 'react';
|
||||
|
||||
import GoBackButton from '../../components/UI/GoBackButton';
|
||||
import Icon from '../../components/UI/Icon';
|
||||
|
||||
import type { MouseEvent } from 'react';
|
||||
import type { AuthenticationPageProps, TranslatedProps } from '../../interface';
|
||||
|
||||
const StyledAuthenticationPage = styled('section')`
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
height: 100vh;
|
||||
`;
|
||||
|
||||
const PageLogoIcon = styled(Icon)`
|
||||
color: #c4c6d2;
|
||||
`;
|
||||
|
||||
const AuthenticationPage = ({
|
||||
inProgress = false,
|
||||
config,
|
||||
onLogin,
|
||||
t,
|
||||
}: TranslatedProps<AuthenticationPageProps>) => {
|
||||
const handleLogin = useCallback(
|
||||
(e: MouseEvent<HTMLButtonElement>) => {
|
||||
e.preventDefault();
|
||||
onLogin({ token: 'fake_token' });
|
||||
},
|
||||
[onLogin],
|
||||
);
|
||||
|
||||
return (
|
||||
<StyledAuthenticationPage>
|
||||
<PageLogoIcon width={300} height={150} type="static-cms" />
|
||||
<Button variant="contained" disabled={inProgress} onClick={handleLogin}>
|
||||
{inProgress ? t('auth.loggingIn') : t('auth.login')}
|
||||
</Button>
|
||||
{config.site_url && <GoBackButton href={config.site_url} t={t}></GoBackButton>}
|
||||
</StyledAuthenticationPage>
|
||||
);
|
||||
};
|
||||
|
||||
export default AuthenticationPage;
|
196
core/src/backends/proxy/implementation.ts
Normal file
196
core/src/backends/proxy/implementation.ts
Normal file
@ -0,0 +1,196 @@
|
||||
import { APIError, basename, blobToFileObj, unsentRequest } from '../../lib/util';
|
||||
import AuthenticationPage from './AuthenticationPage';
|
||||
|
||||
import type {
|
||||
BackendEntry,
|
||||
BackendClass,
|
||||
Config,
|
||||
DisplayURL,
|
||||
ImplementationEntry,
|
||||
ImplementationFile,
|
||||
PersistOptions,
|
||||
User,
|
||||
} from '../../interface';
|
||||
import type { Cursor } from '../../lib/util';
|
||||
import type AssetProxy from '../../valueObjects/AssetProxy';
|
||||
|
||||
async function serializeAsset(assetProxy: AssetProxy) {
|
||||
const base64content = await assetProxy.toBase64!();
|
||||
return { path: assetProxy.path, content: base64content, encoding: 'base64' };
|
||||
}
|
||||
|
||||
type MediaFile = {
|
||||
id: string;
|
||||
content: string;
|
||||
encoding: string;
|
||||
name: string;
|
||||
path: string;
|
||||
};
|
||||
|
||||
function deserializeMediaFile({ id, content, encoding, path, name }: MediaFile) {
|
||||
let byteArray = new Uint8Array(0);
|
||||
if (encoding !== 'base64') {
|
||||
console.error(`Unsupported encoding '${encoding}' for file '${path}'`);
|
||||
} else {
|
||||
const decodedContent = atob(content);
|
||||
byteArray = new Uint8Array(decodedContent.length);
|
||||
for (let i = 0; i < decodedContent.length; i++) {
|
||||
byteArray[i] = decodedContent.charCodeAt(i);
|
||||
}
|
||||
}
|
||||
const blob = new Blob([byteArray]);
|
||||
const file = blobToFileObj(name, blob);
|
||||
const url = URL.createObjectURL(file);
|
||||
return { id, name, path, file, size: file.size, url, displayURL: url };
|
||||
}
|
||||
|
||||
export default class ProxyBackend implements BackendClass {
|
||||
proxyUrl: string;
|
||||
mediaFolder?: string;
|
||||
options: {};
|
||||
branch: string;
|
||||
|
||||
constructor(config: Config, options = {}) {
|
||||
if (!config.backend.proxy_url) {
|
||||
throw new Error('The Proxy backend needs a "proxy_url" in the backend configuration.');
|
||||
}
|
||||
|
||||
this.branch = config.backend.branch || 'main';
|
||||
this.proxyUrl = config.backend.proxy_url;
|
||||
this.mediaFolder = config.media_folder;
|
||||
this.options = options;
|
||||
}
|
||||
|
||||
isGitBackend() {
|
||||
return false;
|
||||
}
|
||||
|
||||
status() {
|
||||
return Promise.resolve({ auth: { status: true }, api: { status: true, statusPage: '' } });
|
||||
}
|
||||
|
||||
authComponent() {
|
||||
return AuthenticationPage;
|
||||
}
|
||||
|
||||
restoreUser() {
|
||||
return this.authenticate();
|
||||
}
|
||||
|
||||
authenticate() {
|
||||
return Promise.resolve() as unknown as Promise<User>;
|
||||
}
|
||||
|
||||
logout() {
|
||||
return null;
|
||||
}
|
||||
|
||||
getToken() {
|
||||
return Promise.resolve('');
|
||||
}
|
||||
|
||||
async request(payload: { action: string; params: Record<string, unknown> }) {
|
||||
const response = await unsentRequest.fetchWithTimeout(this.proxyUrl, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json; charset=utf-8' },
|
||||
body: JSON.stringify({ branch: this.branch, ...payload }),
|
||||
});
|
||||
|
||||
const json = await response.json();
|
||||
|
||||
if (response.ok) {
|
||||
return json;
|
||||
} else {
|
||||
throw new APIError(json.error, response.status, 'Proxy');
|
||||
}
|
||||
}
|
||||
|
||||
entriesByFolder(folder: string, extension: string, depth: number) {
|
||||
return this.request({
|
||||
action: 'entriesByFolder',
|
||||
params: { branch: this.branch, folder, extension, depth },
|
||||
});
|
||||
}
|
||||
|
||||
entriesByFiles(files: ImplementationFile[]) {
|
||||
return this.request({
|
||||
action: 'entriesByFiles',
|
||||
params: { branch: this.branch, files },
|
||||
});
|
||||
}
|
||||
|
||||
getEntry(path: string) {
|
||||
return this.request({
|
||||
action: 'getEntry',
|
||||
params: { branch: this.branch, path },
|
||||
});
|
||||
}
|
||||
|
||||
async persistEntry(entry: BackendEntry, options: PersistOptions) {
|
||||
const assets = await Promise.all(entry.assets.map(serializeAsset));
|
||||
return this.request({
|
||||
action: 'persistEntry',
|
||||
params: {
|
||||
branch: this.branch,
|
||||
dataFiles: entry.dataFiles,
|
||||
assets,
|
||||
options: { ...options },
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
async getMedia(mediaFolder = this.mediaFolder) {
|
||||
const files: { path: string; url: string }[] = await this.request({
|
||||
action: 'getMedia',
|
||||
params: { branch: this.branch, mediaFolder },
|
||||
});
|
||||
|
||||
return files.map(({ url, path }) => {
|
||||
const id = url;
|
||||
const name = basename(path);
|
||||
|
||||
return { id, name, displayURL: { id, path }, path };
|
||||
});
|
||||
}
|
||||
|
||||
async getMediaFile(path: string) {
|
||||
const file = await this.request({
|
||||
action: 'getMediaFile',
|
||||
params: { branch: this.branch, path },
|
||||
});
|
||||
return deserializeMediaFile(file);
|
||||
}
|
||||
|
||||
getMediaDisplayURL(displayURL: DisplayURL) {
|
||||
return Promise.resolve(typeof displayURL === 'string' ? displayURL : displayURL.id);
|
||||
}
|
||||
|
||||
async persistMedia(assetProxy: AssetProxy, options: PersistOptions) {
|
||||
const asset = await serializeAsset(assetProxy);
|
||||
const file: MediaFile = await this.request({
|
||||
action: 'persistMedia',
|
||||
params: { branch: this.branch, asset, options: { commitMessage: options.commitMessage } },
|
||||
});
|
||||
|
||||
return deserializeMediaFile(file);
|
||||
}
|
||||
|
||||
deleteFiles(paths: string[], commitMessage: string) {
|
||||
return this.request({
|
||||
action: 'deleteFiles',
|
||||
params: { branch: this.branch, paths, options: { commitMessage } },
|
||||
});
|
||||
}
|
||||
|
||||
traverseCursor(): Promise<{ entries: ImplementationEntry[]; cursor: Cursor }> {
|
||||
throw new Error('Not supported');
|
||||
}
|
||||
|
||||
allEntriesByFolder(
|
||||
_folder: string,
|
||||
_extension: string,
|
||||
_depth: number,
|
||||
): Promise<ImplementationEntry[]> {
|
||||
throw new Error('Not supported');
|
||||
}
|
||||
}
|
2
core/src/backends/proxy/index.ts
Normal file
2
core/src/backends/proxy/index.ts
Normal file
@ -0,0 +1,2 @@
|
||||
export { default as ProxyBackend } from './implementation';
|
||||
export { default as AuthenticationPage } from './AuthenticationPage';
|
63
core/src/backends/test/AuthenticationPage.tsx
Normal file
63
core/src/backends/test/AuthenticationPage.tsx
Normal file
@ -0,0 +1,63 @@
|
||||
import Button from '@mui/material/Button';
|
||||
import { styled } from '@mui/material/styles';
|
||||
import React, { useCallback, useEffect } from 'react';
|
||||
|
||||
import GoBackButton from '../../components/UI/GoBackButton';
|
||||
import Icon from '../../components/UI/Icon';
|
||||
|
||||
import type { MouseEvent } from 'react';
|
||||
import type { AuthenticationPageProps, TranslatedProps } from '../../interface';
|
||||
|
||||
const StyledAuthenticationPage = styled('section')`
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
height: 100vh;
|
||||
`;
|
||||
|
||||
const PageLogoIcon = styled(Icon)`
|
||||
color: #c4c6d2;
|
||||
`;
|
||||
|
||||
const AuthenticationPage = ({
|
||||
inProgress = false,
|
||||
config,
|
||||
onLogin,
|
||||
t,
|
||||
}: TranslatedProps<AuthenticationPageProps>) => {
|
||||
useEffect(() => {
|
||||
/**
|
||||
* Allow login screen to be skipped for demo purposes.
|
||||
*/
|
||||
const skipLogin = config.backend.login === false;
|
||||
if (skipLogin) {
|
||||
onLogin({ token: 'fake_token' });
|
||||
}
|
||||
}, [config.backend.login, onLogin]);
|
||||
|
||||
const handleLogin = useCallback(
|
||||
(e: MouseEvent<HTMLButtonElement>) => {
|
||||
e.preventDefault();
|
||||
onLogin({ token: 'fake_token' });
|
||||
},
|
||||
[onLogin],
|
||||
);
|
||||
|
||||
return (
|
||||
<StyledAuthenticationPage>
|
||||
<PageLogoIcon width={300} height={150} type="static-cms" />
|
||||
<Button
|
||||
disabled={inProgress}
|
||||
onClick={handleLogin}
|
||||
variant="contained"
|
||||
sx={{ marginBottom: '32px' }}
|
||||
>
|
||||
{inProgress ? t('auth.loggingIn') : t('auth.login')}
|
||||
</Button>
|
||||
{config.site_url && <GoBackButton href={config.site_url} t={t}></GoBackButton>}
|
||||
</StyledAuthenticationPage>
|
||||
);
|
||||
};
|
||||
|
||||
export default AuthenticationPage;
|
292
core/src/backends/test/implementation.ts
Normal file
292
core/src/backends/test/implementation.ts
Normal file
@ -0,0 +1,292 @@
|
||||
import attempt from 'lodash/attempt';
|
||||
import isError from 'lodash/isError';
|
||||
import take from 'lodash/take';
|
||||
import unset from 'lodash/unset';
|
||||
import { extname } from 'path';
|
||||
import uuid from 'uuid/v4';
|
||||
|
||||
import { basename, Cursor, CURSOR_COMPATIBILITY_SYMBOL } from '../../lib/util';
|
||||
import AuthenticationPage from './AuthenticationPage';
|
||||
|
||||
import type {
|
||||
BackendEntry,
|
||||
BackendClass,
|
||||
Config,
|
||||
DisplayURL,
|
||||
ImplementationEntry,
|
||||
ImplementationFile,
|
||||
User,
|
||||
} from '../../interface';
|
||||
import type AssetProxy from '../../valueObjects/AssetProxy';
|
||||
|
||||
type RepoFile = { path: string; content: string | AssetProxy };
|
||||
type RepoTree = { [key: string]: RepoFile | RepoTree };
|
||||
|
||||
declare global {
|
||||
interface Window {
|
||||
repoFiles: RepoTree;
|
||||
}
|
||||
}
|
||||
|
||||
window.repoFiles = window.repoFiles || {};
|
||||
|
||||
function getFile(path: string, tree: RepoTree) {
|
||||
const segments = path.split('/');
|
||||
let obj: RepoTree = tree;
|
||||
while (obj && segments.length) {
|
||||
obj = obj[segments.shift() as string] as RepoTree;
|
||||
}
|
||||
return (obj as unknown as RepoFile) || {};
|
||||
}
|
||||
|
||||
function writeFile(path: string, content: string | AssetProxy, tree: RepoTree) {
|
||||
const segments = path.split('/');
|
||||
let obj = tree;
|
||||
while (segments.length > 1) {
|
||||
const segment = segments.shift() as string;
|
||||
obj[segment] = obj[segment] || {};
|
||||
obj = obj[segment] as RepoTree;
|
||||
}
|
||||
(obj[segments.shift() as string] as RepoFile) = { content, path };
|
||||
}
|
||||
|
||||
function deleteFile(path: string, tree: RepoTree) {
|
||||
unset(tree, path.split('/'));
|
||||
}
|
||||
|
||||
const pageSize = 10;
|
||||
|
||||
function getCursor(
|
||||
folder: string,
|
||||
extension: string,
|
||||
entries: ImplementationEntry[],
|
||||
index: number,
|
||||
depth: number,
|
||||
) {
|
||||
const count = entries.length;
|
||||
const pageCount = Math.floor(count / pageSize);
|
||||
return Cursor.create({
|
||||
actions: [
|
||||
...(index < pageCount ? ['next', 'last'] : []),
|
||||
...(index > 0 ? ['prev', 'first'] : []),
|
||||
],
|
||||
meta: { index, count, pageSize, pageCount },
|
||||
data: { folder, extension, index, pageCount, depth },
|
||||
});
|
||||
}
|
||||
|
||||
export function getFolderFiles(
|
||||
tree: RepoTree,
|
||||
folder: string,
|
||||
extension: string,
|
||||
depth: number,
|
||||
files = [] as RepoFile[],
|
||||
path = folder,
|
||||
) {
|
||||
if (depth <= 0) {
|
||||
return files;
|
||||
}
|
||||
|
||||
Object.keys(tree[folder] || {}).forEach(key => {
|
||||
if (extname(key)) {
|
||||
const file = (tree[folder] as RepoTree)[key] as RepoFile;
|
||||
if (!extension || key.endsWith(`.${extension}`)) {
|
||||
files.unshift({ content: file.content, path: `${path}/${key}` });
|
||||
}
|
||||
} else {
|
||||
const subTree = tree[folder] as RepoTree;
|
||||
return getFolderFiles(subTree, key, extension, depth - 1, files, `${path}/${key}`);
|
||||
}
|
||||
});
|
||||
|
||||
return files;
|
||||
}
|
||||
|
||||
export default class TestBackend implements BackendClass {
|
||||
mediaFolder?: string;
|
||||
options: {};
|
||||
|
||||
constructor(config: Config, options = {}) {
|
||||
this.options = options;
|
||||
this.mediaFolder = config.media_folder;
|
||||
}
|
||||
|
||||
isGitBackend() {
|
||||
return false;
|
||||
}
|
||||
|
||||
status() {
|
||||
return Promise.resolve({ auth: { status: true }, api: { status: true, statusPage: '' } });
|
||||
}
|
||||
|
||||
authComponent() {
|
||||
return AuthenticationPage;
|
||||
}
|
||||
|
||||
restoreUser() {
|
||||
return this.authenticate();
|
||||
}
|
||||
|
||||
authenticate() {
|
||||
return Promise.resolve() as unknown as Promise<User>;
|
||||
}
|
||||
|
||||
logout() {
|
||||
return null;
|
||||
}
|
||||
|
||||
getToken() {
|
||||
return Promise.resolve('');
|
||||
}
|
||||
|
||||
traverseCursor(cursor: Cursor, action: string) {
|
||||
const { folder, extension, index, pageCount, depth } = cursor.data as {
|
||||
folder: string;
|
||||
extension: string;
|
||||
index: number;
|
||||
pageCount: number;
|
||||
depth: number;
|
||||
};
|
||||
const newIndex = (() => {
|
||||
if (action === 'next') {
|
||||
return (index as number) + 1;
|
||||
}
|
||||
if (action === 'prev') {
|
||||
return (index as number) - 1;
|
||||
}
|
||||
if (action === 'first') {
|
||||
return 0;
|
||||
}
|
||||
if (action === 'last') {
|
||||
return pageCount;
|
||||
}
|
||||
return 0;
|
||||
})();
|
||||
// TODO: stop assuming cursors are for collections
|
||||
const allFiles = getFolderFiles(window.repoFiles, folder, extension, depth);
|
||||
const allEntries = allFiles.map(f => ({
|
||||
data: f.content as string,
|
||||
file: { path: f.path, id: f.path },
|
||||
}));
|
||||
const entries = allEntries.slice(newIndex * pageSize, newIndex * pageSize + pageSize);
|
||||
const newCursor = getCursor(folder, extension, allEntries, newIndex, depth);
|
||||
return Promise.resolve({ entries, cursor: newCursor });
|
||||
}
|
||||
|
||||
entriesByFolder(folder: string, extension: string, depth: number) {
|
||||
const files = folder ? getFolderFiles(window.repoFiles, folder, extension, depth) : [];
|
||||
const entries = files.map(f => ({
|
||||
data: f.content as string,
|
||||
file: { path: f.path, id: f.path },
|
||||
}));
|
||||
const cursor = getCursor(folder, extension, entries, 0, depth);
|
||||
const ret = take(entries, pageSize);
|
||||
// TODO Remove
|
||||
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
|
||||
// @ts-ignore
|
||||
ret[CURSOR_COMPATIBILITY_SYMBOL] = cursor;
|
||||
return Promise.resolve(ret);
|
||||
}
|
||||
|
||||
entriesByFiles(files: ImplementationFile[]) {
|
||||
return Promise.all(
|
||||
files.map(file => ({
|
||||
file,
|
||||
data: getFile(file.path, window.repoFiles).content as string,
|
||||
})),
|
||||
);
|
||||
}
|
||||
|
||||
getEntry(path: string) {
|
||||
return Promise.resolve({
|
||||
file: { path, id: null },
|
||||
data: getFile(path, window.repoFiles).content as string,
|
||||
});
|
||||
}
|
||||
|
||||
async persistEntry(entry: BackendEntry) {
|
||||
entry.dataFiles.forEach(dataFile => {
|
||||
const { path, raw } = dataFile;
|
||||
writeFile(path, raw, window.repoFiles);
|
||||
});
|
||||
entry.assets.forEach(a => {
|
||||
writeFile(a.path, a, window.repoFiles);
|
||||
});
|
||||
return Promise.resolve();
|
||||
}
|
||||
|
||||
async getMedia(mediaFolder = this.mediaFolder) {
|
||||
if (!mediaFolder) {
|
||||
return [];
|
||||
}
|
||||
const files = getFolderFiles(window.repoFiles, mediaFolder.split('/')[0], '', 100).filter(f =>
|
||||
f.path.startsWith(mediaFolder),
|
||||
);
|
||||
return files.map(f => this.normalizeAsset(f.content as AssetProxy));
|
||||
}
|
||||
|
||||
async getMediaFile(path: string) {
|
||||
const asset = getFile(path, window.repoFiles).content as AssetProxy;
|
||||
|
||||
const url = asset.toString();
|
||||
const name = basename(path);
|
||||
const blob = await fetch(url).then(res => res.blob());
|
||||
const fileObj = new File([blob], name);
|
||||
|
||||
return {
|
||||
id: url,
|
||||
displayURL: url,
|
||||
path,
|
||||
name,
|
||||
size: fileObj.size,
|
||||
file: fileObj,
|
||||
url,
|
||||
};
|
||||
}
|
||||
|
||||
normalizeAsset(assetProxy: AssetProxy) {
|
||||
const fileObj = assetProxy.fileObj as File;
|
||||
const { name, size } = fileObj;
|
||||
const objectUrl = attempt(window.URL.createObjectURL, fileObj);
|
||||
const url = isError(objectUrl) ? '' : objectUrl;
|
||||
const normalizedAsset = {
|
||||
id: uuid(),
|
||||
name,
|
||||
size,
|
||||
path: assetProxy.path,
|
||||
url,
|
||||
displayURL: url,
|
||||
fileObj,
|
||||
};
|
||||
|
||||
return normalizedAsset;
|
||||
}
|
||||
|
||||
persistMedia(assetProxy: AssetProxy) {
|
||||
const normalizedAsset = this.normalizeAsset(assetProxy);
|
||||
|
||||
writeFile(assetProxy.path, assetProxy, window.repoFiles);
|
||||
|
||||
return Promise.resolve(normalizedAsset);
|
||||
}
|
||||
|
||||
deleteFiles(paths: string[]) {
|
||||
paths.forEach(path => {
|
||||
deleteFile(path, window.repoFiles);
|
||||
});
|
||||
|
||||
return Promise.resolve();
|
||||
}
|
||||
|
||||
async allEntriesByFolder(
|
||||
folder: string,
|
||||
extension: string,
|
||||
depth: number,
|
||||
): Promise<ImplementationEntry[]> {
|
||||
return this.entriesByFolder(folder, extension, depth);
|
||||
}
|
||||
|
||||
getMediaDisplayURL(_displayURL: DisplayURL): Promise<string> {
|
||||
throw new Error('Not supported');
|
||||
}
|
||||
}
|
2
core/src/backends/test/index.ts
Normal file
2
core/src/backends/test/index.ts
Normal file
@ -0,0 +1,2 @@
|
||||
export { default as TestBackend } from './implementation';
|
||||
export { default as AuthenticationPage } from './AuthenticationPage';
|
114
core/src/bootstrap.tsx
Normal file
114
core/src/bootstrap.tsx
Normal file
@ -0,0 +1,114 @@
|
||||
import 'symbol-observable';
|
||||
|
||||
import React from 'react';
|
||||
import { createRoot } from 'react-dom/client';
|
||||
import { I18n } from 'react-polyglot';
|
||||
import { connect, Provider } from 'react-redux';
|
||||
import { HashRouter as Router } from 'react-router-dom';
|
||||
|
||||
import 'what-input';
|
||||
import { authenticateUser } from './actions/auth';
|
||||
import { loadConfig } from './actions/config';
|
||||
import App from './components/App/App';
|
||||
import './components/EditorWidgets';
|
||||
import { ErrorBoundary } from './components/UI';
|
||||
import { addExtensions } from './extensions';
|
||||
import { getPhrases } from './lib/phrases';
|
||||
import './mediaLibrary';
|
||||
import { selectLocale } from './reducers/config';
|
||||
import { store } from './store';
|
||||
|
||||
import type { AnyAction } from '@reduxjs/toolkit';
|
||||
import type { ConnectedProps } from 'react-redux';
|
||||
import type { Config } from './interface';
|
||||
import type { RootState } from './store';
|
||||
|
||||
const ROOT_ID = 'nc-root';
|
||||
|
||||
const TranslatedApp = ({ locale, config }: AppRootProps) => {
|
||||
if (!config) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<I18n locale={locale} messages={getPhrases(locale)}>
|
||||
<ErrorBoundary showBackup config={config}>
|
||||
<Router>
|
||||
<App />
|
||||
</Router>
|
||||
</ErrorBoundary>
|
||||
</I18n>
|
||||
);
|
||||
};
|
||||
|
||||
function mapDispatchToProps(state: RootState) {
|
||||
return { locale: selectLocale(state.config.config), config: state.config.config };
|
||||
}
|
||||
|
||||
const connector = connect(mapDispatchToProps);
|
||||
export type AppRootProps = ConnectedProps<typeof connector>;
|
||||
|
||||
const ConnectedTranslatedApp = connector(TranslatedApp);
|
||||
|
||||
function bootstrap(opts?: { config?: Config; autoInitialize?: boolean }) {
|
||||
const { config, autoInitialize = true } = opts ?? {};
|
||||
|
||||
/**
|
||||
* Log the version number.
|
||||
*/
|
||||
if (typeof STATIC_CMS_CORE_VERSION === 'string') {
|
||||
console.info(`static-cms-core ${STATIC_CMS_CORE_VERSION}`);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get DOM element where app will mount.
|
||||
*/
|
||||
function getRoot() {
|
||||
/**
|
||||
* Return existing root if found.
|
||||
*/
|
||||
const existingRoot = document.getElementById(ROOT_ID);
|
||||
if (existingRoot) {
|
||||
return existingRoot;
|
||||
}
|
||||
|
||||
/**
|
||||
* If no existing root, create and return a new root.
|
||||
*/
|
||||
const newRoot = document.createElement('div');
|
||||
newRoot.id = ROOT_ID;
|
||||
document.body.appendChild(newRoot);
|
||||
return newRoot;
|
||||
}
|
||||
|
||||
if (autoInitialize) {
|
||||
addExtensions();
|
||||
}
|
||||
|
||||
store.dispatch(
|
||||
loadConfig(config, function onLoad() {
|
||||
store.dispatch(authenticateUser() as unknown as AnyAction);
|
||||
}) as AnyAction,
|
||||
);
|
||||
|
||||
/**
|
||||
* Create connected root component.
|
||||
*/
|
||||
function Root() {
|
||||
return (
|
||||
<>
|
||||
<Provider store={store}>
|
||||
<ConnectedTranslatedApp />
|
||||
</Provider>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Render application root.
|
||||
*/
|
||||
const root = createRoot(getRoot());
|
||||
root.render(<Root />);
|
||||
}
|
||||
|
||||
export default bootstrap;
|
264
core/src/components/App/App.tsx
Normal file
264
core/src/components/App/App.tsx
Normal file
@ -0,0 +1,264 @@
|
||||
import { styled } from '@mui/material/styles';
|
||||
import KeyboardArrowUpIcon from '@mui/icons-material/KeyboardArrowUp';
|
||||
import Fab from '@mui/material/Fab';
|
||||
import React, { useCallback, useMemo } from 'react';
|
||||
import { translate } from 'react-polyglot';
|
||||
import { connect } from 'react-redux';
|
||||
import { Navigate, Route, Routes, useParams } from 'react-router-dom';
|
||||
import { ScrollSync } from 'react-scroll-sync';
|
||||
import TopBarProgress from 'react-topbar-progress-indicator';
|
||||
|
||||
import { loginUser as loginUserAction } from '../../actions/auth';
|
||||
import { currentBackend } from '../../backend';
|
||||
import { colors, GlobalStyles } from '../../components/UI/styles';
|
||||
import { history } from '../../routing/history';
|
||||
import CollectionRoute from '../Collection/CollectionRoute';
|
||||
import EditorRoute from '../Editor/EditorRoute';
|
||||
import MediaLibrary from '../MediaLibrary/MediaLibrary';
|
||||
import Snackbars from '../snackbar/Snackbars';
|
||||
import { Alert } from '../UI/Alert';
|
||||
import { Confirm } from '../UI/Confirm';
|
||||
import Loader from '../UI/Loader';
|
||||
import ScrollTop from '../UI/ScrollTop';
|
||||
import NotFoundPage from './NotFoundPage';
|
||||
|
||||
import type { ComponentType } from 'react';
|
||||
import type { ConnectedProps } from 'react-redux';
|
||||
import type { Collections, Credentials, TranslatedProps } from '../../interface';
|
||||
import type { RootState } from '../../store';
|
||||
TopBarProgress.config({
|
||||
barColors: {
|
||||
0: colors.active,
|
||||
'1.0': colors.active,
|
||||
},
|
||||
shadowBlur: 0,
|
||||
barThickness: 2,
|
||||
});
|
||||
|
||||
const AppRoot = styled('div')`
|
||||
width: 100%;
|
||||
min-width: 1200px;
|
||||
height: 100vh;
|
||||
position: relative;
|
||||
`;
|
||||
|
||||
const AppWrapper = styled('div')`
|
||||
width: 100%;
|
||||
min-width: 1200px;
|
||||
min-height: 100vh;
|
||||
`;
|
||||
|
||||
const ErrorContainer = styled('div')`
|
||||
margin: 20px;
|
||||
`;
|
||||
|
||||
const ErrorCodeBlock = styled('pre')`
|
||||
margin-left: 20px;
|
||||
font-size: 15px;
|
||||
line-height: 1.5;
|
||||
`;
|
||||
|
||||
function getDefaultPath(collections: Collections) {
|
||||
const options = Object.values(collections).filter(
|
||||
collection =>
|
||||
collection.hide !== true && (!('files' in collection) || (collection.files?.length ?? 0) > 1),
|
||||
);
|
||||
|
||||
if (options.length > 0) {
|
||||
return `/collections/${options[0].name}`;
|
||||
} else {
|
||||
throw new Error('Could not find a non hidden collection');
|
||||
}
|
||||
}
|
||||
|
||||
function CollectionSearchRedirect() {
|
||||
const { name } = useParams();
|
||||
return <Navigate to={`/collections/${name}`} />;
|
||||
}
|
||||
|
||||
function EditEntityRedirect() {
|
||||
const { name, entryName } = useParams();
|
||||
return <Navigate to={`/collections/${name}/entries/${entryName}`} />;
|
||||
}
|
||||
|
||||
history.listen(e => {
|
||||
console.log(e);
|
||||
});
|
||||
|
||||
const App = ({
|
||||
auth,
|
||||
user,
|
||||
config,
|
||||
collections,
|
||||
loginUser,
|
||||
isFetching,
|
||||
useMediaLibrary,
|
||||
t,
|
||||
scrollSyncEnabled,
|
||||
}: TranslatedProps<AppProps>) => {
|
||||
const configError = useCallback(() => {
|
||||
return (
|
||||
<ErrorContainer>
|
||||
<h1>{t('app.app.errorHeader')}</h1>
|
||||
<div>
|
||||
<strong>{t('app.app.configErrors')}:</strong>
|
||||
<ErrorCodeBlock>{config.error}</ErrorCodeBlock>
|
||||
<span>{t('app.app.checkConfigYml')}</span>
|
||||
</div>
|
||||
</ErrorContainer>
|
||||
);
|
||||
}, [config.error, t]);
|
||||
|
||||
const handleLogin = useCallback(
|
||||
(credentials: Credentials) => {
|
||||
loginUser(credentials);
|
||||
},
|
||||
[loginUser],
|
||||
);
|
||||
|
||||
const authenticating = useCallback(() => {
|
||||
if (!config.config) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const backend = currentBackend(config.config);
|
||||
|
||||
if (backend == null) {
|
||||
return (
|
||||
<div>
|
||||
<h1>{t('app.app.waitingBackend')}</h1>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div>
|
||||
{React.createElement(backend.authComponent(), {
|
||||
onLogin: handleLogin,
|
||||
error: auth.error,
|
||||
inProgress: auth.isFetching,
|
||||
siteId: config.config.backend.site_domain,
|
||||
base_url: config.config.backend.base_url,
|
||||
authEndpoint: config.config.backend.auth_endpoint,
|
||||
config: config.config,
|
||||
clearHash: () => history.replace('/'),
|
||||
t,
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
}, [auth.error, auth.isFetching, config.config, handleLogin, t]);
|
||||
|
||||
const defaultPath = useMemo(() => getDefaultPath(collections), [collections]);
|
||||
|
||||
if (!config.config) {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (config.error) {
|
||||
return configError();
|
||||
}
|
||||
|
||||
if (config.isFetching) {
|
||||
return <Loader>{t('app.app.loadingConfig')}</Loader>;
|
||||
}
|
||||
|
||||
if (!user) {
|
||||
return authenticating();
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<GlobalStyles />
|
||||
<ScrollSync enabled={scrollSyncEnabled}>
|
||||
<>
|
||||
<div id="back-to-top-anchor" />
|
||||
<AppRoot id="cms-root">
|
||||
<AppWrapper className="cms-wrapper">
|
||||
<Snackbars />
|
||||
{isFetching && <TopBarProgress />}
|
||||
<Routes>
|
||||
<Route path="/" element={<Navigate to={defaultPath} />} />
|
||||
<Route path="/search" element={<Navigate to={defaultPath} />} />
|
||||
<Route path="/collections/:name/search/" element={<CollectionSearchRedirect />} />
|
||||
<Route
|
||||
path="/error=access_denied&error_description=Signups+not+allowed+for+this+instance"
|
||||
element={<Navigate to={defaultPath} />}
|
||||
/>
|
||||
<Route
|
||||
path="/collections"
|
||||
element={<CollectionRoute collections={collections} />}
|
||||
/>
|
||||
<Route
|
||||
path="/collections/:name"
|
||||
element={<CollectionRoute collections={collections} />}
|
||||
/>
|
||||
<Route
|
||||
path="/collections/:name/new"
|
||||
element={<EditorRoute collections={collections} newRecord />}
|
||||
/>
|
||||
<Route
|
||||
path="/collections/:name/entries/:slug"
|
||||
element={<EditorRoute collections={collections} />}
|
||||
/>
|
||||
<Route
|
||||
path="/collections/:name/search/:searchTerm"
|
||||
element={
|
||||
<CollectionRoute
|
||||
collections={collections}
|
||||
isSearchResults
|
||||
isSingleSearchResult
|
||||
/>
|
||||
}
|
||||
/>
|
||||
<Route
|
||||
path="/collections/:name/filter/:filterTerm"
|
||||
element={<CollectionRoute collections={collections} />}
|
||||
/>
|
||||
<Route
|
||||
path="/search/:searchTerm"
|
||||
element={<CollectionRoute collections={collections} isSearchResults />}
|
||||
/>
|
||||
<Route path="/edit/:name/:entryName" element={<EditEntityRedirect />} />
|
||||
<Route element={<NotFoundPage />} />
|
||||
</Routes>
|
||||
{useMediaLibrary ? <MediaLibrary /> : null}
|
||||
<Alert />
|
||||
<Confirm />
|
||||
</AppWrapper>
|
||||
</AppRoot>
|
||||
<ScrollTop>
|
||||
<Fab size="small" aria-label="scroll back to top">
|
||||
<KeyboardArrowUpIcon />
|
||||
</Fab>
|
||||
</ScrollTop>
|
||||
</>
|
||||
</ScrollSync>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
function mapStateToProps(state: RootState) {
|
||||
const { auth, config, collections, globalUI, mediaLibrary, scroll } = state;
|
||||
const user = auth.user;
|
||||
const isFetching = globalUI.isFetching;
|
||||
const useMediaLibrary = !mediaLibrary.externalLibrary;
|
||||
const scrollSyncEnabled = scroll.isScrolling;
|
||||
return {
|
||||
auth,
|
||||
config,
|
||||
collections,
|
||||
user,
|
||||
isFetching,
|
||||
useMediaLibrary,
|
||||
scrollSyncEnabled,
|
||||
};
|
||||
}
|
||||
|
||||
const mapDispatchToProps = {
|
||||
loginUser: loginUserAction,
|
||||
};
|
||||
|
||||
const connector = connect(mapStateToProps, mapDispatchToProps);
|
||||
export type AppProps = ConnectedProps<typeof connector>;
|
||||
|
||||
export default connector(translate()(App) as ComponentType<AppProps>);
|
212
core/src/components/App/Header.tsx
Normal file
212
core/src/components/App/Header.tsx
Normal file
@ -0,0 +1,212 @@
|
||||
import { styled } from '@mui/material/styles';
|
||||
import DescriptionIcon from '@mui/icons-material/Description';
|
||||
import ImageIcon from '@mui/icons-material/Image';
|
||||
import KeyboardArrowDownIcon from '@mui/icons-material/KeyboardArrowDown';
|
||||
import OpenInNewIcon from '@mui/icons-material/OpenInNew';
|
||||
import AppBar from '@mui/material/AppBar';
|
||||
import Button from '@mui/material/Button';
|
||||
import Link from '@mui/material/Link';
|
||||
import Menu from '@mui/material/Menu';
|
||||
import MenuItem from '@mui/material/MenuItem';
|
||||
import Toolbar from '@mui/material/Toolbar';
|
||||
import React, { useCallback, useEffect, useMemo } from 'react';
|
||||
import { translate } from 'react-polyglot';
|
||||
import { connect } from 'react-redux';
|
||||
|
||||
import { logoutUser as logoutUserAction } from '../../actions/auth';
|
||||
import { createNewEntry } from '../../actions/collections';
|
||||
import { openMediaLibrary as openMediaLibraryAction } from '../../actions/mediaLibrary';
|
||||
import { checkBackendStatus as checkBackendStatusAction } from '../../actions/status';
|
||||
import { buttons, colors } from '../../components/UI/styles';
|
||||
import { stripProtocol } from '../../lib/urlHelper';
|
||||
import NavLink from '../UI/NavLink';
|
||||
import SettingsDropdown from '../UI/SettingsDropdown';
|
||||
|
||||
import type { ComponentType } from 'react';
|
||||
import type { ConnectedProps } from 'react-redux';
|
||||
import type { TranslatedProps } from '../../interface';
|
||||
import type { RootState } from '../../store';
|
||||
|
||||
const StyledAppBar = styled(AppBar)`
|
||||
background-color: ${colors.foreground};
|
||||
`;
|
||||
|
||||
const StyledToolbar = styled(Toolbar)`
|
||||
gap: 12px;
|
||||
`;
|
||||
|
||||
const StyledButton = styled(Button)`
|
||||
${buttons.button};
|
||||
background: none;
|
||||
color: #7b8290;
|
||||
font-family: inherit;
|
||||
font-size: 16px;
|
||||
font-weight: 500;
|
||||
text-transform: none;
|
||||
gap: 2px;
|
||||
|
||||
&:hover,
|
||||
&:active,
|
||||
&:focus {
|
||||
color: ${colors.active};
|
||||
}
|
||||
`;
|
||||
|
||||
const StyledSpacer = styled('div')`
|
||||
flex-grow: 1;
|
||||
`;
|
||||
|
||||
const StyledAppHeaderActions = styled('div')`
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
`;
|
||||
|
||||
const Header = ({
|
||||
user,
|
||||
collections,
|
||||
logoutUser,
|
||||
openMediaLibrary,
|
||||
displayUrl,
|
||||
isTestRepo,
|
||||
t,
|
||||
showMediaButton,
|
||||
checkBackendStatus,
|
||||
}: TranslatedProps<HeaderProps>) => {
|
||||
const [anchorEl, setAnchorEl] = React.useState<null | HTMLElement>(null);
|
||||
const open = Boolean(anchorEl);
|
||||
const handleClick = useCallback((event: React.MouseEvent<HTMLButtonElement>) => {
|
||||
setAnchorEl(event.currentTarget);
|
||||
}, []);
|
||||
const handleClose = useCallback(() => {
|
||||
setAnchorEl(null);
|
||||
}, []);
|
||||
|
||||
const handleCreatePostClick = useCallback((collectionName: string) => {
|
||||
createNewEntry(collectionName);
|
||||
}, []);
|
||||
|
||||
const createableCollections = useMemo(
|
||||
() => Object.values(collections).filter(collection => collection.create),
|
||||
[collections],
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
const intervalId = setInterval(() => {
|
||||
checkBackendStatus();
|
||||
}, 5 * 60 * 1000);
|
||||
|
||||
return () => {
|
||||
clearInterval(intervalId);
|
||||
};
|
||||
}, [checkBackendStatus]);
|
||||
|
||||
const handleMediaClick = useCallback(() => {
|
||||
openMediaLibrary();
|
||||
}, [openMediaLibrary]);
|
||||
|
||||
return (
|
||||
<StyledAppBar position="sticky">
|
||||
<StyledToolbar>
|
||||
<Link to="/collections" component={NavLink} activeClassName={'header-link-active'}>
|
||||
<DescriptionIcon />
|
||||
{t('app.header.content')}
|
||||
</Link>
|
||||
{showMediaButton ? (
|
||||
<StyledButton onClick={handleMediaClick}>
|
||||
<ImageIcon />
|
||||
{t('app.header.media')}
|
||||
</StyledButton>
|
||||
) : null}
|
||||
<StyledSpacer />
|
||||
<StyledAppHeaderActions>
|
||||
{createableCollections.length > 0 && (
|
||||
<div key="quick-create">
|
||||
<Button
|
||||
id="quick-create-button"
|
||||
aria-controls={open ? 'quick-create-menu' : undefined}
|
||||
aria-haspopup="true"
|
||||
aria-expanded={open ? 'true' : undefined}
|
||||
onClick={handleClick}
|
||||
variant="contained"
|
||||
endIcon={<KeyboardArrowDownIcon />}
|
||||
>
|
||||
{t('app.header.quickAdd')}
|
||||
</Button>
|
||||
<Menu
|
||||
id="quick-create-menu"
|
||||
anchorEl={anchorEl}
|
||||
open={open}
|
||||
onClose={handleClose}
|
||||
MenuListProps={{
|
||||
'aria-labelledby': 'quick-create-button',
|
||||
}}
|
||||
>
|
||||
{createableCollections.map(collection => (
|
||||
<MenuItem
|
||||
key={collection.name}
|
||||
onClick={() => handleCreatePostClick(collection.name)}
|
||||
>
|
||||
{collection.label_singular || collection.label}
|
||||
</MenuItem>
|
||||
))}
|
||||
</Menu>
|
||||
</div>
|
||||
)}
|
||||
{isTestRepo && (
|
||||
<Button
|
||||
href="https://staticjscms.github.io/static-cms/docs/test-backend"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
sx={{ textTransform: 'none' }}
|
||||
endIcon={<OpenInNewIcon />}
|
||||
>
|
||||
Test Backend
|
||||
</Button>
|
||||
)}
|
||||
{displayUrl ? (
|
||||
<Button
|
||||
href={displayUrl}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
sx={{ textTransform: 'none' }}
|
||||
endIcon={<OpenInNewIcon />}
|
||||
>
|
||||
{stripProtocol(displayUrl)}
|
||||
</Button>
|
||||
) : null}
|
||||
<SettingsDropdown
|
||||
displayUrl={displayUrl}
|
||||
isTestRepo={isTestRepo}
|
||||
imageUrl={user?.avatar_url}
|
||||
onLogoutClick={logoutUser}
|
||||
/>
|
||||
</StyledAppHeaderActions>
|
||||
</StyledToolbar>
|
||||
</StyledAppBar>
|
||||
);
|
||||
};
|
||||
|
||||
function mapStateToProps(state: RootState) {
|
||||
const { auth, config, collections, mediaLibrary } = state;
|
||||
const user = auth.user;
|
||||
const showMediaButton = mediaLibrary.showMediaButton;
|
||||
return {
|
||||
user,
|
||||
collections,
|
||||
displayUrl: config.config?.display_url,
|
||||
isTestRepo: config.config?.backend.name === 'test-repo',
|
||||
showMediaButton,
|
||||
};
|
||||
}
|
||||
|
||||
const mapDispatchToProps = {
|
||||
checkBackendStatus: checkBackendStatusAction,
|
||||
openMediaLibrary: openMediaLibraryAction,
|
||||
logoutUser: logoutUserAction,
|
||||
};
|
||||
|
||||
const connector = connect(mapStateToProps, mapDispatchToProps);
|
||||
export type HeaderProps = ConnectedProps<typeof connector>;
|
||||
|
||||
export default connector(translate()(Header) as ComponentType<HeaderProps>);
|
49
core/src/components/App/MainView.tsx
Normal file
49
core/src/components/App/MainView.tsx
Normal file
@ -0,0 +1,49 @@
|
||||
import { styled } from '@mui/material/styles';
|
||||
import React from 'react';
|
||||
import TopBarProgress from 'react-topbar-progress-indicator';
|
||||
|
||||
import { colors } from '../../components/UI/styles';
|
||||
import Header from './Header';
|
||||
|
||||
import type { ReactNode } from 'react';
|
||||
|
||||
TopBarProgress.config({
|
||||
barColors: {
|
||||
0: colors.active,
|
||||
'1.0': colors.active,
|
||||
},
|
||||
shadowBlur: 0,
|
||||
barThickness: 2,
|
||||
});
|
||||
|
||||
const StyledMainContainerWrapper = styled('div')`
|
||||
position: relative;
|
||||
padding: 24px;
|
||||
gap: 24px;
|
||||
`;
|
||||
|
||||
const StyledMainContainer = styled('div')`
|
||||
min-width: 1200px;
|
||||
max-width: 1440px;
|
||||
margin: 0 auto;
|
||||
display: flex;
|
||||
gap: 24px;
|
||||
position: relative;
|
||||
`;
|
||||
|
||||
interface MainViewProps {
|
||||
children: ReactNode;
|
||||
}
|
||||
|
||||
const MainView = ({ children }: MainViewProps) => {
|
||||
return (
|
||||
<>
|
||||
<Header />
|
||||
<StyledMainContainerWrapper>
|
||||
<StyledMainContainer>{children}</StyledMainContainer>
|
||||
</StyledMainContainerWrapper>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
export default MainView;
|
22
core/src/components/App/NotFoundPage.tsx
Normal file
22
core/src/components/App/NotFoundPage.tsx
Normal file
@ -0,0 +1,22 @@
|
||||
import { styled } from '@mui/material/styles';
|
||||
import React from 'react';
|
||||
import { translate } from 'react-polyglot';
|
||||
|
||||
import { lengths } from '../../components/UI/styles';
|
||||
|
||||
import type { ComponentType } from 'react';
|
||||
import type { TranslateProps } from 'react-polyglot';
|
||||
|
||||
const NotFoundContainer = styled('div')`
|
||||
margin: ${lengths.pageMargin};
|
||||
`;
|
||||
|
||||
const NotFoundPage = ({ t }: TranslateProps) => {
|
||||
return (
|
||||
<NotFoundContainer>
|
||||
<h2>{t('app.notFoundPage.header')}</h2>
|
||||
</NotFoundContainer>
|
||||
);
|
||||
};
|
||||
|
||||
export default translate()(NotFoundPage) as ComponentType<{}>;
|
296
core/src/components/Collection/Collection.tsx
Normal file
296
core/src/components/Collection/Collection.tsx
Normal file
@ -0,0 +1,296 @@
|
||||
import { styled } from '@mui/material/styles';
|
||||
import React, { useCallback, useEffect, useMemo, useState } from 'react';
|
||||
import { translate } from 'react-polyglot';
|
||||
import { connect } from 'react-redux';
|
||||
|
||||
import {
|
||||
changeViewStyle as changeViewStyleAction,
|
||||
filterByField as filterByFieldAction,
|
||||
groupByField as groupByFieldAction,
|
||||
sortByField as sortByFieldAction,
|
||||
} from '../../actions/entries';
|
||||
import { components } from '../../components/UI/styles';
|
||||
import { SortDirection } from '../../interface';
|
||||
import { getNewEntryUrl } from '../../lib/urlHelper';
|
||||
import {
|
||||
selectSortableFields,
|
||||
selectViewFilters,
|
||||
selectViewGroups,
|
||||
} from '../../lib/util/collection.util';
|
||||
import {
|
||||
selectEntriesFilter,
|
||||
selectEntriesGroup,
|
||||
selectEntriesSort,
|
||||
selectViewStyle,
|
||||
} from '../../reducers/entries';
|
||||
import CollectionControls from './CollectionControls';
|
||||
import CollectionTop from './CollectionTop';
|
||||
import EntriesCollection from './Entries/EntriesCollection';
|
||||
import EntriesSearch from './Entries/EntriesSearch';
|
||||
import Sidebar from './Sidebar';
|
||||
|
||||
import type { ComponentType } from 'react';
|
||||
import type { ConnectedProps } from 'react-redux';
|
||||
import type { Collection, TranslatedProps, ViewFilter, ViewGroup } from '../../interface';
|
||||
import type { RootState } from '../../store';
|
||||
|
||||
const CollectionMain = styled('main')`
|
||||
width: 100%;
|
||||
`;
|
||||
|
||||
const SearchResultContainer = styled('div')`
|
||||
${components.cardTop};
|
||||
margin-bottom: 22px;
|
||||
`;
|
||||
|
||||
const SearchResultHeading = styled('h1')`
|
||||
${components.cardTopHeading};
|
||||
`;
|
||||
|
||||
const CollectionView = ({
|
||||
collection,
|
||||
collections,
|
||||
collectionName,
|
||||
isSearchEnabled,
|
||||
isSearchResults,
|
||||
isSingleSearchResult,
|
||||
searchTerm,
|
||||
sortableFields,
|
||||
sortByField,
|
||||
sort,
|
||||
viewFilters,
|
||||
viewGroups,
|
||||
filterTerm,
|
||||
t,
|
||||
filterByField,
|
||||
groupByField,
|
||||
filter,
|
||||
group,
|
||||
changeViewStyle,
|
||||
viewStyle,
|
||||
}: TranslatedProps<CollectionViewProps>) => {
|
||||
const [readyToLoad, setReadyToLoad] = useState(false);
|
||||
const [prevCollection, setPrevCollection] = useState<Collection | null>();
|
||||
|
||||
useEffect(() => {
|
||||
setPrevCollection(collection);
|
||||
}, [collection]);
|
||||
|
||||
const newEntryUrl = useMemo(() => {
|
||||
let url = collection.create ? getNewEntryUrl(collectionName) : '';
|
||||
if (url && filterTerm) {
|
||||
url = getNewEntryUrl(collectionName);
|
||||
if (filterTerm) {
|
||||
url = `${newEntryUrl}?path=${filterTerm}`;
|
||||
}
|
||||
}
|
||||
return url;
|
||||
}, [collection, collectionName, filterTerm]);
|
||||
|
||||
const searchResultKey = useMemo(
|
||||
() => `collection.collectionTop.searchResults${isSingleSearchResult ? 'InCollection' : ''}`,
|
||||
[isSingleSearchResult],
|
||||
);
|
||||
|
||||
const entries = useMemo(() => {
|
||||
if (isSearchResults) {
|
||||
let searchCollections = collections;
|
||||
if (isSingleSearchResult) {
|
||||
const searchCollection = Object.values(collections).filter(c => c === collection);
|
||||
if (searchCollection.length === 1) {
|
||||
searchCollections = {
|
||||
[searchCollection[0].name]: searchCollection[0],
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
return <EntriesSearch collections={searchCollections} searchTerm={searchTerm} />;
|
||||
}
|
||||
|
||||
return (
|
||||
<EntriesCollection
|
||||
collection={collection}
|
||||
viewStyle={viewStyle}
|
||||
filterTerm={filterTerm}
|
||||
readyToLoad={readyToLoad && collection === prevCollection}
|
||||
/>
|
||||
);
|
||||
}, [
|
||||
collection,
|
||||
collections,
|
||||
filterTerm,
|
||||
isSearchResults,
|
||||
isSingleSearchResult,
|
||||
prevCollection,
|
||||
readyToLoad,
|
||||
searchTerm,
|
||||
viewStyle,
|
||||
]);
|
||||
|
||||
const onSortClick = useCallback(
|
||||
async (key: string, direction?: SortDirection) => {
|
||||
await sortByField(collection, key, direction);
|
||||
},
|
||||
[collection, sortByField],
|
||||
);
|
||||
|
||||
const onFilterClick = useCallback(
|
||||
async (filter: ViewFilter) => {
|
||||
await filterByField(collection, filter);
|
||||
},
|
||||
[collection, filterByField],
|
||||
);
|
||||
|
||||
const onGroupClick = useCallback(
|
||||
async (group: ViewGroup) => {
|
||||
await groupByField(collection, group);
|
||||
},
|
||||
[collection, groupByField],
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
if (prevCollection === collection) {
|
||||
if (!readyToLoad) {
|
||||
setReadyToLoad(true);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
if (sort?.[0]?.key) {
|
||||
if (!readyToLoad) {
|
||||
setReadyToLoad(true);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
const defaultSort = collection.sortable_fields.default;
|
||||
if (!defaultSort || !defaultSort.field) {
|
||||
if (!readyToLoad) {
|
||||
setReadyToLoad(true);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
setReadyToLoad(false);
|
||||
|
||||
let alive = true;
|
||||
|
||||
const sortEntries = () => {
|
||||
setTimeout(async () => {
|
||||
await onSortClick(defaultSort.field, defaultSort.direction ?? SortDirection.Ascending);
|
||||
|
||||
if (alive) {
|
||||
setReadyToLoad(true);
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
sortEntries();
|
||||
|
||||
return () => {
|
||||
alive = false;
|
||||
};
|
||||
}, [collection, onSortClick, prevCollection, readyToLoad, sort]);
|
||||
|
||||
return (
|
||||
<>
|
||||
<Sidebar
|
||||
collections={collections}
|
||||
collection={(!isSearchResults || isSingleSearchResult) && collection}
|
||||
isSearchEnabled={isSearchEnabled}
|
||||
searchTerm={searchTerm}
|
||||
filterTerm={filterTerm}
|
||||
/>
|
||||
<CollectionMain>
|
||||
<>
|
||||
{isSearchResults ? (
|
||||
<SearchResultContainer>
|
||||
<SearchResultHeading>
|
||||
{t(searchResultKey, { searchTerm, collection: collection.label })}
|
||||
</SearchResultHeading>
|
||||
</SearchResultContainer>
|
||||
) : (
|
||||
<>
|
||||
<CollectionTop collection={collection} newEntryUrl={newEntryUrl} />
|
||||
<CollectionControls
|
||||
viewStyle={viewStyle}
|
||||
onChangeViewStyle={changeViewStyle}
|
||||
sortableFields={sortableFields}
|
||||
onSortClick={onSortClick}
|
||||
sort={sort}
|
||||
viewFilters={viewFilters}
|
||||
viewGroups={viewGroups}
|
||||
t={t}
|
||||
onFilterClick={onFilterClick}
|
||||
onGroupClick={onGroupClick}
|
||||
filter={filter}
|
||||
group={group}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
{entries}
|
||||
</>
|
||||
</CollectionMain>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
interface CollectionViewOwnProps {
|
||||
isSearchResults?: boolean;
|
||||
isSingleSearchResult?: boolean;
|
||||
name: string;
|
||||
searchTerm?: string;
|
||||
filterTerm?: string;
|
||||
}
|
||||
|
||||
function mapStateToProps(state: RootState, ownProps: TranslatedProps<CollectionViewOwnProps>) {
|
||||
const { collections } = state;
|
||||
const isSearchEnabled = state.config.config && state.config.config.search != false;
|
||||
const {
|
||||
isSearchResults,
|
||||
isSingleSearchResult,
|
||||
name,
|
||||
searchTerm = '',
|
||||
filterTerm = '',
|
||||
t,
|
||||
} = ownProps;
|
||||
const collection: Collection = name ? collections[name] : collections[0];
|
||||
const sort = selectEntriesSort(state.entries, collection.name);
|
||||
const sortableFields = selectSortableFields(collection, t);
|
||||
const viewFilters = selectViewFilters(collection);
|
||||
const viewGroups = selectViewGroups(collection);
|
||||
const filter = selectEntriesFilter(state.entries, collection.name);
|
||||
const group = selectEntriesGroup(state.entries, collection.name);
|
||||
const viewStyle = selectViewStyle(state.entries);
|
||||
|
||||
return {
|
||||
isSearchResults,
|
||||
isSingleSearchResult,
|
||||
name,
|
||||
searchTerm,
|
||||
filterTerm,
|
||||
collection,
|
||||
collections,
|
||||
collectionName: name,
|
||||
isSearchEnabled,
|
||||
sort,
|
||||
sortableFields,
|
||||
viewFilters,
|
||||
viewGroups,
|
||||
filter,
|
||||
group,
|
||||
viewStyle,
|
||||
};
|
||||
}
|
||||
|
||||
const mapDispatchToProps = {
|
||||
sortByField: sortByFieldAction,
|
||||
filterByField: filterByFieldAction,
|
||||
changeViewStyle: changeViewStyleAction,
|
||||
groupByField: groupByFieldAction,
|
||||
};
|
||||
|
||||
const connector = connect(mapStateToProps, mapDispatchToProps);
|
||||
export type CollectionViewProps = ConnectedProps<typeof connector>;
|
||||
|
||||
export default translate()(connector(CollectionView)) as ComponentType<CollectionViewOwnProps>;
|
82
core/src/components/Collection/CollectionControls.tsx
Normal file
82
core/src/components/Collection/CollectionControls.tsx
Normal file
@ -0,0 +1,82 @@
|
||||
import { styled } from '@mui/material/styles';
|
||||
import React from 'react';
|
||||
|
||||
import FilterControl from './FilterControl';
|
||||
import GroupControl from './GroupControl';
|
||||
import SortControl from './SortControl';
|
||||
import ViewStyleControl from './ViewStyleControl';
|
||||
|
||||
import type { CollectionViewStyle } from '../../constants/collectionViews';
|
||||
import type {
|
||||
FilterMap,
|
||||
GroupMap,
|
||||
SortableField,
|
||||
SortDirection,
|
||||
SortMap,
|
||||
TranslatedProps,
|
||||
ViewFilter,
|
||||
ViewGroup,
|
||||
} from '../../interface';
|
||||
|
||||
const CollectionControlsContainer = styled('div')`
|
||||
display: flex;
|
||||
align-items: center;
|
||||
flex-direction: row-reverse;
|
||||
margin-top: 22px;
|
||||
max-width: 100%;
|
||||
|
||||
& > div {
|
||||
margin-left: 6px;
|
||||
}
|
||||
`;
|
||||
|
||||
interface CollectionControlsProps {
|
||||
viewStyle: CollectionViewStyle;
|
||||
onChangeViewStyle: (viewStyle: CollectionViewStyle) => void;
|
||||
sortableFields: SortableField[];
|
||||
onSortClick: (key: string, direction?: SortDirection) => Promise<void>;
|
||||
sort: SortMap | undefined;
|
||||
filter: Record<string, FilterMap>;
|
||||
viewFilters: ViewFilter[];
|
||||
onFilterClick: (filter: ViewFilter) => void;
|
||||
group: Record<string, GroupMap>;
|
||||
viewGroups: ViewGroup[];
|
||||
onGroupClick: (filter: ViewGroup) => void;
|
||||
}
|
||||
|
||||
const CollectionControls = ({
|
||||
viewStyle,
|
||||
onChangeViewStyle,
|
||||
sortableFields,
|
||||
onSortClick,
|
||||
sort,
|
||||
viewFilters,
|
||||
viewGroups,
|
||||
onFilterClick,
|
||||
onGroupClick,
|
||||
t,
|
||||
filter,
|
||||
group,
|
||||
}: TranslatedProps<CollectionControlsProps>) => {
|
||||
return (
|
||||
<CollectionControlsContainer>
|
||||
<ViewStyleControl viewStyle={viewStyle} onChangeViewStyle={onChangeViewStyle} />
|
||||
{viewGroups.length > 0 && (
|
||||
<GroupControl viewGroups={viewGroups} onGroupClick={onGroupClick} t={t} group={group} />
|
||||
)}
|
||||
{viewFilters.length > 0 && (
|
||||
<FilterControl
|
||||
viewFilters={viewFilters}
|
||||
onFilterClick={onFilterClick}
|
||||
t={t}
|
||||
filter={filter}
|
||||
/>
|
||||
)}
|
||||
{sortableFields.length > 0 && (
|
||||
<SortControl fields={sortableFields} sort={sort} onSortClick={onSortClick} />
|
||||
)}
|
||||
</CollectionControlsContainer>
|
||||
);
|
||||
};
|
||||
|
||||
export default CollectionControls;
|
60
core/src/components/Collection/CollectionRoute.tsx
Normal file
60
core/src/components/Collection/CollectionRoute.tsx
Normal file
@ -0,0 +1,60 @@
|
||||
import React, { useMemo } from 'react';
|
||||
import { Navigate, useParams } from 'react-router-dom';
|
||||
|
||||
import MainView from '../App/MainView';
|
||||
import Collection from './Collection';
|
||||
|
||||
import type { Collections } from '../../interface';
|
||||
|
||||
function getDefaultPath(collections: Collections) {
|
||||
const first = Object.values(collections).filter(collection => collection.hide !== true)[0];
|
||||
if (first) {
|
||||
return `/collections/${first.name}`;
|
||||
} else {
|
||||
throw new Error('Could not find a non hidden collection');
|
||||
}
|
||||
}
|
||||
|
||||
interface CollectionRouteProps {
|
||||
isSearchResults?: boolean;
|
||||
isSingleSearchResult?: boolean;
|
||||
collections: Collections;
|
||||
}
|
||||
|
||||
const CollectionRoute = ({
|
||||
isSearchResults,
|
||||
isSingleSearchResult,
|
||||
collections,
|
||||
}: CollectionRouteProps) => {
|
||||
const { name, searchTerm, filterTerm } = useParams();
|
||||
const collection = useMemo(() => {
|
||||
if (!name) {
|
||||
return false;
|
||||
}
|
||||
return collections[name];
|
||||
}, [collections, name]);
|
||||
|
||||
const defaultPath = useMemo(() => getDefaultPath(collections), [collections]);
|
||||
|
||||
if (!name || !collection) {
|
||||
return <Navigate to={defaultPath} />;
|
||||
}
|
||||
|
||||
if ('files' in collection && collection.files?.length === 1) {
|
||||
return <Navigate to={`/collections/${collection.name}/entries/${collection.files[0].name}`} />;
|
||||
}
|
||||
|
||||
return (
|
||||
<MainView>
|
||||
<Collection
|
||||
name={name}
|
||||
searchTerm={searchTerm}
|
||||
filterTerm={filterTerm}
|
||||
isSearchResults={isSearchResults}
|
||||
isSingleSearchResult={isSingleSearchResult}
|
||||
/>
|
||||
</MainView>
|
||||
);
|
||||
};
|
||||
|
||||
export default CollectionRoute;
|
229
core/src/components/Collection/CollectionSearch.tsx
Normal file
229
core/src/components/Collection/CollectionSearch.tsx
Normal file
@ -0,0 +1,229 @@
|
||||
import { styled } from '@mui/material/styles';
|
||||
import SearchIcon from '@mui/icons-material/Search';
|
||||
import InputAdornment from '@mui/material/InputAdornment';
|
||||
import TextField from '@mui/material/TextField';
|
||||
import React, { useCallback, useEffect, useState } from 'react';
|
||||
import { translate } from 'react-polyglot';
|
||||
|
||||
import { transientOptions } from '../../lib';
|
||||
import { colors, colorsRaw, lengths, zIndex } from '../../components/UI/styles';
|
||||
|
||||
import type { KeyboardEvent, MouseEvent } from 'react';
|
||||
import type { Collection, Collections, TranslatedProps } from '../../interface';
|
||||
|
||||
const SearchContainer = styled('div')`
|
||||
position: relative;
|
||||
`;
|
||||
|
||||
const SuggestionsContainer = styled('div')`
|
||||
position: relative;
|
||||
width: 100%;
|
||||
`;
|
||||
|
||||
const Suggestions = styled('ul')`
|
||||
position: absolute;
|
||||
top: 0px;
|
||||
left: 0;
|
||||
right: 0;
|
||||
padding: 10px 0;
|
||||
margin: 0;
|
||||
list-style: none;
|
||||
background-color: #fff;
|
||||
border-radius: ${lengths.borderRadius};
|
||||
border: 1px solid ${colors.textFieldBorder};
|
||||
z-index: ${zIndex.zIndex1};
|
||||
`;
|
||||
|
||||
const SuggestionHeader = styled('li')`
|
||||
padding: 0 6px 6px 32px;
|
||||
font-size: 12px;
|
||||
color: ${colors.text};
|
||||
`;
|
||||
|
||||
interface SuggestionItemProps {
|
||||
$isActive: boolean;
|
||||
}
|
||||
|
||||
const SuggestionItem = styled(
|
||||
'li',
|
||||
transientOptions,
|
||||
)<SuggestionItemProps>(
|
||||
({ $isActive }) => `
|
||||
color: ${$isActive ? colors.active : colorsRaw.grayDark};
|
||||
background-color: ${$isActive ? colors.activeBackground : 'inherit'};
|
||||
padding: 6px 6px 6px 32px;
|
||||
cursor: pointer;
|
||||
position: relative;
|
||||
|
||||
&:hover {
|
||||
color: ${colors.active};
|
||||
background-color: ${colors.activeBackground};
|
||||
}
|
||||
`,
|
||||
);
|
||||
|
||||
const SuggestionDivider = styled('div')`
|
||||
width: 100%;
|
||||
`;
|
||||
|
||||
interface CollectionSearchProps {
|
||||
collections: Collections;
|
||||
collection?: Collection;
|
||||
searchTerm: string;
|
||||
onSubmit: (query: string, collection?: string) => void;
|
||||
}
|
||||
|
||||
const CollectionSearch = ({
|
||||
collections,
|
||||
collection,
|
||||
searchTerm,
|
||||
onSubmit,
|
||||
t,
|
||||
}: TranslatedProps<CollectionSearchProps>) => {
|
||||
const [query, setQuery] = useState(searchTerm);
|
||||
const [suggestionsVisible, setSuggestionsVisible] = useState(false);
|
||||
|
||||
const getSelectedSelectionBasedOnProps = useCallback(() => {
|
||||
return collection ? Object.keys(collections).indexOf(collection.name) : -1;
|
||||
}, [collection, collections]);
|
||||
|
||||
const [selectedCollectionIdx, setSelectedCollectionIdx] = useState(
|
||||
getSelectedSelectionBasedOnProps(),
|
||||
);
|
||||
const [prevCollection, setPrevCollection] = useState(collection);
|
||||
|
||||
useEffect(() => {
|
||||
if (prevCollection !== collection) {
|
||||
setSelectedCollectionIdx(getSelectedSelectionBasedOnProps());
|
||||
}
|
||||
setPrevCollection(collection);
|
||||
}, [collection, getSelectedSelectionBasedOnProps, prevCollection]);
|
||||
|
||||
const toggleSuggestions = useCallback((visible: boolean) => {
|
||||
setSuggestionsVisible(visible);
|
||||
}, []);
|
||||
|
||||
const selectNextSuggestion = useCallback(() => {
|
||||
setSelectedCollectionIdx(
|
||||
Math.min(selectedCollectionIdx + 1, Object.keys(collections).length - 1),
|
||||
);
|
||||
}, [collections, selectedCollectionIdx]);
|
||||
|
||||
const selectPreviousSuggestion = useCallback(() => {
|
||||
setSelectedCollectionIdx(Math.max(selectedCollectionIdx - 1, -1));
|
||||
}, [selectedCollectionIdx]);
|
||||
|
||||
const resetSelectedSuggestion = useCallback(() => {
|
||||
setSelectedCollectionIdx(-1);
|
||||
}, []);
|
||||
|
||||
const submitSearch = useCallback(() => {
|
||||
toggleSuggestions(false);
|
||||
if (selectedCollectionIdx !== -1) {
|
||||
onSubmit(query, Object.values(collections)[selectedCollectionIdx]?.name);
|
||||
} else {
|
||||
onSubmit(query);
|
||||
}
|
||||
}, [collections, onSubmit, query, selectedCollectionIdx, toggleSuggestions]);
|
||||
|
||||
const handleKeyDown = useCallback(
|
||||
(event: KeyboardEvent) => {
|
||||
if (event.key === 'Enter') {
|
||||
submitSearch();
|
||||
}
|
||||
|
||||
if (suggestionsVisible) {
|
||||
// allow closing of suggestions with escape key
|
||||
if (event.key === 'Escape') {
|
||||
toggleSuggestions(false);
|
||||
}
|
||||
|
||||
if (event.key === 'ArrowDown') {
|
||||
selectNextSuggestion();
|
||||
event.preventDefault();
|
||||
} else if (event.key === 'ArrowUp') {
|
||||
selectPreviousSuggestion();
|
||||
event.preventDefault();
|
||||
}
|
||||
}
|
||||
},
|
||||
[
|
||||
selectNextSuggestion,
|
||||
selectPreviousSuggestion,
|
||||
submitSearch,
|
||||
suggestionsVisible,
|
||||
toggleSuggestions,
|
||||
],
|
||||
);
|
||||
|
||||
const handleQueryChange = useCallback(
|
||||
(newQuery: string) => {
|
||||
setQuery(newQuery);
|
||||
toggleSuggestions(newQuery !== '');
|
||||
if (newQuery === '') {
|
||||
resetSelectedSuggestion();
|
||||
}
|
||||
},
|
||||
[resetSelectedSuggestion, toggleSuggestions],
|
||||
);
|
||||
|
||||
const handleSuggestionClick = useCallback(
|
||||
(event: MouseEvent, idx: number) => {
|
||||
setSelectedCollectionIdx(idx);
|
||||
submitSearch();
|
||||
event.preventDefault();
|
||||
},
|
||||
[submitSearch],
|
||||
);
|
||||
|
||||
return (
|
||||
<SearchContainer>
|
||||
<TextField
|
||||
onKeyDown={handleKeyDown}
|
||||
onClick={() => toggleSuggestions(true)}
|
||||
placeholder={t('collection.sidebar.searchAll')}
|
||||
onBlur={() => toggleSuggestions(false)}
|
||||
onFocus={() => toggleSuggestions(query !== '')}
|
||||
value={query}
|
||||
onChange={e => handleQueryChange(e.target.value)}
|
||||
variant="outlined"
|
||||
size="small"
|
||||
fullWidth
|
||||
InputProps={{
|
||||
startAdornment: (
|
||||
<InputAdornment position="start">
|
||||
<SearchIcon />
|
||||
</InputAdornment>
|
||||
),
|
||||
}}
|
||||
/>
|
||||
{suggestionsVisible && (
|
||||
<SuggestionsContainer>
|
||||
<Suggestions>
|
||||
<SuggestionHeader>{t('collection.sidebar.searchIn')}</SuggestionHeader>
|
||||
<SuggestionItem
|
||||
$isActive={selectedCollectionIdx === -1}
|
||||
onClick={e => handleSuggestionClick(e, -1)}
|
||||
onMouseDown={e => e.preventDefault()}
|
||||
>
|
||||
{t('collection.sidebar.allCollections')}
|
||||
</SuggestionItem>
|
||||
<SuggestionDivider />
|
||||
{Object.values(collections).map((collection, idx) => (
|
||||
<SuggestionItem
|
||||
key={idx}
|
||||
$isActive={idx === selectedCollectionIdx}
|
||||
onClick={e => handleSuggestionClick(e, idx)}
|
||||
onMouseDown={e => e.preventDefault()}
|
||||
>
|
||||
{collection.label}
|
||||
</SuggestionItem>
|
||||
))}
|
||||
</Suggestions>
|
||||
</SuggestionsContainer>
|
||||
)}
|
||||
</SearchContainer>
|
||||
);
|
||||
};
|
||||
|
||||
export default translate()(CollectionSearch);
|
78
core/src/components/Collection/CollectionTop.tsx
Normal file
78
core/src/components/Collection/CollectionTop.tsx
Normal file
@ -0,0 +1,78 @@
|
||||
import { styled } from '@mui/material/styles';
|
||||
import Button from '@mui/material/Button';
|
||||
import Card from '@mui/material/Card';
|
||||
import CardContent from '@mui/material/CardContent';
|
||||
import React, { useCallback } from 'react';
|
||||
import { translate } from 'react-polyglot';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
|
||||
import { components } from '../../components/UI/styles';
|
||||
|
||||
import type { Collection, TranslatedProps } from '../../interface';
|
||||
|
||||
const CollectionTopRow = styled('div')`
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
`;
|
||||
|
||||
const CollectionTopHeading = styled('h1')`
|
||||
${components.cardTopHeading};
|
||||
`;
|
||||
|
||||
const CollectionTopDescription = styled('p')`
|
||||
${components.cardTopDescription};
|
||||
margin-bottom: 0;
|
||||
`;
|
||||
|
||||
function getCollectionProps(collection: Collection) {
|
||||
const collectionLabel = collection.label;
|
||||
const collectionLabelSingular = collection.label_singular;
|
||||
const collectionDescription = collection.description;
|
||||
|
||||
return {
|
||||
collectionLabel,
|
||||
collectionLabelSingular,
|
||||
collectionDescription,
|
||||
};
|
||||
}
|
||||
|
||||
interface CollectionTopProps {
|
||||
collection: Collection;
|
||||
newEntryUrl?: string;
|
||||
}
|
||||
|
||||
const CollectionTop = ({ collection, newEntryUrl, t }: TranslatedProps<CollectionTopProps>) => {
|
||||
const navigate = useNavigate();
|
||||
|
||||
const { collectionLabel, collectionLabelSingular, collectionDescription } =
|
||||
getCollectionProps(collection);
|
||||
|
||||
const onNewClick = useCallback(() => {
|
||||
if (newEntryUrl) {
|
||||
navigate(newEntryUrl);
|
||||
}
|
||||
}, [navigate, newEntryUrl]);
|
||||
|
||||
return (
|
||||
<Card>
|
||||
<CardContent>
|
||||
<CollectionTopRow>
|
||||
<CollectionTopHeading>{collectionLabel}</CollectionTopHeading>
|
||||
{newEntryUrl ? (
|
||||
<Button onClick={onNewClick} variant="contained">
|
||||
{t('collection.collectionTop.newButton', {
|
||||
collectionLabel: collectionLabelSingular || collectionLabel,
|
||||
})}
|
||||
</Button>
|
||||
) : null}
|
||||
</CollectionTopRow>
|
||||
{collectionDescription ? (
|
||||
<CollectionTopDescription>{collectionDescription}</CollectionTopDescription>
|
||||
) : null}
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
};
|
||||
|
||||
export default translate()(CollectionTop);
|
93
core/src/components/Collection/Entries/Entries.tsx
Normal file
93
core/src/components/Collection/Entries/Entries.tsx
Normal file
@ -0,0 +1,93 @@
|
||||
import { styled } from '@mui/material/styles';
|
||||
import React from 'react';
|
||||
import { translate } from 'react-polyglot';
|
||||
|
||||
import Loader from '../../UI/Loader';
|
||||
import EntryListing from './EntryListing';
|
||||
|
||||
import type { CollectionViewStyle } from '../../../constants/collectionViews';
|
||||
import type { Collection, Collections, Entry, TranslatedProps } from '../../../interface';
|
||||
import type Cursor from '../../../lib/util/Cursor';
|
||||
|
||||
const PaginationMessage = styled('div')`
|
||||
padding: 16px;
|
||||
text-align: center;
|
||||
`;
|
||||
|
||||
const NoEntriesMessage = styled(PaginationMessage)`
|
||||
margin-top: 16px;
|
||||
`;
|
||||
|
||||
export interface BaseEntriesProps {
|
||||
entries: Entry[];
|
||||
page?: number;
|
||||
isFetching: boolean;
|
||||
viewStyle: CollectionViewStyle;
|
||||
cursor: Cursor;
|
||||
handleCursorActions: (action: string) => void;
|
||||
}
|
||||
|
||||
export interface SingleCollectionEntriesProps extends BaseEntriesProps {
|
||||
collection: Collection;
|
||||
}
|
||||
|
||||
export interface MultipleCollectionEntriesProps extends BaseEntriesProps {
|
||||
collections: Collections;
|
||||
}
|
||||
|
||||
export type EntriesProps = SingleCollectionEntriesProps | MultipleCollectionEntriesProps;
|
||||
|
||||
const Entries = ({
|
||||
entries,
|
||||
isFetching,
|
||||
viewStyle,
|
||||
cursor,
|
||||
handleCursorActions,
|
||||
t,
|
||||
page,
|
||||
...otherProps
|
||||
}: TranslatedProps<EntriesProps>) => {
|
||||
const loadingMessages = [
|
||||
t('collection.entries.loadingEntries'),
|
||||
t('collection.entries.cachingEntries'),
|
||||
t('collection.entries.longerLoading'),
|
||||
];
|
||||
|
||||
if (isFetching && page === undefined) {
|
||||
return <Loader>{loadingMessages}</Loader>;
|
||||
}
|
||||
|
||||
const hasEntries = (entries && entries.length > 0) || cursor?.actions?.has('append_next');
|
||||
if (hasEntries) {
|
||||
return (
|
||||
<>
|
||||
{'collection' in otherProps ? (
|
||||
<EntryListing
|
||||
collection={otherProps.collection}
|
||||
entries={entries}
|
||||
viewStyle={viewStyle}
|
||||
cursor={cursor}
|
||||
handleCursorActions={handleCursorActions}
|
||||
page={page}
|
||||
/>
|
||||
) : (
|
||||
<EntryListing
|
||||
collections={otherProps.collections}
|
||||
entries={entries}
|
||||
viewStyle={viewStyle}
|
||||
cursor={cursor}
|
||||
handleCursorActions={handleCursorActions}
|
||||
page={page}
|
||||
/>
|
||||
)}
|
||||
{isFetching && page !== undefined && entries.length > 0 ? (
|
||||
<PaginationMessage>{t('collection.entries.loadingEntries')}</PaginationMessage>
|
||||
) : null}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
return <NoEntriesMessage>{t('collection.entries.noEntries')}</NoEntriesMessage>;
|
||||
};
|
||||
|
||||
export default translate()(Entries);
|
191
core/src/components/Collection/Entries/EntriesCollection.tsx
Normal file
191
core/src/components/Collection/Entries/EntriesCollection.tsx
Normal file
@ -0,0 +1,191 @@
|
||||
import { styled } from '@mui/material/styles';
|
||||
import React, { useCallback, useEffect, useState } from 'react';
|
||||
import { translate } from 'react-polyglot';
|
||||
import { connect } from 'react-redux';
|
||||
|
||||
import {
|
||||
loadEntries as loadEntriesAction,
|
||||
traverseCollectionCursor as traverseCollectionCursorAction,
|
||||
} from '../../../actions/entries';
|
||||
import { colors } from '../../../components/UI/styles';
|
||||
import { Cursor } from '../../../lib/util';
|
||||
import { selectCollectionEntriesCursor } from '../../../reducers/cursors';
|
||||
import {
|
||||
selectEntries,
|
||||
selectEntriesLoaded,
|
||||
selectGroups,
|
||||
selectIsFetching,
|
||||
} from '../../../reducers/entries';
|
||||
import Entries from './Entries';
|
||||
|
||||
import type { ComponentType } from 'react';
|
||||
import type { t } from 'react-polyglot';
|
||||
import type { ConnectedProps } from 'react-redux';
|
||||
import type { CollectionViewStyle } from '../../../constants/collectionViews';
|
||||
import type { Collection, Entry, GroupOfEntries, TranslatedProps } from '../../../interface';
|
||||
import type { RootState } from '../../../store';
|
||||
|
||||
const GroupHeading = styled('h2')`
|
||||
font-size: 23px;
|
||||
font-weight: 600;
|
||||
color: ${colors.textLead};
|
||||
`;
|
||||
|
||||
const GroupContainer = styled('div')``;
|
||||
|
||||
function getGroupEntries(entries: Entry[], paths: Set<string>) {
|
||||
return entries.filter(entry => paths.has(entry.path));
|
||||
}
|
||||
|
||||
function getGroupTitle(group: GroupOfEntries, t: t) {
|
||||
const { label, value } = group;
|
||||
if (value === undefined) {
|
||||
return t('collection.groups.other');
|
||||
}
|
||||
if (typeof value === 'boolean') {
|
||||
return value ? label : t('collection.groups.negateLabel', { label });
|
||||
}
|
||||
return `${label} ${value}`.trim();
|
||||
}
|
||||
|
||||
function withGroups(
|
||||
groups: GroupOfEntries[],
|
||||
entries: Entry[],
|
||||
EntriesToRender: ComponentType<EntriesToRenderProps>,
|
||||
t: t,
|
||||
) {
|
||||
return groups.map(group => {
|
||||
const title = getGroupTitle(group, t);
|
||||
return (
|
||||
<GroupContainer key={group.id} id={group.id}>
|
||||
<GroupHeading>{title}</GroupHeading>
|
||||
<EntriesToRender entries={getGroupEntries(entries, group.paths)} />
|
||||
</GroupContainer>
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
interface EntriesToRenderProps {
|
||||
entries: Entry[];
|
||||
}
|
||||
|
||||
const EntriesCollection = ({
|
||||
collection,
|
||||
entries,
|
||||
groups,
|
||||
isFetching,
|
||||
viewStyle,
|
||||
cursor,
|
||||
page,
|
||||
traverseCollectionCursor,
|
||||
t,
|
||||
entriesLoaded,
|
||||
readyToLoad,
|
||||
loadEntries,
|
||||
}: TranslatedProps<EntriesCollectionProps>) => {
|
||||
const [prevReadyToLoad, setPrevReadyToLoad] = useState(false);
|
||||
const [prevCollection, setPrevCollection] = useState(collection);
|
||||
|
||||
useEffect(() => {
|
||||
if (
|
||||
collection &&
|
||||
!entriesLoaded &&
|
||||
readyToLoad &&
|
||||
(!prevReadyToLoad || prevCollection !== collection)
|
||||
) {
|
||||
loadEntries(collection);
|
||||
}
|
||||
|
||||
setPrevReadyToLoad(readyToLoad);
|
||||
setPrevCollection(collection);
|
||||
}, [collection, entriesLoaded, loadEntries, prevCollection, prevReadyToLoad, readyToLoad]);
|
||||
|
||||
const handleCursorActions = useCallback(
|
||||
(action: string) => {
|
||||
traverseCollectionCursor(collection, action);
|
||||
},
|
||||
[collection, traverseCollectionCursor],
|
||||
);
|
||||
|
||||
const EntriesToRender = useCallback(
|
||||
({ entries }: EntriesToRenderProps) => {
|
||||
return (
|
||||
<Entries
|
||||
collection={collection}
|
||||
entries={entries}
|
||||
isFetching={isFetching}
|
||||
collectionName={collection.label}
|
||||
viewStyle={viewStyle}
|
||||
cursor={cursor}
|
||||
handleCursorActions={handleCursorActions}
|
||||
page={page}
|
||||
/>
|
||||
);
|
||||
},
|
||||
[collection, cursor, handleCursorActions, isFetching, page, viewStyle],
|
||||
);
|
||||
|
||||
if (groups && groups.length > 0) {
|
||||
return <>{withGroups(groups, entries, EntriesToRender, t)}</>;
|
||||
}
|
||||
|
||||
return <EntriesToRender entries={entries} />;
|
||||
};
|
||||
|
||||
export function filterNestedEntries(path: string, collectionFolder: string, entries: Entry[]) {
|
||||
const filtered = entries.filter(e => {
|
||||
const entryPath = e.path.slice(collectionFolder.length + 1);
|
||||
if (!entryPath.startsWith(path)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// only show immediate children
|
||||
if (path) {
|
||||
// non root path
|
||||
const trimmed = entryPath.slice(path.length + 1);
|
||||
return trimmed.split('/').length === 2;
|
||||
} else {
|
||||
// root path
|
||||
return entryPath.split('/').length <= 2;
|
||||
}
|
||||
});
|
||||
return filtered;
|
||||
}
|
||||
|
||||
interface EntriesCollectionOwnProps {
|
||||
collection: Collection;
|
||||
viewStyle: CollectionViewStyle;
|
||||
readyToLoad: boolean;
|
||||
filterTerm: string;
|
||||
}
|
||||
|
||||
function mapStateToProps(state: RootState, ownProps: EntriesCollectionOwnProps) {
|
||||
const { collection, viewStyle, filterTerm } = ownProps;
|
||||
const page = state.entries.pages[collection.name]?.page;
|
||||
|
||||
let entries = selectEntries(state.entries, collection);
|
||||
const groups = selectGroups(state.entries, collection);
|
||||
|
||||
if ('nested' in collection) {
|
||||
const collectionFolder = collection.folder ?? '';
|
||||
entries = filterNestedEntries(filterTerm || '', collectionFolder, entries);
|
||||
}
|
||||
|
||||
const entriesLoaded = selectEntriesLoaded(state.entries, collection.name);
|
||||
const isFetching = selectIsFetching(state.entries, collection.name);
|
||||
|
||||
const rawCursor = selectCollectionEntriesCursor(state.cursors, collection.name);
|
||||
const cursor = Cursor.create(rawCursor).clearData();
|
||||
|
||||
return { ...ownProps, page, entries, groups, entriesLoaded, isFetching, viewStyle, cursor };
|
||||
}
|
||||
|
||||
const mapDispatchToProps = {
|
||||
loadEntries: loadEntriesAction,
|
||||
traverseCollectionCursor: traverseCollectionCursorAction,
|
||||
};
|
||||
|
||||
const connector = connect(mapStateToProps, mapDispatchToProps);
|
||||
export type EntriesCollectionProps = ConnectedProps<typeof connector>;
|
||||
|
||||
export default connector(translate()(EntriesCollection) as ComponentType<EntriesCollectionProps>);
|
101
core/src/components/Collection/Entries/EntriesSearch.tsx
Normal file
101
core/src/components/Collection/Entries/EntriesSearch.tsx
Normal file
@ -0,0 +1,101 @@
|
||||
import isEqual from 'lodash/isEqual';
|
||||
import React, { useCallback, useEffect, useState } from 'react';
|
||||
import { connect } from 'react-redux';
|
||||
|
||||
import {
|
||||
clearSearch as clearSearchAction,
|
||||
searchEntries as searchEntriesAction,
|
||||
} from '../../../actions/search';
|
||||
import { Cursor } from '../../../lib/util';
|
||||
import { selectSearchedEntries } from '../../../reducers';
|
||||
import Entries from './Entries';
|
||||
|
||||
import type { ConnectedProps } from 'react-redux';
|
||||
import type { Collections } from '../../../interface';
|
||||
import type { RootState } from '../../../store';
|
||||
|
||||
const EntriesSearch = ({
|
||||
collections,
|
||||
entries,
|
||||
isFetching,
|
||||
page,
|
||||
searchTerm,
|
||||
searchEntries,
|
||||
collectionNames,
|
||||
clearSearch,
|
||||
}: EntriesSearchProps) => {
|
||||
const getCursor = useCallback(() => {
|
||||
return Cursor.create({
|
||||
actions: Number.isNaN(page) ? [] : ['append_next'],
|
||||
});
|
||||
}, [page]);
|
||||
|
||||
const handleCursorActions = useCallback(
|
||||
(action: string) => {
|
||||
if (action === 'append_next') {
|
||||
const nextPage = page + 1;
|
||||
searchEntries(searchTerm, collectionNames, nextPage);
|
||||
}
|
||||
},
|
||||
[collectionNames, page, searchEntries, searchTerm],
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
searchEntries(searchTerm, collectionNames);
|
||||
}, [collectionNames, searchEntries, searchTerm]);
|
||||
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
clearSearch();
|
||||
};
|
||||
}, [clearSearch]);
|
||||
|
||||
const [prevSearch, setPrevSearch] = useState('');
|
||||
const [prevCollectionNames, setPrevCollectionNames] = useState<string[]>([]);
|
||||
useEffect(() => {
|
||||
// check if the search parameters are the same
|
||||
if (prevSearch === searchTerm && isEqual(prevCollectionNames, collectionNames)) {
|
||||
return;
|
||||
}
|
||||
|
||||
setPrevSearch(searchTerm);
|
||||
setPrevCollectionNames(collectionNames);
|
||||
|
||||
searchEntries(searchTerm, collectionNames);
|
||||
}, [collectionNames, prevCollectionNames, prevSearch, searchEntries, searchTerm]);
|
||||
|
||||
return (
|
||||
<Entries
|
||||
cursor={getCursor()}
|
||||
handleCursorActions={handleCursorActions}
|
||||
collections={collections}
|
||||
entries={entries}
|
||||
isFetching={isFetching}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
interface EntriesSearchOwnProps {
|
||||
searchTerm: string;
|
||||
collections: Collections;
|
||||
}
|
||||
|
||||
function mapStateToProps(state: RootState, ownProps: EntriesSearchOwnProps) {
|
||||
const { searchTerm } = ownProps;
|
||||
const collections = Object.values(ownProps.collections);
|
||||
const collectionNames = Object.keys(ownProps.collections);
|
||||
const isFetching = state.search.isFetching;
|
||||
const page = state.search.page;
|
||||
const entries = selectSearchedEntries(state, collectionNames);
|
||||
return { isFetching, page, collections, collectionNames, entries, searchTerm };
|
||||
}
|
||||
|
||||
const mapDispatchToProps = {
|
||||
searchEntries: searchEntriesAction,
|
||||
clearSearch: clearSearchAction,
|
||||
};
|
||||
|
||||
const connector = connect(mapStateToProps, mapDispatchToProps);
|
||||
export type EntriesSearchProps = ConnectedProps<typeof connector>;
|
||||
|
||||
export default connector(EntriesSearch);
|
102
core/src/components/Collection/Entries/EntryCard.tsx
Normal file
102
core/src/components/Collection/Entries/EntryCard.tsx
Normal file
@ -0,0 +1,102 @@
|
||||
import Card from '@mui/material/Card';
|
||||
import CardActionArea from '@mui/material/CardActionArea';
|
||||
import CardContent from '@mui/material/CardContent';
|
||||
import CardMedia from '@mui/material/CardMedia';
|
||||
import Typography from '@mui/material/Typography';
|
||||
import React, { useMemo } from 'react';
|
||||
import { connect } from 'react-redux';
|
||||
import { Link } from 'react-router-dom';
|
||||
|
||||
import { getAsset as getAssetAction } from '../../../actions/media';
|
||||
import { VIEW_STYLE_GRID, VIEW_STYLE_LIST } from '../../../constants/collectionViews';
|
||||
import { selectEntryCollectionTitle } from '../../../lib/util/collection.util';
|
||||
import { selectIsLoadingAsset } from '../../../reducers/medias';
|
||||
|
||||
import type { ConnectedProps } from 'react-redux';
|
||||
import type { CollectionViewStyle } from '../../../constants/collectionViews';
|
||||
import type { Field, Collection, Entry } from '../../../interface';
|
||||
import type { RootState } from '../../../store';
|
||||
|
||||
const EntryCard = ({
|
||||
collection,
|
||||
entry,
|
||||
path,
|
||||
image,
|
||||
imageField,
|
||||
collectionLabel,
|
||||
viewStyle = VIEW_STYLE_LIST,
|
||||
getAsset,
|
||||
}: NestedCollectionProps) => {
|
||||
const summary = useMemo(() => selectEntryCollectionTitle(collection, entry), [collection, entry]);
|
||||
|
||||
return (
|
||||
<Card>
|
||||
<CardActionArea component={Link} to={path}>
|
||||
{viewStyle === VIEW_STYLE_GRID && image && imageField ? (
|
||||
<CardMedia
|
||||
component="img"
|
||||
height="140"
|
||||
image={getAsset(collection, entry, image, imageField).toString()}
|
||||
/>
|
||||
) : null}
|
||||
<CardContent>
|
||||
{collectionLabel ? (
|
||||
<Typography gutterBottom variant="h5" component="div">
|
||||
{collectionLabel}
|
||||
</Typography>
|
||||
) : null}
|
||||
<Typography gutterBottom variant="h6" component="div" sx={{ margin: 0 }}>
|
||||
{summary}
|
||||
</Typography>
|
||||
</CardContent>
|
||||
</CardActionArea>
|
||||
</Card>
|
||||
);
|
||||
};
|
||||
|
||||
interface EntryCardOwnProps {
|
||||
entry: Entry;
|
||||
inferedFields: {
|
||||
titleField?: string | null | undefined;
|
||||
descriptionField?: string | null | undefined;
|
||||
imageField?: string | null | undefined;
|
||||
remainingFields?: Field[] | undefined;
|
||||
};
|
||||
collection: Collection;
|
||||
imageField?: Field;
|
||||
collectionLabel?: string;
|
||||
viewStyle?: CollectionViewStyle;
|
||||
}
|
||||
|
||||
function mapStateToProps(state: RootState, ownProps: EntryCardOwnProps) {
|
||||
const { entry, inferedFields, collection } = ownProps;
|
||||
const entryData = entry.data;
|
||||
|
||||
let image = inferedFields.imageField
|
||||
? (entryData?.[inferedFields.imageField] as string | undefined)
|
||||
: undefined;
|
||||
if (image) {
|
||||
image = encodeURI(image);
|
||||
}
|
||||
|
||||
const isLoadingAsset = selectIsLoadingAsset(state.medias);
|
||||
|
||||
return {
|
||||
...ownProps,
|
||||
path: `/collections/${collection.name}/entries/${entry.slug}`,
|
||||
image,
|
||||
imageField: collection.fields?.find(
|
||||
f => f.name === inferedFields.imageField && f.widget === 'image',
|
||||
),
|
||||
isLoadingAsset,
|
||||
};
|
||||
}
|
||||
|
||||
const mapDispatchToProps = {
|
||||
getAsset: getAssetAction,
|
||||
};
|
||||
|
||||
const connector = connect(mapStateToProps, mapDispatchToProps);
|
||||
export type NestedCollectionProps = ConnectedProps<typeof connector>;
|
||||
|
||||
export default connector(EntryCard);
|
147
core/src/components/Collection/Entries/EntryListing.tsx
Normal file
147
core/src/components/Collection/Entries/EntryListing.tsx
Normal file
@ -0,0 +1,147 @@
|
||||
import { styled } from '@mui/material/styles';
|
||||
import React, { useCallback, useMemo } from 'react';
|
||||
import { Waypoint } from 'react-waypoint';
|
||||
|
||||
import { VIEW_STYLE_LIST } from '../../../constants/collectionViews';
|
||||
import { transientOptions } from '../../../lib';
|
||||
import { selectFields, selectInferedField } from '../../../lib/util/collection.util';
|
||||
import EntryCard from './EntryCard';
|
||||
|
||||
import type { CollectionViewStyle } from '../../../constants/collectionViews';
|
||||
import type { Field, Collection, Collections, Entry } from '../../../interface';
|
||||
import type Cursor from '../../../lib/util/Cursor';
|
||||
|
||||
interface CardsGridProps {
|
||||
$layout: CollectionViewStyle;
|
||||
}
|
||||
|
||||
const CardsGrid = styled(
|
||||
'div',
|
||||
transientOptions,
|
||||
)<CardsGridProps>(
|
||||
({ $layout }) => `
|
||||
${
|
||||
$layout === VIEW_STYLE_LIST
|
||||
? `
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
`
|
||||
: `
|
||||
display: grid;
|
||||
grid-template-columns: repeat(3, minmax(0, 1fr));
|
||||
`
|
||||
}
|
||||
width: 100%;
|
||||
margin-top: 16px;
|
||||
gap: 16px;
|
||||
`,
|
||||
);
|
||||
|
||||
export interface BaseEntryListingProps {
|
||||
entries: Entry[];
|
||||
viewStyle: CollectionViewStyle;
|
||||
cursor?: Cursor;
|
||||
handleCursorActions: (action: string) => void;
|
||||
page?: number;
|
||||
}
|
||||
|
||||
export interface SingleCollectionEntryListingProps extends BaseEntryListingProps {
|
||||
collection: Collection;
|
||||
}
|
||||
|
||||
export interface MultipleCollectionEntryListingProps extends BaseEntryListingProps {
|
||||
collections: Collections;
|
||||
}
|
||||
|
||||
export type EntryListingProps =
|
||||
| SingleCollectionEntryListingProps
|
||||
| MultipleCollectionEntryListingProps;
|
||||
|
||||
const EntryListing = ({
|
||||
entries,
|
||||
page,
|
||||
cursor,
|
||||
viewStyle,
|
||||
handleCursorActions,
|
||||
...otherProps
|
||||
}: EntryListingProps) => {
|
||||
const hasMore = useCallback(() => {
|
||||
const hasMore = cursor?.actions?.has('append_next');
|
||||
return hasMore;
|
||||
}, [cursor?.actions]);
|
||||
|
||||
const handleLoadMore = useCallback(() => {
|
||||
if (hasMore()) {
|
||||
handleCursorActions('append_next');
|
||||
}
|
||||
}, [handleCursorActions, hasMore]);
|
||||
|
||||
const inferFields = useCallback(
|
||||
(
|
||||
collection?: Collection,
|
||||
): {
|
||||
titleField?: string | null;
|
||||
descriptionField?: string | null;
|
||||
imageField?: string | null;
|
||||
remainingFields?: Field[];
|
||||
} => {
|
||||
if (!collection) {
|
||||
return {};
|
||||
}
|
||||
|
||||
const titleField = selectInferedField(collection, 'title');
|
||||
const descriptionField = selectInferedField(collection, 'description');
|
||||
const imageField = selectInferedField(collection, 'image');
|
||||
const fields = selectFields(collection);
|
||||
const inferedFields = [titleField, descriptionField, imageField];
|
||||
const remainingFields = fields && fields.filter(f => inferedFields.indexOf(f.name) === -1);
|
||||
return { titleField, descriptionField, imageField, remainingFields };
|
||||
},
|
||||
[],
|
||||
);
|
||||
|
||||
const renderedCards = useMemo(() => {
|
||||
if ('collection' in otherProps) {
|
||||
const inferedFields = inferFields(otherProps.collection);
|
||||
return entries.map((entry, idx) => (
|
||||
<EntryCard
|
||||
collection={otherProps.collection}
|
||||
inferedFields={inferedFields}
|
||||
viewStyle={viewStyle}
|
||||
entry={entry}
|
||||
key={idx}
|
||||
/>
|
||||
));
|
||||
}
|
||||
|
||||
const isSingleCollectionInList = Object.keys(otherProps.collections).length === 1;
|
||||
return entries.map((entry, idx) => {
|
||||
const collectionName = entry.collection;
|
||||
const collection = Object.values(otherProps.collections).find(
|
||||
coll => coll.name === collectionName,
|
||||
);
|
||||
const collectionLabel = !isSingleCollectionInList ? collection?.label : undefined;
|
||||
const inferedFields = inferFields(collection);
|
||||
return collection ? (
|
||||
<EntryCard
|
||||
collection={collection}
|
||||
entry={entry}
|
||||
inferedFields={inferedFields}
|
||||
collectionLabel={collectionLabel}
|
||||
key={idx}
|
||||
/>
|
||||
) : null;
|
||||
});
|
||||
}, [entries, inferFields, otherProps, viewStyle]);
|
||||
|
||||
return (
|
||||
<div>
|
||||
<CardsGrid $layout={viewStyle}>
|
||||
{renderedCards}
|
||||
{hasMore() && <Waypoint key={page} onEnter={handleLoadMore} />}
|
||||
</CardsGrid>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default EntryListing;
|
85
core/src/components/Collection/FilterControl.tsx
Normal file
85
core/src/components/Collection/FilterControl.tsx
Normal file
@ -0,0 +1,85 @@
|
||||
import KeyboardArrowDownIcon from '@mui/icons-material/KeyboardArrowDown';
|
||||
import Button from '@mui/material/Button';
|
||||
import Checkbox from '@mui/material/Checkbox';
|
||||
import ListItemIcon from '@mui/material/ListItemIcon';
|
||||
import ListItemText from '@mui/material/ListItemText';
|
||||
import Menu from '@mui/material/Menu';
|
||||
import MenuItem from '@mui/material/MenuItem';
|
||||
import React, { useCallback, useMemo } from 'react';
|
||||
import { translate } from 'react-polyglot';
|
||||
|
||||
import type { FilterMap, TranslatedProps, ViewFilter } from '../../interface';
|
||||
|
||||
interface FilterControlProps {
|
||||
filter: Record<string, FilterMap>;
|
||||
viewFilters: ViewFilter[];
|
||||
onFilterClick: (viewFilter: ViewFilter) => void;
|
||||
}
|
||||
|
||||
const FilterControl = ({
|
||||
viewFilters,
|
||||
t,
|
||||
onFilterClick,
|
||||
filter,
|
||||
}: TranslatedProps<FilterControlProps>) => {
|
||||
const [anchorEl, setAnchorEl] = React.useState<null | HTMLElement>(null);
|
||||
const open = Boolean(anchorEl);
|
||||
const handleClick = useCallback((event: React.MouseEvent<HTMLButtonElement>) => {
|
||||
setAnchorEl(event.currentTarget);
|
||||
}, []);
|
||||
const handleClose = useCallback(() => {
|
||||
setAnchorEl(null);
|
||||
}, []);
|
||||
|
||||
const anyActive = useMemo(() => Object.keys(filter).some(key => filter[key]?.active), [filter]);
|
||||
|
||||
return (
|
||||
<div>
|
||||
<Button
|
||||
id="basic-button"
|
||||
aria-controls={open ? 'basic-menu' : undefined}
|
||||
aria-haspopup="true"
|
||||
aria-expanded={open ? 'true' : undefined}
|
||||
onClick={handleClick}
|
||||
variant={anyActive ? 'contained' : 'outlined'}
|
||||
endIcon={<KeyboardArrowDownIcon />}
|
||||
>
|
||||
{t('collection.collectionTop.filterBy')}
|
||||
</Button>
|
||||
<Menu
|
||||
id="basic-menu"
|
||||
anchorEl={anchorEl}
|
||||
open={open}
|
||||
onClose={handleClose}
|
||||
MenuListProps={{
|
||||
'aria-labelledby': 'basic-button',
|
||||
}}
|
||||
>
|
||||
{viewFilters.map(viewFilter => {
|
||||
const checked = filter[viewFilter.id]?.active ?? false;
|
||||
const labelId = `filter-list-label-${viewFilter.label}`;
|
||||
return (
|
||||
<MenuItem
|
||||
key={viewFilter.id}
|
||||
onClick={() => onFilterClick(viewFilter)}
|
||||
sx={{ height: '36px' }}
|
||||
>
|
||||
<ListItemIcon>
|
||||
<Checkbox
|
||||
edge="start"
|
||||
checked={checked}
|
||||
tabIndex={-1}
|
||||
disableRipple
|
||||
inputProps={{ 'aria-labelledby': labelId }}
|
||||
/>
|
||||
</ListItemIcon>
|
||||
<ListItemText id={labelId} primary={viewFilter.label} />
|
||||
</MenuItem>
|
||||
);
|
||||
})}
|
||||
</Menu>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default translate()(FilterControl);
|
79
core/src/components/Collection/GroupControl.tsx
Normal file
79
core/src/components/Collection/GroupControl.tsx
Normal file
@ -0,0 +1,79 @@
|
||||
import KeyboardArrowDownIcon from '@mui/icons-material/KeyboardArrowDown';
|
||||
import Button from '@mui/material/Button/Button';
|
||||
import ListItemText from '@mui/material/ListItemText';
|
||||
import Menu from '@mui/material/Menu';
|
||||
import MenuItem from '@mui/material/MenuItem';
|
||||
import React, { useCallback, useMemo } from 'react';
|
||||
import { translate } from 'react-polyglot';
|
||||
import CheckIcon from '@mui/icons-material/Check';
|
||||
import { styled } from '@mui/material/styles';
|
||||
|
||||
import type { GroupMap, TranslatedProps, ViewGroup } from '../../interface';
|
||||
|
||||
const StyledMenuIconWrapper = styled('div')`
|
||||
width: 32px;
|
||||
height: 24px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: flex-end;
|
||||
`;
|
||||
|
||||
interface GroupControlProps {
|
||||
group: Record<string, GroupMap>;
|
||||
viewGroups: ViewGroup[];
|
||||
onGroupClick: (viewGroup: ViewGroup) => void;
|
||||
}
|
||||
|
||||
const GroupControl = ({
|
||||
viewGroups,
|
||||
group,
|
||||
t,
|
||||
onGroupClick,
|
||||
}: TranslatedProps<GroupControlProps>) => {
|
||||
const [anchorEl, setAnchorEl] = React.useState<null | HTMLElement>(null);
|
||||
const open = Boolean(anchorEl);
|
||||
const handleClick = useCallback((event: React.MouseEvent<HTMLButtonElement>) => {
|
||||
setAnchorEl(event.currentTarget);
|
||||
}, []);
|
||||
const handleClose = useCallback(() => {
|
||||
setAnchorEl(null);
|
||||
}, []);
|
||||
|
||||
const activeGroup = useMemo(() => Object.values(group).find(f => f.active === true), [group]);
|
||||
|
||||
return (
|
||||
<div>
|
||||
<Button
|
||||
id="basic-button"
|
||||
aria-controls={open ? 'basic-menu' : undefined}
|
||||
aria-haspopup="true"
|
||||
aria-expanded={open ? 'true' : undefined}
|
||||
onClick={handleClick}
|
||||
variant={activeGroup ? 'contained' : 'outlined'}
|
||||
endIcon={<KeyboardArrowDownIcon />}
|
||||
>
|
||||
{t('collection.collectionTop.groupBy')}
|
||||
</Button>
|
||||
<Menu
|
||||
id="basic-menu"
|
||||
anchorEl={anchorEl}
|
||||
open={open}
|
||||
onClose={handleClose}
|
||||
MenuListProps={{
|
||||
'aria-labelledby': 'basic-button',
|
||||
}}
|
||||
>
|
||||
{viewGroups.map(viewGroup => (
|
||||
<MenuItem key={viewGroup.id} onClick={() => onGroupClick(viewGroup)}>
|
||||
<ListItemText>{viewGroup.label}</ListItemText>
|
||||
<StyledMenuIconWrapper>
|
||||
{viewGroup.id === activeGroup?.id ? <CheckIcon fontSize="small" /> : null}
|
||||
</StyledMenuIconWrapper>
|
||||
</MenuItem>
|
||||
))}
|
||||
</Menu>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default translate()(GroupControl);
|
354
core/src/components/Collection/NestedCollection.tsx
Normal file
354
core/src/components/Collection/NestedCollection.tsx
Normal file
@ -0,0 +1,354 @@
|
||||
import { styled } from '@mui/material/styles';
|
||||
import ArticleIcon from '@mui/icons-material/Article';
|
||||
import sortBy from 'lodash/sortBy';
|
||||
import { dirname, sep } from 'path';
|
||||
import React, { useCallback, useEffect, useState } from 'react';
|
||||
import { connect } from 'react-redux';
|
||||
import { NavLink } from 'react-router-dom';
|
||||
|
||||
import { colors, components } from '../../components/UI/styles';
|
||||
import { transientOptions } from '../../lib';
|
||||
import { selectEntryCollectionTitle } from '../../lib/util/collection.util';
|
||||
import { stringTemplate } from '../../lib/widgets';
|
||||
import { selectEntries } from '../../reducers/entries';
|
||||
|
||||
import type { ConnectedProps } from 'react-redux';
|
||||
import type { Collection, Entry } from '../../interface';
|
||||
import type { RootState } from '../../store';
|
||||
|
||||
const { addFileTemplateFields } = stringTemplate;
|
||||
|
||||
const NodeTitleContainer = styled('div')`
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
`;
|
||||
|
||||
const NodeTitle = styled('div')`
|
||||
margin-right: 4px;
|
||||
`;
|
||||
|
||||
const Caret = styled('div')`
|
||||
position: relative;
|
||||
top: 2px;
|
||||
`;
|
||||
|
||||
const CaretDown = styled(Caret)`
|
||||
${components.caretDown};
|
||||
color: currentColor;
|
||||
`;
|
||||
|
||||
const CaretRight = styled(Caret)`
|
||||
${components.caretRight};
|
||||
color: currentColor;
|
||||
left: 2px;
|
||||
`;
|
||||
|
||||
interface TreeNavLinkProps {
|
||||
$activeClassName: string;
|
||||
$depth: number;
|
||||
}
|
||||
|
||||
const TreeNavLink = styled(
|
||||
NavLink,
|
||||
transientOptions,
|
||||
)<TreeNavLinkProps>(
|
||||
({ $activeClassName, $depth }) => `
|
||||
display: flex;
|
||||
font-size: 14px;
|
||||
font-weight: 500;
|
||||
align-items: center;
|
||||
padding: 8px;
|
||||
padding-left: ${$depth * 20 + 12}px;
|
||||
border-left: 2px solid #fff;
|
||||
|
||||
&:hover,
|
||||
&:active,
|
||||
&.${$activeClassName} {
|
||||
color: ${colors.active};
|
||||
background-color: ${colors.activeBackground};
|
||||
border-left-color: #4863c6;
|
||||
|
||||
.MuiListItemIcon-root {
|
||||
color: ${colors.active};
|
||||
}
|
||||
}
|
||||
`,
|
||||
);
|
||||
|
||||
interface BaseTreeNodeData {
|
||||
title: string | undefined;
|
||||
path: string;
|
||||
isDir: boolean;
|
||||
isRoot: boolean;
|
||||
expanded?: boolean;
|
||||
}
|
||||
|
||||
type SingleTreeNodeData = BaseTreeNodeData | (Entry & BaseTreeNodeData);
|
||||
|
||||
type TreeNodeData = SingleTreeNodeData & {
|
||||
children: TreeNodeData[];
|
||||
};
|
||||
|
||||
function getNodeTitle(node: TreeNodeData) {
|
||||
const title = node.isRoot
|
||||
? node.title
|
||||
: node.children.find(c => !c.isDir && c.title)?.title || node.title;
|
||||
return title;
|
||||
}
|
||||
|
||||
interface TreeNodeProps {
|
||||
collection: Collection;
|
||||
treeData: TreeNodeData[];
|
||||
depth?: number;
|
||||
onToggle: ({ node, expanded }: { node: TreeNodeData; expanded: boolean }) => void;
|
||||
}
|
||||
|
||||
const TreeNode = ({ collection, treeData, depth = 0, onToggle }: TreeNodeProps) => {
|
||||
const collectionName = collection.name;
|
||||
|
||||
const sortedData = sortBy(treeData, getNodeTitle);
|
||||
return (
|
||||
<>
|
||||
{sortedData.map(node => {
|
||||
const leaf = node.children.length <= 1 && !node.children[0]?.isDir && depth > 0;
|
||||
if (leaf) {
|
||||
return null;
|
||||
}
|
||||
let to = `/collections/${collectionName}`;
|
||||
if (depth > 0) {
|
||||
to = `${to}/filter${node.path}`;
|
||||
}
|
||||
const title = getNodeTitle(node);
|
||||
|
||||
const hasChildren = depth === 0 || node.children.some(c => c.children.some(c => c.isDir));
|
||||
|
||||
return (
|
||||
<React.Fragment key={node.path}>
|
||||
<TreeNavLink
|
||||
to={to}
|
||||
$activeClassName="sidebar-active"
|
||||
onClick={() => onToggle({ node, expanded: !node.expanded })}
|
||||
$depth={depth}
|
||||
data-testid={node.path}
|
||||
>
|
||||
<ArticleIcon />
|
||||
<NodeTitleContainer>
|
||||
<NodeTitle>{title}</NodeTitle>
|
||||
{hasChildren && (node.expanded ? <CaretDown /> : <CaretRight />)}
|
||||
</NodeTitleContainer>
|
||||
</TreeNavLink>
|
||||
{node.expanded && (
|
||||
<TreeNode
|
||||
collection={collection}
|
||||
depth={depth + 1}
|
||||
treeData={node.children}
|
||||
onToggle={onToggle}
|
||||
/>
|
||||
)}
|
||||
</React.Fragment>
|
||||
);
|
||||
})}
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
export function walk(treeData: TreeNodeData[], callback: (node: TreeNodeData) => void) {
|
||||
function traverse(children: TreeNodeData[]) {
|
||||
for (const child of children) {
|
||||
callback(child);
|
||||
traverse(child.children);
|
||||
}
|
||||
}
|
||||
|
||||
return traverse(treeData);
|
||||
}
|
||||
|
||||
export function getTreeData(collection: Collection, entries: Entry[]): TreeNodeData[] {
|
||||
const collectionFolder = collection.folder ?? '';
|
||||
const rootFolder = '/';
|
||||
const entriesObj = entries.map(e => ({ ...e, path: e.path.slice(collectionFolder.length) }));
|
||||
|
||||
const dirs = entriesObj.reduce((acc, entry) => {
|
||||
let dir: string | undefined = dirname(entry.path);
|
||||
while (dir && !acc[dir] && dir !== rootFolder) {
|
||||
const parts: string[] = dir.split(sep);
|
||||
acc[dir] = parts.pop();
|
||||
dir = parts.length ? parts.join(sep) : undefined;
|
||||
}
|
||||
return acc;
|
||||
}, {} as Record<string, string | undefined>);
|
||||
|
||||
if ('nested' in collection && collection.nested?.summary) {
|
||||
collection = {
|
||||
...collection,
|
||||
summary: collection.nested.summary,
|
||||
};
|
||||
} else {
|
||||
collection = {
|
||||
...collection,
|
||||
};
|
||||
delete collection.summary;
|
||||
}
|
||||
|
||||
const flatData = [
|
||||
{
|
||||
title: collection.label,
|
||||
path: rootFolder,
|
||||
isDir: true,
|
||||
isRoot: true,
|
||||
},
|
||||
...Object.entries(dirs).map(([key, value]) => ({
|
||||
title: value,
|
||||
path: key,
|
||||
isDir: true,
|
||||
isRoot: false,
|
||||
})),
|
||||
...entriesObj.map((e, index) => {
|
||||
let entryMap = entries[index];
|
||||
entryMap = {
|
||||
...entryMap,
|
||||
data: addFileTemplateFields(entryMap.path, entryMap.data as Record<string, string>),
|
||||
};
|
||||
const title = selectEntryCollectionTitle(collection, entryMap);
|
||||
return {
|
||||
...e,
|
||||
title,
|
||||
isDir: false,
|
||||
isRoot: false,
|
||||
};
|
||||
}),
|
||||
];
|
||||
|
||||
const parentsToChildren = flatData.reduce((acc, node) => {
|
||||
const parent = node.path === rootFolder ? '' : dirname(node.path);
|
||||
if (acc[parent]) {
|
||||
acc[parent].push(node);
|
||||
} else {
|
||||
acc[parent] = [node];
|
||||
}
|
||||
return acc;
|
||||
}, {} as Record<string, BaseTreeNodeData[]>);
|
||||
|
||||
function reducer(acc: TreeNodeData[], value: BaseTreeNodeData) {
|
||||
const node = value;
|
||||
let children: TreeNodeData[] = [];
|
||||
if (parentsToChildren[node.path]) {
|
||||
children = parentsToChildren[node.path].reduce(reducer, []);
|
||||
}
|
||||
|
||||
acc.push({ ...node, children });
|
||||
return acc;
|
||||
}
|
||||
|
||||
const treeData = parentsToChildren[''].reduce(reducer, []);
|
||||
|
||||
return treeData;
|
||||
}
|
||||
|
||||
export function updateNode(
|
||||
treeData: TreeNodeData[],
|
||||
node: TreeNodeData,
|
||||
callback: (node: TreeNodeData) => TreeNodeData,
|
||||
) {
|
||||
let stop = false;
|
||||
|
||||
function updater(nodes: TreeNodeData[]) {
|
||||
if (stop) {
|
||||
return nodes;
|
||||
}
|
||||
for (let i = 0; i < nodes.length; i++) {
|
||||
if (nodes[i].path === node.path) {
|
||||
nodes[i] = callback(node);
|
||||
stop = true;
|
||||
return nodes;
|
||||
}
|
||||
}
|
||||
nodes.forEach(node => updater(node.children));
|
||||
return nodes;
|
||||
}
|
||||
|
||||
return updater([...treeData]);
|
||||
}
|
||||
|
||||
const NestedCollection = ({ collection, entries, filterTerm }: NestedCollectionProps) => {
|
||||
const [treeData, setTreeData] = useState<TreeNodeData[]>(getTreeData(collection, entries));
|
||||
const [selected, setSelected] = useState<TreeNodeData | null>(null);
|
||||
const [useFilter, setUseFilter] = useState(true);
|
||||
|
||||
const [prevCollection, setPrevCollection] = useState(collection);
|
||||
const [prevEntries, setPrevEntries] = useState(entries);
|
||||
const [prevFilterTerm, setPrevFilterTerm] = useState(filterTerm);
|
||||
|
||||
useEffect(() => {
|
||||
if (collection !== prevCollection || entries !== prevEntries || filterTerm !== prevFilterTerm) {
|
||||
const expanded: Record<string, boolean> = {};
|
||||
walk(treeData, node => {
|
||||
if (node.expanded) {
|
||||
expanded[node.path] = true;
|
||||
}
|
||||
});
|
||||
const newTreeData = getTreeData(collection, entries);
|
||||
|
||||
const path = `/${filterTerm}`;
|
||||
walk(newTreeData, node => {
|
||||
if (expanded[node.path] || (useFilter && path.startsWith(node.path))) {
|
||||
node.expanded = true;
|
||||
}
|
||||
});
|
||||
|
||||
setTreeData(newTreeData);
|
||||
}
|
||||
|
||||
setPrevCollection(collection);
|
||||
setPrevEntries(entries);
|
||||
setPrevFilterTerm(filterTerm);
|
||||
}, [
|
||||
collection,
|
||||
entries,
|
||||
filterTerm,
|
||||
prevCollection,
|
||||
prevEntries,
|
||||
prevFilterTerm,
|
||||
treeData,
|
||||
useFilter,
|
||||
]);
|
||||
|
||||
const onToggle = useCallback(
|
||||
({ node, expanded }: { node: TreeNodeData; expanded: boolean }) => {
|
||||
if (!selected || selected.path === node.path || expanded) {
|
||||
setTreeData(
|
||||
updateNode(treeData, node, node => ({
|
||||
...node,
|
||||
expanded,
|
||||
})),
|
||||
);
|
||||
setSelected(node);
|
||||
setUseFilter(false);
|
||||
} else {
|
||||
// don't collapse non selected nodes when clicked
|
||||
setSelected(node);
|
||||
setUseFilter(false);
|
||||
}
|
||||
},
|
||||
[selected, treeData],
|
||||
);
|
||||
|
||||
return <TreeNode collection={collection} treeData={treeData} onToggle={onToggle} />;
|
||||
};
|
||||
|
||||
interface NestedCollectionOwnProps {
|
||||
collection: Collection;
|
||||
filterTerm: string;
|
||||
}
|
||||
|
||||
function mapStateToProps(state: RootState, ownProps: NestedCollectionOwnProps) {
|
||||
const { collection } = ownProps;
|
||||
const entries = selectEntries(state.entries, collection) ?? [];
|
||||
return { ...ownProps, entries };
|
||||
}
|
||||
|
||||
const connector = connect(mapStateToProps, {});
|
||||
export type NestedCollectionProps = ConnectedProps<typeof connector>;
|
||||
|
||||
export default connector(NestedCollection);
|
177
core/src/components/Collection/Sidebar.tsx
Normal file
177
core/src/components/Collection/Sidebar.tsx
Normal file
@ -0,0 +1,177 @@
|
||||
import { styled } from '@mui/material/styles';
|
||||
import ArticleIcon from '@mui/icons-material/Article';
|
||||
import Card from '@mui/material/Card';
|
||||
import CardContent from '@mui/material/CardContent';
|
||||
import List from '@mui/material/List';
|
||||
import ListItem from '@mui/material/ListItem';
|
||||
import ListItemButton from '@mui/material/ListItemButton';
|
||||
import ListItemIcon from '@mui/material/ListItemIcon';
|
||||
import ListItemText from '@mui/material/ListItemText';
|
||||
import Typography from '@mui/material/Typography';
|
||||
import React, { useMemo } from 'react';
|
||||
import { translate } from 'react-polyglot';
|
||||
|
||||
import { searchCollections } from '../../actions/collections';
|
||||
import { colors } from '../../components/UI/styles';
|
||||
import { getAdditionalLinks, getIcon } from '../../lib/registry';
|
||||
import NavLink from '../UI/NavLink';
|
||||
import CollectionSearch from './CollectionSearch';
|
||||
import NestedCollection from './NestedCollection';
|
||||
|
||||
import type { ReactNode } from 'react';
|
||||
import type { Collection, Collections, TranslatedProps } from '../../interface';
|
||||
|
||||
const StyledSidebar = styled('div')`
|
||||
position: sticky;
|
||||
top: 88px;
|
||||
align-self: flex-start;
|
||||
`;
|
||||
|
||||
const StyledListItemIcon = styled(ListItemIcon)`
|
||||
min-width: 0;
|
||||
margin-right: 12px;
|
||||
`;
|
||||
|
||||
interface SidebarProps {
|
||||
collections: Collections;
|
||||
collection: Collection;
|
||||
isSearchEnabled: boolean;
|
||||
searchTerm: string;
|
||||
filterTerm: string;
|
||||
}
|
||||
|
||||
const Sidebar = ({
|
||||
collections,
|
||||
collection,
|
||||
isSearchEnabled,
|
||||
searchTerm,
|
||||
t,
|
||||
filterTerm,
|
||||
}: TranslatedProps<SidebarProps>) => {
|
||||
const collectionLinks = useMemo(
|
||||
() =>
|
||||
Object.values(collections)
|
||||
.filter(collection => collection.hide !== true)
|
||||
.map(collection => {
|
||||
const collectionName = collection.name;
|
||||
const iconName = collection.icon;
|
||||
let icon: ReactNode = <ArticleIcon />;
|
||||
if (iconName) {
|
||||
const storedIcon = getIcon(iconName);
|
||||
if (storedIcon) {
|
||||
icon = storedIcon();
|
||||
}
|
||||
}
|
||||
|
||||
if ('nested' in collection) {
|
||||
return (
|
||||
<li key={`nested-${collectionName}`}>
|
||||
<NestedCollection
|
||||
collection={collection}
|
||||
filterTerm={filterTerm}
|
||||
data-testid={collectionName}
|
||||
/>
|
||||
</li>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<ListItem
|
||||
key={collectionName}
|
||||
to={`/collections/${collectionName}`}
|
||||
component={NavLink}
|
||||
disablePadding
|
||||
activeClassName="sidebar-active"
|
||||
>
|
||||
<ListItemButton>
|
||||
<StyledListItemIcon>{icon}</StyledListItemIcon>
|
||||
<ListItemText primary={collection.label} />
|
||||
</ListItemButton>
|
||||
</ListItem>
|
||||
);
|
||||
}),
|
||||
[collections, filterTerm],
|
||||
);
|
||||
|
||||
const additionalLinks = useMemo(() => getAdditionalLinks(), []);
|
||||
const links = useMemo(
|
||||
() =>
|
||||
Object.values(additionalLinks).map(({ id, title, data, options: { iconName } = {} }) => {
|
||||
let icon: ReactNode = <ArticleIcon />;
|
||||
if (iconName) {
|
||||
const storedIcon = getIcon(iconName);
|
||||
if (storedIcon) {
|
||||
icon = storedIcon();
|
||||
}
|
||||
}
|
||||
|
||||
const content = (
|
||||
<>
|
||||
<StyledListItemIcon>{icon}</StyledListItemIcon>
|
||||
<ListItemText primary={title} />
|
||||
</>
|
||||
);
|
||||
|
||||
return typeof data === 'string' ? (
|
||||
<ListItem
|
||||
key={title}
|
||||
href={data}
|
||||
component="a"
|
||||
disablePadding
|
||||
target="_blank"
|
||||
rel="noopener"
|
||||
sx={{
|
||||
color: colors.inactive,
|
||||
'&:hover': {
|
||||
color: colors.active,
|
||||
'.MuiListItemIcon-root': {
|
||||
color: colors.active,
|
||||
},
|
||||
},
|
||||
}}
|
||||
>
|
||||
<ListItemButton>{content}</ListItemButton>
|
||||
</ListItem>
|
||||
) : (
|
||||
<ListItem
|
||||
key={title}
|
||||
to={`/page/${id}`}
|
||||
component={NavLink}
|
||||
disablePadding
|
||||
activeClassName="sidebar-active"
|
||||
>
|
||||
<ListItemButton>{content}</ListItemButton>
|
||||
</ListItem>
|
||||
);
|
||||
}),
|
||||
[additionalLinks],
|
||||
);
|
||||
|
||||
return (
|
||||
<StyledSidebar>
|
||||
<Card sx={{ minWidth: 275 }}>
|
||||
<CardContent sx={{ paddingBottom: 0 }}>
|
||||
<Typography gutterBottom variant="h5" component="div">
|
||||
{t('collection.sidebar.collections')}
|
||||
</Typography>
|
||||
{isSearchEnabled && (
|
||||
<CollectionSearch
|
||||
searchTerm={searchTerm}
|
||||
collections={collections}
|
||||
collection={collection}
|
||||
onSubmit={(query: string, collection?: string) =>
|
||||
searchCollections(query, collection)
|
||||
}
|
||||
/>
|
||||
)}
|
||||
</CardContent>
|
||||
<List>
|
||||
{collectionLinks}
|
||||
{links}
|
||||
</List>
|
||||
</Card>
|
||||
</StyledSidebar>
|
||||
);
|
||||
};
|
||||
|
||||
export default translate()(Sidebar);
|
112
core/src/components/Collection/SortControl.tsx
Normal file
112
core/src/components/Collection/SortControl.tsx
Normal file
@ -0,0 +1,112 @@
|
||||
import KeyboardArrowDownIcon from '@mui/icons-material/KeyboardArrowDown';
|
||||
import KeyboardArrowUpIcon from '@mui/icons-material/KeyboardArrowUp';
|
||||
import { styled } from '@mui/material';
|
||||
import Button from '@mui/material/Button';
|
||||
import ListItemText from '@mui/material/ListItemText';
|
||||
import Menu from '@mui/material/Menu';
|
||||
import MenuItem from '@mui/material/MenuItem';
|
||||
import React, { useCallback, useMemo } from 'react';
|
||||
import { translate } from 'react-polyglot';
|
||||
|
||||
import { SortDirection } from '../../interface';
|
||||
|
||||
import type { SortableField, SortMap, TranslatedProps } from '../../interface';
|
||||
|
||||
const StyledMenuIconWrapper = styled('div')`
|
||||
width: 32px;
|
||||
height: 24px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: flex-end;
|
||||
`;
|
||||
|
||||
function nextSortDirection(direction: SortDirection) {
|
||||
switch (direction) {
|
||||
case SortDirection.Ascending:
|
||||
return SortDirection.Descending;
|
||||
case SortDirection.Descending:
|
||||
return SortDirection.None;
|
||||
default:
|
||||
return SortDirection.Ascending;
|
||||
}
|
||||
}
|
||||
|
||||
interface SortControlProps {
|
||||
fields: SortableField[];
|
||||
onSortClick: (key: string, direction?: SortDirection) => Promise<void>;
|
||||
sort: SortMap | undefined;
|
||||
}
|
||||
|
||||
const SortControl = ({ t, fields, onSortClick, sort }: TranslatedProps<SortControlProps>) => {
|
||||
const [anchorEl, setAnchorEl] = React.useState<null | HTMLElement>(null);
|
||||
const open = Boolean(anchorEl);
|
||||
const handleClick = useCallback((event: React.MouseEvent<HTMLButtonElement>) => {
|
||||
setAnchorEl(event.currentTarget);
|
||||
}, []);
|
||||
const handleClose = useCallback(() => {
|
||||
setAnchorEl(null);
|
||||
}, []);
|
||||
|
||||
const selectedSort = useMemo(() => {
|
||||
if (!sort) {
|
||||
return { key: undefined, direction: undefined };
|
||||
}
|
||||
|
||||
const sortValues = Object.values(sort);
|
||||
if (Object.values(sortValues).length < 1 || sortValues[0].direction === SortDirection.None) {
|
||||
return { key: undefined, direction: undefined };
|
||||
}
|
||||
|
||||
return sortValues[0];
|
||||
}, [sort]);
|
||||
|
||||
return (
|
||||
<div>
|
||||
<Button
|
||||
id="sort-button"
|
||||
aria-controls={open ? 'sort-menu' : undefined}
|
||||
aria-haspopup="true"
|
||||
aria-expanded={open ? 'true' : undefined}
|
||||
onClick={handleClick}
|
||||
variant={selectedSort.key ? 'contained' : 'outlined'}
|
||||
endIcon={<KeyboardArrowDownIcon />}
|
||||
>
|
||||
{t('collection.collectionTop.sortBy')}
|
||||
</Button>
|
||||
<Menu
|
||||
id="sort-menu"
|
||||
anchorEl={anchorEl}
|
||||
open={open}
|
||||
onClose={handleClose}
|
||||
MenuListProps={{
|
||||
'aria-labelledby': 'sort-button',
|
||||
}}
|
||||
>
|
||||
{fields.map(field => {
|
||||
const sortDir = sort?.[field.name]?.direction ?? SortDirection.None;
|
||||
const nextSortDir = nextSortDirection(sortDir);
|
||||
return (
|
||||
<MenuItem
|
||||
key={field.name}
|
||||
onClick={() => onSortClick(field.name, nextSortDir)}
|
||||
selected={field.name === selectedSort.key}
|
||||
>
|
||||
<ListItemText>{field.label ?? field.name}</ListItemText>
|
||||
<StyledMenuIconWrapper>
|
||||
{field.name === selectedSort.key ? (
|
||||
selectedSort.direction === SortDirection.Ascending ? (
|
||||
<KeyboardArrowUpIcon fontSize="small" />
|
||||
) : (
|
||||
<KeyboardArrowDownIcon fontSize="small" />
|
||||
)
|
||||
) : null}
|
||||
</StyledMenuIconWrapper>
|
||||
</MenuItem>
|
||||
);
|
||||
})}
|
||||
</Menu>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default translate()(SortControl);
|
44
core/src/components/Collection/ViewStyleControl.tsx
Normal file
44
core/src/components/Collection/ViewStyleControl.tsx
Normal file
@ -0,0 +1,44 @@
|
||||
import { styled } from '@mui/material/styles';
|
||||
import GridViewSharpIcon from '@mui/icons-material/GridViewSharp';
|
||||
import ReorderSharpIcon from '@mui/icons-material/ReorderSharp';
|
||||
import IconButton from '@mui/material/IconButton';
|
||||
import React from 'react';
|
||||
|
||||
import { VIEW_STYLE_GRID, VIEW_STYLE_LIST } from '../../constants/collectionViews';
|
||||
|
||||
import type { CollectionViewStyle } from '../../constants/collectionViews';
|
||||
|
||||
const ViewControlsSection = styled('div')`
|
||||
margin-left: 24px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: flex-end;
|
||||
`;
|
||||
|
||||
interface ViewStyleControlPros {
|
||||
viewStyle: CollectionViewStyle;
|
||||
onChangeViewStyle: (viewStyle: CollectionViewStyle) => void;
|
||||
}
|
||||
|
||||
const ViewStyleControl = ({ viewStyle, onChangeViewStyle }: ViewStyleControlPros) => {
|
||||
return (
|
||||
<ViewControlsSection>
|
||||
<IconButton
|
||||
color={viewStyle === VIEW_STYLE_LIST ? 'primary' : 'default'}
|
||||
aria-label="list view"
|
||||
onClick={() => onChangeViewStyle(VIEW_STYLE_LIST)}
|
||||
>
|
||||
<ReorderSharpIcon />
|
||||
</IconButton>
|
||||
<IconButton
|
||||
color={viewStyle === VIEW_STYLE_GRID ? 'primary' : 'default'}
|
||||
aria-label="grid view"
|
||||
onClick={() => onChangeViewStyle(VIEW_STYLE_GRID)}
|
||||
>
|
||||
<GridViewSharpIcon />
|
||||
</IconButton>
|
||||
</ViewControlsSection>
|
||||
);
|
||||
};
|
||||
|
||||
export default ViewStyleControl;
|
402
core/src/components/Editor/Editor.tsx
Normal file
402
core/src/components/Editor/Editor.tsx
Normal file
@ -0,0 +1,402 @@
|
||||
import debounce from 'lodash/debounce';
|
||||
import React, { useCallback, useEffect, useMemo, useState } from 'react';
|
||||
import { translate } from 'react-polyglot';
|
||||
import { connect } from 'react-redux';
|
||||
|
||||
import { logoutUser as logoutUserAction } from '../../actions/auth';
|
||||
import {
|
||||
changeDraftFieldValidation as changeDraftFieldValidationAction,
|
||||
createDraftDuplicateFromEntry as createDraftDuplicateFromEntryAction,
|
||||
createEmptyDraft as createEmptyDraftAction,
|
||||
deleteDraftLocalBackup as deleteDraftLocalBackupAction,
|
||||
deleteEntry as deleteEntryAction,
|
||||
deleteLocalBackup as deleteLocalBackupAction,
|
||||
discardDraft as discardDraftAction,
|
||||
loadEntries as loadEntriesAction,
|
||||
loadEntry as loadEntryAction,
|
||||
loadLocalBackup as loadLocalBackupAction,
|
||||
persistEntry as persistEntryAction,
|
||||
persistLocalBackup as persistLocalBackupAction,
|
||||
retrieveLocalBackup as retrieveLocalBackupAction,
|
||||
} from '../../actions/entries';
|
||||
import {
|
||||
loadScroll as loadScrollAction,
|
||||
toggleScroll as toggleScrollAction,
|
||||
} from '../../actions/scroll';
|
||||
import { selectFields } from '../../lib/util/collection.util';
|
||||
import { useWindowEvent } from '../../lib/util/window.util';
|
||||
import { selectEntry } from '../../reducers';
|
||||
import { history, navigateToCollection, navigateToNewEntry } from '../../routing/history';
|
||||
import confirm from '../UI/Confirm';
|
||||
import Loader from '../UI/Loader';
|
||||
import EditorInterface from './EditorInterface';
|
||||
|
||||
import type { TransitionPromptHook } from 'history';
|
||||
import type { ComponentType } from 'react';
|
||||
import type { ConnectedProps } from 'react-redux';
|
||||
import type { Collection, EditorPersistOptions, Entry, TranslatedProps } from '../../interface';
|
||||
import type { RootState } from '../../store';
|
||||
|
||||
const Editor = ({
|
||||
entry,
|
||||
entryDraft,
|
||||
fields,
|
||||
collection,
|
||||
user,
|
||||
hasChanged,
|
||||
displayUrl,
|
||||
isModification,
|
||||
logoutUser,
|
||||
draftKey,
|
||||
t,
|
||||
editorBackLink,
|
||||
toggleScroll,
|
||||
scrollSyncEnabled,
|
||||
loadScroll,
|
||||
showDelete,
|
||||
slug,
|
||||
localBackup,
|
||||
persistLocalBackup,
|
||||
loadEntry,
|
||||
persistEntry,
|
||||
deleteEntry,
|
||||
loadLocalBackup,
|
||||
retrieveLocalBackup,
|
||||
deleteLocalBackup,
|
||||
deleteDraftLocalBackup,
|
||||
createDraftDuplicateFromEntry,
|
||||
createEmptyDraft,
|
||||
discardDraft,
|
||||
}: TranslatedProps<EditorProps>) => {
|
||||
const [version, setVersion] = useState(0);
|
||||
|
||||
const createBackup = useMemo(
|
||||
() =>
|
||||
debounce(function (entry: Entry, collection: Collection) {
|
||||
persistLocalBackup(entry, collection);
|
||||
}, 2000),
|
||||
[persistLocalBackup],
|
||||
);
|
||||
|
||||
const deleteBackup = useCallback(() => {
|
||||
createBackup.cancel();
|
||||
if (slug) {
|
||||
deleteLocalBackup(collection, slug);
|
||||
}
|
||||
deleteDraftLocalBackup();
|
||||
}, [collection, createBackup, deleteDraftLocalBackup, deleteLocalBackup, slug]);
|
||||
|
||||
const [submitted, setSubmitted] = useState(false);
|
||||
const handlePersistEntry = useCallback(
|
||||
async (opts: EditorPersistOptions = {}) => {
|
||||
const { createNew = false, duplicate = false } = opts;
|
||||
|
||||
if (!entryDraft.entry) {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
await persistEntry(collection);
|
||||
setVersion(version + 1);
|
||||
|
||||
deleteBackup();
|
||||
|
||||
if (createNew) {
|
||||
navigateToNewEntry(collection.name);
|
||||
if (duplicate && entryDraft.entry) {
|
||||
createDraftDuplicateFromEntry(entryDraft.entry);
|
||||
}
|
||||
}
|
||||
// eslint-disable-next-line no-empty
|
||||
} catch (e) {}
|
||||
|
||||
setSubmitted(true);
|
||||
},
|
||||
[
|
||||
collection,
|
||||
createDraftDuplicateFromEntry,
|
||||
deleteBackup,
|
||||
entryDraft.entry,
|
||||
persistEntry,
|
||||
version,
|
||||
],
|
||||
);
|
||||
|
||||
const handleDuplicateEntry = useCallback(() => {
|
||||
if (!entryDraft.entry) {
|
||||
return;
|
||||
}
|
||||
|
||||
navigateToNewEntry(collection.name);
|
||||
createDraftDuplicateFromEntry(entryDraft.entry);
|
||||
}, [collection.name, createDraftDuplicateFromEntry, entryDraft.entry]);
|
||||
|
||||
const handleDeleteEntry = useCallback(async () => {
|
||||
if (entryDraft.hasChanged) {
|
||||
if (
|
||||
!(await confirm({
|
||||
title: 'editor.editor.onDeleteWithUnsavedChangesTitle',
|
||||
body: 'editor.editor.onDeleteWithUnsavedChangesBody',
|
||||
color: 'error',
|
||||
}))
|
||||
) {
|
||||
return;
|
||||
}
|
||||
} else if (
|
||||
!(await confirm({
|
||||
title: 'editor.editor.onDeletePublishedEntryTitle',
|
||||
body: 'editor.editor.onDeletePublishedEntryBody',
|
||||
color: 'error',
|
||||
}))
|
||||
) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (!slug) {
|
||||
return navigateToCollection(collection.name);
|
||||
}
|
||||
|
||||
setTimeout(async () => {
|
||||
await deleteEntry(collection, slug);
|
||||
deleteBackup();
|
||||
return navigateToCollection(collection.name);
|
||||
}, 0);
|
||||
}, [collection, deleteBackup, deleteEntry, entryDraft.hasChanged, slug]);
|
||||
|
||||
const [prevLocalBackup, setPrevLocalBackup] = useState<
|
||||
| {
|
||||
entry: Entry;
|
||||
}
|
||||
| undefined
|
||||
>();
|
||||
|
||||
useEffect(() => {
|
||||
if (!prevLocalBackup && localBackup) {
|
||||
const updateLocalBackup = async () => {
|
||||
const confirmLoadBackup = await confirm({
|
||||
title: 'editor.editor.confirmLoadBackupTitle',
|
||||
body: 'editor.editor.confirmLoadBackupBody',
|
||||
});
|
||||
|
||||
if (confirmLoadBackup) {
|
||||
loadLocalBackup();
|
||||
setVersion(version + 1);
|
||||
} else {
|
||||
deleteBackup();
|
||||
}
|
||||
};
|
||||
|
||||
updateLocalBackup();
|
||||
}
|
||||
|
||||
setPrevLocalBackup(localBackup);
|
||||
}, [deleteBackup, loadLocalBackup, localBackup, prevLocalBackup, version]);
|
||||
|
||||
useEffect(() => {
|
||||
if (hasChanged && entryDraft.entry) {
|
||||
createBackup(entryDraft.entry, collection);
|
||||
} else if (localBackup) {
|
||||
deleteBackup();
|
||||
}
|
||||
|
||||
return () => {
|
||||
createBackup.flush();
|
||||
};
|
||||
}, [collection, createBackup, deleteBackup, entryDraft.entry, hasChanged, localBackup]);
|
||||
|
||||
const [prevCollection, setPrevCollection] = useState<Collection | null>(null);
|
||||
const [preSlug, setPrevSlug] = useState<string | undefined | null>(null);
|
||||
useEffect(() => {
|
||||
if (!slug && preSlug !== slug) {
|
||||
setTimeout(() => {
|
||||
createEmptyDraft(collection, location.search);
|
||||
});
|
||||
} else if (slug && (prevCollection !== collection || preSlug !== slug)) {
|
||||
setTimeout(() => {
|
||||
retrieveLocalBackup(collection, slug);
|
||||
loadEntry(collection, slug);
|
||||
});
|
||||
}
|
||||
|
||||
setPrevCollection(collection);
|
||||
setPrevSlug(slug);
|
||||
}, [
|
||||
collection,
|
||||
createEmptyDraft,
|
||||
discardDraft,
|
||||
entryDraft.entry,
|
||||
loadEntry,
|
||||
preSlug,
|
||||
prevCollection,
|
||||
retrieveLocalBackup,
|
||||
slug,
|
||||
]);
|
||||
|
||||
const leaveMessage = useMemo(() => t('editor.editor.onLeavePage'), [t]);
|
||||
|
||||
const exitBlocker = useCallback(
|
||||
(event: BeforeUnloadEvent) => {
|
||||
if (entryDraft.hasChanged) {
|
||||
// This message is ignored in most browsers, but its presence triggers the confirmation dialog
|
||||
event.returnValue = leaveMessage;
|
||||
return leaveMessage;
|
||||
}
|
||||
},
|
||||
[entryDraft.hasChanged, leaveMessage],
|
||||
);
|
||||
|
||||
useWindowEvent('beforeunload', exitBlocker);
|
||||
|
||||
const navigationBlocker: TransitionPromptHook = useCallback(
|
||||
(location, action) => {
|
||||
/**
|
||||
* New entry being saved and redirected to it's new slug based url.
|
||||
*/
|
||||
const isPersisting = entryDraft.entry?.isPersisting;
|
||||
const newRecord = entryDraft.entry?.newRecord;
|
||||
const newEntryPath = `/collections/${collection.name}/new`;
|
||||
if (isPersisting && newRecord && location.pathname === newEntryPath && action === 'PUSH') {
|
||||
return;
|
||||
}
|
||||
|
||||
if (hasChanged) {
|
||||
return leaveMessage;
|
||||
}
|
||||
},
|
||||
[
|
||||
collection.name,
|
||||
entryDraft.entry?.isPersisting,
|
||||
entryDraft.entry?.newRecord,
|
||||
hasChanged,
|
||||
leaveMessage,
|
||||
],
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
const unblock = history.block(navigationBlocker);
|
||||
|
||||
return () => {
|
||||
unblock();
|
||||
};
|
||||
}, [collection.name, deleteBackup, discardDraft, navigationBlocker]);
|
||||
|
||||
// TODO Is this needed?
|
||||
// if (!collectionEntriesLoaded) {
|
||||
// loadEntries(collection);
|
||||
// }
|
||||
|
||||
if (entry && entry.error) {
|
||||
return (
|
||||
<div>
|
||||
<h3>{entry.error}</h3>
|
||||
</div>
|
||||
);
|
||||
} else if (entryDraft == null || entryDraft.entry === undefined || (entry && entry.isFetching)) {
|
||||
return <Loader>{t('editor.editor.loadingEntry')}</Loader>;
|
||||
}
|
||||
|
||||
return (
|
||||
<EditorInterface
|
||||
key={`editor-${version}`}
|
||||
draftKey={draftKey}
|
||||
entry={entryDraft.entry}
|
||||
collection={collection}
|
||||
fields={fields}
|
||||
fieldsErrors={entryDraft.fieldsErrors}
|
||||
onPersist={handlePersistEntry}
|
||||
onDelete={handleDeleteEntry}
|
||||
onDuplicate={handleDuplicateEntry}
|
||||
showDelete={showDelete ?? true}
|
||||
user={user}
|
||||
hasChanged={hasChanged}
|
||||
displayUrl={displayUrl}
|
||||
isNewEntry={!slug}
|
||||
isModification={isModification}
|
||||
onLogoutClick={logoutUser}
|
||||
editorBackLink={editorBackLink}
|
||||
toggleScroll={toggleScroll}
|
||||
scrollSyncEnabled={scrollSyncEnabled}
|
||||
loadScroll={loadScroll}
|
||||
submitted={submitted}
|
||||
t={t}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
interface CollectionViewOwnProps {
|
||||
name: string;
|
||||
slug?: string;
|
||||
newRecord: boolean;
|
||||
showDelete?: boolean;
|
||||
}
|
||||
|
||||
function mapStateToProps(state: RootState, ownProps: CollectionViewOwnProps) {
|
||||
const { collections, entryDraft, auth, config, entries, scroll } = state;
|
||||
const { name, slug } = ownProps;
|
||||
const collection = collections[name];
|
||||
const collectionName = collection.name;
|
||||
const fields = selectFields(collection, slug);
|
||||
const entry = !slug ? null : selectEntry(state, collectionName, slug);
|
||||
const user = auth.user;
|
||||
const hasChanged = entryDraft.hasChanged;
|
||||
const displayUrl = config.config?.display_url;
|
||||
const isModification = entryDraft.entry?.isModification ?? false;
|
||||
const collectionEntriesLoaded = Boolean(entries.pages[collectionName]);
|
||||
const localBackup = entryDraft.localBackup;
|
||||
const draftKey = entryDraft.key;
|
||||
let editorBackLink = `/collections/${collectionName}`;
|
||||
if ('files' in collection && collection.files?.length === 1) {
|
||||
editorBackLink = '/';
|
||||
}
|
||||
|
||||
if ('nested' in collection && collection.nested && slug) {
|
||||
const pathParts = slug.split('/');
|
||||
if (pathParts.length > 2) {
|
||||
editorBackLink = `${editorBackLink}/filter/${pathParts.slice(0, -2).join('/')}`;
|
||||
}
|
||||
}
|
||||
|
||||
const scrollSyncEnabled = scroll.isScrolling;
|
||||
|
||||
return {
|
||||
...ownProps,
|
||||
collection,
|
||||
collections,
|
||||
entryDraft,
|
||||
fields,
|
||||
entry,
|
||||
user,
|
||||
hasChanged,
|
||||
displayUrl,
|
||||
isModification,
|
||||
collectionEntriesLoaded,
|
||||
localBackup,
|
||||
draftKey,
|
||||
editorBackLink,
|
||||
scrollSyncEnabled,
|
||||
};
|
||||
}
|
||||
|
||||
const mapDispatchToProps = {
|
||||
loadEntry: loadEntryAction,
|
||||
loadEntries: loadEntriesAction,
|
||||
loadLocalBackup: loadLocalBackupAction,
|
||||
deleteDraftLocalBackup: deleteDraftLocalBackupAction,
|
||||
retrieveLocalBackup: retrieveLocalBackupAction,
|
||||
persistLocalBackup: persistLocalBackupAction,
|
||||
deleteLocalBackup: deleteLocalBackupAction,
|
||||
changeDraftFieldValidation: changeDraftFieldValidationAction,
|
||||
createDraftDuplicateFromEntry: createDraftDuplicateFromEntryAction,
|
||||
createEmptyDraft: createEmptyDraftAction,
|
||||
discardDraft: discardDraftAction,
|
||||
persistEntry: persistEntryAction,
|
||||
deleteEntry: deleteEntryAction,
|
||||
logoutUser: logoutUserAction,
|
||||
toggleScroll: toggleScrollAction,
|
||||
loadScroll: loadScrollAction,
|
||||
};
|
||||
|
||||
const connector = connect(mapStateToProps, mapDispatchToProps);
|
||||
export type EditorProps = ConnectedProps<typeof connector>;
|
||||
|
||||
export default connector(translate()(Editor) as ComponentType<EditorProps>);
|
329
core/src/components/Editor/EditorControlPane/EditorControl.tsx
Normal file
329
core/src/components/Editor/EditorControlPane/EditorControl.tsx
Normal file
@ -0,0 +1,329 @@
|
||||
import { styled } from '@mui/material/styles';
|
||||
import isEmpty from 'lodash/isEmpty';
|
||||
import React, { useCallback, useEffect, useMemo, useState } from 'react';
|
||||
import { translate } from 'react-polyglot';
|
||||
import { connect } from 'react-redux';
|
||||
|
||||
import {
|
||||
changeDraftField as changeDraftFieldAction,
|
||||
changeDraftFieldValidation as changeDraftFieldValidationAction,
|
||||
clearFieldErrors as clearFieldErrorsAction,
|
||||
tryLoadEntry,
|
||||
} from '../../../actions/entries';
|
||||
import { getAsset as getAssetAction } from '../../../actions/media';
|
||||
import {
|
||||
clearMediaControl as clearMediaControlAction,
|
||||
openMediaLibrary as openMediaLibraryAction,
|
||||
removeInsertedMedia as removeInsertedMediaAction,
|
||||
removeMediaControl as removeMediaControlAction,
|
||||
} from '../../../actions/mediaLibrary';
|
||||
import { clearSearch as clearSearchAction, query as queryAction } from '../../../actions/search';
|
||||
import { borders, colors, lengths, transitions } from '../../../components/UI/styles';
|
||||
import { transientOptions } from '../../../lib';
|
||||
import { resolveWidget } from '../../../lib/registry';
|
||||
import { getFieldLabel } from '../../../lib/util/field.util';
|
||||
import { validate } from '../../../lib/util/validation.util';
|
||||
import { selectIsLoadingAsset } from '../../../reducers/medias';
|
||||
|
||||
import type { ComponentType } from 'react';
|
||||
import type { ConnectedProps } from 'react-redux';
|
||||
import type {
|
||||
Collection,
|
||||
Entry,
|
||||
Field,
|
||||
FieldsErrors,
|
||||
GetAssetFunction,
|
||||
I18nSettings,
|
||||
TranslatedProps,
|
||||
ValueOrNestedValue,
|
||||
Widget,
|
||||
} from '../../../interface';
|
||||
import type { RootState } from '../../../store';
|
||||
import type { EditorControlPaneProps } from './EditorControlPane';
|
||||
|
||||
/**
|
||||
* This is a necessary bridge as we are still passing classnames to widgets
|
||||
* for styling. Once that changes we can stop storing raw style strings like
|
||||
* this.
|
||||
*/
|
||||
const styleStrings = {
|
||||
widget: `
|
||||
display: block;
|
||||
width: 100%;
|
||||
padding: ${lengths.inputPadding};
|
||||
margin: 0;
|
||||
border: ${borders.textField};
|
||||
border-radius: ${lengths.borderRadius};
|
||||
border-top-left-radius: 0;
|
||||
outline: 0;
|
||||
box-shadow: none;
|
||||
background-color: ${colors.inputBackground};
|
||||
color: #444a57;
|
||||
transition: border-color ${transitions.main};
|
||||
position: relative;
|
||||
font-size: 15px;
|
||||
line-height: 1.5;
|
||||
|
||||
select& {
|
||||
text-indent: 14px;
|
||||
height: 58px;
|
||||
}
|
||||
`,
|
||||
widgetActive: `
|
||||
border-color: ${colors.active};
|
||||
`,
|
||||
widgetError: `
|
||||
border-color: ${colors.errorText};
|
||||
`,
|
||||
disabled: `
|
||||
pointer-events: none;
|
||||
opacity: 0.5;
|
||||
background: #ccc;
|
||||
`,
|
||||
hidden: `
|
||||
visibility: hidden;
|
||||
`,
|
||||
};
|
||||
|
||||
interface ControlContainerProps {
|
||||
$isHidden: boolean;
|
||||
}
|
||||
|
||||
const ControlContainer = styled(
|
||||
'div',
|
||||
transientOptions,
|
||||
)<ControlContainerProps>(
|
||||
({ $isHidden }) => `
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 4px;
|
||||
align-items: flex-start;
|
||||
width: 100%;
|
||||
${$isHidden ? styleStrings.hidden : ''};
|
||||
`,
|
||||
);
|
||||
|
||||
const ControlErrorsList = styled('ul')`
|
||||
list-style-type: none;
|
||||
font-size: 12px;
|
||||
color: ${colors.errorText};
|
||||
position: relative;
|
||||
font-weight: 600;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
margin: 0;
|
||||
padding: 4px 8px;
|
||||
`;
|
||||
|
||||
interface ControlHintProps {
|
||||
$error: boolean;
|
||||
}
|
||||
|
||||
export const ControlHint = styled(
|
||||
'p',
|
||||
transientOptions,
|
||||
)<ControlHintProps>(
|
||||
({ $error }) => `
|
||||
margin: 0;
|
||||
margin-left: 8px;
|
||||
padding: 0;
|
||||
font-size: 12px;
|
||||
color: ${$error ? colors.errorText : colors.controlLabel};
|
||||
transition: color ${transitions.main};
|
||||
`,
|
||||
);
|
||||
|
||||
const EditorControl = ({
|
||||
className,
|
||||
clearFieldErrors,
|
||||
clearMediaControl,
|
||||
clearSearch,
|
||||
collection,
|
||||
config: configState,
|
||||
entry,
|
||||
field,
|
||||
fieldsErrors,
|
||||
submitted,
|
||||
getAsset,
|
||||
isDisabled,
|
||||
isEditorComponent,
|
||||
isFetching,
|
||||
isFieldDuplicate,
|
||||
isFieldHidden,
|
||||
isHidden = false,
|
||||
isNewEditorComponent,
|
||||
loadEntry,
|
||||
locale,
|
||||
mediaPaths,
|
||||
changeDraftFieldValidation,
|
||||
openMediaLibrary,
|
||||
parentPath,
|
||||
query,
|
||||
removeInsertedMedia,
|
||||
removeMediaControl,
|
||||
t,
|
||||
value,
|
||||
forList = false,
|
||||
changeDraftField,
|
||||
i18n,
|
||||
}: TranslatedProps<EditorControlProps>) => {
|
||||
const widgetName = field.widget;
|
||||
const widget = resolveWidget(widgetName) as Widget<ValueOrNestedValue>;
|
||||
const fieldHint = field.hint;
|
||||
|
||||
const path = useMemo(
|
||||
() => (parentPath.length > 0 ? `${parentPath}.${field.name}` : field.name),
|
||||
[field.name, parentPath],
|
||||
);
|
||||
|
||||
const [dirty, setDirty] = useState(!isEmpty(value));
|
||||
const errors = useMemo(() => fieldsErrors[path] ?? [], [fieldsErrors, path]);
|
||||
const hasErrors = (submitted || dirty) && Boolean(errors.length);
|
||||
|
||||
const handleGetAsset = useCallback(
|
||||
(collection: Collection, entry: Entry): GetAssetFunction =>
|
||||
(path: string, field?: Field) => {
|
||||
return getAsset(collection, entry, path, field);
|
||||
},
|
||||
[getAsset],
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
const validateValue = async () => {
|
||||
await validate(path, field, value, widget, changeDraftFieldValidation, t);
|
||||
};
|
||||
|
||||
validateValue();
|
||||
}, [field, value, changeDraftFieldValidation, path, t, widget, dirty]);
|
||||
|
||||
const handleChangeDraftField = useCallback(
|
||||
(value: ValueOrNestedValue) => {
|
||||
setDirty(true);
|
||||
changeDraftField({ path, field, value, entry, i18n });
|
||||
},
|
||||
[changeDraftField, entry, field, i18n, path],
|
||||
);
|
||||
|
||||
const config = useMemo(() => configState.config, [configState.config]);
|
||||
if (!collection || !entry || !config) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<ControlContainer className={className} $isHidden={isHidden}>
|
||||
<>
|
||||
{React.createElement(widget.control, {
|
||||
clearFieldErrors,
|
||||
clearSearch,
|
||||
collection,
|
||||
config,
|
||||
entry,
|
||||
field,
|
||||
fieldsErrors,
|
||||
submitted,
|
||||
getAsset: handleGetAsset(collection, entry),
|
||||
isDisabled: isDisabled ?? false,
|
||||
isEditorComponent: isEditorComponent ?? false,
|
||||
isFetching,
|
||||
isFieldDuplicate,
|
||||
isFieldHidden,
|
||||
isNewEditorComponent: isNewEditorComponent ?? false,
|
||||
label: getFieldLabel(field, t),
|
||||
loadEntry,
|
||||
locale,
|
||||
mediaPaths,
|
||||
onChange: handleChangeDraftField,
|
||||
clearMediaControl,
|
||||
openMediaLibrary,
|
||||
removeInsertedMedia,
|
||||
removeMediaControl,
|
||||
path,
|
||||
query,
|
||||
t,
|
||||
value,
|
||||
forList,
|
||||
i18n,
|
||||
hasErrors,
|
||||
})}
|
||||
{fieldHint && <ControlHint $error={hasErrors}>{fieldHint}</ControlHint>}
|
||||
{hasErrors ? (
|
||||
<ControlErrorsList>
|
||||
{errors.map(error => {
|
||||
return (
|
||||
error.message &&
|
||||
typeof error.message === 'string' && (
|
||||
<li key={error.message.trim().replace(/[^a-z0-9]+/gi, '-')}>{error.message}</li>
|
||||
)
|
||||
);
|
||||
})}
|
||||
</ControlErrorsList>
|
||||
) : null}
|
||||
</>
|
||||
</ControlContainer>
|
||||
);
|
||||
};
|
||||
|
||||
interface EditorControlOwnProps {
|
||||
className?: string;
|
||||
clearFieldErrors: EditorControlPaneProps['clearFieldErrors'];
|
||||
field: Field;
|
||||
fieldsErrors: FieldsErrors;
|
||||
submitted: boolean;
|
||||
isDisabled?: boolean;
|
||||
isEditorComponent?: boolean;
|
||||
isFieldDuplicate?: (field: Field) => boolean;
|
||||
isFieldHidden?: (field: Field) => boolean;
|
||||
isHidden?: boolean;
|
||||
isNewEditorComponent?: boolean;
|
||||
locale?: string;
|
||||
parentPath: string;
|
||||
value: ValueOrNestedValue;
|
||||
forList?: boolean;
|
||||
i18n: I18nSettings | undefined;
|
||||
}
|
||||
|
||||
function mapStateToProps(state: RootState, ownProps: EditorControlOwnProps) {
|
||||
const { collections, entryDraft } = state;
|
||||
const entry = entryDraft.entry;
|
||||
const collection = entryDraft.entry ? collections[entryDraft.entry.collection] : null;
|
||||
const isLoadingAsset = selectIsLoadingAsset(state.medias);
|
||||
|
||||
async function loadEntry(collectionName: string, slug: string) {
|
||||
const targetCollection = collections[collectionName];
|
||||
if (targetCollection) {
|
||||
const loadedEntry = await tryLoadEntry(state, targetCollection, slug);
|
||||
return loadedEntry;
|
||||
} else {
|
||||
throw new Error(`Can't find collection '${collectionName}'`);
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
...ownProps,
|
||||
mediaPaths: state.mediaLibrary.controlMedia,
|
||||
isFetching: state.search.isFetching,
|
||||
config: state.config,
|
||||
entry,
|
||||
collection,
|
||||
isLoadingAsset,
|
||||
loadEntry,
|
||||
};
|
||||
}
|
||||
|
||||
const mapDispatchToProps = {
|
||||
changeDraftField: changeDraftFieldAction,
|
||||
changeDraftFieldValidation: changeDraftFieldValidationAction,
|
||||
openMediaLibrary: openMediaLibraryAction,
|
||||
clearMediaControl: clearMediaControlAction,
|
||||
removeMediaControl: removeMediaControlAction,
|
||||
removeInsertedMedia: removeInsertedMediaAction,
|
||||
query: queryAction,
|
||||
clearSearch: clearSearchAction,
|
||||
clearFieldErrors: clearFieldErrorsAction,
|
||||
getAsset: getAssetAction,
|
||||
};
|
||||
|
||||
const connector = connect(mapStateToProps, mapDispatchToProps);
|
||||
export type EditorControlProps = ConnectedProps<typeof connector>;
|
||||
|
||||
export default connector(translate()(EditorControl) as ComponentType<EditorControlProps>);
|
@ -0,0 +1,248 @@
|
||||
import Button from '@mui/material/Button';
|
||||
import Menu from '@mui/material/Menu';
|
||||
import MenuItem from '@mui/material/MenuItem';
|
||||
import { styled } from '@mui/material/styles';
|
||||
import get from 'lodash/get';
|
||||
import React, { useCallback, useMemo } from 'react';
|
||||
import { connect } from 'react-redux';
|
||||
|
||||
import {
|
||||
changeDraftField as changeDraftFieldAction,
|
||||
clearFieldErrors as clearFieldErrorsAction,
|
||||
} from '../../../actions/entries';
|
||||
import confirm from '../../../components/UI/Confirm';
|
||||
import {
|
||||
getI18nInfo,
|
||||
getLocaleDataPath,
|
||||
hasI18n,
|
||||
isFieldDuplicate,
|
||||
isFieldHidden,
|
||||
isFieldTranslatable,
|
||||
} from '../../../lib/i18n';
|
||||
import EditorControl from './EditorControl';
|
||||
|
||||
import type { ConnectedProps } from 'react-redux';
|
||||
import type {
|
||||
Collection,
|
||||
Entry,
|
||||
Field,
|
||||
FieldsErrors,
|
||||
I18nSettings,
|
||||
TranslatedProps,
|
||||
ValueOrNestedValue,
|
||||
} from '../../../interface';
|
||||
import type { RootState } from '../../../store';
|
||||
|
||||
const ControlPaneContainer = styled('div')`
|
||||
max-width: 1000px;
|
||||
width: 100%;
|
||||
font-size: 16px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 16px;
|
||||
`;
|
||||
|
||||
const LocaleRowWrapper = styled('div')`
|
||||
display: flex;
|
||||
`;
|
||||
|
||||
interface LocaleDropdownProps {
|
||||
locales: string[];
|
||||
dropdownText: string;
|
||||
onLocaleChange: (locale: string) => void;
|
||||
}
|
||||
|
||||
const LocaleDropdown = ({ locales, dropdownText, onLocaleChange }: LocaleDropdownProps) => {
|
||||
const [anchorEl, setAnchorEl] = React.useState<null | HTMLElement>(null);
|
||||
const open = Boolean(anchorEl);
|
||||
const handleClick = useCallback((event: React.MouseEvent<HTMLButtonElement>) => {
|
||||
setAnchorEl(event.currentTarget);
|
||||
}, []);
|
||||
const handleClose = useCallback(() => {
|
||||
setAnchorEl(null);
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<div>
|
||||
<Button
|
||||
id="basic-button"
|
||||
aria-controls={open ? 'basic-menu' : undefined}
|
||||
aria-haspopup="true"
|
||||
aria-expanded={open ? 'true' : undefined}
|
||||
onClick={handleClick}
|
||||
>
|
||||
{dropdownText}
|
||||
</Button>
|
||||
<Menu
|
||||
id="basic-menu"
|
||||
anchorEl={anchorEl}
|
||||
open={open}
|
||||
onClose={handleClose}
|
||||
MenuListProps={{
|
||||
'aria-labelledby': 'basic-button',
|
||||
}}
|
||||
>
|
||||
{locales.map(locale => (
|
||||
<MenuItem key={locale} onClick={() => onLocaleChange(locale)}>
|
||||
{locale}
|
||||
</MenuItem>
|
||||
))}
|
||||
</Menu>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
function getFieldValue(
|
||||
field: Field,
|
||||
entry: Entry,
|
||||
isTranslatable: boolean,
|
||||
locale: string | undefined,
|
||||
): ValueOrNestedValue {
|
||||
if (isTranslatable && locale) {
|
||||
const dataPath = getLocaleDataPath(locale);
|
||||
return get(entry, [...dataPath, field.name]);
|
||||
}
|
||||
|
||||
return entry.data?.[field.name];
|
||||
}
|
||||
|
||||
const EditorControlPane = ({
|
||||
collection,
|
||||
entry,
|
||||
fields,
|
||||
fieldsErrors,
|
||||
submitted,
|
||||
changeDraftField,
|
||||
locale,
|
||||
onLocaleChange,
|
||||
clearFieldErrors,
|
||||
t,
|
||||
}: TranslatedProps<EditorControlPaneProps>) => {
|
||||
const i18n = useMemo(() => {
|
||||
if (hasI18n(collection)) {
|
||||
const { locales, defaultLocale } = getI18nInfo(collection);
|
||||
return {
|
||||
currentLocale: locale ?? locales[0],
|
||||
locales,
|
||||
defaultLocale,
|
||||
} as I18nSettings;
|
||||
}
|
||||
|
||||
return undefined;
|
||||
}, [collection, locale]);
|
||||
|
||||
const copyFromOtherLocale = useCallback(
|
||||
({ targetLocale }: { targetLocale?: string }) =>
|
||||
async (sourceLocale: string) => {
|
||||
if (!targetLocale) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (
|
||||
!(await confirm({
|
||||
title: 'editor.editorControlPane.i18n.copyFromLocaleConfirmTitle',
|
||||
body: {
|
||||
key: 'editor.editorControlPane.i18n.copyFromLocaleConfirmBody',
|
||||
options: { locale: sourceLocale.toUpperCase() },
|
||||
},
|
||||
}))
|
||||
) {
|
||||
return;
|
||||
}
|
||||
|
||||
fields.forEach(field => {
|
||||
if (isFieldTranslatable(field, targetLocale, sourceLocale)) {
|
||||
const copyValue = getFieldValue(
|
||||
field,
|
||||
entry,
|
||||
sourceLocale !== i18n?.defaultLocale,
|
||||
sourceLocale,
|
||||
);
|
||||
changeDraftField({ path: field.name, field, value: copyValue, entry, i18n });
|
||||
}
|
||||
});
|
||||
},
|
||||
[fields, entry, i18n, changeDraftField],
|
||||
);
|
||||
|
||||
if (!collection || !fields) {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (!entry || entry.partial === true) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<ControlPaneContainer>
|
||||
{i18n?.locales && locale ? (
|
||||
<LocaleRowWrapper>
|
||||
<LocaleDropdown
|
||||
locales={i18n.locales}
|
||||
dropdownText={t('editor.editorControlPane.i18n.writingInLocale', {
|
||||
locale: locale?.toUpperCase(),
|
||||
})}
|
||||
onLocaleChange={onLocaleChange}
|
||||
/>
|
||||
<LocaleDropdown
|
||||
locales={i18n.locales.filter(l => l !== locale)}
|
||||
dropdownText={t('editor.editorControlPane.i18n.copyFromLocale')}
|
||||
onLocaleChange={copyFromOtherLocale({ targetLocale: locale })}
|
||||
/>
|
||||
</LocaleRowWrapper>
|
||||
) : null}
|
||||
{fields
|
||||
.filter(f => f.widget !== 'hidden')
|
||||
.map((field, i) => {
|
||||
const isTranslatable = isFieldTranslatable(field, locale, i18n?.defaultLocale);
|
||||
const isDuplicate = isFieldDuplicate(field, locale, i18n?.defaultLocale);
|
||||
const isHidden = isFieldHidden(field, locale, i18n?.defaultLocale);
|
||||
const key = i18n ? `${locale}_${i}` : i;
|
||||
|
||||
return (
|
||||
<EditorControl
|
||||
key={key}
|
||||
field={field}
|
||||
value={getFieldValue(field, entry, isTranslatable, locale)}
|
||||
fieldsErrors={fieldsErrors}
|
||||
submitted={submitted}
|
||||
isDisabled={isDuplicate}
|
||||
isHidden={isHidden}
|
||||
isFieldDuplicate={field => isFieldDuplicate(field, locale, i18n?.defaultLocale)}
|
||||
isFieldHidden={field => isFieldHidden(field, locale, i18n?.defaultLocale)}
|
||||
locale={locale}
|
||||
clearFieldErrors={clearFieldErrors}
|
||||
parentPath=""
|
||||
i18n={i18n}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
</ControlPaneContainer>
|
||||
);
|
||||
};
|
||||
|
||||
export interface EditorControlPaneOwnProps {
|
||||
collection: Collection;
|
||||
entry: Entry;
|
||||
fields: Field[];
|
||||
fieldsErrors: FieldsErrors;
|
||||
submitted: boolean;
|
||||
locale?: string;
|
||||
onLocaleChange: (locale: string) => void;
|
||||
}
|
||||
|
||||
function mapStateToProps(_state: RootState, ownProps: EditorControlPaneOwnProps) {
|
||||
return {
|
||||
...ownProps,
|
||||
};
|
||||
}
|
||||
|
||||
const mapDispatchToProps = {
|
||||
changeDraftField: changeDraftFieldAction,
|
||||
clearFieldErrors: clearFieldErrorsAction,
|
||||
};
|
||||
|
||||
const connector = connect(mapStateToProps, mapDispatchToProps);
|
||||
export type EditorControlPaneProps = ConnectedProps<typeof connector>;
|
||||
|
||||
export default connector(EditorControlPane);
|
363
core/src/components/Editor/EditorInterface.tsx
Normal file
363
core/src/components/Editor/EditorInterface.tsx
Normal file
@ -0,0 +1,363 @@
|
||||
import HeightIcon from '@mui/icons-material/Height';
|
||||
import LanguageIcon from '@mui/icons-material/Language';
|
||||
import VisibilityIcon from '@mui/icons-material/Visibility';
|
||||
import Fab from '@mui/material/Fab';
|
||||
import { styled } from '@mui/material/styles';
|
||||
import React, { useCallback, useEffect, useMemo, useState } from 'react';
|
||||
import { ScrollSync, ScrollSyncPane } from 'react-scroll-sync';
|
||||
|
||||
import { colorsRaw, components, zIndex } from '../../components/UI/styles';
|
||||
import { FILES } from '../../constants/collectionTypes';
|
||||
import { transientOptions } from '../../lib';
|
||||
import { getI18nInfo, getPreviewEntry, hasI18n } from '../../lib/i18n';
|
||||
import { getFileFromSlug } from '../../lib/util/collection.util';
|
||||
import EditorControlPane from './EditorControlPane/EditorControlPane';
|
||||
import EditorPreviewPane from './EditorPreviewPane/EditorPreviewPane';
|
||||
import EditorToolbar from './EditorToolbar';
|
||||
|
||||
import type {
|
||||
Collection,
|
||||
EditorPersistOptions,
|
||||
Entry,
|
||||
Field,
|
||||
FieldsErrors,
|
||||
TranslatedProps,
|
||||
User,
|
||||
} from '../../interface';
|
||||
|
||||
const PREVIEW_VISIBLE = 'cms.preview-visible';
|
||||
const I18N_VISIBLE = 'cms.i18n-visible';
|
||||
|
||||
const StyledSplitPane = styled('div')`
|
||||
display: grid;
|
||||
grid-template-columns: min(864px, 50%) auto;
|
||||
height: calc(100vh - 64px);
|
||||
|
||||
> div:nth-of-type(2)::before {
|
||||
content: '';
|
||||
width: 2px;
|
||||
height: calc(100vh - 64px);
|
||||
position: relative;
|
||||
background-color: rgb(223, 223, 227);
|
||||
display: block;
|
||||
}
|
||||
`;
|
||||
|
||||
const NoPreviewContainer = styled('div')`
|
||||
${components.card};
|
||||
border-radius: 0;
|
||||
height: 100%;
|
||||
`;
|
||||
|
||||
const EditorContainer = styled('div')`
|
||||
width: 100%;
|
||||
min-width: 1200px;
|
||||
height: 100vh;
|
||||
overflow: hidden;
|
||||
`;
|
||||
|
||||
const Editor = styled('div')`
|
||||
height: calc(100vh - 64px);
|
||||
position: relative;
|
||||
background-color: ${colorsRaw.white};
|
||||
overflow-y: auto;
|
||||
`;
|
||||
|
||||
interface PreviewPaneContainerProps {
|
||||
$blockEntry?: boolean;
|
||||
$overFlow?: boolean;
|
||||
}
|
||||
|
||||
const PreviewPaneContainer = styled(
|
||||
'div',
|
||||
transientOptions,
|
||||
)<PreviewPaneContainerProps>(
|
||||
({ $blockEntry, $overFlow }) => `
|
||||
height: 100%;
|
||||
pointer-events: ${$blockEntry ? 'none' : 'auto'};
|
||||
overflow-y: ${$overFlow ? 'auto' : 'hidden'};
|
||||
`,
|
||||
);
|
||||
|
||||
const ControlPaneContainer = styled(PreviewPaneContainer)`
|
||||
padding: 24px 16px 16px;
|
||||
position: relative;
|
||||
overflow-x: hidden;
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
justify-content: center;
|
||||
`;
|
||||
|
||||
const StyledViewControls = styled('div')`
|
||||
position: fixed;
|
||||
bottom: 4px;
|
||||
right: 8px;
|
||||
z-index: ${zIndex.zIndex299};
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 4px;
|
||||
`;
|
||||
|
||||
interface EditorContentProps {
|
||||
i18nVisible: boolean;
|
||||
previewVisible: boolean;
|
||||
editor: JSX.Element;
|
||||
editorSideBySideLocale: JSX.Element;
|
||||
editorWithPreview: JSX.Element;
|
||||
}
|
||||
|
||||
const EditorContent = ({
|
||||
i18nVisible,
|
||||
previewVisible,
|
||||
editor,
|
||||
editorSideBySideLocale,
|
||||
editorWithPreview,
|
||||
}: EditorContentProps) => {
|
||||
if (i18nVisible) {
|
||||
return editorSideBySideLocale;
|
||||
} else if (previewVisible) {
|
||||
return editorWithPreview;
|
||||
} else {
|
||||
return <NoPreviewContainer>{editor}</NoPreviewContainer>;
|
||||
}
|
||||
};
|
||||
|
||||
function isPreviewEnabled(collection: Collection, entry: Entry) {
|
||||
if (collection.type === FILES) {
|
||||
const file = getFileFromSlug(collection, entry.slug);
|
||||
const previewEnabled = file?.editor?.preview ?? false;
|
||||
if (previewEnabled) {
|
||||
return previewEnabled;
|
||||
}
|
||||
}
|
||||
return collection.editor?.preview ?? true;
|
||||
}
|
||||
|
||||
interface EditorInterfaceProps {
|
||||
draftKey: string;
|
||||
entry: Entry;
|
||||
collection: Collection;
|
||||
fields: Field[] | undefined;
|
||||
fieldsErrors: FieldsErrors;
|
||||
onPersist: (opts?: EditorPersistOptions) => Promise<void>;
|
||||
onDelete: () => Promise<void>;
|
||||
onDuplicate: () => void;
|
||||
showDelete: boolean;
|
||||
user: User | undefined;
|
||||
hasChanged: boolean;
|
||||
displayUrl: string | undefined;
|
||||
isNewEntry: boolean;
|
||||
isModification: boolean;
|
||||
onLogoutClick: () => void;
|
||||
editorBackLink: string;
|
||||
toggleScroll: () => Promise<{ readonly type: 'TOGGLE_SCROLL' }>;
|
||||
scrollSyncEnabled: boolean;
|
||||
loadScroll: () => void;
|
||||
submitted: boolean;
|
||||
}
|
||||
|
||||
const EditorInterface = ({
|
||||
collection,
|
||||
entry,
|
||||
fields = [],
|
||||
fieldsErrors,
|
||||
showDelete,
|
||||
onDelete,
|
||||
onDuplicate,
|
||||
onPersist,
|
||||
user,
|
||||
hasChanged,
|
||||
displayUrl,
|
||||
isNewEntry,
|
||||
isModification,
|
||||
onLogoutClick,
|
||||
draftKey,
|
||||
editorBackLink,
|
||||
scrollSyncEnabled,
|
||||
t,
|
||||
loadScroll,
|
||||
toggleScroll,
|
||||
submitted,
|
||||
}: TranslatedProps<EditorInterfaceProps>) => {
|
||||
const [previewVisible, setPreviewVisible] = useState(
|
||||
localStorage.getItem(PREVIEW_VISIBLE) !== 'false',
|
||||
);
|
||||
const [i18nVisible, setI18nVisible] = useState(localStorage.getItem(I18N_VISIBLE) !== 'false');
|
||||
|
||||
useEffect(() => {
|
||||
loadScroll();
|
||||
}, [loadScroll]);
|
||||
|
||||
const { locales, defaultLocale } = useMemo(() => getI18nInfo(collection), [collection]) ?? {};
|
||||
const [selectedLocale, setSelectedLocale] = useState(locales?.[0]);
|
||||
const switchToDefaultLocale = useCallback(() => {
|
||||
if (hasI18n(collection)) {
|
||||
const { defaultLocale } = getI18nInfo(collection);
|
||||
setSelectedLocale(defaultLocale);
|
||||
}
|
||||
}, [collection]);
|
||||
|
||||
const handleOnPersist = useCallback(
|
||||
async (opts: EditorPersistOptions = {}) => {
|
||||
const { createNew = false, duplicate = false } = opts;
|
||||
await switchToDefaultLocale();
|
||||
// TODO Trigger field validation on persist
|
||||
// this.controlPaneRef.validate();
|
||||
onPersist({ createNew, duplicate });
|
||||
},
|
||||
[onPersist, switchToDefaultLocale],
|
||||
);
|
||||
|
||||
const handleTogglePreview = useCallback(() => {
|
||||
const newPreviewVisible = !previewVisible;
|
||||
setPreviewVisible(newPreviewVisible);
|
||||
localStorage.setItem(PREVIEW_VISIBLE, `${newPreviewVisible}`);
|
||||
}, [previewVisible]);
|
||||
|
||||
const handleToggleScrollSync = useCallback(() => {
|
||||
toggleScroll();
|
||||
}, [toggleScroll]);
|
||||
|
||||
const handleToggleI18n = useCallback(() => {
|
||||
const newI18nVisible = !i18nVisible;
|
||||
setI18nVisible(newI18nVisible);
|
||||
localStorage.setItem(I18N_VISIBLE, `${newI18nVisible}`);
|
||||
}, [i18nVisible]);
|
||||
|
||||
const handleLocaleChange = useCallback((locale: string) => {
|
||||
setSelectedLocale(locale);
|
||||
}, []);
|
||||
|
||||
const previewEnabled = isPreviewEnabled(collection, entry);
|
||||
|
||||
const collectionI18nEnabled = hasI18n(collection);
|
||||
|
||||
const editor = (
|
||||
<ControlPaneContainer id="control-pane" $overFlow>
|
||||
<EditorControlPane
|
||||
collection={collection}
|
||||
entry={entry}
|
||||
fields={fields}
|
||||
fieldsErrors={fieldsErrors}
|
||||
locale={selectedLocale}
|
||||
onLocaleChange={handleLocaleChange}
|
||||
submitted={submitted}
|
||||
t={t}
|
||||
/>
|
||||
</ControlPaneContainer>
|
||||
);
|
||||
|
||||
const editorLocale = (
|
||||
<ControlPaneContainer $overFlow={!scrollSyncEnabled}>
|
||||
<EditorControlPane
|
||||
collection={collection}
|
||||
entry={entry}
|
||||
fields={fields}
|
||||
fieldsErrors={fieldsErrors}
|
||||
locale={locales?.[1]}
|
||||
onLocaleChange={handleLocaleChange}
|
||||
submitted={submitted}
|
||||
t={t}
|
||||
/>
|
||||
</ControlPaneContainer>
|
||||
);
|
||||
|
||||
const previewEntry = collectionI18nEnabled
|
||||
? getPreviewEntry(entry, selectedLocale, defaultLocale)
|
||||
: entry;
|
||||
|
||||
const editorWithPreview = (
|
||||
<>
|
||||
<StyledSplitPane>
|
||||
<ScrollSyncPane>{editor}</ScrollSyncPane>
|
||||
<PreviewPaneContainer>
|
||||
<EditorPreviewPane collection={collection} entry={previewEntry} fields={fields} />
|
||||
</PreviewPaneContainer>
|
||||
</StyledSplitPane>
|
||||
</>
|
||||
);
|
||||
|
||||
const editorSideBySideLocale = (
|
||||
<ScrollSync enabled={scrollSyncEnabled}>
|
||||
<div>
|
||||
<StyledSplitPane>
|
||||
<ScrollSyncPane>{editor}</ScrollSyncPane>
|
||||
<ScrollSyncPane>{editorLocale}</ScrollSyncPane>
|
||||
</StyledSplitPane>
|
||||
</div>
|
||||
</ScrollSync>
|
||||
);
|
||||
|
||||
const finalI18nVisible = collectionI18nEnabled && i18nVisible;
|
||||
const finalPreviewVisible = previewEnabled && previewVisible;
|
||||
const scrollSyncVisible = finalI18nVisible || finalPreviewVisible;
|
||||
|
||||
return (
|
||||
<EditorContainer>
|
||||
<EditorToolbar
|
||||
isPersisting={entry.isPersisting}
|
||||
isDeleting={entry.isDeleting}
|
||||
onPersist={handleOnPersist}
|
||||
onPersistAndNew={() => handleOnPersist({ createNew: true })}
|
||||
onPersistAndDuplicate={() => handleOnPersist({ createNew: true, duplicate: true })}
|
||||
onDelete={onDelete}
|
||||
showDelete={showDelete}
|
||||
onDuplicate={onDuplicate}
|
||||
user={user}
|
||||
hasChanged={hasChanged}
|
||||
displayUrl={displayUrl}
|
||||
collection={collection}
|
||||
isNewEntry={isNewEntry}
|
||||
isModification={isModification}
|
||||
onLogoutClick={onLogoutClick}
|
||||
editorBackLink={editorBackLink}
|
||||
/>
|
||||
<Editor key={draftKey}>
|
||||
<StyledViewControls>
|
||||
{collectionI18nEnabled && (
|
||||
<Fab
|
||||
size="small"
|
||||
color={finalI18nVisible ? 'primary' : 'default'}
|
||||
aria-label="add"
|
||||
onClick={handleToggleI18n}
|
||||
title={t('editor.editorInterface.toggleI18n')}
|
||||
>
|
||||
<LanguageIcon />
|
||||
</Fab>
|
||||
)}
|
||||
{previewEnabled && (
|
||||
<Fab
|
||||
size="small"
|
||||
color={finalPreviewVisible ? 'primary' : 'default'}
|
||||
aria-label="add"
|
||||
onClick={handleTogglePreview}
|
||||
title={t('editor.editorInterface.togglePreview')}
|
||||
>
|
||||
<VisibilityIcon />
|
||||
</Fab>
|
||||
)}
|
||||
{scrollSyncVisible && (
|
||||
<Fab
|
||||
size="small"
|
||||
color={scrollSyncEnabled ? 'primary' : 'default'}
|
||||
aria-label="add"
|
||||
onClick={handleToggleScrollSync}
|
||||
title={t('editor.editorInterface.toggleScrollSync')}
|
||||
>
|
||||
<HeightIcon />
|
||||
</Fab>
|
||||
)}
|
||||
</StyledViewControls>
|
||||
<EditorContent
|
||||
i18nVisible={finalI18nVisible}
|
||||
previewVisible={finalPreviewVisible}
|
||||
editor={editor}
|
||||
editorSideBySideLocale={editorSideBySideLocale}
|
||||
editorWithPreview={editorWithPreview}
|
||||
/>
|
||||
</Editor>
|
||||
</EditorContainer>
|
||||
);
|
||||
};
|
||||
|
||||
export default EditorInterface;
|
@ -0,0 +1,31 @@
|
||||
import React from 'react';
|
||||
import { styled } from '@mui/material/styles';
|
||||
|
||||
import type { Field, TemplatePreviewProps } from '../../../interface';
|
||||
|
||||
function isVisible(field: Field) {
|
||||
return field.widget !== 'hidden';
|
||||
}
|
||||
|
||||
const PreviewContainer = styled('div')`
|
||||
overflow-y: auto;
|
||||
height: 100%;
|
||||
padding: 24px;
|
||||
font-family: Roboto, 'Helvetica Neue', HelveticaNeue, Helvetica, Arial, sans-serif;
|
||||
`;
|
||||
|
||||
const Preview = ({ collection, fields, widgetFor }: TemplatePreviewProps) => {
|
||||
if (!collection || !fields) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<PreviewContainer>
|
||||
{fields.filter(isVisible).map(field => (
|
||||
<div key={field.name}>{widgetFor(field.name)}</div>
|
||||
))}
|
||||
</PreviewContainer>
|
||||
);
|
||||
};
|
||||
|
||||
export default Preview;
|
@ -0,0 +1,51 @@
|
||||
import { styled } from '@mui/material/styles';
|
||||
import React, { useMemo } from 'react';
|
||||
import ReactDOM from 'react-dom';
|
||||
import { ScrollSyncPane } from 'react-scroll-sync';
|
||||
|
||||
import type { TemplatePreviewComponent, TemplatePreviewProps } from '../../../interface';
|
||||
import type { ReactNode } from 'react';
|
||||
|
||||
interface PreviewContentProps {
|
||||
previewComponent?: TemplatePreviewComponent;
|
||||
previewProps: TemplatePreviewProps;
|
||||
}
|
||||
|
||||
const StyledPreviewContent = styled('div')`
|
||||
width: calc(100% - min(864px, 50%));
|
||||
top: 64px;
|
||||
right: 0;
|
||||
position: absolute;
|
||||
height: calc(100vh - 64px);
|
||||
overflow-y: auto;
|
||||
padding: 16px;
|
||||
`;
|
||||
|
||||
const PreviewContent = ({ previewComponent, previewProps }: PreviewContentProps) => {
|
||||
const element = useMemo(() => document.getElementById('cms-root'), []);
|
||||
|
||||
return useMemo(() => {
|
||||
if (!element) {
|
||||
return null;
|
||||
}
|
||||
|
||||
let children: ReactNode;
|
||||
if (!previewComponent) {
|
||||
children = null;
|
||||
} else if (React.isValidElement(previewComponent)) {
|
||||
children = React.cloneElement(previewComponent, previewProps);
|
||||
} else {
|
||||
children = React.createElement(previewComponent, previewProps);
|
||||
}
|
||||
|
||||
return ReactDOM.createPortal(
|
||||
<ScrollSyncPane>
|
||||
<StyledPreviewContent className="preview-content">{children}</StyledPreviewContent>
|
||||
</ScrollSyncPane>,
|
||||
element,
|
||||
'preview-content',
|
||||
);
|
||||
}, [previewComponent, previewProps, element]);
|
||||
};
|
||||
|
||||
export default PreviewContent;
|
@ -0,0 +1,357 @@
|
||||
import { styled } from '@mui/material/styles';
|
||||
import React, { isValidElement, useCallback, useMemo } from 'react';
|
||||
import { connect } from 'react-redux';
|
||||
|
||||
import { getAsset as getAssetAction } from '../../../actions/media';
|
||||
import { lengths } from '../../../components/UI/styles';
|
||||
import { INFERABLE_FIELDS } from '../../../constants/fieldInference';
|
||||
import { getPreviewTemplate, getRemarkPlugins, resolveWidget } from '../../../lib/registry';
|
||||
import { selectInferedField, selectTemplateName } from '../../../lib/util/collection.util';
|
||||
import { selectField } from '../../../lib/util/field.util';
|
||||
import { selectIsLoadingAsset } from '../../../reducers/medias';
|
||||
import { ErrorBoundary } from '../../UI';
|
||||
import EditorPreview from './EditorPreview';
|
||||
import EditorPreviewContent from './EditorPreviewContent';
|
||||
import PreviewHOC from './PreviewHOC';
|
||||
|
||||
import type { ReactFragment, ReactNode } from 'react';
|
||||
import type { ConnectedProps } from 'react-redux';
|
||||
import type { InferredField } from '../../../constants/fieldInference';
|
||||
import type {
|
||||
Field,
|
||||
TemplatePreviewProps,
|
||||
Collection,
|
||||
Entry,
|
||||
EntryData,
|
||||
GetAssetFunction,
|
||||
ValueOrNestedValue,
|
||||
} from '../../../interface';
|
||||
import type { RootState } from '../../../store';
|
||||
|
||||
const PreviewPaneFrame = styled('div')`
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
border: none;
|
||||
background: #fff;
|
||||
border-radius: ${lengths.borderRadius};
|
||||
overflow: auto;
|
||||
`;
|
||||
|
||||
/**
|
||||
* Returns the widget component for a named field, and makes recursive calls
|
||||
* to retrieve components for nested and deeply nested fields, which occur in
|
||||
* object and list type fields. Used internally to retrieve widgets, and also
|
||||
* exposed for use in custom preview templates.
|
||||
*/
|
||||
function getWidgetFor(
|
||||
collection: Collection,
|
||||
name: string,
|
||||
fields: Field[],
|
||||
entry: Entry,
|
||||
inferedFields: Record<string, InferredField>,
|
||||
getAsset: GetAssetFunction,
|
||||
widgetFields: Field[] = fields,
|
||||
values: EntryData = entry.data,
|
||||
): ReactNode {
|
||||
// We retrieve the field by name so that this function can also be used in
|
||||
// custom preview templates, where the field object can't be passed in.
|
||||
const field = widgetFields && widgetFields.find(f => f.name === name);
|
||||
if (!field) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const value = values?.[field.name];
|
||||
let fieldWithWidgets: Omit<Field, 'fields' | 'field'> & {
|
||||
fields?: ReactNode[];
|
||||
field?: ReactNode;
|
||||
} = Object.entries(field).reduce((acc, [key, fieldValue]) => {
|
||||
if (!['fields', 'fields'].includes(key)) {
|
||||
acc[key] = fieldValue;
|
||||
}
|
||||
return acc;
|
||||
}, {} as Record<string, unknown>) as Omit<Field, 'fields' | 'field'>;
|
||||
|
||||
if ('fields' in field && field.fields) {
|
||||
fieldWithWidgets = {
|
||||
...fieldWithWidgets,
|
||||
fields: getNestedWidgets(
|
||||
collection,
|
||||
fields,
|
||||
entry,
|
||||
inferedFields,
|
||||
getAsset,
|
||||
field.fields,
|
||||
value as EntryData | EntryData[],
|
||||
),
|
||||
};
|
||||
}
|
||||
|
||||
const labelledWidgets = ['string', 'text', 'number'];
|
||||
const inferedField = Object.entries(inferedFields)
|
||||
.filter(([key]) => {
|
||||
const fieldToMatch = selectField(collection, key);
|
||||
return fieldToMatch === fieldWithWidgets;
|
||||
})
|
||||
.map(([, value]) => value)[0];
|
||||
|
||||
let renderedValue: ValueOrNestedValue | ReactNode = value;
|
||||
if (inferedField) {
|
||||
renderedValue = inferedField.defaultPreview(String(value));
|
||||
} else if (
|
||||
value &&
|
||||
fieldWithWidgets.widget &&
|
||||
labelledWidgets.indexOf(fieldWithWidgets.widget) !== -1 &&
|
||||
value.toString().length < 50
|
||||
) {
|
||||
renderedValue = (
|
||||
<div>
|
||||
<>
|
||||
<strong>{field.label ?? field.name}:</strong> {value}
|
||||
</>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
return renderedValue ? getWidget(field, renderedValue, entry, getAsset) : null;
|
||||
}
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
function isJsxElement(value: any): value is JSX.Element {
|
||||
return isValidElement(value);
|
||||
}
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
function isReactFragment(value: any): value is ReactFragment {
|
||||
if (value.type) {
|
||||
return value.type === React.Fragment;
|
||||
}
|
||||
|
||||
return value === React.Fragment;
|
||||
}
|
||||
|
||||
function getWidget(
|
||||
field: Field,
|
||||
value: ValueOrNestedValue | ReactNode,
|
||||
entry: Entry,
|
||||
getAsset: GetAssetFunction,
|
||||
idx: number | null = null,
|
||||
) {
|
||||
if (!field.widget) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const widget = resolveWidget(field.widget);
|
||||
const key = idx ? field.name + '_' + idx : field.name;
|
||||
|
||||
/**
|
||||
* Use an HOC to provide conditional updates for all previews.
|
||||
*/
|
||||
return !widget.preview ? null : (
|
||||
<PreviewHOC
|
||||
previewComponent={widget.preview}
|
||||
key={key}
|
||||
field={field}
|
||||
getAsset={getAsset}
|
||||
value={
|
||||
value &&
|
||||
!widget.allowMapValue &&
|
||||
typeof value === 'object' &&
|
||||
!isJsxElement(value) &&
|
||||
!isReactFragment(value)
|
||||
? (value as Record<string, unknown>)[field.name]
|
||||
: value
|
||||
}
|
||||
entry={entry}
|
||||
resolveWidget={resolveWidget}
|
||||
getRemarkPlugins={getRemarkPlugins}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Use getWidgetFor as a mapping function for recursive widget retrieval
|
||||
*/
|
||||
function widgetsForNestedFields(
|
||||
collection: Collection,
|
||||
fields: Field[],
|
||||
entry: Entry,
|
||||
inferedFields: Record<string, InferredField>,
|
||||
getAsset: GetAssetFunction,
|
||||
widgetFields: Field[],
|
||||
values: EntryData,
|
||||
) {
|
||||
return widgetFields
|
||||
.map(field =>
|
||||
getWidgetFor(
|
||||
collection,
|
||||
field.name,
|
||||
fields,
|
||||
entry,
|
||||
inferedFields,
|
||||
getAsset,
|
||||
widgetFields,
|
||||
values,
|
||||
),
|
||||
)
|
||||
.filter(widget => Boolean(widget)) as JSX.Element[];
|
||||
}
|
||||
|
||||
/**
|
||||
* Retrieves widgets for nested fields (children of object/list fields)
|
||||
*/
|
||||
function getNestedWidgets(
|
||||
collection: Collection,
|
||||
fields: Field[],
|
||||
entry: Entry,
|
||||
inferedFields: Record<string, InferredField>,
|
||||
getAsset: GetAssetFunction,
|
||||
widgetFields: Field[],
|
||||
values: EntryData | EntryData[],
|
||||
) {
|
||||
// Fields nested within a list field will be paired with a List of value Maps.
|
||||
if (Array.isArray(values)) {
|
||||
return values.flatMap(value =>
|
||||
widgetsForNestedFields(
|
||||
collection,
|
||||
fields,
|
||||
entry,
|
||||
inferedFields,
|
||||
getAsset,
|
||||
widgetFields,
|
||||
value,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
// Fields nested within an object field will be paired with a single Record of values.
|
||||
return widgetsForNestedFields(
|
||||
collection,
|
||||
fields,
|
||||
entry,
|
||||
inferedFields,
|
||||
getAsset,
|
||||
widgetFields,
|
||||
values,
|
||||
);
|
||||
}
|
||||
|
||||
const PreviewPane = (props: EditorPreviewPaneProps) => {
|
||||
const { entry, collection, config, fields, getAsset } = props;
|
||||
|
||||
const inferedFields = useMemo(() => {
|
||||
const titleField = selectInferedField(collection, 'title');
|
||||
const shortTitleField = selectInferedField(collection, 'shortTitle');
|
||||
const authorField = selectInferedField(collection, 'author');
|
||||
|
||||
const iFields: Record<string, InferredField> = {};
|
||||
if (titleField) {
|
||||
iFields[titleField] = INFERABLE_FIELDS.title;
|
||||
}
|
||||
if (shortTitleField) {
|
||||
iFields[shortTitleField] = INFERABLE_FIELDS.shortTitle;
|
||||
}
|
||||
if (authorField) {
|
||||
iFields[authorField] = INFERABLE_FIELDS.author;
|
||||
}
|
||||
return iFields;
|
||||
}, [collection]);
|
||||
|
||||
const handleGetAsset = useCallback(
|
||||
(path: string, field?: Field) => {
|
||||
return getAsset(collection, entry, path, field);
|
||||
},
|
||||
[collection, entry, getAsset],
|
||||
);
|
||||
|
||||
/**
|
||||
* This function exists entirely to expose nested widgets for object and list
|
||||
* fields to custom preview templates.
|
||||
*/
|
||||
const widgetsFor = useCallback(
|
||||
(name: string) => {
|
||||
const field = fields.find(f => f.name === name);
|
||||
if (!field) {
|
||||
return {
|
||||
data: null,
|
||||
widgets: {},
|
||||
};
|
||||
}
|
||||
|
||||
const nestedFields = field && 'fields' in field ? field.fields ?? [] : [];
|
||||
const value = entry.data?.[field.name] as EntryData | EntryData[];
|
||||
|
||||
if (Array.isArray(value)) {
|
||||
return value.map(val => {
|
||||
const widgets = nestedFields.reduce((acc, field, index) => {
|
||||
acc[field.name] = <div key={index}>{getWidget(field, val, entry, handleGetAsset)}</div>;
|
||||
return acc;
|
||||
}, {} as Record<string, ReactNode>);
|
||||
return { data: val, widgets };
|
||||
});
|
||||
}
|
||||
|
||||
return {
|
||||
data: value,
|
||||
widgets: nestedFields.reduce((acc, field, index) => {
|
||||
acc[field.name] = <div key={index}>{getWidget(field, value, entry, handleGetAsset)}</div>;
|
||||
return acc;
|
||||
}, {} as Record<string, ReactNode>),
|
||||
};
|
||||
},
|
||||
[entry, fields, handleGetAsset],
|
||||
);
|
||||
|
||||
const widgetFor = useCallback(
|
||||
(name: string) => {
|
||||
return getWidgetFor(collection, name, fields, entry, inferedFields, handleGetAsset);
|
||||
},
|
||||
[collection, entry, fields, handleGetAsset, inferedFields],
|
||||
);
|
||||
|
||||
if (!entry || !entry.data) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const previewComponent =
|
||||
getPreviewTemplate(selectTemplateName(collection, entry.slug)) ?? EditorPreview;
|
||||
|
||||
const previewProps: TemplatePreviewProps = {
|
||||
...props,
|
||||
getAsset: handleGetAsset,
|
||||
widgetFor,
|
||||
widgetsFor,
|
||||
};
|
||||
|
||||
if (!collection) {
|
||||
<PreviewPaneFrame id="preview-pane" />;
|
||||
}
|
||||
|
||||
return (
|
||||
<ErrorBoundary config={config}>
|
||||
<PreviewPaneFrame id="preview-pane">
|
||||
<EditorPreviewContent
|
||||
{...{ previewComponent, previewProps: { ...previewProps, document, window } }}
|
||||
/>
|
||||
</PreviewPaneFrame>
|
||||
</ErrorBoundary>
|
||||
);
|
||||
};
|
||||
|
||||
export interface EditorPreviewPaneOwnProps {
|
||||
collection: Collection;
|
||||
fields: Field[];
|
||||
entry: Entry;
|
||||
}
|
||||
|
||||
function mapStateToProps(state: RootState, ownProps: EditorPreviewPaneOwnProps) {
|
||||
const isLoadingAsset = selectIsLoadingAsset(state.medias);
|
||||
return { ...ownProps, isLoadingAsset, config: state.config };
|
||||
}
|
||||
|
||||
const mapDispatchToProps = {
|
||||
getAsset: getAssetAction,
|
||||
};
|
||||
|
||||
const connector = connect(mapStateToProps, mapDispatchToProps);
|
||||
export type EditorPreviewPaneProps = ConnectedProps<typeof connector>;
|
||||
|
||||
export default connector(PreviewPane);
|
21
core/src/components/Editor/EditorPreviewPane/PreviewHOC.tsx
Normal file
21
core/src/components/Editor/EditorPreviewPane/PreviewHOC.tsx
Normal file
@ -0,0 +1,21 @@
|
||||
import React from 'react';
|
||||
|
||||
import type { WidgetPreviewComponent, WidgetPreviewProps } from '../../../interface';
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
interface PreviewHOCProps extends WidgetPreviewProps {
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
previewComponent: WidgetPreviewComponent;
|
||||
}
|
||||
|
||||
const PreviewHOC = ({ previewComponent, ...props }: PreviewHOCProps) => {
|
||||
if (!previewComponent) {
|
||||
return null;
|
||||
} else if (React.isValidElement(previewComponent)) {
|
||||
return React.cloneElement(previewComponent, props);
|
||||
} else {
|
||||
return React.createElement(previewComponent, props);
|
||||
}
|
||||
};
|
||||
|
||||
export default PreviewHOC;
|
40
core/src/components/Editor/EditorRoute.tsx
Normal file
40
core/src/components/Editor/EditorRoute.tsx
Normal file
@ -0,0 +1,40 @@
|
||||
import React, { useMemo } from 'react';
|
||||
import { Navigate, useParams } from 'react-router-dom';
|
||||
|
||||
import Editor from './Editor';
|
||||
|
||||
import type { Collections } from '../../interface';
|
||||
|
||||
function getDefaultPath(collections: Collections) {
|
||||
const first = Object.values(collections).filter(collection => collection.hide !== true)[0];
|
||||
if (first) {
|
||||
return `/collections/${first.name}`;
|
||||
} else {
|
||||
throw new Error('Could not find a non hidden collection');
|
||||
}
|
||||
}
|
||||
|
||||
interface EditorRouteProps {
|
||||
newRecord?: boolean;
|
||||
collections: Collections;
|
||||
}
|
||||
|
||||
const EditorRoute = ({ newRecord = false, collections }: EditorRouteProps) => {
|
||||
const { name, slug } = useParams();
|
||||
const shouldRedirect = useMemo(() => {
|
||||
if (!name) {
|
||||
return false;
|
||||
}
|
||||
return !collections[name];
|
||||
}, [collections, name]);
|
||||
|
||||
const defaultPath = useMemo(() => getDefaultPath(collections), [collections]);
|
||||
|
||||
if (shouldRedirect || !name || (!newRecord && !slug)) {
|
||||
return <Navigate to={defaultPath} />;
|
||||
}
|
||||
|
||||
return <Editor name={name} slug={slug} newRecord={newRecord} />;
|
||||
};
|
||||
|
||||
export default EditorRoute;
|
245
core/src/components/Editor/EditorToolbar.tsx
Normal file
245
core/src/components/Editor/EditorToolbar.tsx
Normal file
@ -0,0 +1,245 @@
|
||||
import { styled } from '@mui/material/styles';
|
||||
import ArrowBackIcon from '@mui/icons-material/ArrowBack';
|
||||
import KeyboardArrowDownIcon from '@mui/icons-material/KeyboardArrowDown';
|
||||
import AppBar from '@mui/material/AppBar';
|
||||
import Button from '@mui/material/Button';
|
||||
import CircularProgress from '@mui/material/CircularProgress';
|
||||
import green from '@mui/material/colors/green';
|
||||
import Menu from '@mui/material/Menu';
|
||||
import MenuItem from '@mui/material/MenuItem';
|
||||
import Toolbar from '@mui/material/Toolbar';
|
||||
import React, { useCallback, useMemo, useState } from 'react';
|
||||
import { translate } from 'react-polyglot';
|
||||
|
||||
import { colors, components, zIndex } from '../../components/UI/styles';
|
||||
import { SettingsDropdown } from '../UI';
|
||||
import NavLink from '../UI/NavLink';
|
||||
|
||||
import type { MouseEvent } from 'react';
|
||||
import type { Collection, EditorPersistOptions, TranslatedProps, User } from '../../interface';
|
||||
|
||||
const StyledAppBar = styled(AppBar)`
|
||||
background-color: ${colors.foreground};
|
||||
z-index: ${zIndex.zIndex100};
|
||||
`;
|
||||
|
||||
const StyledToolbar = styled(Toolbar)`
|
||||
gap: 12px;
|
||||
`;
|
||||
|
||||
const StyledToolbarSectionBackLink = styled('div')`
|
||||
display: flex;
|
||||
margin: -32px -24px;
|
||||
height: 64px;
|
||||
|
||||
a {
|
||||
display: flex;
|
||||
height: 100%;
|
||||
padding: 16px;
|
||||
align-items: center;
|
||||
}
|
||||
`;
|
||||
|
||||
const StyledToolbarSectionMain = styled('div')`
|
||||
flex-grow: 1;
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
padding: 0 16px;
|
||||
margin-left: 24px;
|
||||
`;
|
||||
|
||||
const StyledBackCollection = styled('div')`
|
||||
color: ${colors.textLead};
|
||||
font-size: 14px;
|
||||
`;
|
||||
|
||||
const StyledBackStatus = styled('div')`
|
||||
margin-top: 6px;
|
||||
`;
|
||||
|
||||
const StyledBackStatusUnchanged = styled(StyledBackStatus)`
|
||||
${components.textBadgeSuccess};
|
||||
`;
|
||||
|
||||
const StyledBackStatusChanged = styled(StyledBackStatus)`
|
||||
${components.textBadgeDanger};
|
||||
`;
|
||||
|
||||
const StyledButtonWrapper = styled('div')`
|
||||
position: relative;
|
||||
`;
|
||||
|
||||
export interface EditorToolbarProps {
|
||||
isPersisting?: boolean;
|
||||
isDeleting?: boolean;
|
||||
onPersist: (opts?: EditorPersistOptions) => Promise<void>;
|
||||
onPersistAndNew: () => Promise<void>;
|
||||
onPersistAndDuplicate: () => Promise<void>;
|
||||
onDelete: () => Promise<void>;
|
||||
showDelete: boolean;
|
||||
onDuplicate: () => void;
|
||||
user: User;
|
||||
hasChanged: boolean;
|
||||
displayUrl: string | undefined;
|
||||
collection: Collection;
|
||||
isNewEntry: boolean;
|
||||
isModification?: boolean;
|
||||
onLogoutClick: () => void;
|
||||
editorBackLink: string;
|
||||
}
|
||||
|
||||
const EditorToolbar = ({
|
||||
user,
|
||||
hasChanged,
|
||||
displayUrl,
|
||||
collection,
|
||||
onLogoutClick,
|
||||
onDuplicate,
|
||||
isPersisting,
|
||||
onPersist,
|
||||
onPersistAndDuplicate,
|
||||
onPersistAndNew,
|
||||
isNewEntry,
|
||||
showDelete,
|
||||
onDelete,
|
||||
t,
|
||||
editorBackLink,
|
||||
}: TranslatedProps<EditorToolbarProps>) => {
|
||||
const canCreate = useMemo(() => collection.create ?? false, [collection.create]);
|
||||
const isPublished = useMemo(() => !isNewEntry && !hasChanged, [hasChanged, isNewEntry]);
|
||||
|
||||
const [anchorEl, setAnchorEl] = useState<null | HTMLElement>(null);
|
||||
const open = Boolean(anchorEl);
|
||||
const handleClick = useCallback((event: MouseEvent<HTMLButtonElement>) => {
|
||||
setAnchorEl(event.currentTarget);
|
||||
}, []);
|
||||
const handleClose = useCallback(() => {
|
||||
setAnchorEl(null);
|
||||
}, []);
|
||||
|
||||
const controls = useMemo(
|
||||
() => (
|
||||
<StyledToolbarSectionMain>
|
||||
<div>
|
||||
<StyledButtonWrapper>
|
||||
<Button
|
||||
id="existing-published-button"
|
||||
aria-controls={open ? 'existing-published-menu' : undefined}
|
||||
aria-haspopup="true"
|
||||
aria-expanded={open ? 'true' : undefined}
|
||||
onClick={handleClick}
|
||||
variant="contained"
|
||||
color={isPublished ? 'success' : 'primary'}
|
||||
endIcon={<KeyboardArrowDownIcon />}
|
||||
>
|
||||
{isPublished
|
||||
? t('editor.editorToolbar.published')
|
||||
: isPersisting
|
||||
? t('editor.editorToolbar.publishing')
|
||||
: t('editor.editorToolbar.publish')}
|
||||
</Button>
|
||||
{isPersisting ? (
|
||||
<CircularProgress
|
||||
size={24}
|
||||
sx={{
|
||||
color: green[500],
|
||||
position: 'absolute',
|
||||
top: '50%',
|
||||
left: '50%',
|
||||
marginTop: '-12px',
|
||||
marginLeft: '-12px',
|
||||
}}
|
||||
/>
|
||||
) : null}
|
||||
</StyledButtonWrapper>
|
||||
<Menu
|
||||
id="existing-published-menu"
|
||||
anchorEl={anchorEl}
|
||||
open={open}
|
||||
onClose={handleClose}
|
||||
MenuListProps={{
|
||||
'aria-labelledby': 'existing-published-button',
|
||||
}}
|
||||
>
|
||||
{!isPublished ? (
|
||||
[
|
||||
<MenuItem key="publishNow" onClick={() => onPersist()}>
|
||||
{t('editor.editorToolbar.publishNow')}
|
||||
</MenuItem>,
|
||||
...(canCreate
|
||||
? [
|
||||
<MenuItem key="publishAndCreateNew" onClick={onPersistAndNew}>
|
||||
{t('editor.editorToolbar.publishAndCreateNew')}
|
||||
</MenuItem>,
|
||||
<MenuItem key="publishAndDuplicate" onClick={onPersistAndDuplicate}>
|
||||
{t('editor.editorToolbar.publishAndDuplicate')}
|
||||
</MenuItem>,
|
||||
]
|
||||
: []),
|
||||
]
|
||||
) : (
|
||||
<MenuItem onClick={onDuplicate}>{t('editor.editorToolbar.duplicate')}</MenuItem>
|
||||
)}
|
||||
</Menu>
|
||||
</div>
|
||||
{showDelete ? (
|
||||
<Button variant="outlined" color="error" key="delete-button" onClick={onDelete}>
|
||||
{t('editor.editorToolbar.deleteEntry')}
|
||||
</Button>
|
||||
) : null}
|
||||
</StyledToolbarSectionMain>
|
||||
),
|
||||
[
|
||||
anchorEl,
|
||||
canCreate,
|
||||
handleClick,
|
||||
handleClose,
|
||||
isPersisting,
|
||||
isPublished,
|
||||
onDelete,
|
||||
onDuplicate,
|
||||
onPersist,
|
||||
onPersistAndDuplicate,
|
||||
onPersistAndNew,
|
||||
open,
|
||||
showDelete,
|
||||
t,
|
||||
],
|
||||
);
|
||||
|
||||
return (
|
||||
<StyledAppBar position="relative">
|
||||
<StyledToolbar>
|
||||
<StyledToolbarSectionBackLink>
|
||||
<Button component={NavLink} to={editorBackLink}>
|
||||
<ArrowBackIcon />
|
||||
<div>
|
||||
<StyledBackCollection>
|
||||
{t('editor.editorToolbar.backCollection', {
|
||||
collectionLabel: collection.label,
|
||||
})}
|
||||
</StyledBackCollection>
|
||||
{hasChanged ? (
|
||||
<StyledBackStatusChanged key="back-changed">
|
||||
{t('editor.editorToolbar.unsavedChanges')}
|
||||
</StyledBackStatusChanged>
|
||||
) : (
|
||||
<StyledBackStatusUnchanged key="back-unchanged">
|
||||
{t('editor.editorToolbar.changesSaved')}
|
||||
</StyledBackStatusUnchanged>
|
||||
)}
|
||||
</div>
|
||||
</Button>
|
||||
</StyledToolbarSectionBackLink>
|
||||
{controls}
|
||||
<SettingsDropdown
|
||||
displayUrl={displayUrl}
|
||||
imageUrl={user?.avatar_url}
|
||||
onLogoutClick={onLogoutClick}
|
||||
/>
|
||||
</StyledToolbar>
|
||||
</StyledAppBar>
|
||||
);
|
||||
};
|
||||
|
||||
export default translate()(EditorToolbar);
|
10
core/src/components/EditorWidgets/Unknown/UnknownControl.tsx
Normal file
10
core/src/components/EditorWidgets/Unknown/UnknownControl.tsx
Normal file
@ -0,0 +1,10 @@
|
||||
import React from 'react';
|
||||
import { translate } from 'react-polyglot';
|
||||
|
||||
import type { WidgetControlProps, TranslatedProps } from '../../../interface';
|
||||
|
||||
const UnknownControl = ({ field, t }: TranslatedProps<WidgetControlProps<unknown>>) => {
|
||||
return <div>{t('editor.editorWidgets.unknownControl.noControl', { widget: field.widget })}</div>;
|
||||
};
|
||||
|
||||
export default translate()(UnknownControl);
|
14
core/src/components/EditorWidgets/Unknown/UnknownPreview.tsx
Normal file
14
core/src/components/EditorWidgets/Unknown/UnknownPreview.tsx
Normal file
@ -0,0 +1,14 @@
|
||||
import React from 'react';
|
||||
import { translate } from 'react-polyglot';
|
||||
|
||||
import type { WidgetPreviewProps, TranslatedProps } from '../../../interface';
|
||||
|
||||
const UnknownPreview = ({ field, t }: TranslatedProps<WidgetPreviewProps>) => {
|
||||
return (
|
||||
<div className="nc-widgetPreview">
|
||||
{t('editor.editorWidgets.unknownPreview.noPreview', { widget: field.widget })}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default translate()(UnknownPreview);
|
5
core/src/components/EditorWidgets/index.ts
Normal file
5
core/src/components/EditorWidgets/index.ts
Normal file
@ -0,0 +1,5 @@
|
||||
import { registerWidget } from '../../lib/registry';
|
||||
import UnknownControl from './Unknown/UnknownControl';
|
||||
import UnknownPreview from './Unknown/UnknownPreview';
|
||||
|
||||
registerWidget('unknown', UnknownControl, UnknownPreview);
|
38
core/src/components/MediaLibrary/EmptyMessage.tsx
Normal file
38
core/src/components/MediaLibrary/EmptyMessage.tsx
Normal file
@ -0,0 +1,38 @@
|
||||
import { styled } from '@mui/material/styles';
|
||||
import React from 'react';
|
||||
|
||||
import { colors } from '../../components/UI/styles';
|
||||
import { transientOptions } from '../../lib';
|
||||
|
||||
interface EmptyMessageContainerProps {
|
||||
$isPrivate: boolean;
|
||||
}
|
||||
|
||||
const EmptyMessageContainer = styled(
|
||||
'div',
|
||||
transientOptions,
|
||||
)<EmptyMessageContainerProps>(
|
||||
({ $isPrivate }) => `
|
||||
height: 100%;
|
||||
width: 100%;
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
${$isPrivate ? `color: ${colors.textFieldBorder};` : ''}
|
||||
`,
|
||||
);
|
||||
|
||||
interface EmptyMessageProps {
|
||||
content: string;
|
||||
isPrivate?: boolean;
|
||||
}
|
||||
|
||||
const EmptyMessage = ({ content, isPrivate = false }: EmptyMessageProps) => {
|
||||
return (
|
||||
<EmptyMessageContainer $isPrivate={isPrivate}>
|
||||
<h1>{content}</h1>
|
||||
</EmptyMessageContainer>
|
||||
);
|
||||
};
|
||||
|
||||
export default EmptyMessage;
|
407
core/src/components/MediaLibrary/MediaLibrary.tsx
Normal file
407
core/src/components/MediaLibrary/MediaLibrary.tsx
Normal file
@ -0,0 +1,407 @@
|
||||
import fuzzy from 'fuzzy';
|
||||
import React, { useCallback, useEffect, useRef, useState } from 'react';
|
||||
import { translate } from 'react-polyglot';
|
||||
import { connect } from 'react-redux';
|
||||
|
||||
import {
|
||||
closeMediaLibrary as closeMediaLibraryAction,
|
||||
deleteMedia as deleteMediaAction,
|
||||
insertMedia as insertMediaAction,
|
||||
loadMedia as loadMediaAction,
|
||||
loadMediaDisplayURL as loadMediaDisplayURLAction,
|
||||
persistMedia as persistMediaAction,
|
||||
} from '../../actions/mediaLibrary';
|
||||
import { fileExtension } from '../../lib/util';
|
||||
import { selectMediaFiles } from '../../reducers/mediaLibrary';
|
||||
import alert from '../UI/Alert';
|
||||
import confirm from '../UI/Confirm';
|
||||
import MediaLibraryModal from './MediaLibraryModal';
|
||||
|
||||
import type { ChangeEvent, KeyboardEvent } from 'react';
|
||||
import type { ConnectedProps } from 'react-redux';
|
||||
import type { MediaFile, TranslatedProps } from '../../interface';
|
||||
import type { RootState } from '../../store';
|
||||
|
||||
/**
|
||||
* Extensions used to determine which files to show when the media library is
|
||||
* accessed from an image insertion field.
|
||||
*/
|
||||
const IMAGE_EXTENSIONS_VIEWABLE = [
|
||||
'jpg',
|
||||
'jpeg',
|
||||
'webp',
|
||||
'gif',
|
||||
'png',
|
||||
'bmp',
|
||||
'tiff',
|
||||
'svg',
|
||||
'avif',
|
||||
];
|
||||
const IMAGE_EXTENSIONS = [...IMAGE_EXTENSIONS_VIEWABLE];
|
||||
|
||||
const MediaLibrary = ({
|
||||
isVisible,
|
||||
loadMediaDisplayURL,
|
||||
displayURLs,
|
||||
canInsert,
|
||||
files = [],
|
||||
dynamicSearch,
|
||||
dynamicSearchActive,
|
||||
forImage,
|
||||
isLoading,
|
||||
isPersisting,
|
||||
isDeleting,
|
||||
hasNextPage,
|
||||
isPaginating,
|
||||
privateUpload = false,
|
||||
config,
|
||||
loadMedia,
|
||||
dynamicSearchQuery,
|
||||
page,
|
||||
persistMedia,
|
||||
deleteMedia,
|
||||
insertMedia,
|
||||
closeMediaLibrary,
|
||||
field,
|
||||
t,
|
||||
}: TranslatedProps<MediaLibraryProps>) => {
|
||||
const [selectedFile, setSelectedFile] = useState<MediaFile | null>(null);
|
||||
const [query, setQuery] = useState<string | undefined>(undefined);
|
||||
|
||||
const [prevIsVisible, setPrevIsVisible] = useState(false);
|
||||
const [prevPrivateUpload, setPrevPrivateUpload] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
loadMedia();
|
||||
}, [loadMedia]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!prevIsVisible && isVisible) {
|
||||
setSelectedFile(null);
|
||||
setQuery('');
|
||||
}
|
||||
|
||||
setPrevIsVisible(isVisible);
|
||||
}, [isVisible, prevIsVisible]);
|
||||
|
||||
useEffect(() => {
|
||||
setPrevPrivateUpload(privateUpload);
|
||||
}, [privateUpload]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!prevIsVisible && isVisible && !prevPrivateUpload && privateUpload) {
|
||||
loadMedia({ privateUpload });
|
||||
}
|
||||
}, [isVisible, loadMedia, prevIsVisible, prevPrivateUpload, privateUpload]);
|
||||
|
||||
const loadDisplayURL = useCallback(
|
||||
(file: MediaFile) => {
|
||||
loadMediaDisplayURL(file);
|
||||
},
|
||||
[loadMediaDisplayURL],
|
||||
);
|
||||
|
||||
/**
|
||||
* Filter an array of file data to include only images.
|
||||
*/
|
||||
const filterImages = useCallback((files: MediaFile[]) => {
|
||||
return files.filter(file => {
|
||||
const ext = fileExtension(file.name).toLowerCase();
|
||||
return IMAGE_EXTENSIONS.includes(ext);
|
||||
});
|
||||
}, []);
|
||||
|
||||
/**
|
||||
* Transform file data for table display.
|
||||
*/
|
||||
const toTableData = useCallback((files: MediaFile[]) => {
|
||||
const tableData =
|
||||
files &&
|
||||
files.map(({ key, name, id, size, path, queryOrder, displayURL, draft }) => {
|
||||
const ext = fileExtension(name).toLowerCase();
|
||||
return {
|
||||
key,
|
||||
id,
|
||||
name,
|
||||
path,
|
||||
type: ext.toUpperCase(),
|
||||
size,
|
||||
queryOrder,
|
||||
displayURL,
|
||||
draft,
|
||||
isImage: IMAGE_EXTENSIONS.includes(ext),
|
||||
isViewableImage: IMAGE_EXTENSIONS_VIEWABLE.includes(ext),
|
||||
};
|
||||
});
|
||||
|
||||
/**
|
||||
* Get the sort order for use with `lodash.orderBy`, and always add the
|
||||
* `queryOrder` sort as the lowest priority sort order.
|
||||
*/
|
||||
// TODO Sorting?
|
||||
// const fieldNames = map(sortFields, 'fieldName').concat('queryOrder');
|
||||
// const directions = map(sortFields, 'direction').concat('asc');
|
||||
// return orderBy(tableData, fieldNames, directions);
|
||||
return tableData;
|
||||
}, []);
|
||||
|
||||
const handleClose = useCallback(() => {
|
||||
closeMediaLibrary();
|
||||
}, [closeMediaLibrary]);
|
||||
|
||||
/**
|
||||
* Toggle asset selection on click.
|
||||
*/
|
||||
const handleAssetClick = useCallback(
|
||||
(asset: MediaFile) => {
|
||||
if (selectedFile?.key !== asset.key) {
|
||||
setSelectedFile(asset);
|
||||
}
|
||||
},
|
||||
[selectedFile?.key],
|
||||
);
|
||||
|
||||
const scrollContainerRef = useRef<HTMLDivElement>();
|
||||
const scrollToTop = () => {
|
||||
if (scrollContainerRef.current) {
|
||||
scrollContainerRef.current.scrollTop = 0;
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* Upload a file.
|
||||
*/
|
||||
const handlePersist = useCallback(
|
||||
async (event: ChangeEvent<HTMLInputElement> | DragEvent) => {
|
||||
/**
|
||||
* Stop the browser from automatically handling the file input click, and
|
||||
* get the file for upload, and retain the synthetic event for access after
|
||||
* the asynchronous persist operation.
|
||||
*/
|
||||
|
||||
let fileList: FileList | null;
|
||||
if ('dataTransfer' in event) {
|
||||
fileList = event.dataTransfer?.files ?? null;
|
||||
} else {
|
||||
event.persist();
|
||||
fileList = event.target.files;
|
||||
}
|
||||
|
||||
if (!fileList) {
|
||||
return;
|
||||
}
|
||||
|
||||
event.stopPropagation();
|
||||
event.preventDefault();
|
||||
const files = [...Array.from(fileList)];
|
||||
const file = files[0];
|
||||
const maxFileSize = typeof config.max_file_size === 'number' ? config.max_file_size : 512000;
|
||||
|
||||
if (maxFileSize && file.size > maxFileSize) {
|
||||
alert({
|
||||
title: 'mediaLibrary.mediaLibrary.fileTooLargeTitle',
|
||||
body: {
|
||||
key: 'mediaLibrary.mediaLibrary.fileTooLargeBody',
|
||||
options: {
|
||||
size: Math.floor(maxFileSize / 1000),
|
||||
},
|
||||
},
|
||||
});
|
||||
} else {
|
||||
await persistMedia(file, { privateUpload, field });
|
||||
|
||||
setSelectedFile(files[0] as unknown as MediaFile);
|
||||
|
||||
scrollToTop();
|
||||
}
|
||||
|
||||
if (!('dataTransfer' in event)) {
|
||||
event.target.value = '';
|
||||
}
|
||||
},
|
||||
[config.max_file_size, field, persistMedia, privateUpload],
|
||||
);
|
||||
|
||||
/**
|
||||
* Stores the public path of the file in the application store, where the
|
||||
* editor field that launched the media library can retrieve it.
|
||||
*/
|
||||
const handleInsert = useCallback(() => {
|
||||
if (!selectedFile) {
|
||||
return;
|
||||
}
|
||||
|
||||
const { path } = selectedFile;
|
||||
insertMedia(path, field);
|
||||
handleClose();
|
||||
}, [field, handleClose, insertMedia, selectedFile]);
|
||||
|
||||
/**
|
||||
* Removes the selected file from the backend.
|
||||
*/
|
||||
const handleDelete = useCallback(async () => {
|
||||
if (
|
||||
!(await confirm({
|
||||
title: 'mediaLibrary.mediaLibrary.onDeleteTitle',
|
||||
body: 'mediaLibrary.mediaLibrary.onDeleteBody',
|
||||
color: 'error',
|
||||
}))
|
||||
) {
|
||||
return;
|
||||
}
|
||||
const file = files.find(file => selectedFile?.key === file.key);
|
||||
if (file) {
|
||||
deleteMedia(file, { privateUpload }).then(() => {
|
||||
setSelectedFile(null);
|
||||
});
|
||||
}
|
||||
}, [deleteMedia, files, privateUpload, selectedFile?.key]);
|
||||
|
||||
/**
|
||||
* Downloads the selected file.
|
||||
*/
|
||||
const handleDownload = useCallback(() => {
|
||||
if (!selectedFile) {
|
||||
return;
|
||||
}
|
||||
|
||||
const url = displayURLs[selectedFile.id]?.url ?? selectedFile.url;
|
||||
if (!url) {
|
||||
return;
|
||||
}
|
||||
|
||||
const filename = selectedFile.name;
|
||||
|
||||
const element = document.createElement('a');
|
||||
element.setAttribute('href', url);
|
||||
element.setAttribute('download', filename);
|
||||
|
||||
element.style.display = 'none';
|
||||
document.body.appendChild(element);
|
||||
|
||||
element.click();
|
||||
|
||||
document.body.removeChild(element);
|
||||
setSelectedFile(null);
|
||||
}, [displayURLs, selectedFile]);
|
||||
|
||||
const handleLoadMore = useCallback(() => {
|
||||
loadMedia({ query: dynamicSearchQuery, page: (page ?? 0) + 1, privateUpload });
|
||||
}, [dynamicSearchQuery, loadMedia, page, privateUpload]);
|
||||
|
||||
/**
|
||||
* Executes media library search for implementations that support dynamic
|
||||
* search via request. For these implementations, the Enter key must be
|
||||
* pressed to execute search. If assets are being stored directly through
|
||||
* the GitHub backend, search is in-memory and occurs as the query is typed,
|
||||
* so this handler has no impact.
|
||||
*/
|
||||
const handleSearchKeyDown = useCallback(
|
||||
async (event: KeyboardEvent) => {
|
||||
if (event.key === 'Enter' && dynamicSearch) {
|
||||
await loadMedia({ query, privateUpload });
|
||||
scrollToTop();
|
||||
}
|
||||
},
|
||||
[dynamicSearch, loadMedia, privateUpload, query],
|
||||
);
|
||||
|
||||
/**
|
||||
* Updates query state as the user types in the search field.
|
||||
*/
|
||||
const handleSearchChange = useCallback((event: ChangeEvent<HTMLInputElement>) => {
|
||||
setQuery(event.target.value);
|
||||
}, []);
|
||||
|
||||
/**
|
||||
* Filters files that do not match the query. Not used for dynamic search.
|
||||
*/
|
||||
const queryFilter = useCallback((query: string, files: { name: string }[]) => {
|
||||
/**
|
||||
* Because file names don't have spaces, typing a space eliminates all
|
||||
* potential matches, so we strip them all out internally before running the
|
||||
* query.
|
||||
*/
|
||||
const strippedQuery = query.replace(/ /g, '');
|
||||
const matches = fuzzy.filter(strippedQuery, files, { extract: file => file.name });
|
||||
const matchFiles = matches.map((match, queryIndex) => {
|
||||
const file = files[match.index];
|
||||
return { ...file, queryIndex };
|
||||
});
|
||||
return matchFiles;
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<MediaLibraryModal
|
||||
isVisible={isVisible}
|
||||
canInsert={canInsert}
|
||||
files={files}
|
||||
dynamicSearch={dynamicSearch}
|
||||
dynamicSearchActive={dynamicSearchActive}
|
||||
forImage={forImage}
|
||||
isLoading={isLoading}
|
||||
isPersisting={isPersisting}
|
||||
isDeleting={isDeleting}
|
||||
hasNextPage={hasNextPage}
|
||||
isPaginating={isPaginating}
|
||||
privateUpload={privateUpload}
|
||||
query={query}
|
||||
selectedFile={selectedFile}
|
||||
handleFilter={filterImages}
|
||||
handleQuery={queryFilter}
|
||||
toTableData={toTableData}
|
||||
handleClose={handleClose}
|
||||
handleSearchChange={handleSearchChange}
|
||||
handleSearchKeyDown={handleSearchKeyDown}
|
||||
handlePersist={handlePersist}
|
||||
handleDelete={handleDelete}
|
||||
handleInsert={handleInsert}
|
||||
handleDownload={handleDownload}
|
||||
setScrollContainerRef={scrollContainerRef}
|
||||
handleAssetClick={handleAssetClick}
|
||||
handleLoadMore={handleLoadMore}
|
||||
displayURLs={displayURLs}
|
||||
loadDisplayURL={loadDisplayURL}
|
||||
t={t}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
function mapStateToProps(state: RootState) {
|
||||
const { mediaLibrary } = state;
|
||||
const field = mediaLibrary.field;
|
||||
const mediaLibraryProps = {
|
||||
isVisible: mediaLibrary.isVisible,
|
||||
canInsert: mediaLibrary.canInsert,
|
||||
files: selectMediaFiles(state, field),
|
||||
displayURLs: mediaLibrary.displayURLs,
|
||||
dynamicSearch: mediaLibrary.dynamicSearch,
|
||||
dynamicSearchActive: mediaLibrary.dynamicSearchActive,
|
||||
dynamicSearchQuery: mediaLibrary.dynamicSearchQuery,
|
||||
forImage: mediaLibrary.forImage,
|
||||
isLoading: mediaLibrary.isLoading,
|
||||
isPersisting: mediaLibrary.isPersisting,
|
||||
isDeleting: mediaLibrary.isDeleting,
|
||||
privateUpload: mediaLibrary.privateUpload,
|
||||
config: mediaLibrary.config,
|
||||
page: mediaLibrary.page,
|
||||
hasNextPage: mediaLibrary.hasNextPage,
|
||||
isPaginating: mediaLibrary.isPaginating,
|
||||
field,
|
||||
};
|
||||
return { ...mediaLibraryProps };
|
||||
}
|
||||
|
||||
const mapDispatchToProps = {
|
||||
loadMedia: loadMediaAction,
|
||||
persistMedia: persistMediaAction,
|
||||
deleteMedia: deleteMediaAction,
|
||||
insertMedia: insertMediaAction,
|
||||
loadMediaDisplayURL: loadMediaDisplayURLAction,
|
||||
closeMediaLibrary: closeMediaLibraryAction,
|
||||
};
|
||||
|
||||
const connector = connect(mapStateToProps, mapDispatchToProps);
|
||||
export type MediaLibraryProps = ConnectedProps<typeof connector>;
|
||||
|
||||
export default connector(translate()(MediaLibrary));
|
130
core/src/components/MediaLibrary/MediaLibraryButtons.tsx
Normal file
130
core/src/components/MediaLibrary/MediaLibraryButtons.tsx
Normal file
@ -0,0 +1,130 @@
|
||||
import React, { useCallback, useEffect, useState } from 'react';
|
||||
import { css } from '@emotion/react';
|
||||
import { styled } from '@mui/material/styles';
|
||||
import copyToClipboard from 'copy-text-to-clipboard';
|
||||
import Button from '@mui/material/Button';
|
||||
|
||||
import { buttons, shadows, zIndex } from '../../components/UI/styles';
|
||||
import { isAbsolutePath } from '../../lib/util';
|
||||
import { FileUploadButton } from '../UI';
|
||||
|
||||
import type { TranslatedProps } from '../../interface';
|
||||
|
||||
const styles = {
|
||||
button: css`
|
||||
${buttons.button};
|
||||
${buttons.default};
|
||||
display: inline-block;
|
||||
margin-left: 15px;
|
||||
margin-right: 2px;
|
||||
|
||||
&[disabled] {
|
||||
${buttons.disabled};
|
||||
cursor: default;
|
||||
}
|
||||
`,
|
||||
};
|
||||
|
||||
export const UploadButton = styled(FileUploadButton)`
|
||||
${styles.button};
|
||||
${buttons.gray};
|
||||
${shadows.dropMain};
|
||||
margin-bottom: 0;
|
||||
|
||||
span {
|
||||
font-size: 14px;
|
||||
font-weight: 500;
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
input {
|
||||
height: 0.1px;
|
||||
width: 0.1px;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
opacity: 0;
|
||||
overflow: hidden;
|
||||
position: absolute;
|
||||
z-index: ${zIndex.zIndex0};
|
||||
outline: none;
|
||||
}
|
||||
`;
|
||||
|
||||
export const DeleteButton = styled('button')`
|
||||
${styles.button};
|
||||
${buttons.lightRed};
|
||||
`;
|
||||
|
||||
export const InsertButton = styled('button')`
|
||||
${styles.button};
|
||||
${buttons.green};
|
||||
`;
|
||||
|
||||
export interface CopyToClipBoardButtonProps {
|
||||
disabled: boolean;
|
||||
draft?: boolean;
|
||||
path?: string;
|
||||
name?: string;
|
||||
}
|
||||
|
||||
export const CopyToClipBoardButton = ({
|
||||
disabled,
|
||||
draft,
|
||||
path,
|
||||
name,
|
||||
t,
|
||||
}: TranslatedProps<CopyToClipBoardButtonProps>) => {
|
||||
const [copied, setCopied] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
let alive = true;
|
||||
|
||||
const timer = setTimeout(() => {
|
||||
if (alive) {
|
||||
setCopied(false);
|
||||
}
|
||||
}, 1500);
|
||||
|
||||
return () => {
|
||||
alive = false;
|
||||
clearTimeout(timer);
|
||||
};
|
||||
}, []);
|
||||
|
||||
const handleCopy = useCallback(() => {
|
||||
if (!path || !name) {
|
||||
return;
|
||||
}
|
||||
|
||||
copyToClipboard(isAbsolutePath(path) || !draft ? path : name);
|
||||
setCopied(true);
|
||||
}, [draft, name, path]);
|
||||
|
||||
const getTitle = useCallback(() => {
|
||||
if (copied) {
|
||||
return t('mediaLibrary.mediaLibraryCard.copied');
|
||||
}
|
||||
|
||||
if (!path) {
|
||||
return t('mediaLibrary.mediaLibraryCard.copy');
|
||||
}
|
||||
|
||||
if (isAbsolutePath(path)) {
|
||||
return t('mediaLibrary.mediaLibraryCard.copyUrl');
|
||||
}
|
||||
|
||||
if (draft) {
|
||||
return t('mediaLibrary.mediaLibraryCard.copyName');
|
||||
}
|
||||
|
||||
return t('mediaLibrary.mediaLibraryCard.copyPath');
|
||||
}, [copied, draft, path, t]);
|
||||
|
||||
return (
|
||||
<Button color="inherit" variant="contained" onClick={handleCopy} disabled={disabled}>
|
||||
{getTitle()}
|
||||
</Button>
|
||||
);
|
||||
};
|
142
core/src/components/MediaLibrary/MediaLibraryCard.tsx
Normal file
142
core/src/components/MediaLibrary/MediaLibraryCard.tsx
Normal file
@ -0,0 +1,142 @@
|
||||
import { styled } from '@mui/material/styles';
|
||||
import React, { useEffect, useMemo } from 'react';
|
||||
|
||||
import { transientOptions } from '../../lib';
|
||||
import { borders, colors, effects, lengths, shadows } from '../../components/UI/styles';
|
||||
|
||||
import type { MediaLibraryDisplayURL } from '../../reducers/mediaLibrary';
|
||||
|
||||
const IMAGE_HEIGHT = 160;
|
||||
|
||||
interface CardProps {
|
||||
$width: string;
|
||||
$height: string;
|
||||
$margin: string;
|
||||
$isSelected: boolean;
|
||||
$isPrivate: boolean;
|
||||
}
|
||||
|
||||
const Card = styled(
|
||||
'div',
|
||||
transientOptions,
|
||||
)<CardProps>(
|
||||
({ $width, $height, $margin, $isSelected, $isPrivate }) => `
|
||||
width: ${$width};
|
||||
height: ${$height};
|
||||
margin: ${$margin};
|
||||
border: ${borders.textField};
|
||||
${$isSelected ? `border-color: ${colors.active};` : ''}
|
||||
border-radius: ${lengths.borderRadius};
|
||||
cursor: pointer;
|
||||
overflow: hidden;
|
||||
${$isPrivate ? `background-color: ${colors.textFieldBorder};` : ''}
|
||||
|
||||
&:focus {
|
||||
outline: none;
|
||||
}
|
||||
`,
|
||||
);
|
||||
|
||||
const CardImageWrapper = styled('div')`
|
||||
height: ${IMAGE_HEIGHT + 2}px;
|
||||
${effects.checkerboard};
|
||||
${shadows.inset};
|
||||
border-bottom: solid ${lengths.borderWidth} ${colors.textFieldBorder};
|
||||
position: relative;
|
||||
`;
|
||||
|
||||
const CardImage = styled('img')`
|
||||
width: 100%;
|
||||
height: ${IMAGE_HEIGHT}px;
|
||||
object-fit: contain;
|
||||
border-radius: 2px 2px 0 0;
|
||||
`;
|
||||
|
||||
const CardFileIcon = styled('div')`
|
||||
width: 100%;
|
||||
height: 160px;
|
||||
object-fit: cover;
|
||||
border-radius: 2px 2px 0 0;
|
||||
padding: 1em;
|
||||
font-size: 3em;
|
||||
`;
|
||||
|
||||
const CardText = styled('p')`
|
||||
color: ${colors.text};
|
||||
padding: 8px;
|
||||
margin-top: 20px;
|
||||
overflow-wrap: break-word;
|
||||
line-height: 1.3;
|
||||
`;
|
||||
|
||||
const DraftText = styled('p')`
|
||||
color: ${colors.mediaDraftText};
|
||||
background-color: ${colors.mediaDraftBackground};
|
||||
position: absolute;
|
||||
padding: 8px;
|
||||
border-radius: ${lengths.borderRadius} 0 ${lengths.borderRadius} 0;
|
||||
`;
|
||||
|
||||
interface MediaLibraryCardProps {
|
||||
isSelected?: boolean;
|
||||
displayURL: MediaLibraryDisplayURL;
|
||||
text: string;
|
||||
onClick: () => void;
|
||||
draftText: string;
|
||||
width: string;
|
||||
height: string;
|
||||
margin: string;
|
||||
isPrivate?: boolean;
|
||||
type?: string;
|
||||
isViewableImage: boolean;
|
||||
loadDisplayURL: () => void;
|
||||
isDraft?: boolean;
|
||||
}
|
||||
|
||||
const MediaLibraryCard = ({
|
||||
isSelected = false,
|
||||
displayURL,
|
||||
text,
|
||||
onClick,
|
||||
draftText,
|
||||
width,
|
||||
height,
|
||||
margin,
|
||||
isPrivate = false,
|
||||
type,
|
||||
isViewableImage,
|
||||
isDraft,
|
||||
loadDisplayURL,
|
||||
}: MediaLibraryCardProps) => {
|
||||
const url = useMemo(() => displayURL.url, [displayURL.url]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!displayURL.url) {
|
||||
loadDisplayURL();
|
||||
}
|
||||
}, [displayURL.url, loadDisplayURL]);
|
||||
|
||||
return (
|
||||
<Card
|
||||
$isSelected={isSelected}
|
||||
$width={width}
|
||||
$height={height}
|
||||
$margin={margin}
|
||||
$isPrivate={isPrivate}
|
||||
onClick={onClick}
|
||||
tabIndex={-1}
|
||||
>
|
||||
<CardImageWrapper>
|
||||
{isDraft ? <DraftText data-testid="draft-text">{draftText}</DraftText> : null}
|
||||
{url && isViewableImage ? (
|
||||
<CardImage src={url} />
|
||||
) : (
|
||||
<CardFileIcon data-testid="card-file-icon">{type}</CardFileIcon>
|
||||
)}
|
||||
</CardImageWrapper>
|
||||
<CardText>{text}</CardText>
|
||||
</Card>
|
||||
);
|
||||
};
|
||||
|
||||
export default MediaLibraryCard;
|
239
core/src/components/MediaLibrary/MediaLibraryCardGrid.tsx
Normal file
239
core/src/components/MediaLibrary/MediaLibraryCardGrid.tsx
Normal file
@ -0,0 +1,239 @@
|
||||
import { styled } from '@mui/material/styles';
|
||||
import React from 'react';
|
||||
import AutoSizer from 'react-virtualized-auto-sizer';
|
||||
import { Waypoint } from 'react-waypoint';
|
||||
import { FixedSizeGrid as Grid } from 'react-window';
|
||||
|
||||
import { transientOptions } from '../../lib';
|
||||
import { colors } from '../../components/UI/styles';
|
||||
import MediaLibraryCard from './MediaLibraryCard';
|
||||
|
||||
import type { GridChildComponentProps } from 'react-window';
|
||||
import type { MediaLibraryDisplayURL, MediaLibraryState } from '../../reducers/mediaLibrary';
|
||||
import type { MediaFile } from '../../interface';
|
||||
|
||||
export interface MediaLibraryCardItem {
|
||||
displayURL?: MediaLibraryDisplayURL;
|
||||
id: string;
|
||||
key: string;
|
||||
name: string;
|
||||
type: string;
|
||||
draft: boolean;
|
||||
isViewableImage?: boolean;
|
||||
url?: string;
|
||||
}
|
||||
|
||||
export interface MediaLibraryCardGridProps {
|
||||
setScrollContainerRef: () => void;
|
||||
mediaItems: MediaFile[];
|
||||
isSelectedFile: (file: MediaFile) => boolean;
|
||||
onAssetClick: (asset: MediaFile) => void;
|
||||
canLoadMore?: boolean;
|
||||
onLoadMore: () => void;
|
||||
isPaginating?: boolean;
|
||||
paginatingMessage?: string;
|
||||
cardDraftText: string;
|
||||
cardWidth: string;
|
||||
cardHeight: string;
|
||||
cardMargin: string;
|
||||
loadDisplayURL: (asset: MediaFile) => void;
|
||||
isPrivate?: boolean;
|
||||
displayURLs: MediaLibraryState['displayURLs'];
|
||||
}
|
||||
|
||||
export type CardGridItemData = MediaLibraryCardGridProps & {
|
||||
columnCount: number;
|
||||
gutter: number;
|
||||
};
|
||||
|
||||
const CardWrapper = ({
|
||||
rowIndex,
|
||||
columnIndex,
|
||||
style,
|
||||
data: {
|
||||
mediaItems,
|
||||
isSelectedFile,
|
||||
onAssetClick,
|
||||
cardDraftText,
|
||||
cardWidth,
|
||||
cardHeight,
|
||||
isPrivate,
|
||||
displayURLs,
|
||||
loadDisplayURL,
|
||||
columnCount,
|
||||
gutter,
|
||||
},
|
||||
}: GridChildComponentProps<CardGridItemData>) => {
|
||||
const index = rowIndex * columnCount + columnIndex;
|
||||
if (index >= mediaItems.length) {
|
||||
return null;
|
||||
}
|
||||
const file = mediaItems[index];
|
||||
|
||||
return (
|
||||
<div
|
||||
style={{
|
||||
...style,
|
||||
left: typeof style.left === 'number' ? style.left ?? +gutter * columnIndex : style.left,
|
||||
top: typeof style.top === 'number' ? style.top + gutter : style.top,
|
||||
width: typeof style.width === 'number' ? style.width - gutter : style.width,
|
||||
height: typeof style.height === 'number' ? style.height - gutter : style.height,
|
||||
}}
|
||||
>
|
||||
<MediaLibraryCard
|
||||
key={file.key}
|
||||
isSelected={isSelectedFile(file)}
|
||||
text={file.name}
|
||||
onClick={() => onAssetClick(file)}
|
||||
isDraft={file.draft}
|
||||
draftText={cardDraftText}
|
||||
width={cardWidth}
|
||||
height={cardHeight}
|
||||
margin={'0px'}
|
||||
isPrivate={isPrivate}
|
||||
displayURL={displayURLs[file.id] ?? (file.url ? { url: file.url } : {})}
|
||||
loadDisplayURL={() => loadDisplayURL(file)}
|
||||
type={file.type}
|
||||
isViewableImage={file.isViewableImage ?? false}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
interface StyledCardGridContainerProps {
|
||||
$width?: number;
|
||||
$height?: number;
|
||||
}
|
||||
|
||||
const StyledCardGridContainer = styled('div')<StyledCardGridContainerProps>(
|
||||
({ $width, $height }) => `
|
||||
overflow-y: auto;
|
||||
overflow-x: hidden;
|
||||
width: ${$width ? `${$width}px` : '100%'};
|
||||
height: ${$height ? `${$height}px` : '100%'};ƒ
|
||||
`,
|
||||
);
|
||||
|
||||
const CardGrid = styled('div')`
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
|
||||
margin-left: -10px;
|
||||
margin-right: -10px;
|
||||
`;
|
||||
|
||||
const VirtualizedGrid = (props: MediaLibraryCardGridProps) => {
|
||||
const {
|
||||
cardWidth: inputCardWidth,
|
||||
cardHeight: inputCardHeight,
|
||||
cardMargin,
|
||||
mediaItems,
|
||||
setScrollContainerRef,
|
||||
} = props;
|
||||
|
||||
return (
|
||||
<AutoSizer>
|
||||
{({ height, width }) => {
|
||||
const cardWidth = parseInt(inputCardWidth, 10);
|
||||
const cardHeight = parseInt(inputCardHeight, 10);
|
||||
const gutter = parseInt(cardMargin, 10);
|
||||
const columnWidth = cardWidth + gutter;
|
||||
const rowHeight = cardHeight + gutter;
|
||||
const columnCount = Math.floor(width / columnWidth);
|
||||
const rowCount = Math.ceil(mediaItems.length / columnCount);
|
||||
|
||||
return (
|
||||
<StyledCardGridContainer $width={width} $height={height} ref={setScrollContainerRef}>
|
||||
<Grid
|
||||
columnCount={columnCount}
|
||||
columnWidth={columnWidth}
|
||||
rowCount={rowCount}
|
||||
rowHeight={rowHeight}
|
||||
width={width}
|
||||
height={height}
|
||||
itemData={
|
||||
{
|
||||
...props,
|
||||
gutter,
|
||||
columnCount,
|
||||
} as CardGridItemData
|
||||
}
|
||||
>
|
||||
{CardWrapper}
|
||||
</Grid>
|
||||
</StyledCardGridContainer>
|
||||
);
|
||||
}}
|
||||
</AutoSizer>
|
||||
);
|
||||
};
|
||||
|
||||
interface PaginatingMessageProps {
|
||||
$isPrivate: boolean;
|
||||
}
|
||||
|
||||
const PaginatingMessage = styled(
|
||||
'h1',
|
||||
transientOptions,
|
||||
)<PaginatingMessageProps>(
|
||||
({ $isPrivate }) => `
|
||||
${$isPrivate ? `color: ${colors.textFieldBorder};` : ''}
|
||||
`,
|
||||
);
|
||||
|
||||
const PaginatedGrid = ({
|
||||
setScrollContainerRef,
|
||||
mediaItems,
|
||||
isSelectedFile,
|
||||
onAssetClick,
|
||||
cardDraftText,
|
||||
cardWidth,
|
||||
cardHeight,
|
||||
cardMargin,
|
||||
isPrivate = false,
|
||||
displayURLs,
|
||||
loadDisplayURL,
|
||||
canLoadMore,
|
||||
onLoadMore,
|
||||
isPaginating,
|
||||
paginatingMessage,
|
||||
}: MediaLibraryCardGridProps) => {
|
||||
return (
|
||||
<StyledCardGridContainer ref={setScrollContainerRef}>
|
||||
<CardGrid>
|
||||
{mediaItems.map(file => (
|
||||
<MediaLibraryCard
|
||||
key={file.key}
|
||||
isSelected={isSelectedFile(file)}
|
||||
text={file.name}
|
||||
onClick={() => onAssetClick(file)}
|
||||
isDraft={file.draft}
|
||||
draftText={cardDraftText}
|
||||
width={cardWidth}
|
||||
height={cardHeight}
|
||||
margin={cardMargin}
|
||||
isPrivate={isPrivate}
|
||||
displayURL={displayURLs[file.id] ?? (file.url ? { url: file.url } : {})}
|
||||
loadDisplayURL={() => loadDisplayURL(file)}
|
||||
type={file.type}
|
||||
isViewableImage={file.isViewableImage ?? false}
|
||||
/>
|
||||
))}
|
||||
{!canLoadMore ? null : <Waypoint onEnter={onLoadMore} />}
|
||||
</CardGrid>
|
||||
{!isPaginating ? null : (
|
||||
<PaginatingMessage $isPrivate={isPrivate}>{paginatingMessage}</PaginatingMessage>
|
||||
)}
|
||||
</StyledCardGridContainer>
|
||||
);
|
||||
};
|
||||
|
||||
function MediaLibraryCardGrid(props: MediaLibraryCardGridProps) {
|
||||
const { canLoadMore, isPaginating } = props;
|
||||
if (canLoadMore || isPaginating) {
|
||||
return <PaginatedGrid {...props} />;
|
||||
}
|
||||
return <VirtualizedGrid {...props} />;
|
||||
}
|
||||
|
||||
export default MediaLibraryCardGrid;
|
227
core/src/components/MediaLibrary/MediaLibraryModal.tsx
Normal file
227
core/src/components/MediaLibrary/MediaLibraryModal.tsx
Normal file
@ -0,0 +1,227 @@
|
||||
import { styled } from '@mui/material/styles';
|
||||
import CloseIcon from '@mui/icons-material/Close';
|
||||
import Dialog from '@mui/material/Dialog';
|
||||
import DialogContent from '@mui/material/DialogContent';
|
||||
import Fab from '@mui/material/Fab';
|
||||
import isEmpty from 'lodash/isEmpty';
|
||||
import React from 'react';
|
||||
import { translate } from 'react-polyglot';
|
||||
|
||||
import { transientOptions } from '../../lib';
|
||||
import { colors, colorsRaw } from '../../components/UI/styles';
|
||||
import EmptyMessage from './EmptyMessage';
|
||||
import MediaLibraryCardGrid from './MediaLibraryCardGrid';
|
||||
import MediaLibraryTop from './MediaLibraryTop';
|
||||
|
||||
import type { ChangeEvent, ChangeEventHandler, KeyboardEventHandler } from 'react';
|
||||
import type { MediaFile, TranslatedProps } from '../../interface';
|
||||
import type { MediaLibraryState } from '../../reducers/mediaLibrary';
|
||||
|
||||
const StyledFab = styled(Fab)`
|
||||
position: absolute;
|
||||
top: -20px;
|
||||
left: -20px;
|
||||
`;
|
||||
|
||||
/**
|
||||
* TODO Responsive styling needs to be overhauled. Current setup requires specifying
|
||||
* widths per breakpoint.
|
||||
*/
|
||||
const cardWidth = `280px`;
|
||||
const cardHeight = `240px`;
|
||||
const cardMargin = `10px`;
|
||||
|
||||
/**
|
||||
* cardWidth + cardMargin * 2 = cardOutsideWidth
|
||||
* (not using calc because this will be nested in other calcs)
|
||||
*/
|
||||
const cardOutsideWidth = `300px`;
|
||||
|
||||
interface StyledModalProps {
|
||||
$isPrivate: boolean;
|
||||
}
|
||||
|
||||
const StyledModal = styled(
|
||||
Dialog,
|
||||
transientOptions,
|
||||
)<StyledModalProps>(
|
||||
({ $isPrivate }) => `
|
||||
.MuiDialog-paper {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
overflow: visible;
|
||||
height: 80%;
|
||||
width: calc(${cardOutsideWidth} + 20px);
|
||||
max-width: calc(${cardOutsideWidth} + 20px);
|
||||
${$isPrivate ? `background-color: ${colorsRaw.grayDark};` : ''}
|
||||
|
||||
@media (min-width: 800px) {
|
||||
width: calc(${cardOutsideWidth} * 2 + 20px);
|
||||
max-width: calc(${cardOutsideWidth} * 2 + 20px);
|
||||
}
|
||||
|
||||
@media (min-width: 1120px) {
|
||||
width: calc(${cardOutsideWidth} * 3 + 20px);
|
||||
max-width: calc(${cardOutsideWidth} * 3 + 20px);
|
||||
}
|
||||
|
||||
@media (min-width: 1440px) {
|
||||
width: calc(${cardOutsideWidth} * 4 + 20px);
|
||||
max-width: calc(${cardOutsideWidth} * 4 + 20px);
|
||||
}
|
||||
|
||||
@media (min-width: 1760px) {
|
||||
width: calc(${cardOutsideWidth} * 5 + 20px);
|
||||
max-width: calc(${cardOutsideWidth} * 5 + 20px);
|
||||
}
|
||||
|
||||
@media (min-width: 2080px) {
|
||||
width: calc(${cardOutsideWidth} * 6 + 20px);
|
||||
max-width: calc(${cardOutsideWidth} * 6 + 20px);
|
||||
}
|
||||
|
||||
h1 {
|
||||
${$isPrivate && `color: ${colors.textFieldBorder};`}
|
||||
}
|
||||
|
||||
button:disabled,
|
||||
label[disabled] {
|
||||
${$isPrivate ? 'background-color: rgba(217, 217, 217, 0.15);' : ''}
|
||||
}
|
||||
}
|
||||
`,
|
||||
);
|
||||
|
||||
interface MediaLibraryModalProps {
|
||||
isVisible?: boolean;
|
||||
canInsert?: boolean;
|
||||
files: MediaFile[];
|
||||
dynamicSearch?: boolean;
|
||||
dynamicSearchActive?: boolean;
|
||||
forImage?: boolean;
|
||||
isLoading?: boolean;
|
||||
isPersisting?: boolean;
|
||||
isDeleting?: boolean;
|
||||
hasNextPage?: boolean;
|
||||
isPaginating?: boolean;
|
||||
privateUpload?: boolean;
|
||||
query?: string;
|
||||
selectedFile?: MediaFile;
|
||||
handleFilter: (files: MediaFile[]) => MediaFile[];
|
||||
handleQuery: (query: string, files: MediaFile[]) => MediaFile[];
|
||||
toTableData: (files: MediaFile[]) => MediaFile[];
|
||||
handleClose: () => void;
|
||||
handleSearchChange: ChangeEventHandler<HTMLInputElement>;
|
||||
handleSearchKeyDown: KeyboardEventHandler<HTMLInputElement>;
|
||||
handlePersist: (event: ChangeEvent<HTMLInputElement> | DragEvent) => void;
|
||||
handleDelete: () => void;
|
||||
handleInsert: () => void;
|
||||
handleDownload: () => void;
|
||||
setScrollContainerRef: () => void;
|
||||
handleAssetClick: (asset: MediaFile) => void;
|
||||
handleLoadMore: () => void;
|
||||
loadDisplayURL: (file: MediaFile) => void;
|
||||
displayURLs: MediaLibraryState['displayURLs'];
|
||||
}
|
||||
|
||||
const MediaLibraryModal = ({
|
||||
isVisible = false,
|
||||
canInsert,
|
||||
files,
|
||||
dynamicSearch,
|
||||
dynamicSearchActive,
|
||||
forImage,
|
||||
isLoading,
|
||||
isPersisting,
|
||||
isDeleting,
|
||||
hasNextPage,
|
||||
isPaginating,
|
||||
privateUpload = false,
|
||||
query,
|
||||
selectedFile,
|
||||
handleFilter,
|
||||
handleQuery,
|
||||
toTableData,
|
||||
handleClose,
|
||||
handleSearchChange,
|
||||
handleSearchKeyDown,
|
||||
handlePersist,
|
||||
handleDelete,
|
||||
handleInsert,
|
||||
handleDownload,
|
||||
setScrollContainerRef,
|
||||
handleAssetClick,
|
||||
handleLoadMore,
|
||||
loadDisplayURL,
|
||||
displayURLs,
|
||||
t,
|
||||
}: TranslatedProps<MediaLibraryModalProps>) => {
|
||||
const filteredFiles = forImage ? handleFilter(files) : files;
|
||||
const queriedFiles = !dynamicSearch && query ? handleQuery(query, filteredFiles) : filteredFiles;
|
||||
const tableData = toTableData(queriedFiles);
|
||||
const hasFiles = files && !!files.length;
|
||||
const hasFilteredFiles = filteredFiles && !!filteredFiles.length;
|
||||
const hasSearchResults = queriedFiles && !!queriedFiles.length;
|
||||
const hasMedia = hasSearchResults;
|
||||
const shouldShowEmptyMessage = !hasMedia;
|
||||
const emptyMessage =
|
||||
(isLoading && !hasMedia && t('mediaLibrary.mediaLibraryModal.loading')) ||
|
||||
(dynamicSearchActive && t('mediaLibrary.mediaLibraryModal.noResults')) ||
|
||||
(!hasFiles && t('mediaLibrary.mediaLibraryModal.noAssetsFound')) ||
|
||||
(!hasFilteredFiles && t('mediaLibrary.mediaLibraryModal.noImagesFound')) ||
|
||||
(!hasSearchResults && t('mediaLibrary.mediaLibraryModal.noResults')) ||
|
||||
'';
|
||||
|
||||
const hasSelection = hasMedia && !isEmpty(selectedFile);
|
||||
|
||||
return (
|
||||
<StyledModal open={isVisible} onClose={handleClose} $isPrivate={privateUpload}>
|
||||
<StyledFab color="default" aria-label="add" onClick={handleClose} size="small">
|
||||
<CloseIcon />
|
||||
</StyledFab>
|
||||
<MediaLibraryTop
|
||||
t={t}
|
||||
onClose={handleClose}
|
||||
privateUpload={privateUpload}
|
||||
forImage={forImage}
|
||||
onDownload={handleDownload}
|
||||
onUpload={handlePersist}
|
||||
query={query}
|
||||
onSearchChange={handleSearchChange}
|
||||
onSearchKeyDown={handleSearchKeyDown}
|
||||
searchDisabled={!dynamicSearchActive && !hasFilteredFiles}
|
||||
onDelete={handleDelete}
|
||||
canInsert={canInsert}
|
||||
onInsert={handleInsert}
|
||||
hasSelection={hasSelection}
|
||||
isPersisting={isPersisting}
|
||||
isDeleting={isDeleting}
|
||||
selectedFile={selectedFile}
|
||||
/>
|
||||
<DialogContent>
|
||||
{!shouldShowEmptyMessage ? null : (
|
||||
<EmptyMessage content={emptyMessage} isPrivate={privateUpload} />
|
||||
)}
|
||||
<MediaLibraryCardGrid
|
||||
setScrollContainerRef={setScrollContainerRef}
|
||||
mediaItems={tableData}
|
||||
isSelectedFile={file => selectedFile?.key === file.key}
|
||||
onAssetClick={handleAssetClick}
|
||||
canLoadMore={hasNextPage}
|
||||
onLoadMore={handleLoadMore}
|
||||
isPaginating={isPaginating}
|
||||
paginatingMessage={t('mediaLibrary.mediaLibraryModal.loading')}
|
||||
cardDraftText={t('mediaLibrary.mediaLibraryCard.draft')}
|
||||
cardWidth={cardWidth}
|
||||
cardHeight={cardHeight}
|
||||
cardMargin={cardMargin}
|
||||
isPrivate={privateUpload}
|
||||
loadDisplayURL={loadDisplayURL}
|
||||
displayURLs={displayURLs}
|
||||
/>
|
||||
</DialogContent>
|
||||
</StyledModal>
|
||||
);
|
||||
};
|
||||
|
||||
export default translate()(MediaLibraryModal);
|
43
core/src/components/MediaLibrary/MediaLibrarySearch.tsx
Normal file
43
core/src/components/MediaLibrary/MediaLibrarySearch.tsx
Normal file
@ -0,0 +1,43 @@
|
||||
import SearchIcon from '@mui/icons-material/Search';
|
||||
import InputAdornment from '@mui/material/InputAdornment';
|
||||
import TextField from '@mui/material/TextField';
|
||||
import React from 'react';
|
||||
|
||||
import type { ChangeEventHandler, KeyboardEventHandler } from 'react';
|
||||
|
||||
export interface MediaLibrarySearchProps {
|
||||
value?: string;
|
||||
onChange: ChangeEventHandler<HTMLInputElement>;
|
||||
onKeyDown: KeyboardEventHandler<HTMLInputElement>;
|
||||
placeholder: string;
|
||||
disabled?: boolean;
|
||||
}
|
||||
|
||||
const MediaLibrarySearch = ({
|
||||
value = '',
|
||||
onChange,
|
||||
onKeyDown,
|
||||
placeholder,
|
||||
disabled,
|
||||
}: MediaLibrarySearchProps) => {
|
||||
return (
|
||||
<TextField
|
||||
onKeyDown={onKeyDown}
|
||||
placeholder={placeholder}
|
||||
value={value}
|
||||
onChange={onChange}
|
||||
variant="outlined"
|
||||
size="small"
|
||||
disabled={disabled}
|
||||
InputProps={{
|
||||
startAdornment: (
|
||||
<InputAdornment position="start">
|
||||
<SearchIcon />
|
||||
</InputAdornment>
|
||||
),
|
||||
}}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
export default MediaLibrarySearch;
|
130
core/src/components/MediaLibrary/MediaLibraryTop.tsx
Normal file
130
core/src/components/MediaLibrary/MediaLibraryTop.tsx
Normal file
@ -0,0 +1,130 @@
|
||||
import { styled } from '@mui/material/styles';
|
||||
import Button from '@mui/material/Button';
|
||||
import DialogTitle from '@mui/material/DialogTitle';
|
||||
import React from 'react';
|
||||
|
||||
import { CopyToClipBoardButton, UploadButton } from './MediaLibraryButtons';
|
||||
import MediaLibrarySearch from './MediaLibrarySearch';
|
||||
|
||||
import type { ChangeEvent, ChangeEventHandler, KeyboardEventHandler } from 'react';
|
||||
import type { MediaFile, TranslatedProps } from '../../interface';
|
||||
|
||||
const LibraryTop = styled('div')`
|
||||
position: relative;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
`;
|
||||
|
||||
const StyledButtonsContainer = styled('div')`
|
||||
flex-shrink: 0;
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
`;
|
||||
|
||||
const StyledDialogTitle = styled(DialogTitle)`
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
`;
|
||||
|
||||
export interface MediaLibraryTopProps {
|
||||
onClose: () => void;
|
||||
privateUpload?: boolean;
|
||||
forImage?: boolean;
|
||||
onDownload: () => void;
|
||||
onUpload: (event: ChangeEvent<HTMLInputElement> | DragEvent) => void;
|
||||
query?: string;
|
||||
onSearchChange: ChangeEventHandler<HTMLInputElement>;
|
||||
onSearchKeyDown: KeyboardEventHandler<HTMLInputElement>;
|
||||
searchDisabled: boolean;
|
||||
onDelete: () => void;
|
||||
canInsert?: boolean;
|
||||
onInsert: () => void;
|
||||
hasSelection: boolean;
|
||||
isPersisting?: boolean;
|
||||
isDeleting?: boolean;
|
||||
selectedFile?: MediaFile;
|
||||
}
|
||||
|
||||
const MediaLibraryTop = ({
|
||||
t,
|
||||
forImage,
|
||||
onDownload,
|
||||
onUpload,
|
||||
query,
|
||||
onSearchChange,
|
||||
onSearchKeyDown,
|
||||
searchDisabled,
|
||||
onDelete,
|
||||
canInsert,
|
||||
onInsert,
|
||||
hasSelection,
|
||||
isPersisting,
|
||||
isDeleting,
|
||||
selectedFile,
|
||||
privateUpload,
|
||||
}: TranslatedProps<MediaLibraryTopProps>) => {
|
||||
const shouldShowButtonLoader = isPersisting || isDeleting;
|
||||
const uploadEnabled = !shouldShowButtonLoader;
|
||||
const deleteEnabled = !shouldShowButtonLoader && hasSelection;
|
||||
|
||||
const uploadButtonLabel = isPersisting
|
||||
? t('mediaLibrary.mediaLibraryModal.uploading')
|
||||
: t('mediaLibrary.mediaLibraryModal.upload');
|
||||
const deleteButtonLabel = isDeleting
|
||||
? t('mediaLibrary.mediaLibraryModal.deleting')
|
||||
: t('mediaLibrary.mediaLibraryModal.deleteSelected');
|
||||
const downloadButtonLabel = t('mediaLibrary.mediaLibraryModal.download');
|
||||
const insertButtonLabel = t('mediaLibrary.mediaLibraryModal.chooseSelected');
|
||||
|
||||
return (
|
||||
<LibraryTop>
|
||||
<StyledDialogTitle>
|
||||
{`${privateUpload ? t('mediaLibrary.mediaLibraryModal.private') : ''}${
|
||||
forImage
|
||||
? t('mediaLibrary.mediaLibraryModal.images')
|
||||
: t('mediaLibrary.mediaLibraryModal.mediaAssets')
|
||||
}`}
|
||||
<StyledButtonsContainer>
|
||||
<CopyToClipBoardButton
|
||||
disabled={!hasSelection}
|
||||
path={selectedFile?.path}
|
||||
name={selectedFile?.name}
|
||||
draft={selectedFile?.draft}
|
||||
t={t}
|
||||
/>
|
||||
<Button color="inherit" variant="contained" onClick={onDownload} disabled={!hasSelection}>
|
||||
{downloadButtonLabel}
|
||||
</Button>
|
||||
<UploadButton
|
||||
label={uploadButtonLabel}
|
||||
imagesOnly={forImage}
|
||||
onChange={onUpload}
|
||||
disabled={!uploadEnabled}
|
||||
/>
|
||||
</StyledButtonsContainer>
|
||||
</StyledDialogTitle>
|
||||
<StyledDialogTitle>
|
||||
<MediaLibrarySearch
|
||||
value={query}
|
||||
onChange={onSearchChange}
|
||||
onKeyDown={onSearchKeyDown}
|
||||
placeholder={t('mediaLibrary.mediaLibraryModal.search')}
|
||||
disabled={searchDisabled}
|
||||
/>
|
||||
<StyledButtonsContainer>
|
||||
<Button color="error" variant="outlined" onClick={onDelete} disabled={!deleteEnabled}>
|
||||
{deleteButtonLabel}
|
||||
</Button>
|
||||
{!canInsert ? null : (
|
||||
<Button color="success" variant="contained" onClick={onInsert} disabled={!hasSelection}>
|
||||
{insertButtonLabel}
|
||||
</Button>
|
||||
)}
|
||||
</StyledButtonsContainer>
|
||||
</StyledDialogTitle>
|
||||
</LibraryTop>
|
||||
);
|
||||
};
|
||||
|
||||
export default MediaLibraryTop;
|
105
core/src/components/UI/Alert.tsx
Normal file
105
core/src/components/UI/Alert.tsx
Normal file
@ -0,0 +1,105 @@
|
||||
import Button from '@mui/material/Button';
|
||||
import Dialog from '@mui/material/Dialog';
|
||||
import DialogActions from '@mui/material/DialogActions';
|
||||
import DialogContent from '@mui/material/DialogContent';
|
||||
import DialogContentText from '@mui/material/DialogContentText';
|
||||
import DialogTitle from '@mui/material/DialogTitle';
|
||||
import React, { useCallback, useMemo, useState } from 'react';
|
||||
import { translate } from 'react-polyglot';
|
||||
|
||||
import AlertEvent from '../../lib/util/events/AlertEvent';
|
||||
import { useWindowEvent } from '../../lib/util/window.util';
|
||||
|
||||
import type { TranslateProps } from 'react-polyglot';
|
||||
|
||||
interface AlertProps {
|
||||
title: string | { key: string; options?: Record<string, unknown> };
|
||||
body: string | { key: string; options?: Record<string, unknown> };
|
||||
okay?: string | { key: string; options?: Record<string, unknown> };
|
||||
color?: 'success' | 'error' | 'primary';
|
||||
}
|
||||
|
||||
export interface AlertDialogProps extends AlertProps {
|
||||
resolve: () => void;
|
||||
}
|
||||
|
||||
const AlertDialog = ({ t }: TranslateProps) => {
|
||||
const [detail, setDetail] = useState<AlertDialogProps | null>(null);
|
||||
const {
|
||||
resolve,
|
||||
title: rawTitle,
|
||||
body: rawBody,
|
||||
okay: rawOkay = 'ui.common.okay',
|
||||
color = 'primary',
|
||||
} = detail ?? {};
|
||||
|
||||
const onAlertMessage = useCallback((event: AlertEvent) => {
|
||||
setDetail(event.detail);
|
||||
}, []);
|
||||
|
||||
useWindowEvent('alert', onAlertMessage);
|
||||
|
||||
const handleClose = useCallback(() => {
|
||||
setDetail(null);
|
||||
resolve?.();
|
||||
}, [resolve]);
|
||||
|
||||
const title = useMemo(() => {
|
||||
if (!rawTitle) {
|
||||
return '';
|
||||
}
|
||||
return typeof rawTitle === 'string' ? t(rawTitle) : t(rawTitle.key, rawTitle.options);
|
||||
}, [rawTitle, t]);
|
||||
|
||||
const body = useMemo(() => {
|
||||
if (!rawBody) {
|
||||
return '';
|
||||
}
|
||||
return typeof rawBody === 'string' ? t(rawBody) : t(rawBody.key, rawBody.options);
|
||||
}, [rawBody, t]);
|
||||
|
||||
const okay = useMemo(
|
||||
() => (typeof rawOkay === 'string' ? t(rawOkay) : t(rawOkay.key, rawOkay.options)),
|
||||
[rawOkay, t],
|
||||
);
|
||||
|
||||
if (!detail) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<div>
|
||||
<Dialog
|
||||
open
|
||||
onClose={handleClose}
|
||||
aria-labelledby="alert-dialog-title"
|
||||
aria-describedby="alert-dialog-description"
|
||||
>
|
||||
<DialogTitle id="alert-dialog-title">{title}</DialogTitle>
|
||||
<DialogContent>
|
||||
<DialogContentText id="alert-dialog-description">{body}</DialogContentText>
|
||||
</DialogContent>
|
||||
<DialogActions>
|
||||
<Button onClick={handleClose} variant="contained" color={color}>
|
||||
{okay}
|
||||
</Button>
|
||||
</DialogActions>
|
||||
</Dialog>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export const Alert = translate()(AlertDialog);
|
||||
|
||||
const alert = (props: AlertProps) => {
|
||||
return new Promise<void>(resolve => {
|
||||
window.dispatchEvent(
|
||||
new AlertEvent({
|
||||
...props,
|
||||
resolve,
|
||||
}),
|
||||
);
|
||||
});
|
||||
};
|
||||
|
||||
export default alert;
|
86
core/src/components/UI/AuthenticationPage.tsx
Normal file
86
core/src/components/UI/AuthenticationPage.tsx
Normal file
@ -0,0 +1,86 @@
|
||||
import Button from '@mui/material/Button';
|
||||
import { styled } from '@mui/material/styles';
|
||||
import React from 'react';
|
||||
|
||||
import GoBackButton from './GoBackButton';
|
||||
import Icon from './Icon';
|
||||
|
||||
import type { MouseEventHandler, ReactNode } from 'react';
|
||||
import type { TranslatedProps } from '../../interface';
|
||||
|
||||
const StyledAuthenticationPage = styled('section')`
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
height: 100vh;
|
||||
`;
|
||||
|
||||
const CustomIconWrapper = styled('span')`
|
||||
width: 300px;
|
||||
height: 15∏0px;
|
||||
margin-top: -150px;
|
||||
`;
|
||||
|
||||
const SimpleLogoIcon = styled(Icon)`
|
||||
color: #c4c6d2;
|
||||
`;
|
||||
|
||||
const StaticCustomIcon = styled(Icon)`
|
||||
color: #c4c6d2;
|
||||
`;
|
||||
|
||||
const CustomLogoIcon = ({ url }: { url: string }) => {
|
||||
return (
|
||||
<CustomIconWrapper>
|
||||
<img src={url} alt="Logo" />
|
||||
</CustomIconWrapper>
|
||||
);
|
||||
};
|
||||
|
||||
const renderPageLogo = (logoUrl?: string) => {
|
||||
if (logoUrl) {
|
||||
return <CustomLogoIcon url={logoUrl} />;
|
||||
}
|
||||
return <SimpleLogoIcon width={300} height={150} type="static-cms" />;
|
||||
};
|
||||
|
||||
export interface AuthenticationPageProps {
|
||||
onLogin?: MouseEventHandler<HTMLButtonElement>;
|
||||
logoUrl?: string;
|
||||
siteUrl?: string;
|
||||
loginDisabled?: boolean;
|
||||
loginErrorMessage?: ReactNode;
|
||||
icon?: ReactNode;
|
||||
buttonContent?: ReactNode;
|
||||
pageContent?: ReactNode;
|
||||
}
|
||||
|
||||
const AuthenticationPage = ({
|
||||
onLogin,
|
||||
loginDisabled,
|
||||
loginErrorMessage,
|
||||
icon,
|
||||
buttonContent,
|
||||
pageContent,
|
||||
logoUrl,
|
||||
siteUrl,
|
||||
t,
|
||||
}: TranslatedProps<AuthenticationPageProps>) => {
|
||||
return (
|
||||
<StyledAuthenticationPage>
|
||||
{renderPageLogo(logoUrl)}
|
||||
{loginErrorMessage ? <p>{loginErrorMessage}</p> : null}
|
||||
{pageContent ?? null}
|
||||
{buttonContent ? (
|
||||
<Button variant="contained" disabled={loginDisabled} onClick={onLogin} startIcon={icon}>
|
||||
{buttonContent}
|
||||
</Button>
|
||||
) : null}
|
||||
{siteUrl ? <GoBackButton href={siteUrl} t={t} /> : null}
|
||||
{logoUrl ? <StaticCustomIcon width={100} height={100} type="static-cms" /> : null}
|
||||
</StyledAuthenticationPage>
|
||||
);
|
||||
};
|
||||
|
||||
export default AuthenticationPage;
|
124
core/src/components/UI/Confirm.tsx
Normal file
124
core/src/components/UI/Confirm.tsx
Normal file
@ -0,0 +1,124 @@
|
||||
import Button from '@mui/material/Button';
|
||||
import Dialog from '@mui/material/Dialog';
|
||||
import DialogActions from '@mui/material/DialogActions';
|
||||
import DialogContent from '@mui/material/DialogContent';
|
||||
import DialogContentText from '@mui/material/DialogContentText';
|
||||
import DialogTitle from '@mui/material/DialogTitle';
|
||||
import React, { useCallback, useMemo, useState } from 'react';
|
||||
import { translate } from 'react-polyglot';
|
||||
|
||||
import ConfirmEvent from '../../lib/util/events/ConfirmEvent';
|
||||
import { useWindowEvent } from '../../lib/util/window.util';
|
||||
|
||||
import type { TranslateProps } from 'react-polyglot';
|
||||
|
||||
interface ConfirmProps {
|
||||
title: string | { key: string; options?: Record<string, unknown> };
|
||||
body: string | { key: string; options?: Record<string, unknown> };
|
||||
cancel?: string | { key: string; options?: Record<string, unknown> };
|
||||
confirm?: string | { key: string; options?: Record<string, unknown> };
|
||||
color?: 'success' | 'error' | 'primary';
|
||||
}
|
||||
|
||||
export interface ConfirmDialogProps extends ConfirmProps {
|
||||
resolve: (value: boolean | PromiseLike<boolean>) => void;
|
||||
}
|
||||
|
||||
const ConfirmDialog = ({ t }: TranslateProps) => {
|
||||
const [detail, setDetail] = useState<ConfirmDialogProps | null>(null);
|
||||
const {
|
||||
resolve,
|
||||
title: rawTitle,
|
||||
body: rawBody,
|
||||
cancel: rawCancel = 'ui.common.no',
|
||||
confirm: rawConfirm = 'ui.common.yes',
|
||||
color = 'primary',
|
||||
} = detail ?? {};
|
||||
|
||||
const onConfirmMessage = useCallback((event: ConfirmEvent) => {
|
||||
setDetail(event.detail);
|
||||
}, []);
|
||||
|
||||
useWindowEvent('confirm', onConfirmMessage);
|
||||
|
||||
const handleClose = useCallback(() => {
|
||||
setDetail(null);
|
||||
}, []);
|
||||
|
||||
const handleCancel = useCallback(() => {
|
||||
resolve?.(false);
|
||||
handleClose();
|
||||
}, [handleClose, resolve]);
|
||||
|
||||
const handleConfirm = useCallback(() => {
|
||||
resolve?.(true);
|
||||
handleClose();
|
||||
}, [handleClose, resolve]);
|
||||
|
||||
const title = useMemo(() => {
|
||||
if (!rawTitle) {
|
||||
return '';
|
||||
}
|
||||
return typeof rawTitle === 'string' ? t(rawTitle) : t(rawTitle.key, rawTitle.options);
|
||||
}, [rawTitle, t]);
|
||||
|
||||
const body = useMemo(() => {
|
||||
if (!rawBody) {
|
||||
return '';
|
||||
}
|
||||
return typeof rawBody === 'string' ? t(rawBody) : t(rawBody.key, rawBody.options);
|
||||
}, [rawBody, t]);
|
||||
|
||||
const cancel = useMemo(
|
||||
() => (typeof rawCancel === 'string' ? t(rawCancel) : t(rawCancel.key, rawCancel.options)),
|
||||
[rawCancel, t],
|
||||
);
|
||||
|
||||
const confirm = useMemo(
|
||||
() => (typeof rawConfirm === 'string' ? t(rawConfirm) : t(rawConfirm.key, rawConfirm.options)),
|
||||
[rawConfirm, t],
|
||||
);
|
||||
|
||||
if (!detail) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<div>
|
||||
<Dialog
|
||||
open
|
||||
onClose={handleCancel}
|
||||
aria-labelledby="confirm-dialog-title"
|
||||
aria-describedby="confirm-dialog-description"
|
||||
>
|
||||
<DialogTitle id="confirm-dialog-title">{title}</DialogTitle>
|
||||
<DialogContent>
|
||||
<DialogContentText id="confirm-dialog-description">{body}</DialogContentText>
|
||||
</DialogContent>
|
||||
<DialogActions>
|
||||
<Button onClick={handleCancel} color="inherit">
|
||||
{cancel}
|
||||
</Button>
|
||||
<Button onClick={handleConfirm} variant="contained" color={color}>
|
||||
{confirm}
|
||||
</Button>
|
||||
</DialogActions>
|
||||
</Dialog>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export const Confirm = translate()(ConfirmDialog);
|
||||
|
||||
const confirm = (props: ConfirmProps) => {
|
||||
return new Promise<boolean>(resolve => {
|
||||
window.dispatchEvent(
|
||||
new ConfirmEvent({
|
||||
...props,
|
||||
resolve,
|
||||
}),
|
||||
);
|
||||
});
|
||||
};
|
||||
|
||||
export default confirm;
|
227
core/src/components/UI/ErrorBoundary.tsx
Normal file
227
core/src/components/UI/ErrorBoundary.tsx
Normal file
@ -0,0 +1,227 @@
|
||||
import { styled } from '@mui/material/styles';
|
||||
import cleanStack from 'clean-stack';
|
||||
import copyToClipboard from 'copy-text-to-clipboard';
|
||||
import truncate from 'lodash/truncate';
|
||||
import React from 'react';
|
||||
import { translate } from 'react-polyglot';
|
||||
import yaml from 'yaml';
|
||||
|
||||
import { localForage } from '../../lib/util';
|
||||
import { buttons, colors } from '../../components/UI/styles';
|
||||
|
||||
import type { ReactNode } from 'react';
|
||||
import type { Config, TranslatedProps } from '../../interface';
|
||||
|
||||
const ISSUE_URL = 'https://github.com/StaticJsCMS/static-cms/issues/new?';
|
||||
|
||||
function getIssueTemplate(version: string, provider: string, browser: string, config: string) {
|
||||
return `
|
||||
**Describe the bug**
|
||||
|
||||
**To Reproduce**
|
||||
|
||||
**Expected behavior**
|
||||
|
||||
**Screenshots**
|
||||
|
||||
**Applicable Versions:**
|
||||
- Static CMS version: \`${version}\`
|
||||
- Git provider: \`${provider}\`
|
||||
- Browser version: \`${browser}\`
|
||||
|
||||
**CMS configuration**
|
||||
\`\`\`
|
||||
${config}
|
||||
\`\`\`
|
||||
|
||||
**Additional context**
|
||||
`;
|
||||
}
|
||||
|
||||
function buildIssueTemplate(config: Config) {
|
||||
let version = '';
|
||||
if (typeof STATIC_CMS_CORE_VERSION === 'string') {
|
||||
version = `static-cms@${STATIC_CMS_CORE_VERSION}`;
|
||||
}
|
||||
const template = getIssueTemplate(
|
||||
version,
|
||||
config.backend.name,
|
||||
navigator.userAgent,
|
||||
yaml.stringify(config),
|
||||
);
|
||||
|
||||
return template;
|
||||
}
|
||||
|
||||
function buildIssueUrl(title: string, config: Config) {
|
||||
try {
|
||||
const body = buildIssueTemplate(config);
|
||||
|
||||
const params = new URLSearchParams();
|
||||
params.append('title', truncate(title, { length: 100 }));
|
||||
params.append('body', truncate(body, { length: 4000, omission: '\n...' }));
|
||||
params.append('labels', 'type: bug');
|
||||
|
||||
return `${ISSUE_URL}${params.toString()}`;
|
||||
} catch (e) {
|
||||
console.info(e);
|
||||
return `${ISSUE_URL}template=bug_report.md`;
|
||||
}
|
||||
}
|
||||
|
||||
const ErrorBoundaryContainer = styled('div')`
|
||||
padding: 40px;
|
||||
|
||||
h1 {
|
||||
font-size: 28px;
|
||||
color: ${colors.text};
|
||||
}
|
||||
|
||||
h2 {
|
||||
font-size: 20px;
|
||||
}
|
||||
|
||||
strong {
|
||||
color: ${colors.textLead};
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
hr {
|
||||
width: 200px;
|
||||
margin: 30px 0;
|
||||
border: 0;
|
||||
height: 1px;
|
||||
background-color: ${colors.text};
|
||||
}
|
||||
|
||||
a {
|
||||
color: ${colors.active};
|
||||
}
|
||||
`;
|
||||
|
||||
const PrivacyWarning = styled('span')`
|
||||
color: ${colors.text};
|
||||
`;
|
||||
|
||||
const CopyButton = styled('button')`
|
||||
${buttons.button};
|
||||
${buttons.default};
|
||||
${buttons.gray};
|
||||
display: block;
|
||||
margin: 12px 0;
|
||||
`;
|
||||
|
||||
interface RecoveredEntryProps {
|
||||
entry: string;
|
||||
}
|
||||
|
||||
const RecoveredEntry = ({ entry, t }: TranslatedProps<RecoveredEntryProps>) => {
|
||||
console.info(entry);
|
||||
return (
|
||||
<>
|
||||
<hr />
|
||||
<h2>{t('ui.errorBoundary.recoveredEntry.heading')}</h2>
|
||||
<strong>{t('ui.errorBoundary.recoveredEntry.warning')}</strong>
|
||||
<CopyButton onClick={() => copyToClipboard(entry)}>
|
||||
{t('ui.errorBoundary.recoveredEntry.copyButtonLabel')}
|
||||
</CopyButton>
|
||||
<pre>
|
||||
<code>{entry}</code>
|
||||
</pre>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
interface ErrorBoundaryProps {
|
||||
children: ReactNode;
|
||||
config: Config;
|
||||
showBackup?: boolean;
|
||||
}
|
||||
|
||||
interface ErrorBoundaryState {
|
||||
hasError: boolean;
|
||||
errorMessage: string;
|
||||
errorTitle: string;
|
||||
backup: string;
|
||||
}
|
||||
|
||||
export class ErrorBoundary extends React.Component<
|
||||
TranslatedProps<ErrorBoundaryProps>,
|
||||
ErrorBoundaryState
|
||||
> {
|
||||
state: ErrorBoundaryState = {
|
||||
hasError: false,
|
||||
errorMessage: '',
|
||||
errorTitle: '',
|
||||
backup: '',
|
||||
};
|
||||
|
||||
static getDerivedStateFromError(error: Error) {
|
||||
console.error(error);
|
||||
return {
|
||||
hasError: true,
|
||||
errorMessage: cleanStack(error.stack, { basePath: window.location.origin || '' }),
|
||||
errorTitle: error.toString(),
|
||||
};
|
||||
}
|
||||
|
||||
shouldComponentUpdate(
|
||||
_nextProps: TranslatedProps<ErrorBoundaryProps>,
|
||||
nextState: ErrorBoundaryState,
|
||||
) {
|
||||
if (this.props.showBackup) {
|
||||
return (
|
||||
this.state.errorMessage !== nextState.errorMessage || this.state.backup !== nextState.backup
|
||||
);
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
async componentDidUpdate() {
|
||||
if (this.props.showBackup) {
|
||||
const backup = await localForage.getItem<string>('backup');
|
||||
if (backup) {
|
||||
console.info(backup);
|
||||
this.setState({ backup });
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
render() {
|
||||
const { hasError, errorMessage, backup, errorTitle } = this.state;
|
||||
const { showBackup, t } = this.props;
|
||||
if (!hasError) {
|
||||
return this.props.children;
|
||||
}
|
||||
return (
|
||||
<ErrorBoundaryContainer key="error-boundary-container">
|
||||
<h1>{t('ui.errorBoundary.title')}</h1>
|
||||
<p>
|
||||
<span>{t('ui.errorBoundary.details')}</span>
|
||||
<a
|
||||
href={buildIssueUrl(errorTitle, this.props.config)}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
data-testid="issue-url"
|
||||
>
|
||||
{t('ui.errorBoundary.reportIt')}
|
||||
</a>
|
||||
</p>
|
||||
<p>
|
||||
{t('ui.errorBoundary.privacyWarning')
|
||||
.split('\n')
|
||||
.map((item, index) => [
|
||||
<PrivacyWarning key={`private-warning-${index}`}>{item}</PrivacyWarning>,
|
||||
<br key={`break-${index}`} />,
|
||||
])}
|
||||
</p>
|
||||
<hr />
|
||||
<h2>{t('ui.errorBoundary.detailsHeading')}</h2>
|
||||
<p>{errorMessage}</p>
|
||||
{backup && showBackup && <RecoveredEntry key="backup" entry={backup} t={t} />}
|
||||
</ErrorBoundaryContainer>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
export default translate()(ErrorBoundary);
|
55
core/src/components/UI/FieldLabel.tsx
Normal file
55
core/src/components/UI/FieldLabel.tsx
Normal file
@ -0,0 +1,55 @@
|
||||
import Typography from '@mui/material/Typography';
|
||||
import React from 'react';
|
||||
|
||||
import { colors } from './styles';
|
||||
|
||||
import type { MouseEventHandler } from 'react';
|
||||
|
||||
const stateColors = {
|
||||
default: {
|
||||
text: colors.controlLabel,
|
||||
},
|
||||
error: {
|
||||
text: colors.errorText,
|
||||
},
|
||||
};
|
||||
|
||||
export interface StyledLabelProps {
|
||||
hasErrors: boolean;
|
||||
}
|
||||
|
||||
function getStateColors({ hasErrors }: StyledLabelProps) {
|
||||
if (hasErrors) {
|
||||
return stateColors.error;
|
||||
}
|
||||
|
||||
return stateColors.default;
|
||||
}
|
||||
|
||||
interface FieldLabelProps {
|
||||
children: string | string[];
|
||||
htmlFor?: string;
|
||||
hasErrors?: boolean;
|
||||
isActive?: boolean;
|
||||
onClick?: MouseEventHandler<HTMLLabelElement>;
|
||||
}
|
||||
|
||||
const FieldLabel = ({ children, htmlFor, onClick, hasErrors = false }: FieldLabelProps) => {
|
||||
return (
|
||||
<Typography
|
||||
key="field-label"
|
||||
variant="body2"
|
||||
component="label"
|
||||
htmlFor={htmlFor}
|
||||
onClick={onClick}
|
||||
sx={{
|
||||
color: getStateColors({ hasErrors }).text,
|
||||
marginLeft: '4px',
|
||||
}}
|
||||
>
|
||||
{children}
|
||||
</Typography>
|
||||
);
|
||||
};
|
||||
|
||||
export default FieldLabel;
|
27
core/src/components/UI/FileUploadButton.tsx
Normal file
27
core/src/components/UI/FileUploadButton.tsx
Normal file
@ -0,0 +1,27 @@
|
||||
import Button from '@mui/material/Button';
|
||||
import React from 'react';
|
||||
|
||||
export interface FileUploadButtonProps {
|
||||
label: string;
|
||||
imagesOnly?: boolean;
|
||||
onChange: React.ChangeEventHandler<HTMLInputElement>;
|
||||
disabled?: boolean;
|
||||
}
|
||||
|
||||
const FileUploadButton = ({ label, imagesOnly, onChange, disabled }: FileUploadButtonProps) => {
|
||||
return (
|
||||
<Button variant="contained" component="label">
|
||||
{label}
|
||||
<input
|
||||
hidden
|
||||
multiple
|
||||
type="file"
|
||||
accept={imagesOnly ? 'image/*' : '*/*'}
|
||||
onChange={onChange}
|
||||
disabled={disabled}
|
||||
/>
|
||||
</Button>
|
||||
);
|
||||
};
|
||||
|
||||
export default FileUploadButton;
|
19
core/src/components/UI/GoBackButton.tsx
Normal file
19
core/src/components/UI/GoBackButton.tsx
Normal file
@ -0,0 +1,19 @@
|
||||
import ArrowBackIcon from '@mui/icons-material/ArrowBack';
|
||||
import Button from '@mui/material/Button';
|
||||
import React from 'react';
|
||||
|
||||
import type { TranslatedProps } from '../../interface';
|
||||
|
||||
interface GoBackButtonProps {
|
||||
href: string;
|
||||
}
|
||||
|
||||
const GoBackButton = ({ href, t }: TranslatedProps<GoBackButtonProps>) => {
|
||||
return (
|
||||
<Button href={href} startIcon={<ArrowBackIcon />}>
|
||||
{t('ui.default.goBackToSite')}
|
||||
</Button>
|
||||
);
|
||||
};
|
||||
|
||||
export default GoBackButton;
|
97
core/src/components/UI/Icon.tsx
Normal file
97
core/src/components/UI/Icon.tsx
Normal file
@ -0,0 +1,97 @@
|
||||
import { styled } from '@mui/material/styles';
|
||||
import React from 'react';
|
||||
|
||||
import icons from './Icon/icons';
|
||||
import transientOptions from '../../lib/util/transientOptions';
|
||||
|
||||
import type { IconType } from './Icon/icons';
|
||||
|
||||
interface IconWrapperProps {
|
||||
$width: number;
|
||||
$height: number;
|
||||
$rotation: string;
|
||||
}
|
||||
|
||||
const IconWrapper = styled(
|
||||
'span',
|
||||
transientOptions,
|
||||
)<IconWrapperProps>(
|
||||
({ $width, $height, $rotation }) => `
|
||||
display: inline-block;
|
||||
line-height: 0;
|
||||
width: ${$width}px;
|
||||
height: ${$height}px;
|
||||
transform: rotate(${$rotation});
|
||||
|
||||
& path:not(.no-fill),
|
||||
& circle:not(.no-fill),
|
||||
& polygon:not(.no-fill),
|
||||
& rect:not(.no-fill) {
|
||||
fill: currentColor;
|
||||
}
|
||||
|
||||
& path.clipped {
|
||||
fill: transparent;
|
||||
}
|
||||
|
||||
svg {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
}
|
||||
`,
|
||||
);
|
||||
|
||||
const rotations = { right: 90, down: 180, left: 270, up: 360 };
|
||||
|
||||
export type Direction = keyof typeof rotations;
|
||||
|
||||
/**
|
||||
* Calculates rotation for icons that have a `direction` property configured
|
||||
* in the imported icon definition object. If no direction is configured, a
|
||||
* neutral rotation value is returned.
|
||||
*
|
||||
* Returned value is a string of shape `${degrees}deg`, for use in a CSS
|
||||
* transform.
|
||||
*/
|
||||
function getRotation(iconDirection?: Direction, newDirection?: Direction) {
|
||||
if (!iconDirection || !newDirection) {
|
||||
return '0deg';
|
||||
}
|
||||
const degrees = rotations[newDirection] - rotations[iconDirection];
|
||||
return `${degrees}deg`;
|
||||
}
|
||||
|
||||
const sizes = {
|
||||
xsmall: 12,
|
||||
small: 18,
|
||||
medium: 24,
|
||||
large: 32,
|
||||
};
|
||||
|
||||
export interface IconProps {
|
||||
type: IconType;
|
||||
direction?: Direction;
|
||||
width?: number;
|
||||
height?: number;
|
||||
size?: keyof typeof sizes;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
const Icon = ({ type, direction, width, height, size = 'medium', className }: IconProps) => {
|
||||
const IconSvg = icons[type].image;
|
||||
|
||||
return (
|
||||
<IconWrapper
|
||||
className={className}
|
||||
$width={width ? width : size in sizes ? sizes[size as keyof typeof sizes] : sizes['medium']}
|
||||
$height={
|
||||
height ? height : size in sizes ? sizes[size as keyof typeof sizes] : sizes['medium']
|
||||
}
|
||||
$rotation={getRotation(icons[type].direction, direction)}
|
||||
>
|
||||
<IconSvg />
|
||||
</IconWrapper>
|
||||
);
|
||||
};
|
||||
|
||||
export default styled(Icon)``;
|
42
core/src/components/UI/Icon/icons.tsx
Normal file
42
core/src/components/UI/Icon/icons.tsx
Normal file
@ -0,0 +1,42 @@
|
||||
import images from './images/_index';
|
||||
|
||||
import type { Direction } from '../Icon';
|
||||
|
||||
export type IconType = keyof typeof images;
|
||||
|
||||
/**
|
||||
* This module outputs icon objects with the following shape:
|
||||
*
|
||||
* {
|
||||
* image: <svg>...</svg>,
|
||||
* ...props
|
||||
* }
|
||||
*
|
||||
* `props` here are config properties defined in this file for specific icons.
|
||||
* For example, an icon may face a specific direction, and the Icon component
|
||||
* accepts a `direction` prop to rotate directional icons, which relies on
|
||||
* defining the default direction here.
|
||||
*/
|
||||
|
||||
interface IconTypeConfig {
|
||||
direction: Direction;
|
||||
}
|
||||
|
||||
export interface IconTypeProps extends Partial<IconTypeConfig> {
|
||||
image: () => JSX.Element;
|
||||
}
|
||||
|
||||
/**
|
||||
* Record icon definition objects - imported object of images simply maps the icon
|
||||
* name to the raw svg, so we move that to the `image` property of the
|
||||
* definition object and set any additional configured properties for each icon.
|
||||
*/
|
||||
const icons = (Object.keys(images) as IconType[]).reduce((acc, name) => {
|
||||
const image = images[name];
|
||||
acc[name] = {
|
||||
image,
|
||||
};
|
||||
return acc;
|
||||
}, {} as Record<IconType, IconTypeProps>);
|
||||
|
||||
export default icons;
|
15
core/src/components/UI/Icon/images/_index.tsx
Normal file
15
core/src/components/UI/Icon/images/_index.tsx
Normal file
@ -0,0 +1,15 @@
|
||||
import azure from './azure.svg';
|
||||
import bitbucket from './bitbucket.svg';
|
||||
import github from './github.svg';
|
||||
import gitlab from './gitlab.svg';
|
||||
import staticCms from './static-cms-logo.svg';
|
||||
|
||||
const images = {
|
||||
azure,
|
||||
bitbucket,
|
||||
github,
|
||||
gitlab,
|
||||
'static-cms': staticCms,
|
||||
};
|
||||
|
||||
export default images;
|
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user