Feature/toast UI editor (#45)
This commit is contained in:
parent
e60e1fa755
commit
3674ee5bd8
@ -25,7 +25,7 @@ module.exports = {
|
||||
'react/prop-types': [0],
|
||||
'react/require-default-props': 0,
|
||||
'import/no-named-as-default': 0,
|
||||
"react/react-in-jsx-scope": "off",
|
||||
'react/react-in-jsx-scope': 'off',
|
||||
'import/order': [
|
||||
'error',
|
||||
{
|
||||
@ -89,7 +89,7 @@ module.exports = {
|
||||
},
|
||||
},
|
||||
rules: {
|
||||
"react/react-in-jsx-scope": "off",
|
||||
'react/react-in-jsx-scope': 'off',
|
||||
'react/prop-types': [0],
|
||||
'react/require-default-props': 0,
|
||||
'no-duplicate-imports': [0], // handled by @typescript-eslint
|
||||
|
@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@staticcms/core",
|
||||
"version": "0.3.7",
|
||||
"version": "1.0.0-alpha1",
|
||||
"license": "MIT",
|
||||
"description": "Static CMS core application.",
|
||||
"repository": "https://github.com/StaticJsCMS/static-cms",
|
||||
@ -93,7 +93,6 @@
|
||||
"lodash": "4.17.21",
|
||||
"mdast-util-definitions": "1.2.5",
|
||||
"mdast-util-to-string": "1.1.0",
|
||||
"mime-types": "^2.1.35",
|
||||
"minimatch": "3.0.4",
|
||||
"moment": "2.29.4",
|
||||
"node-polyglot": "2.4.2",
|
||||
@ -170,7 +169,6 @@
|
||||
"@types/js-yaml": "4.0.5",
|
||||
"@types/jwt-decode": "2.2.1",
|
||||
"@types/lodash": "4.14.185",
|
||||
"@types/mime-types": "^2.1.1",
|
||||
"@types/minimatch": "5.1.2",
|
||||
"@types/node-fetch": "2.6.2",
|
||||
"@types/react": "18.0.21",
|
||||
|
@ -10,7 +10,7 @@ import type { MouseEvent } from 'react';
|
||||
import type {
|
||||
AuthenticationPageProps,
|
||||
AuthenticatorConfig,
|
||||
TranslatedProps
|
||||
TranslatedProps,
|
||||
} from '../../interface';
|
||||
|
||||
const LoginButtonIcon = styled(Icon)`
|
||||
|
@ -195,12 +195,14 @@ const Editor = ({
|
||||
useEffect(() => {
|
||||
if (hasChanged && entryDraft.entry) {
|
||||
createBackup(entryDraft.entry, collection);
|
||||
} else if (localBackup) {
|
||||
deleteBackup();
|
||||
}
|
||||
|
||||
return () => {
|
||||
createBackup.flush();
|
||||
};
|
||||
}, [collection, createBackup, entryDraft.entry, hasChanged]);
|
||||
}, [collection, createBackup, deleteBackup, entryDraft.entry, hasChanged, localBackup]);
|
||||
|
||||
const [prevCollection, setPrevCollection] = useState<Collection | null>(null);
|
||||
const [preSlug, setPrevSlug] = useState<string | undefined | null>(null);
|
||||
|
@ -5,13 +5,12 @@ import { translate } from 'react-polyglot';
|
||||
import { connect } from 'react-redux';
|
||||
|
||||
import {
|
||||
addDraftEntryMediaFile as addDraftEntryMediaFileAction,
|
||||
changeDraftField as changeDraftFieldAction,
|
||||
changeDraftFieldValidation as changeDraftFieldValidationAction,
|
||||
clearFieldErrors as clearFieldErrorsAction,
|
||||
tryLoadEntry,
|
||||
} from '../../../actions/entries';
|
||||
import { addAsset as addAssetAction, getAsset as getAssetAction } from '../../../actions/media';
|
||||
import { getAsset as getAssetAction } from '../../../actions/media';
|
||||
import {
|
||||
clearMediaControl as clearMediaControlAction,
|
||||
openMediaLibrary as openMediaLibraryAction,
|
||||
@ -157,8 +156,6 @@ const EditorControl = ({
|
||||
locale,
|
||||
mediaPaths,
|
||||
changeDraftFieldValidation,
|
||||
addAsset,
|
||||
addDraftEntryMediaFile,
|
||||
openMediaLibrary,
|
||||
parentPath,
|
||||
query,
|
||||
@ -237,8 +234,6 @@ const EditorControl = ({
|
||||
mediaPaths,
|
||||
onChange: handleChangeDraftField,
|
||||
clearMediaControl,
|
||||
addAsset,
|
||||
addDraftEntryMediaFile,
|
||||
openMediaLibrary,
|
||||
removeInsertedMedia,
|
||||
removeMediaControl,
|
||||
@ -318,8 +313,6 @@ function mapStateToProps(state: RootState, ownProps: EditorControlOwnProps) {
|
||||
const mapDispatchToProps = {
|
||||
changeDraftField: changeDraftFieldAction,
|
||||
changeDraftFieldValidation: changeDraftFieldValidationAction,
|
||||
addAsset: addAssetAction,
|
||||
addDraftEntryMediaFile: addDraftEntryMediaFileAction,
|
||||
openMediaLibrary: openMediaLibraryAction,
|
||||
clearMediaControl: clearMediaControlAction,
|
||||
removeMediaControl: removeMediaControlAction,
|
||||
|
@ -117,7 +117,9 @@ const ListItemTopBar = ({
|
||||
/>
|
||||
</IconButton>
|
||||
) : null}
|
||||
<StyledTitle key="title" onClick={onCollapseToggle}>{title}</StyledTitle>
|
||||
<StyledTitle key="title" onClick={onCollapseToggle}>
|
||||
{title}
|
||||
</StyledTitle>
|
||||
{dragHandleHOC ? <DragHandle dragHandleHOC={dragHandleHOC} /> : null}
|
||||
{onRemove ? (
|
||||
<TopBarButton onClick={onRemove}>
|
||||
|
2
src/constants/files.ts
Normal file
2
src/constants/files.ts
Normal file
@ -0,0 +1,2 @@
|
||||
export const IMAGE_EXTENSION_REGEX =
|
||||
/(\.apng|\.avif|\.gif|\.jpg|\.jpeg|\.jfif|\.pjpeg|\.pjp|\.png|\.svg|\.webp)$/g;
|
@ -1,20 +1,44 @@
|
||||
import { useCallback } from 'react';
|
||||
import { useEffect, useMemo } from 'react';
|
||||
|
||||
import type { EditorProps } from '@toast-ui/react-editor';
|
||||
import type { PluginContext } from '@toast-ui/editor/types/editor';
|
||||
import type { Field } from '../interface';
|
||||
|
||||
export interface ShortCodePluginProps {
|
||||
fields: Field;
|
||||
import type { ToolbarItemOptions } from '@toast-ui/editor/types/ui';
|
||||
export interface ImagePluginProps {
|
||||
openMediaLibrary: (forImages: boolean) => void;
|
||||
}
|
||||
|
||||
const useShortCodePlugin = (_props: ShortCodePluginProps) => {
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
const plugin: Required<EditorProps>['plugins'][number] = useCallback((_context: PluginContext, _options?: any) => {
|
||||
return null;
|
||||
const PREFIX = 'toastui-editor-';
|
||||
|
||||
const useImagePlugin = ({ openMediaLibrary }: ImagePluginProps): ToolbarItemOptions => {
|
||||
const toolbarButton = useMemo(() => {
|
||||
const btn = document.createElement('button');
|
||||
btn.type = 'button';
|
||||
btn.className = 'image toastui-editor-toolbar-icons';
|
||||
btn.ariaLabel = 'Insert image';
|
||||
btn.setAttribute('style', 'margin: 0;');
|
||||
return btn;
|
||||
}, []);
|
||||
|
||||
return plugin;
|
||||
useEffect(() => {
|
||||
const handler = () => {
|
||||
openMediaLibrary(true);
|
||||
};
|
||||
|
||||
export default useShortCodePlugin;
|
||||
toolbarButton.addEventListener('click', handler);
|
||||
|
||||
return () => {
|
||||
toolbarButton.removeEventListener('click', handler);
|
||||
};
|
||||
}, [openMediaLibrary, toolbarButton]);
|
||||
|
||||
const toolbarItem: ToolbarItemOptions = useMemo(
|
||||
() => ({
|
||||
name: 'cmsimage',
|
||||
className: `${PREFIX}toolbar-icons color`,
|
||||
el: toolbarButton,
|
||||
}),
|
||||
[toolbarButton],
|
||||
);
|
||||
|
||||
return toolbarItem;
|
||||
};
|
||||
|
||||
export default useImagePlugin;
|
||||
|
@ -246,8 +246,6 @@ export interface WidgetControlProps<T, F extends Field = Field> {
|
||||
locale: string | undefined;
|
||||
mediaPaths: Record<string, string | string[]>;
|
||||
onChange: (value: T | null | undefined) => void;
|
||||
addAsset: EditorControlProps['addAsset'];
|
||||
addDraftEntryMediaFile: EditorControlProps['addDraftEntryMediaFile'];
|
||||
clearMediaControl: EditorControlProps['clearMediaControl'];
|
||||
openMediaLibrary: EditorControlProps['openMediaLibrary'];
|
||||
removeInsertedMedia: EditorControlProps['removeInsertedMedia'];
|
||||
|
12
src/lib/util/fetch.util.ts
Normal file
12
src/lib/util/fetch.util.ts
Normal file
@ -0,0 +1,12 @@
|
||||
export async function doesUrlFileExist(url: string): Promise<{ type: string; exists: boolean }> {
|
||||
const cleanUrl = url.replace(/^blob:/g, '');
|
||||
|
||||
const baseUrl = `${window.location.protocol}//${window.location.host}/`;
|
||||
|
||||
if (!cleanUrl.startsWith('/') && !cleanUrl.startsWith(baseUrl)) {
|
||||
return { type: 'Unknown', exists: true };
|
||||
}
|
||||
|
||||
const response = await fetch(cleanUrl, { method: 'HEAD' });
|
||||
return { type: response.headers.get('Content-Type') ?? 'text', exists: response.ok };
|
||||
}
|
@ -8,7 +8,7 @@ import type {
|
||||
FieldValidationMethod,
|
||||
FieldValidationMethodProps,
|
||||
ValueOrNestedValue,
|
||||
Widget
|
||||
Widget,
|
||||
} from '../../interface';
|
||||
|
||||
export function isEmpty(value: ValueOrNestedValue) {
|
||||
|
@ -9,9 +9,11 @@ type MessageType = 'error' | 'warning' | 'info' | 'success';
|
||||
export interface SnackbarMessage {
|
||||
id: string;
|
||||
type: MessageType;
|
||||
message: string | {
|
||||
message:
|
||||
| string
|
||||
| ({
|
||||
key: string;
|
||||
} & Record<string, unknown>;
|
||||
} & Record<string, unknown>);
|
||||
}
|
||||
|
||||
// Define a type for the slice state
|
||||
|
@ -1,20 +1,24 @@
|
||||
import { styled } from '@mui/material/styles';
|
||||
import { Editor } from '@toast-ui/react-editor';
|
||||
import mime from 'mime-types';
|
||||
import React, { useCallback, useMemo, useState } from 'react';
|
||||
import isEmpty from 'lodash/isEmpty';
|
||||
import React, { useCallback, useEffect, useMemo, useState } from 'react';
|
||||
import uuid from 'uuid';
|
||||
|
||||
import FieldLabel from '../../components/UI/FieldLabel';
|
||||
import Outline from '../../components/UI/Outline';
|
||||
import { sanitizeSlug } from '../../lib/urlHelper';
|
||||
import { selectMediaFilePath } from '../../lib/util/media.util';
|
||||
import { createAssetProxy } from '../../valueObjects/AssetProxy';
|
||||
import { IMAGE_EXTENSION_REGEX } from '../../constants/files';
|
||||
import useImagePlugin from '../../editor-components/editorPlugin';
|
||||
import { doesUrlFileExist } from '../../lib/util/fetch.util';
|
||||
import { isNotNullish } from '../../lib/util/null.util';
|
||||
import { isNotEmpty } from '../../lib/util/string.util';
|
||||
|
||||
import type { RefObject } from 'react';
|
||||
import type { MarkdownField, WidgetControlProps } from '../../interface';
|
||||
import type { MarkdownField, MediaLibrary, WidgetControlProps } from '../../interface';
|
||||
|
||||
import '@toast-ui/editor/dist/toastui-editor.css';
|
||||
|
||||
const imageFilePattern = /(!)?\[([^\]]*)\]\(([^)]+)\)/;
|
||||
|
||||
const StyledEditorWrapper = styled('div')`
|
||||
position: relative;
|
||||
width: 100%;
|
||||
@ -42,11 +46,9 @@ const MarkdownControl = ({
|
||||
onChange,
|
||||
hasErrors,
|
||||
field,
|
||||
addAsset,
|
||||
addDraftEntryMediaFile,
|
||||
config,
|
||||
collection,
|
||||
entry,
|
||||
openMediaLibrary,
|
||||
mediaPaths,
|
||||
getAsset,
|
||||
}: WidgetControlProps<string, MarkdownField>) => {
|
||||
const [internalValue, setInternalValue] = useState(value ?? '');
|
||||
const editorRef = useMemo(() => React.createRef(), []) as RefObject<Editor>;
|
||||
@ -70,44 +72,89 @@ const MarkdownControl = ({
|
||||
editorRef.current?.getInstance().focus();
|
||||
}, [editorRef]);
|
||||
|
||||
const imageUpload = useCallback(
|
||||
(blob: Blob | File, callback: (url: string, text?: string) => void) => {
|
||||
let file: File;
|
||||
if (blob instanceof Blob) {
|
||||
blob.type;
|
||||
file = new File([blob], `${uuid()}.${mime.extension(blob.type)}`);
|
||||
} else {
|
||||
file = blob;
|
||||
}
|
||||
const fileName = sanitizeSlug(file.name.toLowerCase(), config.slug);
|
||||
const path = selectMediaFilePath(config, collection, entry, fileName, field);
|
||||
const blobUrl = URL.createObjectURL(file);
|
||||
addAsset(
|
||||
createAssetProxy({
|
||||
url: blobUrl,
|
||||
file,
|
||||
path,
|
||||
field,
|
||||
}),
|
||||
const controlID: string = useMemo(() => uuid(), []);
|
||||
const mediaLibraryFieldOptions: MediaLibrary = useMemo(
|
||||
() => field.media_library ?? {},
|
||||
[field.media_library],
|
||||
);
|
||||
addDraftEntryMediaFile({
|
||||
name: file.name,
|
||||
id: file.name,
|
||||
size: file.size,
|
||||
displayURL: blobUrl,
|
||||
path,
|
||||
draft: true,
|
||||
url: blobUrl,
|
||||
file,
|
||||
const handleOpenMedialLibrary = useCallback(
|
||||
(forImage: boolean) => {
|
||||
openMediaLibrary({
|
||||
controlID,
|
||||
forImage,
|
||||
privateUpload: false,
|
||||
allowMultiple: false,
|
||||
field,
|
||||
config: 'config' in mediaLibraryFieldOptions ? mediaLibraryFieldOptions.config : undefined,
|
||||
});
|
||||
console.log(blob);
|
||||
callback(path);
|
||||
handleOnChange();
|
||||
},
|
||||
[addAsset, addDraftEntryMediaFile, collection, config, entry, field, handleOnChange],
|
||||
[controlID, field, mediaLibraryFieldOptions, openMediaLibrary],
|
||||
);
|
||||
|
||||
const imageToolbarButton = useImagePlugin({
|
||||
openMediaLibrary: handleOpenMedialLibrary,
|
||||
});
|
||||
|
||||
const getMedia = useCallback(
|
||||
async (path: string) => {
|
||||
const { type, exists } = await doesUrlFileExist(path);
|
||||
if (!exists) {
|
||||
const asset = getAsset(path, field);
|
||||
if (isNotNullish(asset)) {
|
||||
return {
|
||||
type: IMAGE_EXTENSION_REGEX.test(path) ? 'image' : 'file',
|
||||
exists: false,
|
||||
url: asset.toString(),
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
return { url: path, type, exists };
|
||||
},
|
||||
[field, getAsset],
|
||||
);
|
||||
|
||||
const mediaPath = mediaPaths[controlID];
|
||||
useEffect(() => {
|
||||
if (isEmpty(mediaPath) || Array.isArray(mediaPath)) {
|
||||
return;
|
||||
}
|
||||
|
||||
const addMedia = async () => {
|
||||
const { type } = await getMedia(mediaPath);
|
||||
let content: string | undefined;
|
||||
const name = mediaPath.split('/').pop();
|
||||
if (type.startsWith('image')) {
|
||||
content = `![${name}](${mediaPath})`;
|
||||
} else {
|
||||
content = `[${name}](${mediaPath})`;
|
||||
}
|
||||
|
||||
if (isNotEmpty(content)) {
|
||||
const editorInstance = editorRef.current?.getInstance();
|
||||
if (!editorInstance) {
|
||||
return;
|
||||
}
|
||||
|
||||
editorInstance.focus();
|
||||
const isOnMarkdown = editorInstance.isMarkdownMode();
|
||||
if (!isOnMarkdown) {
|
||||
editorInstance.changeMode('markdown');
|
||||
}
|
||||
editorInstance.insertText(content);
|
||||
if (!isOnMarkdown) {
|
||||
editorInstance.changeMode('wysiwyg');
|
||||
}
|
||||
setTimeout(() => {
|
||||
handleOnChange();
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
addMedia();
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [field, mediaPath]);
|
||||
|
||||
return (
|
||||
<StyledEditorWrapper key="markdown-control-wrapper">
|
||||
<FieldLabel
|
||||
@ -130,16 +177,42 @@ const MarkdownControl = ({
|
||||
['heading', 'bold', 'italic', 'strike'],
|
||||
['hr', 'quote'],
|
||||
['ul', 'ol', 'task', 'indent', 'outdent'],
|
||||
['table', 'image', 'link'],
|
||||
['table', imageToolbarButton, 'link'],
|
||||
['code', 'codeblock'],
|
||||
]}
|
||||
ref={editorRef}
|
||||
onFocus={handleOnFocus}
|
||||
onBlur={handleOnBlur}
|
||||
hooks={{
|
||||
addImageBlobHook: imageUpload,
|
||||
}}
|
||||
autofocus={false}
|
||||
widgetRules={[
|
||||
{
|
||||
rule: imageFilePattern,
|
||||
toDOM(text) {
|
||||
const rule = imageFilePattern;
|
||||
const matched = text.match(rule);
|
||||
|
||||
if (matched) {
|
||||
if (matched?.length === 4) {
|
||||
// Image
|
||||
const img = document.createElement('img');
|
||||
img.setAttribute('src', getAsset(matched[3], field).url);
|
||||
img.setAttribute('style', 'width: 100%;');
|
||||
img.innerHTML = 'test';
|
||||
return img;
|
||||
} else {
|
||||
// File
|
||||
const a = document.createElement('a');
|
||||
a.setAttribute('target', '_blank');
|
||||
a.setAttribute('href', matched[2]);
|
||||
a.innerHTML = matched[1];
|
||||
return a;
|
||||
}
|
||||
}
|
||||
|
||||
return document.createElement('div');
|
||||
},
|
||||
},
|
||||
]}
|
||||
/>
|
||||
<Outline key="markdown-control-outline" hasLabel hasError={hasErrors} />
|
||||
</StyledEditorWrapper>
|
||||
|
@ -39,20 +39,15 @@ const SelectControl = ({
|
||||
const isEmpty =
|
||||
isMultiple && Array.isArray(selectedOption) ? !selectedOption?.length : !selectedOption;
|
||||
|
||||
console.log('[value]', selectedOption, field.required, isEmpty, isMultiple);
|
||||
|
||||
if (field.required && isEmpty && isMultiple) {
|
||||
setInternalValue([]);
|
||||
onChange([]);
|
||||
console.log('emtpy array!');
|
||||
} else if (isEmpty) {
|
||||
setInternalValue('');
|
||||
onChange('');
|
||||
console.log('emtpy string!');
|
||||
} else if (typeof selectedOption === 'string' || isMultiple) {
|
||||
setInternalValue(selectedOption);
|
||||
onChange(selectedOption);
|
||||
console.log('valid value!', selectedOption);
|
||||
}
|
||||
},
|
||||
[field, onChange],
|
||||
@ -85,7 +80,7 @@ const SelectControl = ({
|
||||
<Box sx={{ display: 'flex', flexWrap: 'wrap', gap: 0.5 }}>
|
||||
{selectValues.map(selectValue => {
|
||||
const label = optionsByValue[selectValue]?.label ?? selectValue;
|
||||
return <Chip key={selectValue} label={label} />
|
||||
return <Chip key={selectValue} label={label} />;
|
||||
})}
|
||||
</Box>
|
||||
) : (
|
||||
|
@ -4,7 +4,12 @@ import React, { useCallback, useState } from 'react';
|
||||
import type { ChangeEvent } from 'react';
|
||||
import type { StringOrTextField, WidgetControlProps } from '../../interface';
|
||||
|
||||
const TextControl = ({ label, value, onChange, hasErrors }: WidgetControlProps<string, StringOrTextField>) => {
|
||||
const TextControl = ({
|
||||
label,
|
||||
value,
|
||||
onChange,
|
||||
hasErrors,
|
||||
}: WidgetControlProps<string, StringOrTextField>) => {
|
||||
const [internalValue, setInternalValue] = useState(value ?? '');
|
||||
|
||||
const handleChange = useCallback(
|
||||
|
@ -7,7 +7,10 @@ class GitHubStarButton extends PureComponent {
|
||||
href="https://github.com/StaticJsCMS/static-cms"
|
||||
aria-label="Star StaticJsCMS/static-cms on GitHub"
|
||||
>
|
||||
<img alt="Star StaticJsCMS/static-cms on GitHub" src="https://img.shields.io/github/stars/StaticJsCMS/static-cms?style=social" />
|
||||
<img
|
||||
alt="Star StaticJsCMS/static-cms on GitHub"
|
||||
src="https://img.shields.io/github/stars/StaticJsCMS/static-cms?style=social"
|
||||
/>
|
||||
</a>
|
||||
);
|
||||
}
|
||||
|
@ -2250,11 +2250,6 @@
|
||||
dependencies:
|
||||
"@types/unist" "*"
|
||||
|
||||
"@types/mime-types@^2.1.1":
|
||||
version "2.1.1"
|
||||
resolved "https://registry.yarnpkg.com/@types/mime-types/-/mime-types-2.1.1.tgz#d9ba43490fa3a3df958759adf69396c3532cf2c1"
|
||||
integrity sha512-vXOTGVSLR2jMw440moWTC7H19iUyLtP3Z1YTj7cSsubOICinjMxFeb/V57v9QdyyPGbbWolUFSSmSiRSn94tFw==
|
||||
|
||||
"@types/mime@*":
|
||||
version "3.0.1"
|
||||
resolved "https://registry.yarnpkg.com/@types/mime/-/mime-3.0.1.tgz#5f8f2bca0a5863cb69bc0b0acd88c96cb1d4ae10"
|
||||
@ -7193,7 +7188,7 @@ mime-db@1.52.0, "mime-db@>= 1.43.0 < 2":
|
||||
resolved "https://registry.yarnpkg.com/mime-db/-/mime-db-1.52.0.tgz#bbabcdc02859f4987301c856e3387ce5ec43bf70"
|
||||
integrity sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg==
|
||||
|
||||
mime-types@^2.1.12, mime-types@^2.1.27, mime-types@^2.1.31, mime-types@^2.1.35, mime-types@~2.1.17, mime-types@~2.1.24, mime-types@~2.1.34:
|
||||
mime-types@^2.1.12, mime-types@^2.1.27, mime-types@^2.1.31, mime-types@~2.1.17, mime-types@~2.1.24, mime-types@~2.1.34:
|
||||
version "2.1.35"
|
||||
resolved "https://registry.yarnpkg.com/mime-types/-/mime-types-2.1.35.tgz#381a871b62a734450660ae3deee44813f70d959a"
|
||||
integrity sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw==
|
||||
|
Loading…
x
Reference in New Issue
Block a user