mirror of
https://github.com/lencx/ChatGPT.git
synced 2024-10-01 01:06:13 -04:00
chore: export
This commit is contained in:
parent
ae2c56805c
commit
f1a807ed46
@ -40,7 +40,9 @@
|
|||||||
"lodash": "^4.17.21",
|
"lodash": "^4.17.21",
|
||||||
"react": "^18.2.0",
|
"react": "^18.2.0",
|
||||||
"react-dom": "^18.2.0",
|
"react-dom": "^18.2.0",
|
||||||
|
"react-markdown": "^8.0.4",
|
||||||
"react-router-dom": "^6.4.5",
|
"react-router-dom": "^6.4.5",
|
||||||
|
"react-syntax-highlighter": "^15.5.0",
|
||||||
"uuid": "^9.0.0"
|
"uuid": "^9.0.0"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
@ -50,6 +52,7 @@
|
|||||||
"@types/node": "^18.7.10",
|
"@types/node": "^18.7.10",
|
||||||
"@types/react": "^18.0.15",
|
"@types/react": "^18.0.15",
|
||||||
"@types/react-dom": "^18.0.6",
|
"@types/react-dom": "^18.0.6",
|
||||||
|
"@types/react-syntax-highlighter": "^15.5.6",
|
||||||
"@types/uuid": "^9.0.0",
|
"@types/uuid": "^9.0.0",
|
||||||
"@vitejs/plugin-react": "^3.0.0",
|
"@vitejs/plugin-react": "^3.0.0",
|
||||||
"sass": "^1.56.2",
|
"sass": "^1.56.2",
|
||||||
|
@ -203,14 +203,14 @@ pub fn get_download_list(pathname: &str) -> (Vec<serde_json::Value>, PathBuf) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
#[command]
|
#[command]
|
||||||
pub fn download_list(pathname: &str, filename: Option<String>, id: Option<String>) {
|
pub fn download_list(pathname: &str, dir: &str, filename: Option<String>, id: Option<String>) {
|
||||||
info!("download_list: {}", pathname);
|
info!("download_list: {}", pathname);
|
||||||
let data = get_download_list(pathname);
|
let data = get_download_list(pathname);
|
||||||
let mut list = vec![];
|
let mut list = vec![];
|
||||||
let mut idmap = HashMap::new();
|
let mut idmap = HashMap::new();
|
||||||
utils::vec_to_hashmap(data.0.into_iter(), "id", &mut idmap);
|
utils::vec_to_hashmap(data.0.into_iter(), "id", &mut idmap);
|
||||||
|
|
||||||
for entry in WalkDir::new(utils::chat_root().join("download"))
|
for entry in WalkDir::new(utils::chat_root().join(dir))
|
||||||
.into_iter()
|
.into_iter()
|
||||||
.filter_entry(|e| !utils::is_hidden(e))
|
.filter_entry(|e| !utils::is_hidden(e))
|
||||||
.filter_map(|e| e.ok())
|
.filter_map(|e| e.ok())
|
||||||
|
@ -30,7 +30,8 @@ async fn main() {
|
|||||||
trace: Color::Cyan,
|
trace: Color::Cyan,
|
||||||
};
|
};
|
||||||
|
|
||||||
cmd::download_list("chat.download.json", None, None);
|
cmd::download_list("chat.download.json", "download", None, None);
|
||||||
|
cmd::download_list("chat.notes.json", "notes", None, None);
|
||||||
|
|
||||||
let chat_conf = ChatConfJson::get_chat_conf();
|
let chat_conf = ChatConfJson::get_chat_conf();
|
||||||
|
|
||||||
|
8
src-tauri/src/scripts/export.js
vendored
8
src-tauri/src/scripts/export.js
vendored
@ -134,7 +134,9 @@ function addActionsButtons(actionsArea, TryAgainButton) {
|
|||||||
|
|
||||||
async function exportMarkdown() {
|
async function exportMarkdown() {
|
||||||
const data = ExportMD.turndown(document.querySelector("main div>div>div").innerHTML);
|
const data = ExportMD.turndown(document.querySelector("main div>div>div").innerHTML);
|
||||||
await invoke('save_file', { name: `notes/${uid().toString(36)}.md`, content: data });
|
const { id, filename } = getName();
|
||||||
|
await invoke('save_file', { name: `notes/${id}.md`, content: data });
|
||||||
|
await invoke('download_list', { pathname: 'chat.notes.json', filename, id, dir: 'notes' });
|
||||||
}
|
}
|
||||||
|
|
||||||
function downloadThread({ as = Format.PNG } = {}) {
|
function downloadThread({ as = Format.PNG } = {}) {
|
||||||
@ -168,7 +170,7 @@ async function handleImg(imgData) {
|
|||||||
}
|
}
|
||||||
const { pathname, id, filename } = getName();
|
const { pathname, id, filename } = getName();
|
||||||
await invoke('download', { name: `download/img/${id}.png`, blob: data });
|
await invoke('download', { name: `download/img/${id}.png`, blob: data });
|
||||||
await invoke('download_list', { pathname, filename, id });
|
await invoke('download_list', { pathname, filename, id, dir: 'download' });
|
||||||
}
|
}
|
||||||
|
|
||||||
async function handlePdf(imgData, canvas, pixelRatio) {
|
async function handlePdf(imgData, canvas, pixelRatio) {
|
||||||
@ -184,7 +186,7 @@ async function handlePdf(imgData, canvas, pixelRatio) {
|
|||||||
const { pathname, id, filename } = getName();
|
const { pathname, id, filename } = getName();
|
||||||
const data = pdf.__private__.getArrayBuffer(pdf.__private__.buildDocument());
|
const data = pdf.__private__.getArrayBuffer(pdf.__private__.buildDocument());
|
||||||
await invoke('download', { name: `download/pdf/${id}.pdf`, blob: Array.from(new Uint8Array(data)) });
|
await invoke('download', { name: `download/pdf/${id}.pdf`, blob: Array.from(new Uint8Array(data)) });
|
||||||
await invoke('download_list', { pathname, filename, id });
|
await invoke('download_list', { pathname, filename, id, dir: 'download' });
|
||||||
}
|
}
|
||||||
|
|
||||||
function getName() {
|
function getName() {
|
||||||
|
1
src/hooks/useJson.ts
vendored
1
src/hooks/useJson.ts
vendored
@ -9,6 +9,7 @@ export default function useJson<T>(file: string) {
|
|||||||
const refreshJson = async () => {
|
const refreshJson = async () => {
|
||||||
const data = await readJSON(file);
|
const data = await readJSON(file);
|
||||||
setData(data);
|
setData(data);
|
||||||
|
return data;
|
||||||
};
|
};
|
||||||
|
|
||||||
const updateJson = async (data: any) => {
|
const updateJson = async (data: any) => {
|
||||||
|
22
src/routes.tsx
vendored
22
src/routes.tsx
vendored
@ -1,11 +1,12 @@
|
|||||||
import { useRoutes } from 'react-router-dom';
|
import { useRoutes } from 'react-router-dom';
|
||||||
import {
|
import {
|
||||||
DesktopOutlined,
|
SettingOutlined,
|
||||||
BulbOutlined,
|
BulbOutlined,
|
||||||
SyncOutlined,
|
SyncOutlined,
|
||||||
FileSyncOutlined,
|
FileSyncOutlined,
|
||||||
UserOutlined,
|
UserOutlined,
|
||||||
DownloadOutlined,
|
DownloadOutlined,
|
||||||
|
FormOutlined,
|
||||||
} from '@ant-design/icons';
|
} from '@ant-design/icons';
|
||||||
import type { MenuProps } from 'antd';
|
import type { MenuProps } from 'antd';
|
||||||
|
|
||||||
@ -15,6 +16,7 @@ import SyncPrompts from '@/view/model/SyncPrompts';
|
|||||||
import SyncCustom from '@/view/model/SyncCustom';
|
import SyncCustom from '@/view/model/SyncCustom';
|
||||||
import SyncRecord from '@/view/model/SyncRecord';
|
import SyncRecord from '@/view/model/SyncRecord';
|
||||||
import Download from '@/view/download';
|
import Download from '@/view/download';
|
||||||
|
import Notes from '@/view/notes';
|
||||||
|
|
||||||
export type ChatRouteMetaObject = {
|
export type ChatRouteMetaObject = {
|
||||||
label: string;
|
label: string;
|
||||||
@ -35,15 +37,15 @@ export const routes: Array<ChatRouteObject> = [
|
|||||||
element: <General />,
|
element: <General />,
|
||||||
meta: {
|
meta: {
|
||||||
label: 'General',
|
label: 'General',
|
||||||
icon: <DesktopOutlined />,
|
icon: <SettingOutlined />,
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
path: 'download',
|
path: '/notes',
|
||||||
element: <Download />,
|
element: <Notes />,
|
||||||
meta: {
|
meta: {
|
||||||
label: 'Download',
|
label: 'Notes',
|
||||||
icon: <DownloadOutlined />,
|
icon: <FormOutlined />,
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
@ -85,6 +87,14 @@ export const routes: Array<ChatRouteObject> = [
|
|||||||
},
|
},
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
path: 'download',
|
||||||
|
element: <Download />,
|
||||||
|
meta: {
|
||||||
|
label: 'Download',
|
||||||
|
icon: <DownloadOutlined />,
|
||||||
|
},
|
||||||
|
},
|
||||||
];
|
];
|
||||||
|
|
||||||
type MenuItem = Required<MenuProps>['items'][number];
|
type MenuItem = Required<MenuProps>['items'][number];
|
||||||
|
1
src/utils.ts
vendored
1
src/utils.ts
vendored
@ -5,6 +5,7 @@ import dayjs from 'dayjs';
|
|||||||
export const CHAT_MODEL_JSON = 'chat.model.json';
|
export const CHAT_MODEL_JSON = 'chat.model.json';
|
||||||
export const CHAT_MODEL_CMD_JSON = 'chat.model.cmd.json';
|
export const CHAT_MODEL_CMD_JSON = 'chat.model.cmd.json';
|
||||||
export const CHAT_DOWNLOAD_JSON = 'chat.download.json';
|
export const CHAT_DOWNLOAD_JSON = 'chat.download.json';
|
||||||
|
export const CHAT_NOTES_JSON = 'chat.notes.json';
|
||||||
export const CHAT_PROMPTS_CSV = 'chat.prompts.csv';
|
export const CHAT_PROMPTS_CSV = 'chat.prompts.csv';
|
||||||
export const GITHUB_PROMPTS_CSV_URL = 'https://raw.githubusercontent.com/f/awesome-chatgpt-prompts/main/prompts.csv';
|
export const GITHUB_PROMPTS_CSV_URL = 'https://raw.githubusercontent.com/f/awesome-chatgpt-prompts/main/prompts.csv';
|
||||||
export const DISABLE_AUTO_COMPLETE = {
|
export const DISABLE_AUTO_COMPLETE = {
|
||||||
|
12
src/view/download/index.tsx
vendored
12
src/view/download/index.tsx
vendored
@ -18,7 +18,7 @@ function renderFile(buff: Uint8Array, type: string) {
|
|||||||
return URL.createObjectURL(new Blob([buff], { type: renderType }));
|
return URL.createObjectURL(new Blob([buff], { type: renderType }));
|
||||||
}
|
}
|
||||||
|
|
||||||
export default function SyncPrompts() {
|
export default function Download() {
|
||||||
const [downloadPath, setDownloadPath] = useState('');
|
const [downloadPath, setDownloadPath] = useState('');
|
||||||
const [source, setSource] = useState('');
|
const [source, setSource] = useState('');
|
||||||
const [isVisible, setVisible] = useState(false);
|
const [isVisible, setVisible] = useState(false);
|
||||||
@ -51,9 +51,6 @@ export default function SyncPrompts() {
|
|||||||
setVisible(true);
|
setVisible(true);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if (opInfo.opType === 'file') {
|
|
||||||
await shell.open(file);
|
|
||||||
}
|
|
||||||
if (opInfo.opType === 'delete') {
|
if (opInfo.opType === 'delete') {
|
||||||
await fs.removeFile(file);
|
await fs.removeFile(file);
|
||||||
await handleRefresh();
|
await handleRefresh();
|
||||||
@ -90,8 +87,9 @@ export default function SyncPrompts() {
|
|||||||
};
|
};
|
||||||
|
|
||||||
const handleRefresh = async () => {
|
const handleRefresh = async () => {
|
||||||
await invoke('download_list', { pathname: CHAT_DOWNLOAD_JSON });
|
await invoke('download_list', { pathname: CHAT_DOWNLOAD_JSON, dir: 'download' });
|
||||||
refreshJson();
|
const data = await refreshJson();
|
||||||
|
opInit(data);
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleCancel = () => {
|
const handleCancel = () => {
|
||||||
@ -107,7 +105,7 @@ export default function SyncPrompts() {
|
|||||||
<>
|
<>
|
||||||
<Popconfirm
|
<Popconfirm
|
||||||
overlayStyle={{ width: 250 }}
|
overlayStyle={{ width: 250 }}
|
||||||
title="Sync will overwrite the previous data, confirm to sync?"
|
title="Files cannot be recovered after deletion, are you sure you want to delete them?"
|
||||||
placement="topLeft"
|
placement="topLeft"
|
||||||
onConfirm={handleDelete}
|
onConfirm={handleDelete}
|
||||||
okText="Yes"
|
okText="Yes"
|
||||||
|
69
src/view/notes/config.tsx
vendored
Normal file
69
src/view/notes/config.tsx
vendored
Normal file
@ -0,0 +1,69 @@
|
|||||||
|
import { useState } from 'react';
|
||||||
|
import { Space, Popconfirm } from 'antd';
|
||||||
|
import { path, shell } from '@tauri-apps/api';
|
||||||
|
|
||||||
|
import { EditRow } from '@/hooks/useColumns';
|
||||||
|
|
||||||
|
import useInit from '@/hooks/useInit';
|
||||||
|
import { fmtDate, chatRoot } from '@/utils';
|
||||||
|
|
||||||
|
export const notesColumns = () => [
|
||||||
|
{
|
||||||
|
title: 'Name',
|
||||||
|
dataIndex: 'name',
|
||||||
|
fixed: 'left',
|
||||||
|
key: 'name',
|
||||||
|
width: 240,
|
||||||
|
render: (_: string, row: any, actions: any) => (
|
||||||
|
<EditRow rowKey="name" row={row} actions={actions} />
|
||||||
|
),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: 'Path',
|
||||||
|
dataIndex: 'path',
|
||||||
|
key: 'path',
|
||||||
|
width: 200,
|
||||||
|
render: (_: string, row: any) => <RenderPath row={row} />,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: 'Created',
|
||||||
|
dataIndex: 'created',
|
||||||
|
key: 'created',
|
||||||
|
width: 150,
|
||||||
|
render: fmtDate,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: 'Action',
|
||||||
|
fixed: 'right',
|
||||||
|
width: 160,
|
||||||
|
render: (_: any, row: any, actions: any) => {
|
||||||
|
return (
|
||||||
|
<Space>
|
||||||
|
<a onClick={() => actions.setRecord(row, 'preview')}>Preview</a>
|
||||||
|
<a onClick={() => actions.setRecord(row, 'edit')}>Edit</a>
|
||||||
|
<Popconfirm
|
||||||
|
title="Are you sure to delete this file?"
|
||||||
|
onConfirm={() => actions.setRecord(row, 'delete')}
|
||||||
|
okText="Yes"
|
||||||
|
cancelText="No"
|
||||||
|
>
|
||||||
|
<a>Delete</a>
|
||||||
|
</Popconfirm>
|
||||||
|
</Space>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
];
|
||||||
|
|
||||||
|
const RenderPath = ({ row }: any) => {
|
||||||
|
const [filePath, setFilePath] = useState('');
|
||||||
|
useInit(async () => {
|
||||||
|
setFilePath(await getPath(row));
|
||||||
|
})
|
||||||
|
return <a onClick={() => shell.open(filePath)}>{filePath}</a>;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const getPath = async (row: any) => {
|
||||||
|
const isImg = ['png'].includes(row?.ext);
|
||||||
|
return await path.join(await chatRoot(), 'notes', row.id) + `.${row.ext}`;
|
||||||
|
}
|
160
src/view/notes/index.tsx
vendored
Normal file
160
src/view/notes/index.tsx
vendored
Normal file
@ -0,0 +1,160 @@
|
|||||||
|
import { useEffect, useState } from 'react';
|
||||||
|
import { Table, Modal, Popconfirm, Button, message } from 'antd';
|
||||||
|
import { invoke, path, shell, fs } from '@tauri-apps/api';
|
||||||
|
import ReactMarkdown from 'react-markdown';
|
||||||
|
import { Prism as SyntaxHighlighter } from 'react-syntax-highlighter';
|
||||||
|
import { a11yDark } from 'react-syntax-highlighter/dist/esm/styles/prism';
|
||||||
|
|
||||||
|
import useInit from '@/hooks/useInit';
|
||||||
|
import useJson from '@/hooks/useJson';
|
||||||
|
import useData from '@/hooks/useData';
|
||||||
|
import useColumns from '@/hooks/useColumns';
|
||||||
|
import { useTableRowSelection, TABLE_PAGINATION } from '@/hooks/useTable';
|
||||||
|
import { chatRoot, CHAT_NOTES_JSON } from '@/utils';
|
||||||
|
import { notesColumns } from './config';
|
||||||
|
|
||||||
|
export default function Notes() {
|
||||||
|
const [notesPath, setNotesPath] = useState('');
|
||||||
|
const [source, setSource] = useState('');
|
||||||
|
const [isVisible, setVisible] = useState(false);
|
||||||
|
const { opData, opInit, opReplace, opSafeKey } = useData([]);
|
||||||
|
const { columns, ...opInfo } = useColumns(notesColumns());
|
||||||
|
const { rowSelection, selectedRows, rowReset } = useTableRowSelection({ rowType: 'row' });
|
||||||
|
const { json, refreshJson, updateJson } = useJson<any[]>(CHAT_NOTES_JSON);
|
||||||
|
const selectedItems = rowSelection.selectedRowKeys || [];
|
||||||
|
|
||||||
|
useInit(async () => {
|
||||||
|
const file = await path.join(await chatRoot(), CHAT_NOTES_JSON);
|
||||||
|
setNotesPath(file);
|
||||||
|
});
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!json || json.length <= 0) return;
|
||||||
|
opInit(json);
|
||||||
|
}, [json?.length]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!opInfo.opType) return;
|
||||||
|
(async () => {
|
||||||
|
const record = opInfo?.opRecord;
|
||||||
|
const file = await path.join(await chatRoot(), 'notes', `${record?.id}.${record?.ext}`);
|
||||||
|
if (opInfo.opType === 'preview') {
|
||||||
|
const data = await fs.readTextFile(file);
|
||||||
|
setSource(data);
|
||||||
|
setVisible(true);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (opInfo.opType === 'edit') {
|
||||||
|
alert('TODO');
|
||||||
|
}
|
||||||
|
if (opInfo.opType === 'delete') {
|
||||||
|
await fs.removeFile(file);
|
||||||
|
await handleRefresh();
|
||||||
|
}
|
||||||
|
if (opInfo.opType === 'rowedit') {
|
||||||
|
const data = opReplace(opInfo?.opRecord?.[opSafeKey], opInfo?.opRecord);
|
||||||
|
await updateJson(data);
|
||||||
|
message.success('Name has been changed!');
|
||||||
|
}
|
||||||
|
opInfo.resetRecord();
|
||||||
|
})()
|
||||||
|
}, [opInfo.opType])
|
||||||
|
|
||||||
|
const handleDelete = async () => {
|
||||||
|
if (opData?.length === selectedRows.length) {
|
||||||
|
const notesDir = await path.join(await chatRoot(), 'notes');
|
||||||
|
await fs.removeDir(notesDir, { recursive: true });
|
||||||
|
await handleRefresh();
|
||||||
|
rowReset();
|
||||||
|
message.success('All files have been cleared!');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const rows = selectedRows.map(async (i) => {
|
||||||
|
const file = await path.join(await chatRoot(), 'notes', `${i?.id}.${i?.ext}`);
|
||||||
|
await fs.removeFile(file);
|
||||||
|
return file;
|
||||||
|
})
|
||||||
|
Promise.all(rows).then(async () => {
|
||||||
|
await handleRefresh();
|
||||||
|
message.success('All files selected are cleared!');
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleRefresh = async () => {
|
||||||
|
await invoke('download_list', { pathname: CHAT_NOTES_JSON, dir: 'notes' });
|
||||||
|
const data = await refreshJson();
|
||||||
|
opInit(data);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleCancel = () => {
|
||||||
|
setVisible(false);
|
||||||
|
opInfo.resetRecord();
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div>
|
||||||
|
<div className="chat-table-btns">
|
||||||
|
<div>
|
||||||
|
{selectedItems.length > 0 && (
|
||||||
|
<>
|
||||||
|
<Popconfirm
|
||||||
|
overlayStyle={{ width: 250 }}
|
||||||
|
title="Files cannot be recovered after deletion, are you sure you want to delete them?"
|
||||||
|
placement="topLeft"
|
||||||
|
onConfirm={handleDelete}
|
||||||
|
okText="Yes"
|
||||||
|
cancelText="No"
|
||||||
|
>
|
||||||
|
<Button>Batch delete</Button>
|
||||||
|
</Popconfirm>
|
||||||
|
<span className="num">Selected {selectedItems.length} items</span>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="chat-table-tip">
|
||||||
|
<div className="chat-file-path">
|
||||||
|
<div>PATH: <a onClick={() => shell.open(notesPath)} title={notesPath}>{notesPath}</a></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<Table
|
||||||
|
rowKey="id"
|
||||||
|
columns={columns}
|
||||||
|
scroll={{ x: 800 }}
|
||||||
|
dataSource={opData}
|
||||||
|
rowSelection={rowSelection}
|
||||||
|
pagination={TABLE_PAGINATION}
|
||||||
|
/>
|
||||||
|
<Modal
|
||||||
|
open={isVisible}
|
||||||
|
title={<div>{opInfo?.opRecord?.name || ''}</div>}
|
||||||
|
onCancel={handleCancel}
|
||||||
|
footer={false}
|
||||||
|
destroyOnClose
|
||||||
|
>
|
||||||
|
<ReactMarkdown
|
||||||
|
children={source}
|
||||||
|
components={{
|
||||||
|
code({node, inline, className, children, ...props}) {
|
||||||
|
const match = /language-(\w+)/.exec(className || '')
|
||||||
|
return !inline && match ? (
|
||||||
|
<SyntaxHighlighter
|
||||||
|
children={String(children).replace(/\n$/, '')}
|
||||||
|
style={a11yDark as any}
|
||||||
|
language={match[1]}
|
||||||
|
PreTag="div"
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<code className={className} {...props}>
|
||||||
|
{children}
|
||||||
|
</code>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</Modal>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
Loading…
Reference in New Issue
Block a user