mirror of
https://github.com/Sonny93/my-links.git
synced 2025-12-09 07:03:25 +00:00
feat: recreate admin dashboard using mantine
This commit is contained in:
@@ -31,22 +31,16 @@ export default class AdminController {
|
|||||||
protected collectionsController: CollectionsController
|
protected collectionsController: CollectionsController
|
||||||
) {}
|
) {}
|
||||||
|
|
||||||
async index({ response }: HttpContext) {
|
async index({ inertia }: HttpContext) {
|
||||||
const users = await this.usersController.getAllUsersWithTotalRelations();
|
const users = await this.usersController.getAllUsersWithTotalRelations();
|
||||||
const linksCount = await this.linksController.getTotalLinksCount();
|
const linksCount = await this.linksController.getTotalLinksCount();
|
||||||
const collectionsCount =
|
const collectionsCount =
|
||||||
await this.collectionsController.getTotalCollectionsCount();
|
await this.collectionsController.getTotalCollectionsCount();
|
||||||
|
|
||||||
// TODO: return view
|
return inertia.render('admin/dashboard', {
|
||||||
return response.json({
|
|
||||||
users: users.map((user) => new UserWithRelationCountDto(user).toJson()),
|
users: users.map((user) => new UserWithRelationCountDto(user).toJson()),
|
||||||
totalLinks: linksCount,
|
totalLinks: linksCount,
|
||||||
totalCollections: collectionsCount,
|
totalCollections: collectionsCount,
|
||||||
});
|
});
|
||||||
// return inertia.render('admin/dashboard', {
|
|
||||||
// users: users.map((user) => new UserWithRelationCountDto(user).toJson()),
|
|
||||||
// totalLinks: linksCount,
|
|
||||||
// totalCollections: collectionsCount,
|
|
||||||
// });
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
28
inertia/components/admin/users/th.tsx
Normal file
28
inertia/components/admin/users/th.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
21
inertia/components/admin/users/users_table.module.css
Normal file
21
inertia/components/admin/users/users_table.module.css
Normal 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);
|
||||||
|
}
|
||||||
168
inertia/components/admin/users/users_table.tsx
Normal file
168
inertia/components/admin/users/users_table.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
39
inertia/components/admin/users/utils.ts
Normal file
39
inertia/components/admin/users/utils.ts
Normal 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
|
||||||
|
);
|
||||||
|
}
|
||||||
15
inertia/pages/admin/dashboard.tsx
Normal file
15
inertia/pages/admin/dashboard.tsx
Normal 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;
|
||||||
5
inertia/types/app.d.ts
vendored
5
inertia/types/app.d.ts
vendored
@@ -8,13 +8,14 @@ type CommonBase = {
|
|||||||
|
|
||||||
type User = CommonBase & {
|
type User = CommonBase & {
|
||||||
email: string;
|
email: string;
|
||||||
name: string;
|
|
||||||
nickName: string;
|
|
||||||
fullname: string;
|
fullname: string;
|
||||||
avatarUrl: string;
|
avatarUrl: string;
|
||||||
isAdmin: boolean;
|
isAdmin: boolean;
|
||||||
|
lastSeenAt: string;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
type Users = User[];
|
||||||
|
|
||||||
type UserWithCollections = User & {
|
type UserWithCollections = User & {
|
||||||
collections: Collection[];
|
collections: Collection[];
|
||||||
};
|
};
|
||||||
|
|||||||
Reference in New Issue
Block a user