mirror of
https://github.com/Sonny93/my-links.git
synced 2025-12-08 14:43:24 +00:00
feat: add basic admin dashboard
This commit is contained in:
42
app/controllers/admin_controller.ts
Normal file
42
app/controllers/admin_controller.ts
Normal 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),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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'));
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
11
app/middleware/admin_middleware.ts
Normal file
11
app/middleware/admin_middleware.ts
Normal 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();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
@@ -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() {
|
||||||
|
|||||||
@@ -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({
|
||||||
|
|||||||
@@ -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>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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);
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|||||||
@@ -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]} />
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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)';
|
||||||
|
|||||||
@@ -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));
|
||||||
|
|||||||
137
inertia/pages/admin/dashboard.tsx
Normal file
137
inertia/pages/admin/dashboard.tsx
Normal 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>;
|
||||||
|
}
|
||||||
@@ -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',
|
||||||
|
|||||||
14
inertia/types/app.d.ts
vendored
14
inertia/types/app.d.ts
vendored
@@ -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;
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|||||||
2
inertia/types/emotion.d.ts
vendored
2
inertia/types/emotion.d.ts
vendored
@@ -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
1
package-lock.json
generated
@@ -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",
|
||||||
|
|||||||
@@ -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",
|
||||||
|
|||||||
@@ -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'),
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -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
14
start/routes/admin.ts
Normal 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');
|
||||||
Reference in New Issue
Block a user