mirror of
https://github.com/strapi/strapi.git
synced 2026-01-04 03:03:38 +00:00
Init edit existing file and add copy button
Signed-off-by: soupette <cyril.lpz@gmail.com>
This commit is contained in:
parent
664db67436
commit
6ae7395bf0
@ -13,7 +13,9 @@ module.exports = {
|
||||
],
|
||||
globals: {
|
||||
__webpack_public_path__: 'http://localhost:4000',
|
||||
strapi: {},
|
||||
strapi: {
|
||||
backendURL: 'http://localhost:1337',
|
||||
},
|
||||
BACKEND_URL: 'http://localhost:1337',
|
||||
MODE: 'host',
|
||||
PUBLIC_PATH: '/admin',
|
||||
@ -26,8 +28,7 @@ module.exports = {
|
||||
'<rootDir>/test/config/front',
|
||||
],
|
||||
moduleNameMapper: {
|
||||
'.*\\.(css|less|styl|scss|sass)$':
|
||||
'<rootDir>/test/config/front/mocks/cssModule.js',
|
||||
'.*\\.(css|less|styl|scss|sass)$': '<rootDir>/test/config/front/mocks/cssModule.js',
|
||||
'.*\\.(jpg|jpeg|png|gif|eot|otf|webp|svg|ttf|woff|woff2|mp4|webm|wav|mp3|m4a|aac|oga|ico)$':
|
||||
'<rootDir>/test/config/front/mocks/image.js',
|
||||
},
|
||||
@ -49,8 +50,6 @@ module.exports = {
|
||||
'\\.(jpg|jpeg|png|gif|eot|otf|webp|svg|ttf|woff|woff2|mp4|webm|wav|mp3|m4a|aac|oga)$':
|
||||
'<rootDir>/fileTransformer.js',
|
||||
},
|
||||
transformIgnorePatterns: [
|
||||
'node_modules/(?!(react-dnd|dnd-core|react-dnd-html5-backend)/)',
|
||||
],
|
||||
transformIgnorePatterns: ['node_modules/(?!(react-dnd|dnd-core|react-dnd-html5-backend)/)'],
|
||||
testURL: 'http://localhost:4000/admin',
|
||||
};
|
||||
|
||||
@ -14,12 +14,14 @@ import ErrorMessage from './ErrorMessage';
|
||||
import Border from './Border';
|
||||
|
||||
const Card = ({
|
||||
id,
|
||||
checked,
|
||||
children,
|
||||
errorMessage,
|
||||
hasError,
|
||||
mime,
|
||||
name,
|
||||
onClick,
|
||||
small,
|
||||
size,
|
||||
type,
|
||||
@ -28,8 +30,12 @@ const Card = ({
|
||||
const fileSize = formatBytes(size, 0);
|
||||
const fileType = mime || type;
|
||||
|
||||
const handleClick = () => {
|
||||
onClick(id);
|
||||
};
|
||||
|
||||
return (
|
||||
<Wrapper>
|
||||
<Wrapper onClick={handleClick}>
|
||||
<CardImgWrapper checked={checked} small={small}>
|
||||
<CardPreview hasError={hasError} url={url} type={fileType} />
|
||||
<Border color={hasError ? 'orange' : 'mediumBlue'} shown={checked || hasError} />
|
||||
@ -51,9 +57,11 @@ Card.defaultProps = {
|
||||
checked: false,
|
||||
children: null,
|
||||
errorMessage: null,
|
||||
id: null,
|
||||
hasError: false,
|
||||
mime: null,
|
||||
name: null,
|
||||
onClick: () => {},
|
||||
size: 0,
|
||||
small: false,
|
||||
type: null,
|
||||
@ -61,12 +69,14 @@ Card.defaultProps = {
|
||||
};
|
||||
|
||||
Card.propTypes = {
|
||||
id: PropTypes.oneOfType([PropTypes.string, PropTypes.number]),
|
||||
checked: PropTypes.bool,
|
||||
children: PropTypes.node,
|
||||
errorMessage: PropTypes.string,
|
||||
hasError: PropTypes.bool,
|
||||
mime: PropTypes.string,
|
||||
name: PropTypes.string,
|
||||
onClick: PropTypes.func,
|
||||
size: PropTypes.number,
|
||||
small: PropTypes.bool,
|
||||
type: PropTypes.string,
|
||||
|
||||
@ -15,14 +15,22 @@ const Wrapper = styled.div`
|
||||
cursor: pointer;
|
||||
font-size: 11px;
|
||||
color: ${({ color }) => color};
|
||||
|
||||
${({ type }) =>
|
||||
type === 'link' &&
|
||||
`
|
||||
transform: rotate(90deg)
|
||||
`};
|
||||
`;
|
||||
|
||||
Wrapper.defaultProps = {
|
||||
color: '#b3b5b9',
|
||||
type: null,
|
||||
};
|
||||
|
||||
Wrapper.propTypes = {
|
||||
color: PropTypes.string,
|
||||
type: PropTypes.string,
|
||||
...themePropTypes,
|
||||
};
|
||||
|
||||
|
||||
@ -7,7 +7,7 @@ import Wrapper from './Wrapper';
|
||||
|
||||
const CardControl = ({ color, onClick, type }) => {
|
||||
return (
|
||||
<Wrapper onClick={onClick} color={color}>
|
||||
<Wrapper onClick={onClick} color={color} type={type}>
|
||||
{type === 'pencil' && <Pencil fill={color} />}
|
||||
{type === 'clear' && <ClearIcon fill={color} />}
|
||||
{!['pencil', 'clear'].includes(type) && <FontAwesomeIcon icon={type} />}
|
||||
|
||||
@ -1,10 +1,12 @@
|
||||
import React, { useState, useEffect, useRef } from 'react';
|
||||
import { CopyToClipboard } from 'react-copy-to-clipboard';
|
||||
import { get } from 'lodash';
|
||||
import PropTypes from 'prop-types';
|
||||
import { Inputs } from '@buffetjs/custom';
|
||||
import { useGlobalContext } from 'strapi-helper-plugin';
|
||||
import Cropper from 'cropperjs';
|
||||
import 'cropperjs/dist/cropper.css';
|
||||
import { getTrad, prefixFileUrlWithBackendUrl } from '../../utils';
|
||||
import CardControl from '../CardControl';
|
||||
import CardControlsWrapper from '../CardControlsWrapper';
|
||||
import CardPreview from '../CardPreview';
|
||||
@ -32,7 +34,9 @@ const EditForm = ({
|
||||
const [infos, setInfos] = useState({ width: 0, height: 0 });
|
||||
const [src, setSrc] = useState(null);
|
||||
|
||||
const mimeType = get(fileToEdit, ['file', 'type'], '');
|
||||
const fileURL = get(fileToEdit, ['file', 'url'], null);
|
||||
const prefixedFileURL = fileURL ? prefixFileUrlWithBackendUrl(fileURL) : null;
|
||||
const mimeType = get(fileToEdit, ['file', 'type'], null) || get(fileToEdit, ['file', 'mime'], '');
|
||||
const isImg = isImageType(mimeType);
|
||||
// TODO
|
||||
const canCrop = isImg && !mimeType.includes('svg');
|
||||
@ -42,16 +46,19 @@ const EditForm = ({
|
||||
|
||||
useEffect(() => {
|
||||
if (isImg) {
|
||||
// TODO: update when editing existing file
|
||||
const reader = new FileReader();
|
||||
if (prefixedFileURL) {
|
||||
setSrc(prefixedFileURL);
|
||||
} else {
|
||||
const reader = new FileReader();
|
||||
|
||||
reader.onloadend = () => {
|
||||
setSrc(reader.result);
|
||||
};
|
||||
reader.onloadend = () => {
|
||||
setSrc(reader.result);
|
||||
};
|
||||
|
||||
reader.readAsDataURL(fileToEdit.file);
|
||||
reader.readAsDataURL(fileToEdit.file);
|
||||
}
|
||||
}
|
||||
}, [isImg, fileToEdit]);
|
||||
}, [isImg, fileToEdit, prefixedFileURL]);
|
||||
|
||||
useEffect(() => {
|
||||
if (isCropping) {
|
||||
@ -78,6 +85,7 @@ const EditForm = ({
|
||||
}, [cropper, isCropping]);
|
||||
|
||||
const handleResize = () => {
|
||||
// 130
|
||||
const cropBox = cropper.current.getCropBoxData();
|
||||
const { width, height } = cropBox;
|
||||
const roundedWidth = Math.round(width);
|
||||
@ -128,6 +136,10 @@ const EditForm = ({
|
||||
onClickDeleteFileToUpload(fileToEdit.originalIndex);
|
||||
};
|
||||
|
||||
const handleCopy = () => {
|
||||
strapi.notification.info(getTrad('notification.link-copied'));
|
||||
};
|
||||
|
||||
const handleSubmit = e => {
|
||||
e.preventDefault();
|
||||
|
||||
@ -145,6 +157,11 @@ const EditForm = ({
|
||||
{!isCropping ? (
|
||||
<>
|
||||
<CardControl color="#9EA7B8" type="trash-alt" onClick={handleClickDelete} />
|
||||
{fileURL && (
|
||||
<CopyToClipboard onCopy={handleCopy} text={prefixedFileURL}>
|
||||
<CardControl color="#9EA7B8" type="link" onClick={handleClickDelete} />
|
||||
</CopyToClipboard>
|
||||
)}
|
||||
{canCrop && (
|
||||
<CardControl type="crop" color="#9EA7B8" onClick={handleToggleCropMode} />
|
||||
)}
|
||||
|
||||
@ -8,7 +8,7 @@ import Card from '../Card';
|
||||
import CardControlsWrapper from '../CardControlsWrapper';
|
||||
import Wrapper from './Wrapper';
|
||||
|
||||
const List = ({ data, onChange, selectedItems }) => {
|
||||
const List = ({ data, onChange, onClickEditFile, selectedItems }) => {
|
||||
const matrix = createMatrix(data);
|
||||
|
||||
return (
|
||||
@ -22,7 +22,12 @@ const List = ({ data, onChange, selectedItems }) => {
|
||||
|
||||
return (
|
||||
<div className="col-xs-12 col-md-6 col-xl-3" key={id}>
|
||||
<Card checked={checked} {...item} url={`${strapi.backendURL}${url}`}>
|
||||
<Card
|
||||
checked={checked}
|
||||
{...item}
|
||||
onClick={onClickEditFile}
|
||||
url={`${strapi.backendURL}${url}`}
|
||||
>
|
||||
<CardControlsWrapper leftAlign className="card-control-wrapper">
|
||||
<Checkbox name={`${id}`} onChange={onChange} value={checked} />
|
||||
</CardControlsWrapper>
|
||||
@ -40,12 +45,14 @@ const List = ({ data, onChange, selectedItems }) => {
|
||||
List.defaultProps = {
|
||||
data: [],
|
||||
onChange: () => {},
|
||||
onClickEditFile: () => {},
|
||||
selectedItems: [],
|
||||
};
|
||||
|
||||
List.propTypes = {
|
||||
data: PropTypes.array,
|
||||
onChange: PropTypes.func,
|
||||
onClickEditFile: PropTypes.func,
|
||||
selectedItems: PropTypes.array,
|
||||
};
|
||||
|
||||
|
||||
@ -1,5 +1,5 @@
|
||||
import React, { useReducer, useState, useEffect } from 'react';
|
||||
import { includes } from 'lodash';
|
||||
import { includes, toString } from 'lodash';
|
||||
import { useHistory, useLocation } from 'react-router-dom';
|
||||
import { Header } from '@buffetjs/custom';
|
||||
import { useDebounce, useIsMounted } from '@buffetjs/hooks';
|
||||
@ -14,7 +14,7 @@ import {
|
||||
useQuery,
|
||||
} from 'strapi-helper-plugin';
|
||||
|
||||
import { getRequestUrl, getTrad } from '../../utils';
|
||||
import { formatFileForEditing, getRequestUrl, getTrad } from '../../utils';
|
||||
|
||||
import Container from '../../components/Container';
|
||||
import ControlsWrapper from '../../components/ControlsWrapper';
|
||||
@ -39,6 +39,8 @@ const HomePage = () => {
|
||||
const query = useQuery();
|
||||
const [isModalOpen, setIsModalOpen] = useState(false);
|
||||
const [isPopupOpen, setIsPopupOpen] = useState(false);
|
||||
const [fileToEdit, setFileToEdit] = useState(null);
|
||||
const [modalInitialStep, setModalInitialStep] = useState('browse');
|
||||
const [searchValue, setSearchValue] = useState(query.get('_q') || '');
|
||||
const { push } = useHistory();
|
||||
const { search } = useLocation();
|
||||
@ -171,6 +173,14 @@ const HomePage = () => {
|
||||
setSearchValue(value);
|
||||
};
|
||||
|
||||
const handleClickEditFile = id => {
|
||||
const file = formatFileForEditing(data.find(file => toString(file.id) === toString(id)));
|
||||
|
||||
setFileToEdit(file);
|
||||
setModalInitialStep('edit');
|
||||
handleClickToggleModal();
|
||||
};
|
||||
|
||||
const handleClearSearch = () => {
|
||||
setSearchValue('');
|
||||
};
|
||||
@ -207,6 +217,11 @@ const HomePage = () => {
|
||||
fetchListData();
|
||||
};
|
||||
|
||||
const handleModalClose = () => {
|
||||
setModalInitialStep('browse');
|
||||
setFileToEdit(null);
|
||||
};
|
||||
|
||||
const handleSelectAll = () => {
|
||||
dispatch({
|
||||
type: 'TOGGLE_SELECT_ALL',
|
||||
@ -288,7 +303,12 @@ const HomePage = () => {
|
||||
</ControlsWrapper>
|
||||
{dataCount > 0 ? (
|
||||
<>
|
||||
<List data={data} onChange={handleChangeCheck} selectedItems={selectedItems} />
|
||||
<List
|
||||
data={data}
|
||||
onChange={handleChangeCheck}
|
||||
onClickEditFile={handleClickEditFile}
|
||||
selectedItems={selectedItems}
|
||||
/>
|
||||
<PageFooter
|
||||
context={{ emitEvent: () => {} }}
|
||||
count={paginationCount}
|
||||
@ -299,7 +319,13 @@ const HomePage = () => {
|
||||
) : (
|
||||
<ListEmpty onClick={handleClickToggleModal} />
|
||||
)}
|
||||
<ModalStepper isOpen={isModalOpen} onToggle={handleClickToggleModal} />
|
||||
<ModalStepper
|
||||
initialFileToEdit={fileToEdit}
|
||||
initialStep={modalInitialStep}
|
||||
isOpen={isModalOpen}
|
||||
onClosed={handleModalClose}
|
||||
onToggle={handleClickToggleModal}
|
||||
/>
|
||||
<PopUpWarning
|
||||
isOpen={isPopupOpen}
|
||||
toggleModal={handleClickTogglePopup}
|
||||
|
||||
@ -11,7 +11,7 @@ import init from './init';
|
||||
import reducer, { initialState } from './reducer';
|
||||
import { getTrad } from '../../utils';
|
||||
|
||||
const ModalStepper = ({ isOpen, onToggle }) => {
|
||||
const ModalStepper = ({ initialFileToEdit, initialStep, isOpen, onClosed, onToggle }) => {
|
||||
const { formatMessage } = useGlobalContext();
|
||||
const [reducerState, dispatch] = useReducer(reducer, initialState, init);
|
||||
const { currentStep, fileToEdit, filesToUpload } = reducerState.toJS();
|
||||
@ -27,6 +27,22 @@ const ModalStepper = ({ isOpen, onToggle }) => {
|
||||
}
|
||||
}, [filesToUploadLength, currentStep]);
|
||||
|
||||
useEffect(() => {
|
||||
if (isOpen) {
|
||||
goTo(initialStep);
|
||||
|
||||
if (initialFileToEdit) {
|
||||
dispatch({
|
||||
type: 'INIT_FILE_TO_EDIT',
|
||||
fileToEdit: initialFileToEdit,
|
||||
});
|
||||
}
|
||||
}
|
||||
// Disabling the rule because we just want to let the ability to open the modal
|
||||
// at a specific step then we will let the stepper handle the navigation
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [isOpen]);
|
||||
|
||||
const addFilesToUpload = ({ target: { value } }) => {
|
||||
dispatch({
|
||||
type: 'ADD_FILES_TO_UPLOAD',
|
||||
@ -72,6 +88,8 @@ const ModalStepper = ({ isOpen, onToggle }) => {
|
||||
};
|
||||
|
||||
const handleClosed = () => {
|
||||
onClosed();
|
||||
|
||||
dispatch({
|
||||
type: 'RESET_PROPS',
|
||||
});
|
||||
@ -255,11 +273,17 @@ const ModalStepper = ({ isOpen, onToggle }) => {
|
||||
};
|
||||
|
||||
ModalStepper.defaultProps = {
|
||||
initialFileToEdit: null,
|
||||
initialStep: 'browse',
|
||||
onClosed: () => {},
|
||||
onToggle: () => {},
|
||||
};
|
||||
|
||||
ModalStepper.propTypes = {
|
||||
initialFileToEdit: PropTypes.object,
|
||||
initialStep: PropTypes.string,
|
||||
isOpen: PropTypes.bool.isRequired,
|
||||
onClosed: PropTypes.func,
|
||||
onToggle: PropTypes.func,
|
||||
};
|
||||
|
||||
|
||||
@ -25,6 +25,8 @@ const reducer = (state, action) => {
|
||||
);
|
||||
case 'GO_TO':
|
||||
return state.update('currentStep', () => action.to);
|
||||
case 'INIT_FILE_TO_EDIT':
|
||||
return state.update('fileToEdit', () => fromJS(action.fileToEdit));
|
||||
case 'ON_CHANGE':
|
||||
return state.updateIn(['fileToEdit', ...action.keys.split('.')], () => action.value);
|
||||
case 'ON_SUBMIT_EDIT_NEW_FILE': {
|
||||
|
||||
@ -29,6 +29,13 @@ const stepper = {
|
||||
prev: 'upload',
|
||||
withBackButton: true,
|
||||
},
|
||||
edit: {
|
||||
Component: EditForm,
|
||||
headers: [getTrad('modal.header.file-detail')],
|
||||
next: null,
|
||||
prev: null,
|
||||
withBackButton: false,
|
||||
},
|
||||
};
|
||||
|
||||
export default stepper;
|
||||
|
||||
@ -24,6 +24,7 @@
|
||||
"modal.upload-list.sub-header.button": "Add more assets",
|
||||
"modal.upload-list.footer.button.singular": "Upload {number} asset to the library",
|
||||
"modal.upload-list.footer.button.plural": "Upload {number} assets to the library",
|
||||
"notification.link-copied": "Link copied into the clipboard",
|
||||
"plugin.name": "Media Library",
|
||||
"plugin.description.long": "Media file management.",
|
||||
"plugin.description.short": "Media file management.",
|
||||
|
||||
@ -0,0 +1,13 @@
|
||||
import { pick } from 'lodash';
|
||||
|
||||
const formatFileForEditing = file => {
|
||||
return {
|
||||
file: {
|
||||
...pick(file, ['size', 'ext', 'width', 'height', 'url', 'mime']),
|
||||
created_at: file.created_at || file.createdAt,
|
||||
},
|
||||
fileInfo: pick(file, ['alternativeText', 'caption', 'name']),
|
||||
};
|
||||
};
|
||||
|
||||
export default formatFileForEditing;
|
||||
@ -1,6 +1,8 @@
|
||||
export { default as createMatrix } from './createMatrix';
|
||||
export { default as formatBytes } from './formatBytes';
|
||||
export { default as formatFileForEditing } from './formatFileForEditing';
|
||||
export { default as getExtension } from './getExtension';
|
||||
export { default as getRequestUrl } from './getRequestUrl';
|
||||
export { default as getTrad } from './getTrad';
|
||||
export { default as getType } from './getType';
|
||||
export { default as prefixFileUrlWithBackendUrl } from './prefixFileUrlWithBackendUrl';
|
||||
|
||||
@ -0,0 +1,5 @@
|
||||
const prefixFileUrlWithBackendUrl = fileURL => {
|
||||
return fileURL.startsWith('/') ? `${strapi.backendURL}${fileURL}` : fileURL;
|
||||
};
|
||||
|
||||
export default prefixFileUrlWithBackendUrl;
|
||||
@ -0,0 +1,87 @@
|
||||
import formatFileForEditing from '../formatFileForEditing';
|
||||
|
||||
describe('UPLOAD | utils | formatFileForEditing', () => {
|
||||
it('should format a file correctly with bookshelf', () => {
|
||||
const data = {
|
||||
size: 22.8,
|
||||
ext: '.png',
|
||||
width: 110,
|
||||
caption: 'test',
|
||||
previewUrl: null,
|
||||
height: 110,
|
||||
created_at: '2020-03-23T11:43:46.729Z',
|
||||
related: [],
|
||||
name: 'test',
|
||||
hash: 'Screenshot_2020-03-09_at_17.52.42.png_edbdfb6446',
|
||||
url: '/uploads/Screenshot_2020-03-09_at_17.52.42.png_edbdfb6446.png',
|
||||
provider: 'local',
|
||||
mime: 'image/png',
|
||||
updated_at: '2020-03-23T11:43:46.729Z',
|
||||
alternativeText: 'test',
|
||||
id: 12,
|
||||
formats: null,
|
||||
provider_metadata: null,
|
||||
};
|
||||
|
||||
const expected = {
|
||||
file: {
|
||||
size: 22.8,
|
||||
ext: '.png',
|
||||
width: 110,
|
||||
height: 110,
|
||||
created_at: '2020-03-23T11:43:46.729Z',
|
||||
url: '/uploads/Screenshot_2020-03-09_at_17.52.42.png_edbdfb6446.png',
|
||||
mime: 'image/png',
|
||||
},
|
||||
fileInfo: {
|
||||
caption: 'test',
|
||||
alternativeText: 'test',
|
||||
name: 'test',
|
||||
},
|
||||
};
|
||||
|
||||
expect(formatFileForEditing(data)).toEqual(expected);
|
||||
});
|
||||
|
||||
it('should format a file correctly with mongoose', () => {
|
||||
const data = {
|
||||
size: 22.8,
|
||||
ext: '.png',
|
||||
width: 110,
|
||||
caption: 'test',
|
||||
previewUrl: null,
|
||||
height: 110,
|
||||
createdAt: '2020-03-23T11:43:46.729Z',
|
||||
related: [],
|
||||
name: 'test',
|
||||
hash: 'Screenshot_2020-03-09_at_17.52.42.png_edbdfb6446',
|
||||
url: '/uploads/Screenshot_2020-03-09_at_17.52.42.png_edbdfb6446.png',
|
||||
provider: 'local',
|
||||
mime: 'image/png',
|
||||
updated_at: '2020-03-23T11:43:46.729Z',
|
||||
alternativeText: 'test',
|
||||
id: 12,
|
||||
formats: null,
|
||||
provider_metadata: null,
|
||||
};
|
||||
|
||||
const expected = {
|
||||
file: {
|
||||
size: 22.8,
|
||||
ext: '.png',
|
||||
width: 110,
|
||||
height: 110,
|
||||
created_at: '2020-03-23T11:43:46.729Z',
|
||||
url: '/uploads/Screenshot_2020-03-09_at_17.52.42.png_edbdfb6446.png',
|
||||
mime: 'image/png',
|
||||
},
|
||||
fileInfo: {
|
||||
alternativeText: 'test',
|
||||
caption: 'test',
|
||||
name: 'test',
|
||||
},
|
||||
};
|
||||
|
||||
expect(formatFileForEditing(data)).toEqual(expected);
|
||||
});
|
||||
});
|
||||
@ -0,0 +1,17 @@
|
||||
import prefixFileUrlWithBackendUrl from '../prefixFileUrlWithBackendUrl';
|
||||
|
||||
describe('UPLOAD | utils | prefixFileUrlWithBackendUrl', () => {
|
||||
it("should add the strapi back-end url if the file's url startsWith '/'", () => {
|
||||
const data = '/upload/test';
|
||||
const expected = 'http://localhost:1337/upload/test';
|
||||
|
||||
expect(prefixFileUrlWithBackendUrl(data)).toEqual(expected);
|
||||
});
|
||||
|
||||
it("should not add the strapi back-end url if the file's url does not start with '/'", () => {
|
||||
const data = 'test/upload/test';
|
||||
const expected = 'test/upload/test';
|
||||
|
||||
expect(prefixFileUrlWithBackendUrl(data)).toEqual(expected);
|
||||
});
|
||||
});
|
||||
@ -23,6 +23,7 @@ const hoc = () => WrappedComponent => {
|
||||
};
|
||||
|
||||
global.strapi = {
|
||||
backendURL: 'http://localhost:1337',
|
||||
injectReducer: hoc,
|
||||
injectSaga: hoc,
|
||||
notification: {
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user