feat: add basic admin dashboard

This commit is contained in:
Sonny
2024-05-26 03:18:25 +02:00
committed by Sonny
parent f3f7f6272f
commit 202f70b010
24 changed files with 324 additions and 33 deletions

View File

@@ -0,0 +1,42 @@
import UsersController from '#controllers/users_controller';
import User from '#models/user';
import { inject } from '@adonisjs/core';
import type { HttpContext } from '@adonisjs/core/http';
import db from '@adonisjs/lucid/services/db';
class UserWithRelationCountDto {
constructor(private user: User) {}
public toJson() {
return {
id: this.user.id,
email: this.user.email,
fullname: this.user.name,
avatarUrl: this.user.avatarUrl,
isAdmin: this.user.isAdmin,
createdAt: this.user.createdAt,
updatedAt: this.user.updatedAt,
count: {
link: Number(this.user.$extras.totalLinks),
collection: Number(this.user.$extras.totalCollections),
},
};
}
}
@inject()
export default class AdminController {
constructor(protected usersController: UsersController) {}
async index({ inertia }: HttpContext) {
const users = await this.usersController.getAllUsersWithTotalRelations();
const links = await db.from('links').count('* as total');
const collections = await db.from('collections').count('* as total');
return inertia.render('admin/dashboard', {
users: users.map((user) => new UserWithRelationCountDto(user).toJson()),
totalLinks: Number(links[0].total),
totalCollections: Number(collections[0].total),
});
}
}

View File

@@ -1,6 +1,7 @@
import User from '#models/user'; import User from '#models/user';
import type { HttpContext } from '@adonisjs/core/http'; import type { HttpContext } from '@adonisjs/core/http';
import logger from '@adonisjs/core/services/logger'; import logger from '@adonisjs/core/services/logger';
import db from '@adonisjs/lucid/services/db';
import { RouteName } from '@izzyjs/route/types'; import { RouteName } from '@izzyjs/route/types';
export default class UsersController { export default class UsersController {
@@ -30,6 +31,7 @@ export default class UsersController {
return response.redirectToNamedRoute(this.redirectTo); return response.redirectToNamedRoute(this.redirectTo);
} }
const userCount = await db.from('users').count('* as total');
const { const {
email, email,
id: providerId, id: providerId,
@@ -50,6 +52,7 @@ export default class UsersController {
avatarUrl, avatarUrl,
token, token,
providerType: 'google', providerType: 'google',
isAdmin: userCount[0].total === '0',
} }
); );
@@ -66,4 +69,10 @@ export default class UsersController {
logger.info(`[${auth.user?.email}] disconnected successfully`); logger.info(`[${auth.user?.email}] disconnected successfully`);
response.redirectToNamedRoute(this.redirectTo); response.redirectToNamedRoute(this.redirectTo);
} }
async getAllUsersWithTotalRelations() {
return User.query()
.withCount('collections', (q) => q.as('totalCollections'))
.withCount('links', (q) => q.as('totalLinks'));
}
} }

View File

@@ -0,0 +1,11 @@
import type { HttpContext } from '@adonisjs/core/http';
import type { NextFn } from '@adonisjs/core/types/http';
export default class AdminMiddleware {
async handle(ctx: HttpContext, next: NextFn) {
if (!ctx.auth.user?.isAdmin) {
return ctx.response.redirectToNamedRoute('dashboard');
}
return next();
}
}

View File

@@ -7,21 +7,19 @@ import { DateTime } from 'luxon';
export default class AppBaseModel extends BaseModel { export default class AppBaseModel extends BaseModel {
static namingStrategy = new CamelCaseNamingStrategy(); static namingStrategy = new CamelCaseNamingStrategy();
static selfAssignPrimaryKey = true; serializeExtras = true;
@column({ isPrimary: true }) @column({ isPrimary: true })
declare id: number; declare id: number;
@column.dateTime({ @column.dateTime({
autoCreate: true, autoCreate: true,
serializeAs: 'created_at',
}) })
declare created_at: DateTime; declare createdAt: DateTime;
@column.dateTime({ @column.dateTime({
autoCreate: true, autoCreate: true,
autoUpdate: true, autoUpdate: true,
serializeAs: 'updated_at',
}) })
declare updated_at: DateTime; declare updatedAt: DateTime;
} }

View File

@@ -21,7 +21,7 @@ export default class Collection extends AppBaseModel {
@column() @column()
declare authorId: number; declare authorId: number;
@belongsTo(() => User, { foreignKey: 'authorId' }) @belongsTo(() => User, { foreignKey: 'author_id' })
declare author: BelongsTo<typeof User>; declare author: BelongsTo<typeof User>;
@hasMany(() => Link) @hasMany(() => Link)

View File

@@ -1,8 +1,8 @@
import Collection from '#models/collection'; import Collection from '#models/collection';
import Link from '#models/link'; import Link from '#models/link';
import type { GoogleToken } from '@adonisjs/ally/types'; import type { GoogleToken } from '@adonisjs/ally/types';
import { column, computed, manyToMany } from '@adonisjs/lucid/orm'; import { column, computed, hasMany } from '@adonisjs/lucid/orm';
import type { ManyToMany } from '@adonisjs/lucid/types/relations'; import type { HasMany } from '@adonisjs/lucid/types/relations';
import AppBaseModel from './app_base_model.js'; import AppBaseModel from './app_base_model.js';
export default class User extends AppBaseModel { export default class User extends AppBaseModel {
@@ -30,15 +30,15 @@ export default class User extends AppBaseModel {
@column({ serializeAs: null }) @column({ serializeAs: null })
declare providerType: 'google'; declare providerType: 'google';
@manyToMany(() => Collection, { @hasMany(() => Collection, {
relatedKey: 'authorId', foreignKey: 'authorId',
}) })
declare collections: ManyToMany<typeof Collection>; declare collections: HasMany<typeof Collection>;
@manyToMany(() => Link, { @hasMany(() => Link, {
relatedKey: 'authorId', foreignKey: 'authorId',
}) })
declare links: ManyToMany<typeof Link>; declare links: HasMany<typeof Link>;
@computed() @computed()
get fullname() { get fullname() {

View File

@@ -1,11 +1,13 @@
import { resolvePageComponent } from '@adonisjs/inertia/helpers'; import { resolvePageComponent } from '@adonisjs/inertia/helpers';
import { createInertiaApp } from '@inertiajs/react'; import { createInertiaApp } from '@inertiajs/react';
import { hydrateRoot } from 'react-dom/client'; import { hydrateRoot } from 'react-dom/client';
import { primaryColor } from '~/styles/theme';
import 'react-toggle/style.css'; import 'react-toggle/style.css';
import { primaryColor } from '~/styles/theme';
import '../i18n/index'; import '../i18n/index';
import 'dayjs/locale/en';
import 'dayjs/locale/fr';
const appName = import.meta.env.VITE_APP_NAME || 'MyLinks'; const appName = import.meta.env.VITE_APP_NAME || 'MyLinks';
createInertiaApp({ createInertiaApp({

View File

@@ -2,6 +2,7 @@ import type Link from '#models/link';
import styled from '@emotion/styled'; import styled from '@emotion/styled';
import LinkItem from '~/components/dashboard/link/link_item'; import LinkItem from '~/components/dashboard/link/link_item';
import { NoLink } from '~/components/dashboard/link/no_item'; import { NoLink } from '~/components/dashboard/link/no_item';
import { sortByCreationDate } from '~/lib/array';
const LinkListStyle = styled.ul({ const LinkListStyle = styled.ul({
height: '100%', height: '100%',
@@ -23,11 +24,9 @@ export default function LinkList({ links }: { links: Link[] }) {
return ( return (
<LinkListStyle> <LinkListStyle>
{links {sortByCreationDate(links).map((link) => (
.sort((a, b) => (a.created_at > b.created_at ? 1 : -1)) <LinkItem link={link} key={link.id} showUserControls />
.map((link) => ( ))}
<LinkItem link={link} key={link.id} showUserControls />
))}
</LinkListStyle> </LinkListStyle>
); );
} }

View File

@@ -9,6 +9,7 @@ import { Item, ItemLink } from '~/components/dashboard/side_nav/nav_item';
import UserCard from '~/components/dashboard/side_nav/user_card'; import UserCard from '~/components/dashboard/side_nav/user_card';
import ModalSettings from '~/components/settings/modal'; import ModalSettings from '~/components/settings/modal';
import useActiveCollection from '~/hooks/use_active_collection'; import useActiveCollection from '~/hooks/use_active_collection';
import useUser from '~/hooks/use_user';
import { rgba } from '~/lib/color'; import { rgba } from '~/lib/color';
import { appendCollectionId } from '~/lib/navigation'; import { appendCollectionId } from '~/lib/navigation';
@@ -19,7 +20,7 @@ const SideMenu = styled.nav({
flexDirection: 'column', flexDirection: 'column',
}); });
const AdminButton = styled(Item)(({ theme }) => ({ const AdminButton = styled(ItemLink)(({ theme }) => ({
color: theme.colors.lightRed, color: theme.colors.lightRed,
'&:hover': { '&:hover': {
backgroundColor: `${rgba(theme.colors.lightRed, 0.1)}!important`, backgroundColor: `${rgba(theme.colors.lightRed, 0.1)}!important`,
@@ -43,15 +44,18 @@ const AddButton = styled(ItemLink)(({ theme }) => ({
const SearchButton = AddButton.withComponent(Item); const SearchButton = AddButton.withComponent(Item);
export default function SideNavigation() { export default function SideNavigation() {
const { user } = useUser();
const { t } = useTranslation('common'); const { t } = useTranslation('common');
const { activeCollection } = useActiveCollection(); const { activeCollection } = useActiveCollection();
return ( return (
<SideMenu> <SideMenu>
<div css={{ paddingInline: '10px' }}> <div css={{ paddingInline: '10px' }}>
<UserCard /> <UserCard />
<AdminButton> {user!.isAdmin && (
<IoShieldHalfSharp /> {t('admin')} <AdminButton href={route('admin.dashboard').url}>
</AdminButton> <IoShieldHalfSharp /> {t('admin')}
</AdminButton>
)}
<ModalSettings openItem={SettingsButton} /> <ModalSettings openItem={SettingsButton} />
<SearchModal openItem={SearchButton} /> <SearchModal openItem={SearchButton} />
<AddButton <AddButton

View File

@@ -1,3 +1,4 @@
import dayjs from 'dayjs';
import { ChangeEvent } from 'react'; import { ChangeEvent } from 'react';
import { useTranslation } from 'react-i18next'; import { useTranslation } from 'react-i18next';
import { LS_LANG_KEY } from '~/constants'; import { LS_LANG_KEY } from '~/constants';
@@ -9,6 +10,7 @@ export default function LangSelector() {
const onToggleLanguageClick = ({ const onToggleLanguageClick = ({
target, target,
}: ChangeEvent<HTMLSelectElement>) => { }: ChangeEvent<HTMLSelectElement>) => {
dayjs.locale(target.value);
i18n.changeLanguage(target.value); i18n.changeLanguage(target.value);
localStorage.setItem(LS_LANG_KEY, target.value); localStorage.setItem(LS_LANG_KEY, target.value);
}; };

View File

@@ -1,11 +1,15 @@
import dayjs from 'dayjs';
import { ReactNode } from 'react'; import { ReactNode } from 'react';
import { useTranslation } from 'react-i18next';
import ContextThemeProvider from '~/components/layouts/_theme_layout'; import ContextThemeProvider from '~/components/layouts/_theme_layout';
import DarkThemeContextProvider from '~/contexts/dark_theme_context'; import DarkThemeContextProvider from '~/contexts/dark_theme_context';
const BaseLayout = ({ children }: { children: ReactNode }) => ( export default function BaseLayout({ children }: { children: ReactNode }) {
<DarkThemeContextProvider> const { i18n } = useTranslation();
<ContextThemeProvider>{children}</ContextThemeProvider> dayjs.locale(i18n.language);
</DarkThemeContextProvider> return (
); <DarkThemeContextProvider>
<ContextThemeProvider>{children}</ContextThemeProvider>
export default BaseLayout; </DarkThemeContextProvider>
);
}

View File

@@ -109,5 +109,35 @@ function GlobalStyles() {
}, },
}); });
return <Global styles={[cssReset, documentStyle, scrollbarStyle]} />; const tableStyle = css({
table: {
height: 'auto',
width: '100%',
borderCollapse: 'collapse',
borderRadius: localTheme.border.radius,
overflow: 'hidden',
},
th: {
textAlign: 'center',
fontWeight: 400,
backgroundColor: localTheme.colors.secondary,
},
'td, th': {
padding: '0.45em',
},
'th, td': {
whiteSpace: 'nowrap',
},
'tr:nth-of-type(even)': {
backgroundColor: localTheme.colors.secondary,
},
});
return (
<Global styles={[cssReset, documentStyle, scrollbarStyle, tableStyle]} />
);
} }

View File

@@ -41,6 +41,10 @@ const NavList = styled(UnstyledList)<NavbarListDirection>(
}) })
); );
const AdminLink = styled(Link)(({ theme }) => ({
color: theme.colors.lightRed,
}));
const UserCard = styled.div({ const UserCard = styled.div({
padding: '0.25em 0.5em', padding: '0.25em 0.5em',
display: 'flex', display: 'flex',
@@ -68,6 +72,13 @@ export default function Navbar() {
</li> </li>
{isAuthenticated && !!user ? ( {isAuthenticated && !!user ? (
<> <>
{user.isAdmin && (
<li>
<AdminLink href={route('admin.dashboard').url}>
{t('admin')}
</AdminLink>
</li>
)}
<li> <li>
<Link href={route('dashboard').url}>Dashboard</Link> <Link href={route('dashboard').url}>Dashboard</Link>
</li> </li>

View File

@@ -1,2 +1,3 @@
export const LS_LANG_KEY = 'language'; export const LS_LANG_KEY = 'language';
export const GOOGLE_SEARCH_URL = 'https://google.com/search?q='; export const GOOGLE_SEARCH_URL = 'https://google.com/search?q=';
export const DATE_FORMAT = 'DD MMM YYYY (HH:mm)';

View File

@@ -37,3 +37,6 @@ export function arrayMove<T>(
arrayCopy.splice(nextIndex, 0, removedElement); arrayCopy.splice(nextIndex, 0, removedElement);
return arrayCopy; return arrayCopy;
} }
export const sortByCreationDate = <T extends { createdAt: any }>(arr: T[]) =>
arr.sort((a, b) => (a.createdAt > b.createdAt ? 1 : -1));

View File

@@ -0,0 +1,137 @@
import { useTheme } from '@emotion/react';
import styled from '@emotion/styled';
import dayjs from 'dayjs';
import relativeTime from 'dayjs/plugin/relativeTime';
import { ReactNode } from 'react';
import { useTranslation } from 'react-i18next';
import RoundedImage from '~/components/common/rounded_image';
import TextEllipsis from '~/components/common/text_ellipsis';
import ContentLayout from '~/components/layouts/content_layout';
import { DATE_FORMAT } from '~/constants';
import { sortByCreationDate } from '~/lib/array';
import { UserWithRelationCount } from '~/types/app';
dayjs.extend(relativeTime);
interface AdminDashboardProps {
users: UserWithRelationCount[];
totalCollections: number;
totalLinks: number;
}
const Cell = styled.div<{ column?: boolean; fixed?: boolean }>(
({ column, fixed }) => ({
width: '100%',
display: 'flex',
alignItems: 'center',
justifyContent: fixed ? 'unset' : 'center',
gap: column ? 0 : '0.35em',
flexDirection: column ? 'column' : 'row',
})
);
const ThemeProvider = (props: AdminDashboardProps) => (
<ContentLayout>
<AdminDashboard {...props} />
</ContentLayout>
);
export default ThemeProvider;
function AdminDashboard({
users,
totalCollections,
totalLinks,
}: AdminDashboardProps) {
const { t } = useTranslation();
const theme = useTheme();
return (
<div style={{ overflow: 'auto', marginTop: '1em' }}>
<table>
<thead>
<tr>
<TableCell type="th">#</TableCell>
<TableCell type="th">{t('common:name')}</TableCell>
<TableCell type="th">{t('common:email')}</TableCell>
<TableCell type="th">
{t('common:collection.collections', { count: totalCollections })}{' '}
<span css={{ color: theme.colors.grey }}>
({totalCollections})
</span>
</TableCell>
<TableCell type="th">
{t('common:link.links', { count: totalLinks })}{' '}
<span css={{ color: theme.colors.grey }}>({totalLinks})</span>
</TableCell>
<TableCell type="th">{t('admin:role')}</TableCell>
<TableCell type="th">{t('admin:created_at')}</TableCell>
<TableCell type="th">{t('admin:updated_at')}</TableCell>
</tr>
</thead>
<tbody>
{users.length !== 0 &&
sortByCreationDate(users).map((user) => (
<TableUserRow user={user} key={user.id} />
))}
</tbody>
</table>
</div>
);
}
function TableUserRow({ user }: { user: UserWithRelationCount }) {
const { t } = useTranslation();
const theme = useTheme();
const { id, fullname, avatarUrl, email, isAdmin, createdAt, updatedAt } =
user;
return (
<tr>
<TableCell type="td">{id}</TableCell>
<TableCell type="td" fixed>
{avatarUrl && (
<RoundedImage
src={avatarUrl}
width={24}
height={24}
alt={fullname}
title={fullname}
/>
)}
<TextEllipsis>{fullname ?? '-'}</TextEllipsis>
</TableCell>
<TableCell type="td">
<TextEllipsis>{email}</TextEllipsis>
</TableCell>
<TableCell type="td">{user.count.collection}</TableCell>
<TableCell type="td">{user.count.link}</TableCell>
<TableCell type="td">
{isAdmin ? (
<span style={{ color: theme.colors.lightRed }}>
{t('admin:admin')}
</span>
) : (
<span style={{ color: theme.colors.green }}>{t('admin:user')}</span>
)}
</TableCell>
<TableCell type="td" column>
<span>{dayjs(createdAt.toString()).fromNow()}</span>
<span>{dayjs(createdAt.toString()).format(DATE_FORMAT)}</span>
</TableCell>
<TableCell type="td" column>
<span>{dayjs(updatedAt.toString()).fromNow()}</span>
<span>{dayjs(updatedAt.toString()).format(DATE_FORMAT)}</span>
</TableCell>
</tr>
);
}
type TableItem = {
children: ReactNode;
type: 'td' | 'th';
fixed?: boolean;
column?: boolean;
};
function TableCell({ children, type, ...props }: TableItem) {
const child = <Cell {...props}>{children}</Cell>;
return type === 'td' ? <td>{child}</td> : <th>{child}</th>;
}

View File

@@ -47,6 +47,8 @@ export const lightTheme: Theme = {
darkBlue, darkBlue,
darkestBlue, darkestBlue,
green: 'green',
lightRed, lightRed,
yellow: '#FF8A08', yellow: '#FF8A08',
@@ -78,6 +80,8 @@ export const darkTheme: Theme = {
darkBlue, darkBlue,
darkestBlue, darkestBlue,
green: '#09b909',
lightRed, lightRed,
yellow: '#ffc107', yellow: '#ffc107',

View File

@@ -10,3 +10,17 @@ type User = {
isAdmin: boolean; isAdmin: boolean;
collections: Collection[]; collections: Collection[];
}; };
type UserWithRelationCount = {
id: number;
email: string;
fullname: string;
avatarUrl: string;
isAdmin: string;
createdAt: string;
updatedAt: string;
count: {
link: number;
collection: number;
};
};

View File

@@ -20,6 +20,8 @@ declare module '@emotion/react' {
darkBlue: string; darkBlue: string;
darkestBlue: string; darkestBlue: string;
green: string;
lightRed: string; lightRed: string;
yellow: string; yellow: string;

1
package-lock.json generated
View File

@@ -25,6 +25,7 @@
"@izzyjs/route": "^1.1.0-0", "@izzyjs/route": "^1.1.0-0",
"@vinejs/vine": "^2.0.0", "@vinejs/vine": "^2.0.0",
"bentocache": "^1.0.0-beta.9", "bentocache": "^1.0.0-beta.9",
"dayjs": "^1.11.11",
"edge.js": "^6.0.2", "edge.js": "^6.0.2",
"hex-rgb": "^5.0.0", "hex-rgb": "^5.0.0",
"i18next": "^23.11.5", "i18next": "^23.11.5",

View File

@@ -76,6 +76,7 @@
"@izzyjs/route": "^1.1.0-0", "@izzyjs/route": "^1.1.0-0",
"@vinejs/vine": "^2.0.0", "@vinejs/vine": "^2.0.0",
"bentocache": "^1.0.0-beta.9", "bentocache": "^1.0.0-beta.9",
"dayjs": "^1.11.11",
"edge.js": "^6.0.2", "edge.js": "^6.0.2",
"hex-rgb": "^5.0.0", "hex-rgb": "^5.0.0",
"i18next": "^23.11.5", "i18next": "^23.11.5",

View File

@@ -48,6 +48,7 @@ router.use([
* the routes or the routes group. * the routes or the routes group.
*/ */
export const middleware = router.named({ export const middleware = router.named({
admin: () => import('#middleware/admin_middleware'),
guest: () => import('#middleware/guest_middleware'), guest: () => import('#middleware/guest_middleware'),
auth: () => import('#middleware/auth_middleware'), auth: () => import('#middleware/auth_middleware'),
}); });

View File

@@ -4,3 +4,4 @@ import './routes/collection.js';
import './routes/favicon.js'; import './routes/favicon.js';
import './routes/link.js'; import './routes/link.js';
import './routes/search.js'; import './routes/search.js';
import './routes/admin.js';

14
start/routes/admin.ts Normal file
View File

@@ -0,0 +1,14 @@
import { middleware } from '#start/kernel';
import router from '@adonisjs/core/services/router';
const AdminController = () => import('#controllers/admin_controller');
/**
* Routes for admin dashboard
*/
router
.group(() => {
router.get('/', [AdminController, 'index']).as('admin.dashboard');
})
.middleware([middleware.auth(), middleware.admin()])
.prefix('/admin');