mirror of
https://github.com/diced/zipline.git
synced 2025-04-11 23:31:17 -05:00
feat: list view for urls, invites, users: #302
This commit is contained in:
parent
0d36f5f091
commit
d1a486ac1f
6 changed files with 531 additions and 134 deletions
|
@ -9,7 +9,6 @@ import {
|
|||
IconPhotoUp,
|
||||
} from '@tabler/icons-react';
|
||||
import FileModal from 'components/File/FileModal';
|
||||
import Link from 'components/Link';
|
||||
import MutedText from 'components/MutedText';
|
||||
import useFetch from 'lib/hooks/useFetch';
|
||||
import { usePaginatedFiles, useRecent } from 'lib/queries/files';
|
||||
|
|
|
@ -17,10 +17,20 @@ import { useForm } from '@mantine/form';
|
|||
import { useClipboard } from '@mantine/hooks';
|
||||
import { useModals } from '@mantine/modals';
|
||||
import { showNotification } from '@mantine/notifications';
|
||||
import { IconClipboardCopy, IconPlus, IconTag, IconTagOff, IconTrash } from '@tabler/icons-react';
|
||||
import type { Invite } from '@prisma/client';
|
||||
import {
|
||||
IconClipboardCopy,
|
||||
IconGridDots,
|
||||
IconList,
|
||||
IconPlus,
|
||||
IconTag,
|
||||
IconTagOff,
|
||||
IconTrash,
|
||||
} from '@tabler/icons-react';
|
||||
import MutedText from 'components/MutedText';
|
||||
import useFetch from 'hooks/useFetch';
|
||||
import { expireText, relativeTime } from 'lib/utils/client';
|
||||
import { DataTable, DataTableSortStatus } from 'mantine-datatable';
|
||||
import { useRouter } from 'next/router';
|
||||
import { useEffect, useState } from 'react';
|
||||
|
||||
|
@ -125,9 +135,35 @@ export default function Invites() {
|
|||
const modals = useModals();
|
||||
const clipboard = useClipboard();
|
||||
|
||||
const [invites, setInvites] = useState([]);
|
||||
const [invites, setInvites] = useState<Invite[]>([]);
|
||||
const [open, setOpen] = useState(false);
|
||||
|
||||
const [listView, setListView] = useState(true);
|
||||
|
||||
const [sortStatus, setSortStatus] = useState<DataTableSortStatus>({
|
||||
columnAccessor: 'createdAt',
|
||||
direction: 'asc',
|
||||
});
|
||||
const [records, setRecords] = useState(invites);
|
||||
|
||||
useEffect(() => {
|
||||
setRecords(invites);
|
||||
}, [invites]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!records || records.length === 0) return;
|
||||
|
||||
const sortedRecords = [...records].sort((a, b) => {
|
||||
if (sortStatus.direction === 'asc') {
|
||||
return a[sortStatus.columnAccessor] > b[sortStatus.columnAccessor] ? 1 : -1;
|
||||
}
|
||||
|
||||
return a[sortStatus.columnAccessor] < b[sortStatus.columnAccessor] ? 1 : -1;
|
||||
});
|
||||
|
||||
setRecords(sortedRecords);
|
||||
}, [sortStatus]);
|
||||
|
||||
const openDeleteModal = (invite) =>
|
||||
modals.openConfirmModal({
|
||||
title: `Delete ${invite.code}?`,
|
||||
|
@ -193,46 +229,132 @@ export default function Invites() {
|
|||
<ActionIcon variant='filled' color='primary' onClick={() => setOpen(true)}>
|
||||
<IconPlus size='1rem' />
|
||||
</ActionIcon>
|
||||
<Tooltip label={listView ? 'Switch to grid view' : 'Switch to list view'}>
|
||||
<ActionIcon variant='filled' color='primary' onClick={() => setListView(!listView)}>
|
||||
{listView ? <IconList size='1rem' /> : <IconGridDots size='1rem' />}
|
||||
</ActionIcon>
|
||||
</Tooltip>
|
||||
</Group>
|
||||
<SimpleGrid cols={3} spacing='lg' breakpoints={[{ maxWidth: 'sm', cols: 1, spacing: 'sm' }]}>
|
||||
{invites.length
|
||||
? invites.map((invite) => (
|
||||
<Card key={invite.id} sx={{ maxWidth: '100%' }}>
|
||||
<Group position='apart'>
|
||||
<Group position='left'>
|
||||
<Avatar size='lg' color={invite.used ? 'dark' : 'primary'}>
|
||||
{invite.id}
|
||||
</Avatar>
|
||||
<Stack spacing={0}>
|
||||
<Title>
|
||||
{invite.code}
|
||||
{invite.used && <> (Used)</>}
|
||||
</Title>
|
||||
<Tooltip label={new Date(invite.createdAt).toLocaleString()}>
|
||||
<div>
|
||||
<MutedText size='sm'>Created {relativeTime(new Date(invite.createdAt))}</MutedText>
|
||||
</div>
|
||||
</Tooltip>
|
||||
<Tooltip label={new Date(invite.expiresAt).toLocaleString()}>
|
||||
<div>
|
||||
<MutedText size='sm'>{expireText(invite.expiresAt)}</MutedText>
|
||||
</div>
|
||||
</Tooltip>
|
||||
</Stack>
|
||||
</Group>
|
||||
<Stack>
|
||||
<ActionIcon aria-label='copy' onClick={() => handleCopy(invite)}>
|
||||
{listView ? (
|
||||
<DataTable
|
||||
withBorder
|
||||
borderRadius='md'
|
||||
highlightOnHover
|
||||
verticalSpacing='sm'
|
||||
columns={[
|
||||
{ accessor: 'id', sortable: true },
|
||||
{ accessor: 'code', sortable: true },
|
||||
{
|
||||
accessor: 'createdAt',
|
||||
title: 'Created At',
|
||||
sortable: true,
|
||||
render: (invite) => new Date(invite.createdAt).toLocaleString(),
|
||||
},
|
||||
{
|
||||
accessor: 'expiresAt',
|
||||
title: 'Expires At',
|
||||
sortable: true,
|
||||
render: (invite) => new Date(invite.expiresAt).toLocaleString(),
|
||||
},
|
||||
{
|
||||
accessor: 'used',
|
||||
sortable: true,
|
||||
render: (invite) => (invite.used ? 'Yes' : 'No'),
|
||||
},
|
||||
{
|
||||
accessor: 'actions',
|
||||
textAlignment: 'right',
|
||||
render: (invite) => (
|
||||
<Group spacing={4} position='right' noWrap>
|
||||
<Tooltip label='Copy invite link'>
|
||||
<ActionIcon variant='subtle' color='primary' onClick={() => handleCopy(invite)}>
|
||||
<IconClipboardCopy size='1rem' />
|
||||
</ActionIcon>
|
||||
<ActionIcon aria-label='delete' onClick={() => openDeleteModal(invite)}>
|
||||
</Tooltip>
|
||||
<Tooltip label='Delete invite'>
|
||||
<ActionIcon variant='subtle' color='red' onClick={() => openDeleteModal(invite)}>
|
||||
<IconTrash size='1rem' />
|
||||
</ActionIcon>
|
||||
</Stack>
|
||||
</Tooltip>
|
||||
</Group>
|
||||
</Card>
|
||||
))
|
||||
: [1, 2, 3].map((x) => <Skeleton key={x} width='100%' height={100} radius='sm' />)}
|
||||
</SimpleGrid>
|
||||
),
|
||||
},
|
||||
]}
|
||||
sortStatus={sortStatus}
|
||||
onSortStatusChange={setSortStatus}
|
||||
records={records ?? []}
|
||||
fetching={records.length === 0}
|
||||
loaderBackgroundBlur={5}
|
||||
loaderVariant='dots'
|
||||
rowContextMenu={{
|
||||
shadow: 'xl',
|
||||
borderRadius: 'md',
|
||||
items: (invite) => [
|
||||
{
|
||||
key: 'copy',
|
||||
icon: <IconClipboardCopy size='1rem' />,
|
||||
title: `Copy invite code: "${invite.code}"`,
|
||||
onClick: () => clipboard.copy(invite.code),
|
||||
},
|
||||
{
|
||||
key: 'copyLink',
|
||||
icon: <IconClipboardCopy size='1rem' />,
|
||||
title: 'Copy invite link',
|
||||
onClick: () => handleCopy(invite),
|
||||
},
|
||||
{
|
||||
key: 'delete',
|
||||
icon: <IconTrash size='1rem' />,
|
||||
title: `Delete invite ${invite.code}`,
|
||||
onClick: () => openDeleteModal(invite),
|
||||
},
|
||||
],
|
||||
}}
|
||||
/>
|
||||
) : (
|
||||
<SimpleGrid cols={3} spacing='lg' breakpoints={[{ maxWidth: 'sm', cols: 1, spacing: 'sm' }]}>
|
||||
{invites.length
|
||||
? invites.map((invite) => (
|
||||
<Card key={invite.id} sx={{ maxWidth: '100%' }}>
|
||||
<Group position='apart'>
|
||||
<Group position='left'>
|
||||
<Avatar size='lg' color={invite.used ? 'dark' : 'primary'}>
|
||||
{invite.id}
|
||||
</Avatar>
|
||||
<Stack spacing={0}>
|
||||
<Title>
|
||||
{invite.code}
|
||||
{invite.used && <> (Used)</>}
|
||||
</Title>
|
||||
<Tooltip label={new Date(invite.createdAt).toLocaleString()}>
|
||||
<div>
|
||||
<MutedText size='sm'>
|
||||
Created {relativeTime(new Date(invite.createdAt))}
|
||||
</MutedText>
|
||||
</div>
|
||||
</Tooltip>
|
||||
<Tooltip label={new Date(invite.expiresAt).toLocaleString()}>
|
||||
<div>
|
||||
{/* @ts-ignore */}
|
||||
<MutedText size='sm'>{expireText(new Date(invite.expiresAt))}</MutedText>
|
||||
</div>
|
||||
</Tooltip>
|
||||
</Stack>
|
||||
</Group>
|
||||
<Stack>
|
||||
<ActionIcon aria-label='copy' onClick={() => handleCopy(invite)}>
|
||||
<IconClipboardCopy size='1rem' />
|
||||
</ActionIcon>
|
||||
<ActionIcon aria-label='delete' onClick={() => openDeleteModal(invite)}>
|
||||
<IconTrash size='1rem' />
|
||||
</ActionIcon>
|
||||
</Stack>
|
||||
</Group>
|
||||
</Card>
|
||||
))
|
||||
: [1, 2, 3].map((x) => <Skeleton key={x} width='100%' height={100} radius='sm' />)}
|
||||
</SimpleGrid>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
|
|
@ -7,52 +7,17 @@ import MutedText from 'components/MutedText';
|
|||
import { URLResponse, useURLDelete } from 'lib/queries/url';
|
||||
import { relativeTime } from 'lib/utils/client';
|
||||
|
||||
export default function URLCard({ url }: { url: URLResponse }) {
|
||||
const clipboard = useClipboard();
|
||||
const urlDelete = useURLDelete();
|
||||
|
||||
const copyURL = (u) => {
|
||||
clipboard.copy(`${window.location.protocol}//${window.location.host}${u.url}`);
|
||||
if (!navigator.clipboard)
|
||||
showNotification({
|
||||
title: 'Unable to copy to clipboard',
|
||||
message: 'Zipline is unable to copy to clipboard due to security reasons.',
|
||||
color: 'red',
|
||||
});
|
||||
else
|
||||
showNotification({
|
||||
title: 'Copied to clipboard',
|
||||
message: '',
|
||||
icon: <IconClipboardCopy size='1rem' />,
|
||||
});
|
||||
};
|
||||
|
||||
const deleteURL = async (u) => {
|
||||
urlDelete.mutate(u.id, {
|
||||
onSuccess: () => {
|
||||
showNotification({
|
||||
title: 'Deleted URL',
|
||||
message: '',
|
||||
icon: <IconLink size='1rem' />,
|
||||
color: 'green',
|
||||
});
|
||||
},
|
||||
|
||||
onError: (url: any) => {
|
||||
showNotification({
|
||||
title: 'Failed to delete URL',
|
||||
message: url.error,
|
||||
icon: <IconLinkOff size='1rem' />,
|
||||
color: 'red',
|
||||
});
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
export default function URLCard({
|
||||
url,
|
||||
copyURL,
|
||||
deleteURL,
|
||||
}: {
|
||||
url: URLResponse;
|
||||
copyURL: (u: URLResponse) => void;
|
||||
deleteURL: (u: URLResponse) => void;
|
||||
}) {
|
||||
return (
|
||||
<Card key={url.id} sx={{ maxWidth: '100%' }} shadow='sm'>
|
||||
<LoadingOverlay visible={urlDelete.isLoading} />
|
||||
|
||||
<Group position='apart'>
|
||||
<Group position='left'>
|
||||
<Stack spacing={0}>
|
||||
|
|
|
@ -1,5 +1,6 @@
|
|||
import {
|
||||
ActionIcon,
|
||||
Anchor,
|
||||
Button,
|
||||
Card,
|
||||
Center,
|
||||
|
@ -11,16 +12,25 @@ import {
|
|||
TextInput,
|
||||
Title,
|
||||
Tooltip,
|
||||
Text,
|
||||
} from '@mantine/core';
|
||||
import { useForm } from '@mantine/form';
|
||||
import { useClipboard } from '@mantine/hooks';
|
||||
import { useModals } from '@mantine/modals';
|
||||
import { showNotification } from '@mantine/notifications';
|
||||
import { IconClipboardCopy, IconExternalLink, IconLink, IconLinkOff } from '@tabler/icons-react';
|
||||
import {
|
||||
IconClipboardCopy,
|
||||
IconExternalLink,
|
||||
IconGridDots,
|
||||
IconLink,
|
||||
IconLinkOff,
|
||||
IconList,
|
||||
} from '@tabler/icons-react';
|
||||
import Link from 'components/Link';
|
||||
import MutedText from 'components/MutedText';
|
||||
import { useURLs } from 'lib/queries/url';
|
||||
import { useURLDelete, useURLs } from 'lib/queries/url';
|
||||
import { userSelector } from 'lib/recoil/user';
|
||||
import { DataTable, DataTableSortStatus } from 'mantine-datatable';
|
||||
import { useEffect, useState } from 'react';
|
||||
import { useRecoilValue } from 'recoil';
|
||||
import URLCard from './URLCard';
|
||||
|
@ -36,6 +46,32 @@ export default function Urls() {
|
|||
|
||||
const updateURLs = async () => urls.refetch();
|
||||
|
||||
const [listView, setListView] = useState(true);
|
||||
|
||||
const [sortStatus, setSortStatus] = useState<DataTableSortStatus>({
|
||||
columnAccessor: 'id',
|
||||
direction: 'asc',
|
||||
});
|
||||
const [records, setRecords] = useState(urls.data);
|
||||
|
||||
useEffect(() => {
|
||||
setRecords(urls.data);
|
||||
}, [urls.data]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!records || records.length === 0) return;
|
||||
|
||||
const sortedRecords = [...records].sort((a, b) => {
|
||||
if (sortStatus.direction === 'asc') {
|
||||
return a[sortStatus.columnAccessor] > b[sortStatus.columnAccessor] ? 1 : -1;
|
||||
}
|
||||
|
||||
return a[sortStatus.columnAccessor] < b[sortStatus.columnAccessor] ? 1 : -1;
|
||||
});
|
||||
|
||||
setRecords(sortedRecords);
|
||||
}, [sortStatus]);
|
||||
|
||||
const form = useForm({
|
||||
initialValues: {
|
||||
url: '',
|
||||
|
@ -130,6 +166,45 @@ export default function Urls() {
|
|||
updateURLs();
|
||||
}, []);
|
||||
|
||||
const copyURL = (u) => {
|
||||
clipboard.copy(`${window.location.protocol}//${window.location.host}${u.url}`);
|
||||
if (!navigator.clipboard)
|
||||
showNotification({
|
||||
title: 'Unable to copy to clipboard',
|
||||
message: 'Zipline is unable to copy to clipboard due to security reasons.',
|
||||
color: 'red',
|
||||
});
|
||||
else
|
||||
showNotification({
|
||||
title: 'Copied to clipboard',
|
||||
message: '',
|
||||
icon: <IconClipboardCopy size='1rem' />,
|
||||
});
|
||||
};
|
||||
|
||||
const urlDelete = useURLDelete();
|
||||
const deleteURL = async (u) => {
|
||||
urlDelete.mutate(u.id, {
|
||||
onSuccess: () => {
|
||||
showNotification({
|
||||
title: 'Deleted URL',
|
||||
message: '',
|
||||
icon: <IconLink size='1rem' />,
|
||||
color: 'green',
|
||||
});
|
||||
},
|
||||
|
||||
onError: (url: any) => {
|
||||
showNotification({
|
||||
title: 'Failed to delete URL',
|
||||
message: url.error,
|
||||
icon: <IconLinkOff size='1rem' />,
|
||||
color: 'red',
|
||||
});
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
<Modal opened={createOpen} onClose={() => setCreateOpen(false)} title={<Title>Shorten URL</Title>}>
|
||||
|
@ -150,6 +225,11 @@ export default function Urls() {
|
|||
<ActionIcon variant='filled' color='primary' onClick={() => setCreateOpen(true)}>
|
||||
<IconLink size='1rem' />
|
||||
</ActionIcon>
|
||||
<Tooltip label={listView ? 'Switch to grid view' : 'Switch to list view'}>
|
||||
<ActionIcon variant='filled' color='primary' onClick={() => setListView(!listView)}>
|
||||
{listView ? <IconList size='1rem' /> : <IconGridDots size='1rem' />}
|
||||
</ActionIcon>
|
||||
</Tooltip>
|
||||
</Group>
|
||||
|
||||
{urls.data && urls.data.length === 0 && (
|
||||
|
@ -168,11 +248,106 @@ export default function Urls() {
|
|||
</Card>
|
||||
)}
|
||||
|
||||
<SimpleGrid cols={4} spacing='lg' breakpoints={[{ maxWidth: 'sm', cols: 1, spacing: 'sm' }]}>
|
||||
{urls.isLoading || !urls.data
|
||||
? [1, 2, 3, 4].map((x) => <Skeleton key={x} width='100%' height={80} radius='sm' />)
|
||||
: urls.data.map((url) => <URLCard key={url.id} url={url} />)}
|
||||
</SimpleGrid>
|
||||
{listView ? (
|
||||
<DataTable
|
||||
withBorder
|
||||
borderRadius='md'
|
||||
highlightOnHover
|
||||
verticalSpacing='sm'
|
||||
columns={[
|
||||
{ accessor: 'id', title: 'ID', sortable: true },
|
||||
{
|
||||
accessor: 'vanity',
|
||||
title: 'Vanity',
|
||||
sortable: true,
|
||||
render: (url) => <Text>{url.vanity ?? ''}</Text>,
|
||||
},
|
||||
{
|
||||
accessor: 'destination',
|
||||
title: 'URL',
|
||||
sortable: true,
|
||||
render: (url) => (
|
||||
<Anchor component={Link} href={url.url} target='_blank'>
|
||||
{url.destination}
|
||||
</Anchor>
|
||||
),
|
||||
},
|
||||
{
|
||||
accessor: 'views',
|
||||
sortable: true,
|
||||
},
|
||||
{
|
||||
accessor: 'maxViews',
|
||||
sortable: true,
|
||||
},
|
||||
{
|
||||
accessor: 'actions',
|
||||
textAlignment: 'right',
|
||||
render: (url) => (
|
||||
<Group spacing={4} position='right' noWrap>
|
||||
<Tooltip label='Open link in a new tab'>
|
||||
<ActionIcon
|
||||
onClick={() => window.open(url.url, '_blank')}
|
||||
variant='subtle'
|
||||
color='primary'
|
||||
>
|
||||
<IconExternalLink size='1rem' />
|
||||
</ActionIcon>
|
||||
</Tooltip>
|
||||
<Tooltip label='Copy link to clipboard'>
|
||||
<ActionIcon onClick={() => copyURL(url)} variant='subtle' color='primary'>
|
||||
<IconClipboardCopy size='1rem' />
|
||||
</ActionIcon>
|
||||
</Tooltip>
|
||||
<Tooltip label='Delete URL'>
|
||||
<ActionIcon onClick={() => deleteURL(url)} variant='subtle' color='red'>
|
||||
<IconLinkOff size='1rem' />
|
||||
</ActionIcon>
|
||||
</Tooltip>
|
||||
</Group>
|
||||
),
|
||||
},
|
||||
]}
|
||||
sortStatus={sortStatus}
|
||||
onSortStatusChange={setSortStatus}
|
||||
records={records ?? []}
|
||||
fetching={urls.isLoading}
|
||||
loaderBackgroundBlur={5}
|
||||
loaderVariant='dots'
|
||||
rowContextMenu={{
|
||||
shadow: 'xl',
|
||||
borderRadius: 'md',
|
||||
items: (url) => [
|
||||
{
|
||||
key: 'openLink',
|
||||
title: 'Open link in a new tab',
|
||||
icon: <IconExternalLink size='1rem' />,
|
||||
onClick: () => window.open(url.url, '_blank'),
|
||||
},
|
||||
{
|
||||
key: 'copyLink',
|
||||
title: 'Copy link to clipboard',
|
||||
icon: <IconClipboardCopy size='1rem' />,
|
||||
onClick: () => copyURL(url),
|
||||
},
|
||||
{
|
||||
key: 'deleteURL',
|
||||
title: 'Delete URL',
|
||||
icon: <IconLinkOff size='1rem' />,
|
||||
onClick: () => deleteURL(url),
|
||||
},
|
||||
],
|
||||
}}
|
||||
/>
|
||||
) : (
|
||||
<SimpleGrid cols={4} spacing='lg' breakpoints={[{ maxWidth: 'sm', cols: 1, spacing: 'sm' }]}>
|
||||
{urls.isLoading || !urls.data
|
||||
? [1, 2, 3, 4].map((x) => <Skeleton key={x} width='100%' height={80} radius='sm' />)
|
||||
: urls.data.map((url) => (
|
||||
<URLCard key={url.id} url={url} deleteURL={deleteURL} copyURL={copyURL} />
|
||||
))}
|
||||
</SimpleGrid>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
|
|
@ -52,7 +52,11 @@ export function EditUserModal({ open, setOpen, updateUsers, user }) {
|
|||
};
|
||||
|
||||
return (
|
||||
<Modal opened={open} onClose={() => setOpen(false)} title={<Title>Edit User {user?.username}</Title>}>
|
||||
<Modal
|
||||
opened={open}
|
||||
onClose={() => setOpen(false)}
|
||||
title={<Title>Edit "{user?.username}"</Title>}
|
||||
>
|
||||
{user && (
|
||||
<form onSubmit={form.onSubmit((v) => onSubmit(v))}>
|
||||
<TextInput id='username' label='Username' {...form.getInputProps('username')} />
|
||||
|
|
|
@ -1,10 +1,21 @@
|
|||
import { ActionIcon, Avatar, Card, Group, SimpleGrid, Skeleton, Stack, Title } from '@mantine/core';
|
||||
import { ActionIcon, Avatar, Card, Group, SimpleGrid, Skeleton, Stack, Title, Tooltip } from '@mantine/core';
|
||||
import { useClipboard } from '@mantine/hooks';
|
||||
import { useModals } from '@mantine/modals';
|
||||
import { showNotification } from '@mantine/notifications';
|
||||
import { IconEdit, IconUserExclamation, IconUserMinus, IconUserPlus } from '@tabler/icons-react';
|
||||
import type { User } from '@prisma/client';
|
||||
import {
|
||||
IconClipboardCopy,
|
||||
IconEdit,
|
||||
IconGridDots,
|
||||
IconList,
|
||||
IconUserExclamation,
|
||||
IconUserMinus,
|
||||
IconUserPlus,
|
||||
} from '@tabler/icons-react';
|
||||
import MutedText from 'components/MutedText';
|
||||
import useFetch from 'hooks/useFetch';
|
||||
import { userSelector } from 'lib/recoil/user';
|
||||
import { DataTable, DataTableSortStatus } from 'mantine-datatable';
|
||||
import { useRouter } from 'next/router';
|
||||
import { useEffect, useState } from 'react';
|
||||
import { useRecoilValue } from 'recoil';
|
||||
|
@ -15,12 +26,39 @@ export default function Users() {
|
|||
const self = useRecoilValue(userSelector);
|
||||
const router = useRouter();
|
||||
const modals = useModals();
|
||||
const clipboard = useClipboard();
|
||||
|
||||
const [users, setUsers] = useState([]);
|
||||
const [users, setUsers] = useState<User[]>([]);
|
||||
const [createOpen, setCreateOpen] = useState(false);
|
||||
const [editOpen, setEditOpen] = useState(false);
|
||||
const [selectedUser, setSelectedUser] = useState(null);
|
||||
|
||||
const [listView, setListView] = useState(true);
|
||||
|
||||
const [sortStatus, setSortStatus] = useState<DataTableSortStatus>({
|
||||
columnAccessor: 'id',
|
||||
direction: 'asc',
|
||||
});
|
||||
const [records, setRecords] = useState(users);
|
||||
|
||||
useEffect(() => {
|
||||
setRecords(users);
|
||||
}, [users]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!records || records.length === 0) return;
|
||||
|
||||
const sortedRecords = [...records].sort((a, b) => {
|
||||
if (sortStatus.direction === 'asc') {
|
||||
return a[sortStatus.columnAccessor] > b[sortStatus.columnAccessor] ? 1 : -1;
|
||||
}
|
||||
|
||||
return a[sortStatus.columnAccessor] < b[sortStatus.columnAccessor] ? 1 : -1;
|
||||
});
|
||||
|
||||
setRecords(sortedRecords);
|
||||
}, [sortStatus]);
|
||||
|
||||
const handleDelete = async (user, delete_files) => {
|
||||
const res = await useFetch(`/api/user/${user.id}`, 'DELETE', {
|
||||
delete_files,
|
||||
|
@ -91,51 +129,145 @@ export default function Users() {
|
|||
<ActionIcon variant='filled' color='primary' onClick={() => setCreateOpen(true)}>
|
||||
<IconUserPlus size='1rem' />
|
||||
</ActionIcon>
|
||||
<Tooltip label={listView ? 'Switch to grid view' : 'Switch to list view'}>
|
||||
<ActionIcon variant='filled' color='primary' onClick={() => setListView(!listView)}>
|
||||
{listView ? <IconList size='1rem' /> : <IconGridDots size='1rem' />}
|
||||
</ActionIcon>
|
||||
</Tooltip>
|
||||
</Group>
|
||||
<SimpleGrid cols={3} spacing='lg' breakpoints={[{ maxWidth: 'sm', cols: 1, spacing: 'sm' }]}>
|
||||
{users.length
|
||||
? users
|
||||
.filter((x) => x.username !== self.username)
|
||||
.map((user) => (
|
||||
<Card key={user.id} sx={{ maxWidth: '100%' }}>
|
||||
<Group position='apart'>
|
||||
<Group position='left'>
|
||||
<Avatar
|
||||
size='lg'
|
||||
color={user.administrator ? 'primary' : 'dark'}
|
||||
src={user.avatar ?? null}
|
||||
>
|
||||
{user.username[0]}
|
||||
</Avatar>
|
||||
<Stack spacing={0}>
|
||||
<Title>{user.username}</Title>
|
||||
<MutedText size='sm'>ID: {user.id}</MutedText>
|
||||
<MutedText size='sm'>Administrator: {user.administrator ? 'yes' : 'no'}</MutedText>
|
||||
{listView ? (
|
||||
<DataTable
|
||||
withBorder
|
||||
borderRadius='md'
|
||||
highlightOnHover
|
||||
verticalSpacing='sm'
|
||||
columns={[
|
||||
{
|
||||
accessor: 'avatar',
|
||||
sortable: false,
|
||||
render: (user) => (
|
||||
<Avatar src={user.avatar} color={user.administrator ? 'primary' : 'dark'} size='md'>
|
||||
{user.username[0]}
|
||||
</Avatar>
|
||||
),
|
||||
width: 80,
|
||||
},
|
||||
{ accessor: 'id', title: 'ID', sortable: true },
|
||||
{ accessor: 'username', sortable: true },
|
||||
{
|
||||
accessor: 'administrator',
|
||||
sortable: true,
|
||||
render: (user) => (user.administrator ? 'Yes' : 'No'),
|
||||
},
|
||||
{
|
||||
accessor: 'actions',
|
||||
textAlignment: 'right',
|
||||
render: (user) => (
|
||||
<Group spacing={4} position='right' noWrap>
|
||||
<Tooltip label='Delete user'>
|
||||
<ActionIcon onClick={() => openDeleteModal(user)} color='red'>
|
||||
<IconUserMinus size='1rem' />
|
||||
</ActionIcon>
|
||||
</Tooltip>
|
||||
<Tooltip label='Edit user'>
|
||||
<ActionIcon
|
||||
onClick={() => {
|
||||
setSelectedUser(user);
|
||||
setEditOpen(true);
|
||||
}}
|
||||
color='blue'
|
||||
>
|
||||
<IconEdit size='1rem' />
|
||||
</ActionIcon>
|
||||
</Tooltip>
|
||||
</Group>
|
||||
),
|
||||
},
|
||||
]}
|
||||
sortStatus={sortStatus}
|
||||
onSortStatusChange={setSortStatus}
|
||||
records={records ? records.filter((x) => x.username !== self.username) : []}
|
||||
fetching={users.length === 0}
|
||||
loaderBackgroundBlur={5}
|
||||
loaderVariant='dots'
|
||||
rowContextMenu={{
|
||||
shadow: 'xl',
|
||||
borderRadius: 'md',
|
||||
items: (user) => [
|
||||
{
|
||||
key: 'copy',
|
||||
icon: <IconClipboardCopy size='1rem' />,
|
||||
title: `Copy Username: "${user.username}"`,
|
||||
onClick: () => clipboard.copy(user.username),
|
||||
},
|
||||
{
|
||||
key: 'edit',
|
||||
icon: <IconEdit size='1rem' />,
|
||||
title: `Edit ${user.username}`,
|
||||
onClick: () => {
|
||||
setSelectedUser(user);
|
||||
setEditOpen(true);
|
||||
},
|
||||
},
|
||||
{
|
||||
key: 'delete',
|
||||
icon: <IconUserMinus size='1rem' />,
|
||||
title: `Delete ${user.username}`,
|
||||
onClick: () => openDeleteModal(user),
|
||||
},
|
||||
],
|
||||
}}
|
||||
onRowClick={(user) => {
|
||||
setSelectedUser(user);
|
||||
setEditOpen(true);
|
||||
}}
|
||||
/>
|
||||
) : (
|
||||
<SimpleGrid cols={3} spacing='lg' breakpoints={[{ maxWidth: 'sm', cols: 1, spacing: 'sm' }]}>
|
||||
{users.length
|
||||
? users
|
||||
.filter((x) => x.username !== self.username)
|
||||
.map((user) => (
|
||||
<Card key={user.id} sx={{ maxWidth: '100%' }}>
|
||||
<Group position='apart'>
|
||||
<Group position='left'>
|
||||
<Avatar
|
||||
size='lg'
|
||||
color={user.administrator ? 'primary' : 'dark'}
|
||||
src={user.avatar ?? null}
|
||||
>
|
||||
{user.username[0]}
|
||||
</Avatar>
|
||||
<Stack spacing={0}>
|
||||
<Title>{user.username}</Title>
|
||||
<MutedText size='sm'>ID: {user.id}</MutedText>
|
||||
<MutedText size='sm'>Administrator: {user.administrator ? 'yes' : 'no'}</MutedText>
|
||||
</Stack>
|
||||
</Group>
|
||||
<Stack>
|
||||
{user.administrator && !self.superAdmin ? null : (
|
||||
<>
|
||||
<ActionIcon
|
||||
aria-label='edit'
|
||||
onClick={() => {
|
||||
setEditOpen(true);
|
||||
setSelectedUser(user);
|
||||
}}
|
||||
>
|
||||
<IconEdit size='1rem' />
|
||||
</ActionIcon>
|
||||
<ActionIcon aria-label='delete' onClick={() => openDeleteModal(user)}>
|
||||
<IconUserMinus size='1rem' />
|
||||
</ActionIcon>
|
||||
</>
|
||||
)}
|
||||
</Stack>
|
||||
</Group>
|
||||
<Stack>
|
||||
{user.administrator && !self.superAdmin ? null : (
|
||||
<>
|
||||
<ActionIcon
|
||||
aria-label='edit'
|
||||
onClick={() => {
|
||||
setEditOpen(true);
|
||||
setSelectedUser(user);
|
||||
}}
|
||||
>
|
||||
<IconEdit size='1rem' />
|
||||
</ActionIcon>
|
||||
<ActionIcon aria-label='delete' onClick={() => openDeleteModal(user)}>
|
||||
<IconUserMinus size='1rem' />
|
||||
</ActionIcon>
|
||||
</>
|
||||
)}
|
||||
</Stack>
|
||||
</Group>
|
||||
</Card>
|
||||
))
|
||||
: [1, 2, 3].map((x) => <Skeleton key={x} width='100%' height={100} radius='sm' />)}
|
||||
</SimpleGrid>
|
||||
</Card>
|
||||
))
|
||||
: [1, 2, 3].map((x) => <Skeleton key={x} width='100%' height={100} radius='sm' />)}
|
||||
</SimpleGrid>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
|
Loading…
Add table
Reference in a new issue