feat: add filter and sorting functionality to torrents table
This commit is contained in:
@@ -91,6 +91,10 @@
|
|||||||
"html-entities": "^2.3.3",
|
"html-entities": "^2.3.3",
|
||||||
"i18next": "^22.5.1",
|
"i18next": "^22.5.1",
|
||||||
"immer": "^10.0.2",
|
"immer": "^10.0.2",
|
||||||
|
"js-file-download": "^0.4.12",
|
||||||
|
"mantine-react-table": "^1.3.4",
|
||||||
|
"moment": "^2.29.4",
|
||||||
|
"moment-timezone": "^0.5.43",
|
||||||
"next": "13.4.12",
|
"next": "13.4.12",
|
||||||
"next-auth": "^4.23.0",
|
"next-auth": "^4.23.0",
|
||||||
"next-i18next": "^14.0.0",
|
"next-i18next": "^14.0.0",
|
||||||
|
|||||||
@@ -41,12 +41,22 @@
|
|||||||
},
|
},
|
||||||
"table": {
|
"table": {
|
||||||
"header": {
|
"header": {
|
||||||
|
"isCompleted": "Downloading",
|
||||||
"name": "Name",
|
"name": "Name",
|
||||||
|
"dateAdded": "Added On",
|
||||||
"size": "Size",
|
"size": "Size",
|
||||||
"download": "Down",
|
"download": "Down",
|
||||||
"upload": "Up",
|
"upload": "Up",
|
||||||
"estimatedTimeOfArrival": "ETA",
|
"estimatedTimeOfArrival": "ETA",
|
||||||
"progress": "Progress"
|
"progress": "Progress",
|
||||||
|
"totalUploaded": "Total Upload",
|
||||||
|
"totalDownloaded": "Total Download",
|
||||||
|
"ratio": "Ratio",
|
||||||
|
"seeds": "Seeds (Connected)",
|
||||||
|
"peers": "Peers (Connected)",
|
||||||
|
"label": "Label",
|
||||||
|
"state": "State",
|
||||||
|
"stateMessage": "State Message"
|
||||||
},
|
},
|
||||||
"item": {
|
"item": {
|
||||||
"text": "Managed by {{appName}}, {{ratio}} ratio"
|
"text": "Managed by {{appName}}, {{ratio}} ratio"
|
||||||
|
|||||||
@@ -6,12 +6,11 @@ import {
|
|||||||
Group,
|
Group,
|
||||||
List,
|
List,
|
||||||
MantineColor,
|
MantineColor,
|
||||||
Popover,
|
|
||||||
Progress,
|
Progress,
|
||||||
Stack,
|
Stack,
|
||||||
Text,
|
Text,
|
||||||
createStyles,
|
createStyles,
|
||||||
useMantineTheme,
|
useMantineTheme
|
||||||
} from '@mantine/core';
|
} from '@mantine/core';
|
||||||
import {
|
import {
|
||||||
IconAffiliate,
|
IconAffiliate,
|
||||||
@@ -24,8 +23,6 @@ import {
|
|||||||
IconUpload,
|
IconUpload,
|
||||||
} from '@tabler/icons-react';
|
} from '@tabler/icons-react';
|
||||||
import { useTranslation } from 'next-i18next';
|
import { useTranslation } from 'next-i18next';
|
||||||
import { MIN_WIDTH_MOBILE } from '~/constants/constants';
|
|
||||||
import { calculateETA } from '~/tools/client/calculateEta';
|
|
||||||
import { humanFileSize } from '~/tools/humanFileSize';
|
import { humanFileSize } from '~/tools/humanFileSize';
|
||||||
import { AppType } from '~/types/app';
|
import { AppType } from '~/types/app';
|
||||||
|
|
||||||
@@ -35,89 +32,7 @@ interface TorrentQueueItemProps {
|
|||||||
width: number;
|
width: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
export const BitTorrentQueueItem = ({ torrent, width, app }: TorrentQueueItemProps) => {
|
export const TorrentQueuePopover = ({ torrent, app }: Omit<TorrentQueueItemProps, 'width'>) => {
|
||||||
const { classes } = useStyles();
|
|
||||||
const { t } = useTranslation('modules/torrents-status');
|
|
||||||
|
|
||||||
const size = torrent.totalSelected;
|
|
||||||
return (
|
|
||||||
<Popover
|
|
||||||
withArrow
|
|
||||||
withinPortal
|
|
||||||
radius="lg"
|
|
||||||
shadow="sm"
|
|
||||||
transitionProps={{
|
|
||||||
transition: 'pop',
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<Popover.Target>
|
|
||||||
<tr key={torrent.id} style={{ cursor: 'pointer' }}>
|
|
||||||
<td>
|
|
||||||
<Text
|
|
||||||
style={{
|
|
||||||
maxWidth: '30vw',
|
|
||||||
}}
|
|
||||||
size="xs"
|
|
||||||
lineClamp={1}
|
|
||||||
>
|
|
||||||
{torrent.name}
|
|
||||||
</Text>
|
|
||||||
{app && (
|
|
||||||
<Text size="xs" color="dimmed">
|
|
||||||
{t('card.table.item.text', {
|
|
||||||
appName: app.name,
|
|
||||||
ratio: torrent.ratio.toFixed(2),
|
|
||||||
})}
|
|
||||||
</Text>
|
|
||||||
)}
|
|
||||||
</td>
|
|
||||||
<td>
|
|
||||||
<Text className={classes.noTextBreak} size="xs">
|
|
||||||
{humanFileSize(size, false)}
|
|
||||||
</Text>
|
|
||||||
</td>
|
|
||||||
{width > MIN_WIDTH_MOBILE && (
|
|
||||||
<td>
|
|
||||||
<Text className={classes.noTextBreak} size="xs">
|
|
||||||
{torrent.downloadSpeed > 0 ? `${humanFileSize(torrent.downloadSpeed,false)}/s` : '-'}
|
|
||||||
</Text>
|
|
||||||
</td>
|
|
||||||
)}
|
|
||||||
{width > MIN_WIDTH_MOBILE && (
|
|
||||||
<td>
|
|
||||||
<Text className={classes.noTextBreak} size="xs">
|
|
||||||
{torrent.uploadSpeed > 0 ? `${humanFileSize(torrent.uploadSpeed,false)}/s` : '-'}
|
|
||||||
</Text>
|
|
||||||
</td>
|
|
||||||
)}
|
|
||||||
{width > MIN_WIDTH_MOBILE && (
|
|
||||||
<td>
|
|
||||||
<Text className={classes.noTextBreak} size="xs">
|
|
||||||
{torrent.eta <= 0 ? '∞' : calculateETA(torrent.eta)}
|
|
||||||
</Text>
|
|
||||||
</td>
|
|
||||||
)}
|
|
||||||
<td>
|
|
||||||
<Text className={classes.noTextBreak}>{(torrent.progress * 100).toFixed(1)}%</Text>
|
|
||||||
<Progress
|
|
||||||
radius="lg"
|
|
||||||
color={
|
|
||||||
torrent.progress === 1 ? 'green' : torrent.state === 'paused' ? 'yellow' : 'blue'
|
|
||||||
}
|
|
||||||
value={torrent.progress * 100}
|
|
||||||
size="lg"
|
|
||||||
/>
|
|
||||||
</td>
|
|
||||||
</tr>
|
|
||||||
</Popover.Target>
|
|
||||||
<Popover.Dropdown>
|
|
||||||
<TorrentQueuePopover torrent={torrent} app={app} />
|
|
||||||
</Popover.Dropdown>
|
|
||||||
</Popover>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
const TorrentQueuePopover = ({ torrent, app }: Omit<TorrentQueueItemProps, 'width'>) => {
|
|
||||||
const { t } = useTranslation('modules/torrents-status');
|
const { t } = useTranslation('modules/torrents-status');
|
||||||
const { colors } = useMantineTheme();
|
const { colors } = useMantineTheme();
|
||||||
|
|
||||||
|
|||||||
@@ -1,31 +1,42 @@
|
|||||||
import { NormalizedTorrent, TorrentState } from '@ctrl/shared-torrent';
|
import {
|
||||||
|
MRT_Table,
|
||||||
|
useMantineReactTable,
|
||||||
|
type MRT_ColumnDef,
|
||||||
|
} from 'mantine-react-table';
|
||||||
|
|
||||||
|
import { NormalizedTorrent } from '@ctrl/shared-torrent';
|
||||||
import {
|
import {
|
||||||
Badge,
|
Badge,
|
||||||
Center,
|
Center,
|
||||||
|
createStyles,
|
||||||
Flex,
|
Flex,
|
||||||
Group,
|
Group,
|
||||||
Loader,
|
Loader,
|
||||||
|
Popover,
|
||||||
|
Progress,
|
||||||
ScrollArea,
|
ScrollArea,
|
||||||
Stack,
|
Stack,
|
||||||
Table,
|
|
||||||
Text,
|
Text,
|
||||||
Title,
|
Title,
|
||||||
} from '@mantine/core';
|
} from '@mantine/core';
|
||||||
import { useElementSize } from '@mantine/hooks';
|
import { useElementSize } from '@mantine/hooks';
|
||||||
import { IconFileDownload, IconInfoCircle } from '@tabler/icons-react';
|
import { IconFileDownload } from '@tabler/icons-react';
|
||||||
import dayjs from 'dayjs';
|
import dayjs from 'dayjs';
|
||||||
import duration from 'dayjs/plugin/duration';
|
import duration from 'dayjs/plugin/duration';
|
||||||
import relativeTime from 'dayjs/plugin/relativeTime';
|
import relativeTime from 'dayjs/plugin/relativeTime';
|
||||||
import { useTranslation } from 'next-i18next';
|
import { useTranslation } from 'next-i18next';
|
||||||
|
import { useMemo } from 'react';
|
||||||
import { useCardStyles } from '~/components/layout/Common/useCardStyles';
|
import { useCardStyles } from '~/components/layout/Common/useCardStyles';
|
||||||
import { MIN_WIDTH_MOBILE } from '~/constants/constants';
|
import { MIN_WIDTH_MOBILE } from '~/constants/constants';
|
||||||
|
import { calculateETA } from '~/tools/client/calculateEta';
|
||||||
|
import { humanFileSize } from '~/tools/humanFileSize';
|
||||||
import { NormalizedDownloadQueueResponse } from '~/types/api/downloads/queue/NormalizedDownloadQueueResponse';
|
import { NormalizedDownloadQueueResponse } from '~/types/api/downloads/queue/NormalizedDownloadQueueResponse';
|
||||||
import { AppIntegrationType } from '~/types/app';
|
import { AppIntegrationType } from '~/types/app';
|
||||||
|
|
||||||
import { useGetDownloadClientsQueue } from '../download-speed/useGetNetworkSpeed';
|
import { useGetDownloadClientsQueue } from '../download-speed/useGetNetworkSpeed';
|
||||||
import { defineWidget } from '../helper';
|
import { defineWidget } from '../helper';
|
||||||
import { IWidget } from '../widgets';
|
import { IWidget } from '../widgets';
|
||||||
import { BitTorrentQueueItem } from './TorrentQueueItem';
|
import { TorrentQueuePopover } from './TorrentQueueItem';
|
||||||
|
|
||||||
dayjs.extend(duration);
|
dayjs.extend(duration);
|
||||||
dayjs.extend(relativeTime);
|
dayjs.extend(relativeTime);
|
||||||
@@ -44,7 +55,8 @@ const definition = defineWidget({
|
|||||||
type: 'switch',
|
type: 'switch',
|
||||||
defaultValue: true,
|
defaultValue: true,
|
||||||
},
|
},
|
||||||
speedLimitOfActiveTorrents: { // Unit : kB/s
|
speedLimitOfActiveTorrents: {
|
||||||
|
// Unit : kB/s
|
||||||
type: 'number',
|
type: 'number',
|
||||||
defaultValue: 10,
|
defaultValue: 10,
|
||||||
},
|
},
|
||||||
@@ -98,6 +110,137 @@ function TorrentTile({ widget }: TorrentTileProps) {
|
|||||||
dataUpdatedAt: number;
|
dataUpdatedAt: number;
|
||||||
} = useGetDownloadClientsQueue();
|
} = useGetDownloadClientsQueue();
|
||||||
|
|
||||||
|
let torrents: NormalizedTorrent[] = [];
|
||||||
|
if(!(isError || !data || data.apps.length === 0 || Object.values(data.apps).length < 1)) {
|
||||||
|
torrents = data.apps.flatMap((app) => (app.type === 'torrent' ? app.torrents : []))
|
||||||
|
}
|
||||||
|
|
||||||
|
const filteredTorrents = filterTorrents(widget, torrents);
|
||||||
|
|
||||||
|
|
||||||
|
const difference = new Date().getTime() - dataUpdatedAt;
|
||||||
|
const duration = dayjs.duration(difference, 'ms');
|
||||||
|
const humanizedDuration = duration.humanize();
|
||||||
|
|
||||||
|
const ratioGlobal = getTorrentsRatio(widget, torrents, false);
|
||||||
|
const ratioWithFilter = getTorrentsRatio(widget, torrents, true);
|
||||||
|
|
||||||
|
const columns = useMemo<MRT_ColumnDef<NormalizedTorrent>[]>(() => [
|
||||||
|
{
|
||||||
|
id: "dateAdded",
|
||||||
|
accessorFn: (row) => new Date(row.dateAdded),
|
||||||
|
header: "dateAdded",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
accessorKey: 'name',
|
||||||
|
header: t('card.table.header.name'),
|
||||||
|
Cell: ({ cell, row }) => (
|
||||||
|
<Popover
|
||||||
|
withArrow
|
||||||
|
withinPortal
|
||||||
|
radius="lg"
|
||||||
|
shadow="sm"
|
||||||
|
transitionProps={{
|
||||||
|
transition: 'pop',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Popover.Target>
|
||||||
|
<Text
|
||||||
|
style={{
|
||||||
|
maxWidth: '30vw',
|
||||||
|
}}
|
||||||
|
size="xs"
|
||||||
|
lineClamp={1}
|
||||||
|
>
|
||||||
|
{String(cell.getValue())}
|
||||||
|
</Text>
|
||||||
|
</Popover.Target>
|
||||||
|
<Popover.Dropdown>
|
||||||
|
<TorrentQueuePopover torrent={row.original} app={undefined} />
|
||||||
|
</Popover.Dropdown>
|
||||||
|
</Popover>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
accessorKey: 'totalSize',
|
||||||
|
header: t('card.table.header.size'),
|
||||||
|
Cell: ({ cell }) => formatSize(Number(cell.getValue())),
|
||||||
|
sortDescFirst: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
accessorKey: 'uploadSpeed',
|
||||||
|
header: t('card.table.header.upload'),
|
||||||
|
Cell: ({ cell }) => formatSpeed(Number(cell.getValue())),
|
||||||
|
sortDescFirst: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
accessorKey: 'downloadSpeed',
|
||||||
|
header: t('card.table.header.download'),
|
||||||
|
Cell: ({ cell }) => formatSpeed(Number(cell.getValue())),
|
||||||
|
sortDescFirst: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
accessorKey: 'eta',
|
||||||
|
header: t('card.table.header.estimatedTimeOfArrival'),
|
||||||
|
Cell: ({ cell }) => formatETA(Number(cell.getValue())),
|
||||||
|
sortDescFirst: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
accessorKey: 'progress',
|
||||||
|
header: t('card.table.header.progress'),
|
||||||
|
Cell: ({ cell, row }) => (
|
||||||
|
<Flex>
|
||||||
|
<Text className={useStyles().classes.noTextBreak}>{(Number(cell.getValue()) * 100).toFixed(1)}%</Text>
|
||||||
|
<Progress
|
||||||
|
radius="lg"
|
||||||
|
color={
|
||||||
|
Number(cell.getValue()) === 1 ? 'green' : row.original.state === 'paused' ? 'yellow' : 'blue'
|
||||||
|
}
|
||||||
|
value={Number(cell.getValue()) * 100}
|
||||||
|
size="lg"
|
||||||
|
/>,
|
||||||
|
</Flex>),
|
||||||
|
sortDescFirst: true,
|
||||||
|
},
|
||||||
|
], []);
|
||||||
|
|
||||||
|
const torrentsTable = useMantineReactTable({
|
||||||
|
columns,
|
||||||
|
data: filteredTorrents,
|
||||||
|
enablePagination: false,
|
||||||
|
enableBottomToolbar: false,
|
||||||
|
enableMultiSort: true,
|
||||||
|
enableColumnActions: false,
|
||||||
|
enableColumnFilters: false,
|
||||||
|
enableSorting: true,
|
||||||
|
initialState: {
|
||||||
|
showColumnFilters: false,
|
||||||
|
showGlobalFilter: false,
|
||||||
|
density: 'xs',
|
||||||
|
sorting: [{ id: 'dateAdded', desc: true }],
|
||||||
|
columnVisibility: {
|
||||||
|
isCompleted: false,
|
||||||
|
dateAdded: false,
|
||||||
|
uploadSpeed: false,
|
||||||
|
downloadSpeed: false,
|
||||||
|
eta: false,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
state: {
|
||||||
|
showColumnFilters: false,
|
||||||
|
showGlobalFilter: false,
|
||||||
|
density: 'xs',
|
||||||
|
columnVisibility: {
|
||||||
|
isCompleted: false,
|
||||||
|
dateAdded: false,
|
||||||
|
uploadSpeed: width > MIN_WIDTH_MOBILE,
|
||||||
|
downloadSpeed: width > MIN_WIDTH_MOBILE,
|
||||||
|
eta: width > MIN_WIDTH_MOBILE,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
|
||||||
if (isError) {
|
if (isError) {
|
||||||
return (
|
return (
|
||||||
<Stack>
|
<Stack>
|
||||||
@@ -146,51 +289,10 @@ function TorrentTile({ widget }: TorrentTileProps) {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
const torrents = data.apps.flatMap((app) => (app.type === 'torrent' ? app.torrents : []));
|
|
||||||
const filteredTorrents = filterTorrents(widget, torrents);
|
|
||||||
|
|
||||||
const difference = new Date().getTime() - dataUpdatedAt;
|
|
||||||
const duration = dayjs.duration(difference, 'ms');
|
|
||||||
const humanizedDuration = duration.humanize();
|
|
||||||
|
|
||||||
const ratioGlobal = getTorrentsRatio(widget, torrents, false);
|
|
||||||
const ratioWithFilter = getTorrentsRatio(widget, torrents, true);
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Flex direction="column" sx={{ height: '100%' }} ref={ref}>
|
<Flex direction="column" sx={{ height: '100%' }} ref={ref}>
|
||||||
<ScrollArea sx={{ height: '100%', width: '100%' }} mb="xs">
|
<ScrollArea>
|
||||||
<Table striped highlightOnHover p="sm">
|
<MRT_Table table={torrentsTable} />
|
||||||
<thead>
|
|
||||||
<tr>
|
|
||||||
<th>{t('card.table.header.name')}</th>
|
|
||||||
<th>{t('card.table.header.size')}</th>
|
|
||||||
{width > MIN_WIDTH_MOBILE && <th>{t('card.table.header.download')}</th>}
|
|
||||||
{width > MIN_WIDTH_MOBILE && <th>{t('card.table.header.upload')}</th>}
|
|
||||||
{width > MIN_WIDTH_MOBILE && <th>{t('card.table.header.estimatedTimeOfArrival')}</th>}
|
|
||||||
<th>{t('card.table.header.progress')}</th>
|
|
||||||
</tr>
|
|
||||||
</thead>
|
|
||||||
<tbody>
|
|
||||||
{filteredTorrents.map((torrent, index) => (
|
|
||||||
<BitTorrentQueueItem key={index} torrent={torrent} width={width} app={undefined} />
|
|
||||||
))}
|
|
||||||
|
|
||||||
{filteredTorrents.length !== torrents.length && (
|
|
||||||
<tr className={classes.card}>
|
|
||||||
<td colSpan={width > MIN_WIDTH_MOBILE ? 6 : 3}>
|
|
||||||
<Flex gap="xs" align="center" justify="center">
|
|
||||||
<IconInfoCircle opacity={0.7} size={18} />
|
|
||||||
<Text align="center" color="dimmed">
|
|
||||||
{t('card.table.body.filterHidingItems', {
|
|
||||||
count: torrents.length - filteredTorrents.length,
|
|
||||||
})}
|
|
||||||
</Text>
|
|
||||||
</Flex>
|
|
||||||
</td>
|
|
||||||
</tr>
|
|
||||||
)}
|
|
||||||
</tbody>
|
|
||||||
</Table>
|
|
||||||
</ScrollArea>
|
</ScrollArea>
|
||||||
<Group spacing="sm">
|
<Group spacing="sm">
|
||||||
{data.apps.some((x) => !x.success) && (
|
{data.apps.some((x) => !x.success) && (
|
||||||
@@ -198,9 +300,8 @@ function TorrentTile({ widget }: TorrentTileProps) {
|
|||||||
{t('card.footer.error')}
|
{t('card.footer.error')}
|
||||||
</Badge>
|
</Badge>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
<Text color="dimmed" size="xs">
|
<Text color="dimmed" size="xs">
|
||||||
{t('card.footer.lastUpdated', { time: humanizedDuration })}
|
{t('card.footer.lastUpdated', { time: humanizedDuration })}
|
||||||
{` - ${t('card.footer.ratioGlobal')} : ${
|
{` - ${t('card.footer.ratioGlobal')} : ${
|
||||||
ratioGlobal === -1 ? '∞' : ratioGlobal.toFixed(2)
|
ratioGlobal === -1 ? '∞' : ratioGlobal.toFixed(2)
|
||||||
}`}
|
}`}
|
||||||
@@ -217,7 +318,12 @@ function TorrentTile({ widget }: TorrentTileProps) {
|
|||||||
export const filterTorrents = (widget: ITorrent, torrents: NormalizedTorrent[]) => {
|
export const filterTorrents = (widget: ITorrent, torrents: NormalizedTorrent[]) => {
|
||||||
let result = torrents;
|
let result = torrents;
|
||||||
if (!widget.properties.displayCompletedTorrents) {
|
if (!widget.properties.displayCompletedTorrents) {
|
||||||
result = result.filter((torrent) => !torrent.isCompleted || (widget.properties.displayActiveTorrents && torrent.uploadSpeed > widget.properties.speedLimitOfActiveTorrents * 1024));
|
result = result.filter(
|
||||||
|
(torrent) =>
|
||||||
|
!torrent.isCompleted ||
|
||||||
|
(widget.properties.displayActiveTorrents &&
|
||||||
|
torrent.uploadSpeed > widget.properties.speedLimitOfActiveTorrents * 1024)
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (widget.properties.labelFilter.length > 0) {
|
if (widget.properties.labelFilter.length > 0) {
|
||||||
@@ -279,4 +385,22 @@ export const getTorrentsRatio = (
|
|||||||
: -1;
|
: -1;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const formatSize = (sizeInBytes: number) => {
|
||||||
|
return humanFileSize(sizeInBytes, false);
|
||||||
|
};
|
||||||
|
|
||||||
|
const formatSpeed = (speedInBytesPerSecond: number) => {
|
||||||
|
return `${humanFileSize(speedInBytesPerSecond, false)}/s`;
|
||||||
|
};
|
||||||
|
|
||||||
|
const formatETA = (seconds: number) => {
|
||||||
|
return calculateETA(seconds);
|
||||||
|
};
|
||||||
|
|
||||||
|
const useStyles = createStyles(() => ({
|
||||||
|
noTextBreak: {
|
||||||
|
whiteSpace: 'nowrap',
|
||||||
|
},
|
||||||
|
}));
|
||||||
|
|
||||||
export default definition;
|
export default definition;
|
||||||
|
|||||||
Reference in New Issue
Block a user