Feature/website overhaul (#49)

* Reorganize repo
* Overhaul website design and rewrite in NextJS and Typescript
* Delete website-publish.yml
This commit is contained in:
Daniel Lautzenheiser
2022-10-25 09:18:18 -04:00
committed by GitHub
parent 3674ee5bd8
commit 421ecf17e6
629 changed files with 6917 additions and 17824 deletions

125
core/src/actions/auth.ts Normal file
View 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
>;

View 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
View 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

File diff suppressed because it is too large Load Diff

128
core/src/actions/media.ts Normal file
View 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
>;

View 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
>;

View 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
View 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
>;

View 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
>;

View 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

File diff suppressed because it is too large Load Diff

View 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;
}
}
}

View 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;

View 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');
}
}

View File

@ -0,0 +1,3 @@
export { default as AzureBackend } from './implementation';
export { default as API } from './API';
export { default as AuthenticationPage } from './AuthenticationPage';

View 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`)),
);
};
}

View 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;

View 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;
});
}
}

View 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,
};
}
}

View File

@ -0,0 +1,3 @@
export { default as BitbucketBackend } from './implementation';
export { default as API } from './API';
export { default as AuthenticationPage } from './AuthenticationPage';

View 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;

View 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}/`);
}
}

View 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);
}

View 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);
}
}

View File

@ -0,0 +1,2 @@
export { default as GitGatewayBackend } from './implementation';
export { default as AuthenticationPage } from './AuthenticationPage';

View 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);
}

View 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;
}
}

View 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;

View 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);
}
}

View 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' }],
},
],
},
};

View 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
}
}
}
`;

View 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,
};
}
}

View File

@ -0,0 +1,3 @@
export { default as GitHubBackend } from './implementation';
export { default as API } from './API';
export { default as AuthenticationPage } from './AuthenticationPage';

View 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}
`;

View 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}
`;

View 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!');
}
},
);
});

View 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);
}
}

View 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;

View 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,
};
});
}
}

View File

@ -0,0 +1,3 @@
export { default as GitLabBackend } from './implementation';
export { default as API } from './API';
export { default as AuthenticationPage } from './AuthenticationPage';

View 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;
}

View 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';

View 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;

View 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');
}
}

View File

@ -0,0 +1,2 @@
export { default as ProxyBackend } from './implementation';
export { default as AuthenticationPage } from './AuthenticationPage';

View 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;

View 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');
}
}

View File

@ -0,0 +1,2 @@
export { default as TestBackend } from './implementation';
export { default as AuthenticationPage } from './AuthenticationPage';

114
core/src/bootstrap.tsx Normal file
View 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;

View 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>);

View 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>);

View 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;

View 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<{}>;

View 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>;

View 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;

View 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;

View 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);

View 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);

View 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);

View 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>);

View 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);

View 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);

View 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;

View 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);

View 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);

View 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);

View 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);

View 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);

View 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;

View 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>);

View 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>);

View File

@ -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);

View 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;

View File

@ -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;

View File

@ -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;

View File

@ -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);

View 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;

View 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;

View 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);

View 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);

View 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);

View File

@ -0,0 +1,5 @@
import { registerWidget } from '../../lib/registry';
import UnknownControl from './Unknown/UnknownControl';
import UnknownPreview from './Unknown/UnknownPreview';
registerWidget('unknown', UnknownControl, UnknownPreview);

View 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;

View 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));

View 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>
);
};

View 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;

View 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;

View 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);

View 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;

View 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;

View 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;

View 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;

View 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;

View 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);

View 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;

View 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;

View 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;

View 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)``;

View 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;

View 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