1
Fork 0
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:
diced 2023-03-19 16:44:04 -07:00
parent 0d36f5f091
commit d1a486ac1f
No known key found for this signature in database
GPG key ID: 370BD1BA142842D1
6 changed files with 531 additions and 134 deletions

View file

@ -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';

View file

@ -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>
)}
</>
);
}

View file

@ -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}>

View file

@ -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>
)}
</>
);
}

View file

@ -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 &quot;{user?.username}&quot;</Title>}
>
{user && (
<form onSubmit={form.onSubmit((v) => onSubmit(v))}>
<TextInput id='username' label='Username' {...form.getInputProps('username')} />

View file

@ -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>
)}
</>
);
}