diff --git a/app/controllers/admin_controller.ts b/app/controllers/admin_controller.ts
index 4d1b3b0..0c4e3af 100644
--- a/app/controllers/admin_controller.ts
+++ b/app/controllers/admin_controller.ts
@@ -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,
- // });
}
}
diff --git a/inertia/components/admin/users/th.tsx b/inertia/components/admin/users/th.tsx
new file mode 100644
index 0000000..6257939
--- /dev/null
+++ b/inertia/components/admin/users/th.tsx
@@ -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 (
+
+
+
+
+ {children}
+
+
+
+
+
+
+
+ );
+}
diff --git a/inertia/components/admin/users/users_table.module.css b/inertia/components/admin/users/users_table.module.css
new file mode 100644
index 0000000..2c11f9c
--- /dev/null
+++ b/inertia/components/admin/users/users_table.module.css
@@ -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);
+}
diff --git a/inertia/components/admin/users/users_table.tsx b/inertia/components/admin/users/users_table.tsx
new file mode 100644
index 0000000..24994dd
--- /dev/null
+++ b/inertia/components/admin/users/users_table.tsx
@@ -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(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) => {
+ const { value } = event.currentTarget;
+ setSearch(value);
+ setSortedData(
+ sortData(users, {
+ sortBy: sortBy,
+ reversed: reverseSortDirection,
+ search: value,
+ })
+ );
+ };
+
+ const renderDateCell = (date: string) => (
+
+ {dayjs(date).fromNow()}
+
+ );
+
+ const rows = sortedData.map((user) => (
+
+ {user.fullname}
+
+ {user.isAdmin ? (
+
+ {t('admin:admin')}
+
+ ) : (
+
+ {t('admin:user')}
+
+ )}
+
+ {user.collectionsCount}
+ {user.linksCount}
+ {renderDateCell(user.createdAt)}
+ {renderDateCell(user.lastSeenAt)}
+
+ ));
+
+ return (
+
+ }
+ value={search}
+ onChange={handleSearchChange}
+ />
+
+
+
+ | setSorting('fullname')}
+ >
+ {t('common:name')}
+ |
+ setSorting('isAdmin')}
+ >
+ {t('admin:role')}
+ |
+ setSorting('collectionsCount')}
+ >
+ {t('common:collection.collections')} ({totalCollections})
+ |
+ setSorting('linksCount')}
+ >
+ {t('common:link.links')} ({totalLinks})
+ |
+ setSorting('createdAt')}
+ >
+ {t('admin:created_at')}
+ |
+ setSorting('lastSeenAt')}
+ >
+ {t('admin:last_seen_at')}
+ |
+
+
+
+ {rows.length > 0 ? (
+ rows
+ ) : (
+
+
+
+ Nothing found
+
+
+
+ )}
+
+
+
+ );
+}
diff --git a/inertia/components/admin/users/utils.ts b/inertia/components/admin/users/utils.ts
new file mode 100644
index 0000000..095a4e0
--- /dev/null
+++ b/inertia/components/admin/users/utils.ts
@@ -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
+ );
+}
diff --git a/inertia/pages/admin/dashboard.tsx b/inertia/pages/admin/dashboard.tsx
new file mode 100644
index 0000000..e23d0c2
--- /dev/null
+++ b/inertia/pages/admin/dashboard.tsx
@@ -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 ;
+}
+
+AdminDashboardPage.layout = (page: ReactNode) => (
+
+);
+export default AdminDashboardPage;
diff --git a/inertia/types/app.d.ts b/inertia/types/app.d.ts
index ddeb579..4bafdba 100644
--- a/inertia/types/app.d.ts
+++ b/inertia/types/app.d.ts
@@ -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[];
};