feat: recreate admin dashboard using mantine

This commit is contained in:
Sonny
2024-11-08 18:05:00 +01:00
committed by Sonny
parent 6eb88871e8
commit 174a21288a
7 changed files with 276 additions and 10 deletions

View File

@@ -31,22 +31,16 @@ export default class AdminController {
protected collectionsController: CollectionsController
) {}
async index({ response }: HttpContext) {
async index({ inertia }: HttpContext) {
const users = await this.usersController.getAllUsersWithTotalRelations();
const linksCount = await this.linksController.getTotalLinksCount();
const collectionsCount =
await this.collectionsController.getTotalCollectionsCount();
// TODO: return view
return response.json({
return inertia.render('admin/dashboard', {
users: users.map((user) => new UserWithRelationCountDto(user).toJson()),
totalLinks: linksCount,
totalCollections: collectionsCount,
});
// return inertia.render('admin/dashboard', {
// users: users.map((user) => new UserWithRelationCountDto(user).toJson()),
// totalLinks: linksCount,
// totalCollections: collectionsCount,
// });
}
}

View File

@@ -0,0 +1,28 @@
import { Center, Group, rem, Table, Text, UnstyledButton } from '@mantine/core';
import { PropsWithChildren } from 'react';
import { TbChevronDown, TbChevronUp, TbSelector } from 'react-icons/tb';
import classes from './users_table.module.css';
interface ThProps extends PropsWithChildren {
reversed: boolean;
sorted: boolean;
onSort(): void;
}
export function Th({ children, reversed, sorted, onSort }: ThProps) {
const Icon = sorted ? (reversed ? TbChevronUp : TbChevronDown) : TbSelector;
return (
<Table.Th className={classes.th}>
<UnstyledButton onClick={onSort} className={classes.control}>
<Group justify="space-between">
<Text fw={500} fz="sm">
{children}
</Text>
<Center className={classes.icon}>
<Icon style={{ width: rem(16), height: rem(16) }} />
</Center>
</Group>
</UnstyledButton>
</Table.Th>
);
}

View File

@@ -0,0 +1,21 @@
.th {
padding: 0;
}
.control {
width: 100%;
padding: var(--mantine-spacing-xs) var(--mantine-spacing-md);
@mixin hover {
background-color: light-dark(
var(--mantine-color-gray-0),
var(--mantine-color-dark-6)
);
}
}
.icon {
width: rem(21px);
height: rem(21px);
border-radius: rem(21px);
}

View File

@@ -0,0 +1,168 @@
import {
Badge,
ScrollArea,
Table,
Text,
TextInput,
Tooltip,
rem,
} from '@mantine/core';
import dayjs from 'dayjs';
import relativeTime from 'dayjs/plugin/relativeTime';
import { useState } from 'react';
import { useTranslation } from 'react-i18next';
import { TbSearch } from 'react-icons/tb';
import { Th } from '~/components/admin/users/th';
import { sortData } from '~/components/admin/users/utils';
import { DATE_FORMAT } from '~/constants';
import { User } from '~/types/app';
dayjs.extend(relativeTime);
export type UserWithCounts = User & {
linksCount: number;
collectionsCount: number;
};
export type UsersWithCounts = UserWithCounts[];
export interface UsersTableProps {
users: UsersWithCounts;
totalCollections: number;
totalLinks: number;
}
export function UsersTable({
users,
totalCollections,
totalLinks,
}: UsersTableProps) {
const { t } = useTranslation();
const [search, setSearch] = useState('');
const [sortedData, setSortedData] = useState(users);
const [sortBy, setSortBy] = useState<keyof UserWithCounts | null>(null);
const [reverseSortDirection, setReverseSortDirection] = useState(false);
const setSorting = (field: keyof UserWithCounts) => {
const reversed = field === sortBy ? !reverseSortDirection : false;
setReverseSortDirection(reversed);
setSortBy(field);
setSortedData(sortData(users, { sortBy: field, reversed, search }));
};
const handleSearchChange = (event: React.ChangeEvent<HTMLInputElement>) => {
const { value } = event.currentTarget;
setSearch(value);
setSortedData(
sortData(users, {
sortBy: sortBy,
reversed: reverseSortDirection,
search: value,
})
);
};
const renderDateCell = (date: string) => (
<Tooltip label={dayjs(date).format(DATE_FORMAT).toString()}>
<Text>{dayjs(date).fromNow()}</Text>
</Tooltip>
);
const rows = sortedData.map((user) => (
<Table.Tr key={user.id}>
<Table.Td>{user.fullname}</Table.Td>
<Table.Td>
{user.isAdmin ? (
<Badge variant="light" color="red">
{t('admin:admin')}
</Badge>
) : (
<Badge variant="light" color="green">
{t('admin:user')}
</Badge>
)}
</Table.Td>
<Table.Td>{user.collectionsCount}</Table.Td>
<Table.Td>{user.linksCount}</Table.Td>
<Table.Td>{renderDateCell(user.createdAt)}</Table.Td>
<Table.Td>{renderDateCell(user.lastSeenAt)}</Table.Td>
</Table.Tr>
));
return (
<ScrollArea>
<TextInput
placeholder="Search by any field"
mb="md"
leftSection={<TbSearch style={{ width: rem(16), height: rem(16) }} />}
value={search}
onChange={handleSearchChange}
/>
<Table
horizontalSpacing="md"
verticalSpacing="xs"
miw={700}
layout="fixed"
>
<Table.Tbody>
<Table.Tr>
<Th
sorted={sortBy === 'fullname'}
reversed={reverseSortDirection}
onSort={() => setSorting('fullname')}
>
{t('common:name')}
</Th>
<Th
sorted={sortBy === 'isAdmin'}
reversed={reverseSortDirection}
onSort={() => setSorting('isAdmin')}
>
{t('admin:role')}
</Th>
<Th
sorted={sortBy === 'collectionsCount'}
reversed={reverseSortDirection}
onSort={() => setSorting('collectionsCount')}
>
{t('common:collection.collections')} ({totalCollections})
</Th>
<Th
sorted={sortBy === 'linksCount'}
reversed={reverseSortDirection}
onSort={() => setSorting('linksCount')}
>
{t('common:link.links')} ({totalLinks})
</Th>
<Th
sorted={sortBy === 'createdAt'}
reversed={reverseSortDirection}
onSort={() => setSorting('createdAt')}
>
{t('admin:created_at')}
</Th>
<Th
sorted={sortBy === 'lastSeenAt'}
reversed={reverseSortDirection}
onSort={() => setSorting('lastSeenAt')}
>
{t('admin:last_seen_at')}
</Th>
</Table.Tr>
</Table.Tbody>
<Table.Tbody>
{rows.length > 0 ? (
rows
) : (
<Table.Tr>
<Table.Td colSpan={4}>
<Text fw={500} ta="center">
Nothing found
</Text>
</Table.Td>
</Table.Tr>
)}
</Table.Tbody>
</Table>
</ScrollArea>
);
}

View File

@@ -0,0 +1,39 @@
import {
UsersWithCounts,
UserWithCounts,
} from '~/components/admin/users/users_table';
export function filterData(data: UsersWithCounts, search: string) {
const query = search.toLowerCase().trim();
return data.filter((item) =>
['email', 'name', 'nickName', 'fullname'].some((key) => {
const value = item[key as keyof UserWithCounts];
return typeof value === 'string' && value.toLowerCase().includes(query);
})
);
}
export function sortData(
data: UsersWithCounts,
payload: {
sortBy: keyof UserWithCounts | null;
reversed: boolean;
search: string;
}
) {
const { sortBy } = payload;
if (!sortBy) {
return filterData(data, payload.search);
}
return filterData(
[...data].sort((a, b) => {
if (payload.reversed) {
return b[sortBy] > a[sortBy] ? 1 : -1;
}
return a[sortBy] > b[sortBy] ? 1 : -1;
}),
payload.search
);
}

View File

@@ -0,0 +1,15 @@
import { ReactNode } from 'react';
import {
UsersTable,
UsersTableProps,
} from '~/components/admin/users/users_table';
import { ContentLayout } from '~/layouts/content_layout';
function AdminDashboardPage(props: UsersTableProps) {
return <UsersTable {...props} />;
}
AdminDashboardPage.layout = (page: ReactNode) => (
<ContentLayout children={page} />
);
export default AdminDashboardPage;

View File

@@ -8,13 +8,14 @@ type CommonBase = {
type User = CommonBase & {
email: string;
name: string;
nickName: string;
fullname: string;
avatarUrl: string;
isAdmin: boolean;
lastSeenAt: string;
};
type Users = User[];
type UserWithCollections = User & {
collections: Collection[];
};