mirror of
https://github.com/labring/FastGPT.git
synced 2025-08-02 20:58:12 +00:00
Feat: App folder and permission (#1726)
* app folder * feat: app foldere * fix: run app param error * perf: select app ux * perf: folder rerender * fix: ts * fix: parentId * fix: permission * perf: loading ux * perf: per select ux * perf: clb context * perf: query extension tip * fix: ts * perf: app detail per * perf: default per
This commit is contained in:
184
projects/app/src/components/common/folder/MoveModal.tsx
Normal file
184
projects/app/src/components/common/folder/MoveModal.tsx
Normal file
@@ -0,0 +1,184 @@
|
||||
import React, { useCallback, useState } from 'react';
|
||||
import MyModal from '@fastgpt/web/components/common/MyModal';
|
||||
import { useTranslation } from 'next-i18next';
|
||||
import { Box, Button, Flex, ModalBody, ModalFooter } from '@chakra-ui/react';
|
||||
import {
|
||||
GetResourceFolderListProps,
|
||||
GetResourceFolderListItemResponse,
|
||||
ParentIdType
|
||||
} from '@fastgpt/global/common/parentFolder/type';
|
||||
import { useMemoizedFn, useMount } from 'ahooks';
|
||||
import MyIcon from '@fastgpt/web/components/common/Icon';
|
||||
import { FolderIcon } from '@fastgpt/global/common/file/image/constants';
|
||||
import { useRequest2 } from '@fastgpt/web/hooks/useRequest';
|
||||
|
||||
type FolderItemType = {
|
||||
id: string;
|
||||
name: string;
|
||||
open: boolean;
|
||||
children?: FolderItemType[];
|
||||
};
|
||||
|
||||
const rootId = 'root';
|
||||
|
||||
type Props = {
|
||||
moveResourceId: string;
|
||||
title: string;
|
||||
server: (e: GetResourceFolderListProps) => Promise<GetResourceFolderListItemResponse[]>;
|
||||
onConfirm: (id: ParentIdType) => Promise<any>;
|
||||
onClose: () => void;
|
||||
};
|
||||
|
||||
const MoveModal = ({ moveResourceId, title, server, onConfirm, onClose }: Props) => {
|
||||
const { t } = useTranslation();
|
||||
const [selectedId, setSelectedId] = React.useState<string>();
|
||||
const [requestingIdList, setRequestingIdList] = useState<ParentIdType[]>([]);
|
||||
const [folderList, setFolderList] = useState<FolderItemType[]>([]);
|
||||
|
||||
const { runAsync: requestServer } = useRequest2((e: GetResourceFolderListProps) => {
|
||||
if (requestingIdList.includes(e.parentId)) return Promise.reject(null);
|
||||
|
||||
setRequestingIdList((state) => [...state, e.parentId]);
|
||||
return server(e).finally(() =>
|
||||
setRequestingIdList((state) => state.filter((id) => id !== e.parentId))
|
||||
);
|
||||
}, {});
|
||||
|
||||
useMount(async () => {
|
||||
const data = await requestServer({ parentId: null });
|
||||
setFolderList([
|
||||
{
|
||||
id: rootId,
|
||||
name: t('common.folder.Root Path'),
|
||||
open: true,
|
||||
children: data.map((item) => ({
|
||||
id: item.id,
|
||||
name: item.name,
|
||||
open: false
|
||||
}))
|
||||
}
|
||||
]);
|
||||
});
|
||||
|
||||
const RenderList = useMemoizedFn(
|
||||
({ list, index = 0 }: { list: FolderItemType[]; index?: number }) => {
|
||||
return (
|
||||
<>
|
||||
{list
|
||||
// can not move to itself
|
||||
.filter((item) => moveResourceId !== item.id)
|
||||
.map((item) => (
|
||||
<Box key={item.id} _notLast={{ mb: 0.5 }} userSelect={'none'}>
|
||||
<Flex
|
||||
alignItems={'center'}
|
||||
cursor={'pointer'}
|
||||
py={1}
|
||||
pl={index === 0 ? '0.5rem' : `${1.75 * (index - 1) + 0.5}rem`}
|
||||
pr={2}
|
||||
borderRadius={'md'}
|
||||
_hover={{
|
||||
bg: 'myGray.100'
|
||||
}}
|
||||
{...(item.id === selectedId
|
||||
? {
|
||||
bg: 'primary.50 !important',
|
||||
onClick: () => setSelectedId(undefined)
|
||||
}
|
||||
: {
|
||||
onClick: () => setSelectedId(item.id)
|
||||
})}
|
||||
>
|
||||
{index !== 0 && (
|
||||
<Flex
|
||||
alignItems={'center'}
|
||||
justifyContent={'center'}
|
||||
visibility={!item.children || item.children.length > 0 ? 'visible' : 'hidden'}
|
||||
w={'1.25rem'}
|
||||
h={'1.25rem'}
|
||||
cursor={'pointer'}
|
||||
borderRadius={'xs'}
|
||||
_hover={{
|
||||
bg: 'rgba(31, 35, 41, 0.08)'
|
||||
}}
|
||||
onClick={async (e) => {
|
||||
e.stopPropagation();
|
||||
if (requestingIdList.includes(item.id)) return;
|
||||
|
||||
if (!item.children) {
|
||||
const data = await requestServer({ parentId: item.id });
|
||||
item.children = data.map((item) => ({
|
||||
id: item.id,
|
||||
name: item.name,
|
||||
open: false
|
||||
}));
|
||||
}
|
||||
item.open = !item.open;
|
||||
setFolderList([...folderList]);
|
||||
}}
|
||||
>
|
||||
<MyIcon
|
||||
name={
|
||||
requestingIdList.includes(item.id)
|
||||
? 'common/loading'
|
||||
: 'common/rightArrowFill'
|
||||
}
|
||||
w={'1.25rem'}
|
||||
color={'myGray.500'}
|
||||
transform={item.open ? 'rotate(90deg)' : 'none'}
|
||||
/>
|
||||
</Flex>
|
||||
)}
|
||||
<MyIcon ml={index !== 0 ? '0.5rem' : 0} name={FolderIcon} w={'1.25rem'} />
|
||||
<Box fontSize={'sm'} ml={2}>
|
||||
{item.name}
|
||||
</Box>
|
||||
</Flex>
|
||||
{item.children && item.open && (
|
||||
<Box mt={0.5}>
|
||||
<RenderList list={item.children} index={index + 1} />
|
||||
</Box>
|
||||
)}
|
||||
</Box>
|
||||
))}
|
||||
</>
|
||||
);
|
||||
}
|
||||
);
|
||||
|
||||
const { runAsync: onConfirmSelect, loading: confirming } = useRequest2(
|
||||
() => {
|
||||
if (selectedId) {
|
||||
return onConfirm(selectedId === rootId ? null : selectedId);
|
||||
}
|
||||
return Promise.reject('');
|
||||
},
|
||||
{
|
||||
onSuccess: () => {
|
||||
onClose();
|
||||
},
|
||||
successToast: t('common.folder.Move Success')
|
||||
}
|
||||
);
|
||||
|
||||
return (
|
||||
<MyModal
|
||||
isLoading={folderList.length === 0}
|
||||
iconSrc="/imgs/modal/move.svg"
|
||||
isOpen
|
||||
w={'30rem'}
|
||||
title={title}
|
||||
onClose={onClose}
|
||||
>
|
||||
<ModalBody flex={'1 0 0'} overflow={'auto'} minH={'400px'}>
|
||||
<RenderList list={folderList} />
|
||||
</ModalBody>
|
||||
<ModalFooter>
|
||||
<Button isLoading={confirming} isDisabled={!selectedId} onClick={onConfirmSelect}>
|
||||
{t('common.Confirm')}
|
||||
</Button>
|
||||
</ModalFooter>
|
||||
</MyModal>
|
||||
);
|
||||
};
|
||||
|
||||
export default MoveModal;
|
68
projects/app/src/components/common/folder/Path.tsx
Normal file
68
projects/app/src/components/common/folder/Path.tsx
Normal file
@@ -0,0 +1,68 @@
|
||||
import { Box, Flex } from '@chakra-ui/react';
|
||||
import { ParentTreePathItemType } from '@fastgpt/global/common/parentFolder/type';
|
||||
import React, { useMemo } from 'react';
|
||||
import { useTranslation } from 'next-i18next';
|
||||
|
||||
const FolderPath = (props: {
|
||||
paths: ParentTreePathItemType[];
|
||||
rootName?: string;
|
||||
FirstPathDom?: React.ReactNode;
|
||||
onClick: (parentId: string) => void;
|
||||
fontSize?: string;
|
||||
}) => {
|
||||
const { t } = useTranslation();
|
||||
const { paths, rootName = t('common.folder.Root Path'), FirstPathDom, onClick, fontSize } = props;
|
||||
|
||||
const concatPaths = useMemo(
|
||||
() => [
|
||||
{
|
||||
parentId: '',
|
||||
parentName: rootName
|
||||
},
|
||||
...paths
|
||||
],
|
||||
[rootName, paths]
|
||||
);
|
||||
|
||||
return paths.length === 0 && !!FirstPathDom ? (
|
||||
<>{FirstPathDom}</>
|
||||
) : (
|
||||
<Flex flex={1} ml={-1.5}>
|
||||
{concatPaths.map((item, i) => (
|
||||
<Flex key={item.parentId || i} alignItems={'center'}>
|
||||
<Box
|
||||
fontSize={['sm', fontSize || 'sm']}
|
||||
py={0.5}
|
||||
px={1.5}
|
||||
borderRadius={'md'}
|
||||
{...(i === concatPaths.length - 1
|
||||
? {
|
||||
cursor: 'default',
|
||||
color: 'myGray.700',
|
||||
fontWeight: 'bold'
|
||||
}
|
||||
: {
|
||||
cursor: 'pointer',
|
||||
color: 'myGray.600',
|
||||
_hover: {
|
||||
bg: 'myGray.100'
|
||||
},
|
||||
onClick: () => {
|
||||
onClick(item.parentId);
|
||||
}
|
||||
})}
|
||||
>
|
||||
{item.parentName}
|
||||
</Box>
|
||||
{i !== concatPaths.length - 1 && (
|
||||
<Box mx={1.5} color={'myGray.500'}>
|
||||
/
|
||||
</Box>
|
||||
)}
|
||||
</Flex>
|
||||
))}
|
||||
</Flex>
|
||||
);
|
||||
};
|
||||
|
||||
export default React.memo(FolderPath);
|
140
projects/app/src/components/common/folder/SelectOneResource.tsx
Normal file
140
projects/app/src/components/common/folder/SelectOneResource.tsx
Normal file
@@ -0,0 +1,140 @@
|
||||
import React, { useState } from 'react';
|
||||
import { Box, Flex } from '@chakra-ui/react';
|
||||
import {
|
||||
GetResourceFolderListProps,
|
||||
GetResourceListItemResponse,
|
||||
ParentIdType
|
||||
} from '@fastgpt/global/common/parentFolder/type';
|
||||
import MyIcon from '@fastgpt/web/components/common/Icon';
|
||||
import Loading from '@fastgpt/web/components/common/MyLoading';
|
||||
import Avatar from '@/components/Avatar';
|
||||
import { useRequest2 } from '@fastgpt/web/hooks/useRequest';
|
||||
import { useMemoizedFn } from 'ahooks';
|
||||
|
||||
type ResourceItemType = GetResourceListItemResponse & {
|
||||
open: boolean;
|
||||
children?: ResourceItemType[];
|
||||
};
|
||||
|
||||
const SelectOneResource = ({
|
||||
server,
|
||||
value,
|
||||
onSelect
|
||||
}: {
|
||||
server: (e: GetResourceFolderListProps) => Promise<GetResourceListItemResponse[]>;
|
||||
value?: ParentIdType;
|
||||
onSelect: (e?: string) => any;
|
||||
}) => {
|
||||
const [dataList, setDataList] = useState<ResourceItemType[]>([]);
|
||||
const [requestingIdList, setRequestingIdList] = useState<ParentIdType[]>([]);
|
||||
|
||||
const { runAsync: requestServer } = useRequest2((e: GetResourceFolderListProps) => {
|
||||
if (requestingIdList.includes(e.parentId)) return Promise.reject(null);
|
||||
|
||||
setRequestingIdList((state) => [...state, e.parentId]);
|
||||
return server(e).finally(() =>
|
||||
setRequestingIdList((state) => state.filter((id) => id !== e.parentId))
|
||||
);
|
||||
}, {});
|
||||
|
||||
const { loading } = useRequest2(() => requestServer({ parentId: null }), {
|
||||
manual: false,
|
||||
onSuccess: (data) => {
|
||||
setDataList(
|
||||
data.map((item) => ({
|
||||
...item,
|
||||
open: false
|
||||
}))
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
const Render = useMemoizedFn(
|
||||
({ list, index = 0 }: { list: ResourceItemType[]; index?: number }) => {
|
||||
return (
|
||||
<>
|
||||
{list.map((item) => (
|
||||
<Box key={item.id} _notLast={{ mb: 0.5 }} userSelect={'none'}>
|
||||
<Flex
|
||||
alignItems={'center'}
|
||||
cursor={'pointer'}
|
||||
py={1}
|
||||
pl={`${1.25 * index + 0.5}rem`}
|
||||
pr={2}
|
||||
borderRadius={'md'}
|
||||
_hover={{
|
||||
bg: 'myGray.100'
|
||||
}}
|
||||
{...(item.id === value
|
||||
? {
|
||||
bg: 'primary.50 !important',
|
||||
onClick: () => onSelect(undefined)
|
||||
}
|
||||
: {
|
||||
onClick: async () => {
|
||||
// folder => open(request children) or close
|
||||
if (item.isFolder) {
|
||||
if (!item.children) {
|
||||
const data = await requestServer({ parentId: item.id });
|
||||
item.children = data.map((item) => ({
|
||||
...item,
|
||||
open: false
|
||||
}));
|
||||
}
|
||||
|
||||
item.open = !item.open;
|
||||
setDataList([...dataList]);
|
||||
} else {
|
||||
onSelect(item.id);
|
||||
}
|
||||
}
|
||||
})}
|
||||
>
|
||||
<Flex
|
||||
alignItems={'center'}
|
||||
justifyContent={'center'}
|
||||
visibility={
|
||||
item.isFolder && (!item.children || item.children.length > 0)
|
||||
? 'visible'
|
||||
: 'hidden'
|
||||
}
|
||||
w={'1.25rem'}
|
||||
h={'1.25rem'}
|
||||
cursor={'pointer'}
|
||||
borderRadius={'xs'}
|
||||
_hover={{
|
||||
bg: 'rgba(31, 35, 41, 0.08)'
|
||||
}}
|
||||
>
|
||||
<MyIcon
|
||||
name={
|
||||
requestingIdList.includes(item.id)
|
||||
? 'common/loading'
|
||||
: 'common/rightArrowFill'
|
||||
}
|
||||
w={'14px'}
|
||||
color={'myGray.500'}
|
||||
transform={item.open ? 'rotate(90deg)' : 'none'}
|
||||
/>
|
||||
</Flex>
|
||||
<Avatar ml={index !== 0 ? '0.5rem' : 0} src={item.avatar} w={'1.25rem'} />
|
||||
<Box fontSize={'sm'} ml={2}>
|
||||
{item.name}
|
||||
</Box>
|
||||
</Flex>
|
||||
{item.children && item.open && (
|
||||
<Box mt={0.5}>
|
||||
<Render list={item.children} index={index + 1} />
|
||||
</Box>
|
||||
)}
|
||||
</Box>
|
||||
))}
|
||||
</>
|
||||
);
|
||||
}
|
||||
);
|
||||
|
||||
return loading ? <Loading fixed={false} /> : <Render list={dataList} />;
|
||||
};
|
||||
|
||||
export default SelectOneResource;
|
178
projects/app/src/components/common/folder/SlideCard.tsx
Normal file
178
projects/app/src/components/common/folder/SlideCard.tsx
Normal file
@@ -0,0 +1,178 @@
|
||||
import { Box, Button, Flex, HStack } from '@chakra-ui/react';
|
||||
import React from 'react';
|
||||
import MyIcon from '@fastgpt/web/components/common/Icon';
|
||||
import { FolderIcon } from '@fastgpt/global/common/file/image/constants';
|
||||
import FormLabel from '@fastgpt/web/components/common/MyBox/FormLabel';
|
||||
import MyDivider from '@fastgpt/web/components/common/MyDivider';
|
||||
import { useTranslation } from 'next-i18next';
|
||||
import { useConfirm } from '@fastgpt/web/hooks/useConfirm';
|
||||
import { PermissionValueType } from '@fastgpt/global/support/permission/type';
|
||||
import DefaultPermissionList from '@/components/support/permission/DefaultPerList';
|
||||
import {
|
||||
CollaboratorContextProvider,
|
||||
MemberManagerInputPropsType
|
||||
} from '../../support/permission/MemberManager/context';
|
||||
import MyTooltip from '@fastgpt/web/components/common/MyTooltip';
|
||||
|
||||
const FolderSlideCard = ({
|
||||
name,
|
||||
intro,
|
||||
onEdit,
|
||||
onMove,
|
||||
deleteTip,
|
||||
onDelete,
|
||||
|
||||
defaultPer,
|
||||
managePer
|
||||
}: {
|
||||
name: string;
|
||||
intro?: string;
|
||||
onEdit: () => void;
|
||||
onMove: () => void;
|
||||
deleteTip: string;
|
||||
onDelete: () => void;
|
||||
|
||||
defaultPer: {
|
||||
value: PermissionValueType;
|
||||
defaultValue: PermissionValueType;
|
||||
onChange: (v: PermissionValueType) => Promise<any>;
|
||||
};
|
||||
managePer: MemberManagerInputPropsType;
|
||||
}) => {
|
||||
const { t } = useTranslation();
|
||||
|
||||
const { ConfirmModal, openConfirm } = useConfirm({
|
||||
type: 'delete',
|
||||
content: deleteTip
|
||||
});
|
||||
|
||||
return (
|
||||
<Box w={'13rem'}>
|
||||
<Box>
|
||||
<HStack>
|
||||
<MyIcon name={FolderIcon} w={'1.5rem'} />
|
||||
<Box color={'myGray.900'}>{name}</Box>
|
||||
<MyIcon
|
||||
name={'edit'}
|
||||
_hover={{ color: 'primary.600' }}
|
||||
w={'0.875rem'}
|
||||
cursor={'pointer'}
|
||||
onClick={onEdit}
|
||||
/>
|
||||
</HStack>
|
||||
<Box mt={3} fontSize={'sm'} color={'myGray.500'} cursor={'pointer'} onClick={onEdit}>
|
||||
{intro || '暂无介绍'}
|
||||
</Box>
|
||||
</Box>
|
||||
|
||||
{managePer.permission.hasManagePer && (
|
||||
<>
|
||||
<MyDivider my={6} />
|
||||
|
||||
<Box>
|
||||
<FormLabel>{t('common.Operation')}</FormLabel>
|
||||
|
||||
<Button
|
||||
variant={'transparentBase'}
|
||||
pl={1}
|
||||
leftIcon={<MyIcon name={'common/file/move'} w={'1rem'} />}
|
||||
transform={'none !important'}
|
||||
w={'100%'}
|
||||
justifyContent={'flex-start'}
|
||||
size={'sm'}
|
||||
fontSize={'mini'}
|
||||
mt={4}
|
||||
onClick={onMove}
|
||||
>
|
||||
{t('common.Move')}
|
||||
</Button>
|
||||
<Button
|
||||
variant={'transparentDanger'}
|
||||
pl={1}
|
||||
leftIcon={<MyIcon name={'delete'} w={'1rem'} />}
|
||||
transform={'none !important'}
|
||||
w={'100%'}
|
||||
justifyContent={'flex-start'}
|
||||
size={'sm'}
|
||||
fontSize={'mini'}
|
||||
mt={3}
|
||||
onClick={() => {
|
||||
openConfirm(onDelete)();
|
||||
}}
|
||||
>
|
||||
{t('common.Delete folder')}
|
||||
</Button>
|
||||
</Box>
|
||||
</>
|
||||
)}
|
||||
|
||||
<MyDivider my={6} />
|
||||
|
||||
<Box>
|
||||
<FormLabel>{t('support.permission.Permission')}</FormLabel>
|
||||
|
||||
{managePer.permission.hasManagePer && (
|
||||
<Box mt={5}>
|
||||
<Box fontSize={'sm'} color={'myGray.500'}>
|
||||
{t('permission.Default permission')}
|
||||
</Box>
|
||||
<DefaultPermissionList
|
||||
mt="1"
|
||||
per={defaultPer.value}
|
||||
defaultPer={defaultPer.defaultValue}
|
||||
onChange={defaultPer.onChange}
|
||||
/>
|
||||
</Box>
|
||||
)}
|
||||
<Box mt={6}>
|
||||
<CollaboratorContextProvider {...managePer}>
|
||||
{({ MemberListCard, onOpenManageModal, onOpenAddMember }) => {
|
||||
return (
|
||||
<>
|
||||
<Flex alignItems="center" justifyContent="space-between">
|
||||
<Box fontSize={'sm'} color={'myGray.500'}>
|
||||
{t('permission.Collaborator')}
|
||||
</Box>
|
||||
{managePer.permission.hasManagePer && (
|
||||
<HStack spacing={3}>
|
||||
<MyTooltip label={t('permission.Manage')}>
|
||||
<MyIcon
|
||||
w="1rem"
|
||||
name="common/settingLight"
|
||||
cursor={'pointer'}
|
||||
_hover={{ color: 'primary.600' }}
|
||||
onClick={onOpenManageModal}
|
||||
/>
|
||||
</MyTooltip>
|
||||
<MyTooltip label={t('common.Add')}>
|
||||
<MyIcon
|
||||
w="1rem"
|
||||
name="support/permission/collaborator"
|
||||
cursor={'pointer'}
|
||||
_hover={{ color: 'primary.600' }}
|
||||
onClick={onOpenAddMember}
|
||||
/>
|
||||
</MyTooltip>
|
||||
</HStack>
|
||||
)}
|
||||
</Flex>
|
||||
<MemberListCard
|
||||
mt={2}
|
||||
tagStyle={{
|
||||
type: 'borderSolid',
|
||||
colorSchema: 'gray'
|
||||
}}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}}
|
||||
</CollaboratorContextProvider>
|
||||
</Box>
|
||||
</Box>
|
||||
|
||||
<ConfirmModal />
|
||||
</Box>
|
||||
);
|
||||
};
|
||||
|
||||
export default FolderSlideCard;
|
54
projects/app/src/components/common/folder/useFolderDrag.tsx
Normal file
54
projects/app/src/components/common/folder/useFolderDrag.tsx
Normal file
@@ -0,0 +1,54 @@
|
||||
import React, { useState, DragEvent, useCallback } from 'react';
|
||||
import type { BoxProps } from '@chakra-ui/react';
|
||||
|
||||
export const useFolderDrag = ({
|
||||
onDrop,
|
||||
activeStyles
|
||||
}: {
|
||||
onDrop: (dragId: string, targetId: string) => any;
|
||||
activeStyles: BoxProps;
|
||||
}) => {
|
||||
const [dragId, setDragId] = useState<string>();
|
||||
const [targetId, setTargetId] = useState<string>();
|
||||
|
||||
const getBoxProps = useCallback(
|
||||
({ dataId, isFolder }: { dataId: string; isFolder: boolean }) => {
|
||||
return {
|
||||
draggable: true,
|
||||
'data-drag-id': isFolder ? dataId : undefined,
|
||||
onDragStart: (e: DragEvent<HTMLDivElement>) => {
|
||||
setDragId(dataId);
|
||||
},
|
||||
onDragOver: (e: DragEvent<HTMLDivElement>) => {
|
||||
e.preventDefault();
|
||||
const targetId = e.currentTarget.getAttribute('data-drag-id');
|
||||
if (!targetId) return;
|
||||
setTargetId(targetId);
|
||||
},
|
||||
onDragLeave: (e: DragEvent<HTMLDivElement>) => {
|
||||
e.preventDefault();
|
||||
setTargetId(undefined);
|
||||
},
|
||||
onDrop: (e: DragEvent<HTMLDivElement>) => {
|
||||
e.preventDefault();
|
||||
|
||||
if (targetId && dragId && targetId !== dragId) {
|
||||
onDrop(dragId, targetId);
|
||||
}
|
||||
|
||||
setTargetId(undefined);
|
||||
setDragId(undefined);
|
||||
},
|
||||
...(activeStyles &&
|
||||
targetId === dataId && {
|
||||
...activeStyles
|
||||
})
|
||||
};
|
||||
},
|
||||
[activeStyles, dragId, onDrop, targetId]
|
||||
);
|
||||
|
||||
return {
|
||||
getBoxProps
|
||||
};
|
||||
};
|
Reference in New Issue
Block a user