diff --git a/app/controllers/admin_controller.ts b/app/controllers/admin_controller.ts new file mode 100644 index 0000000..6d89e12 --- /dev/null +++ b/app/controllers/admin_controller.ts @@ -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), + }); + } +} diff --git a/app/controllers/users_controller.ts b/app/controllers/users_controller.ts index 04faef4..4346c2a 100644 --- a/app/controllers/users_controller.ts +++ b/app/controllers/users_controller.ts @@ -1,6 +1,7 @@ import User from '#models/user'; import type { HttpContext } from '@adonisjs/core/http'; import logger from '@adonisjs/core/services/logger'; +import db from '@adonisjs/lucid/services/db'; import { RouteName } from '@izzyjs/route/types'; export default class UsersController { @@ -30,6 +31,7 @@ export default class UsersController { return response.redirectToNamedRoute(this.redirectTo); } + const userCount = await db.from('users').count('* as total'); const { email, id: providerId, @@ -50,6 +52,7 @@ export default class UsersController { avatarUrl, token, providerType: 'google', + isAdmin: userCount[0].total === '0', } ); @@ -66,4 +69,10 @@ export default class UsersController { logger.info(`[${auth.user?.email}] disconnected successfully`); response.redirectToNamedRoute(this.redirectTo); } + + async getAllUsersWithTotalRelations() { + return User.query() + .withCount('collections', (q) => q.as('totalCollections')) + .withCount('links', (q) => q.as('totalLinks')); + } } diff --git a/app/middleware/admin_middleware.ts b/app/middleware/admin_middleware.ts new file mode 100644 index 0000000..b7f2467 --- /dev/null +++ b/app/middleware/admin_middleware.ts @@ -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(); + } +} diff --git a/app/models/app_base_model.ts b/app/models/app_base_model.ts index 5152dfa..b3a5f72 100644 --- a/app/models/app_base_model.ts +++ b/app/models/app_base_model.ts @@ -7,21 +7,19 @@ import { DateTime } from 'luxon'; export default class AppBaseModel extends BaseModel { static namingStrategy = new CamelCaseNamingStrategy(); - static selfAssignPrimaryKey = true; + serializeExtras = true; @column({ isPrimary: true }) declare id: number; @column.dateTime({ autoCreate: true, - serializeAs: 'created_at', }) - declare created_at: DateTime; + declare createdAt: DateTime; @column.dateTime({ autoCreate: true, autoUpdate: true, - serializeAs: 'updated_at', }) - declare updated_at: DateTime; + declare updatedAt: DateTime; } diff --git a/app/models/collection.ts b/app/models/collection.ts index b8139fe..e638cd7 100644 --- a/app/models/collection.ts +++ b/app/models/collection.ts @@ -21,7 +21,7 @@ export default class Collection extends AppBaseModel { @column() declare authorId: number; - @belongsTo(() => User, { foreignKey: 'authorId' }) + @belongsTo(() => User, { foreignKey: 'author_id' }) declare author: BelongsTo; @hasMany(() => Link) diff --git a/app/models/user.ts b/app/models/user.ts index a666869..8922270 100644 --- a/app/models/user.ts +++ b/app/models/user.ts @@ -1,8 +1,8 @@ import Collection from '#models/collection'; import Link from '#models/link'; import type { GoogleToken } from '@adonisjs/ally/types'; -import { column, computed, manyToMany } from '@adonisjs/lucid/orm'; -import type { ManyToMany } from '@adonisjs/lucid/types/relations'; +import { column, computed, hasMany } from '@adonisjs/lucid/orm'; +import type { HasMany } from '@adonisjs/lucid/types/relations'; import AppBaseModel from './app_base_model.js'; export default class User extends AppBaseModel { @@ -30,15 +30,15 @@ export default class User extends AppBaseModel { @column({ serializeAs: null }) declare providerType: 'google'; - @manyToMany(() => Collection, { - relatedKey: 'authorId', + @hasMany(() => Collection, { + foreignKey: 'authorId', }) - declare collections: ManyToMany; + declare collections: HasMany; - @manyToMany(() => Link, { - relatedKey: 'authorId', + @hasMany(() => Link, { + foreignKey: 'authorId', }) - declare links: ManyToMany; + declare links: HasMany; @computed() get fullname() { diff --git a/inertia/app/app.tsx b/inertia/app/app.tsx index 33eda7c..0f92cf2 100644 --- a/inertia/app/app.tsx +++ b/inertia/app/app.tsx @@ -1,11 +1,13 @@ import { resolvePageComponent } from '@adonisjs/inertia/helpers'; import { createInertiaApp } from '@inertiajs/react'; import { hydrateRoot } from 'react-dom/client'; -import { primaryColor } from '~/styles/theme'; import 'react-toggle/style.css'; - +import { primaryColor } from '~/styles/theme'; import '../i18n/index'; +import 'dayjs/locale/en'; +import 'dayjs/locale/fr'; + const appName = import.meta.env.VITE_APP_NAME || 'MyLinks'; createInertiaApp({ diff --git a/inertia/components/dashboard/link/link_list.tsx b/inertia/components/dashboard/link/link_list.tsx index 7237440..3e183cb 100644 --- a/inertia/components/dashboard/link/link_list.tsx +++ b/inertia/components/dashboard/link/link_list.tsx @@ -2,6 +2,7 @@ import type Link from '#models/link'; import styled from '@emotion/styled'; import LinkItem from '~/components/dashboard/link/link_item'; import { NoLink } from '~/components/dashboard/link/no_item'; +import { sortByCreationDate } from '~/lib/array'; const LinkListStyle = styled.ul({ height: '100%', @@ -23,11 +24,9 @@ export default function LinkList({ links }: { links: Link[] }) { return ( - {links - .sort((a, b) => (a.created_at > b.created_at ? 1 : -1)) - .map((link) => ( - - ))} + {sortByCreationDate(links).map((link) => ( + + ))} ); } diff --git a/inertia/components/dashboard/side_nav/side_navigation.tsx b/inertia/components/dashboard/side_nav/side_navigation.tsx index 92d7f46..3155c32 100644 --- a/inertia/components/dashboard/side_nav/side_navigation.tsx +++ b/inertia/components/dashboard/side_nav/side_navigation.tsx @@ -9,6 +9,7 @@ import { Item, ItemLink } from '~/components/dashboard/side_nav/nav_item'; import UserCard from '~/components/dashboard/side_nav/user_card'; import ModalSettings from '~/components/settings/modal'; import useActiveCollection from '~/hooks/use_active_collection'; +import useUser from '~/hooks/use_user'; import { rgba } from '~/lib/color'; import { appendCollectionId } from '~/lib/navigation'; @@ -19,7 +20,7 @@ const SideMenu = styled.nav({ flexDirection: 'column', }); -const AdminButton = styled(Item)(({ theme }) => ({ +const AdminButton = styled(ItemLink)(({ theme }) => ({ color: theme.colors.lightRed, '&:hover': { backgroundColor: `${rgba(theme.colors.lightRed, 0.1)}!important`, @@ -43,15 +44,18 @@ const AddButton = styled(ItemLink)(({ theme }) => ({ const SearchButton = AddButton.withComponent(Item); export default function SideNavigation() { + const { user } = useUser(); const { t } = useTranslation('common'); const { activeCollection } = useActiveCollection(); return (
- - {t('admin')} - + {user!.isAdmin && ( + + {t('admin')} + + )} ) => { + dayjs.locale(target.value); i18n.changeLanguage(target.value); localStorage.setItem(LS_LANG_KEY, target.value); }; diff --git a/inertia/components/layouts/_base_layout.tsx b/inertia/components/layouts/_base_layout.tsx index 4c27f22..b23c740 100644 --- a/inertia/components/layouts/_base_layout.tsx +++ b/inertia/components/layouts/_base_layout.tsx @@ -1,11 +1,15 @@ +import dayjs from 'dayjs'; import { ReactNode } from 'react'; +import { useTranslation } from 'react-i18next'; import ContextThemeProvider from '~/components/layouts/_theme_layout'; import DarkThemeContextProvider from '~/contexts/dark_theme_context'; -const BaseLayout = ({ children }: { children: ReactNode }) => ( - - {children} - -); - -export default BaseLayout; +export default function BaseLayout({ children }: { children: ReactNode }) { + const { i18n } = useTranslation(); + dayjs.locale(i18n.language); + return ( + + {children} + + ); +} diff --git a/inertia/components/layouts/_theme_layout.tsx b/inertia/components/layouts/_theme_layout.tsx index ab01375..5c8d6af 100644 --- a/inertia/components/layouts/_theme_layout.tsx +++ b/inertia/components/layouts/_theme_layout.tsx @@ -109,5 +109,35 @@ function GlobalStyles() { }, }); - return ; + 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 ( + + ); } diff --git a/inertia/components/navbar/navbar.tsx b/inertia/components/navbar/navbar.tsx index 6c19cce..677aada 100644 --- a/inertia/components/navbar/navbar.tsx +++ b/inertia/components/navbar/navbar.tsx @@ -41,6 +41,10 @@ const NavList = styled(UnstyledList)( }) ); +const AdminLink = styled(Link)(({ theme }) => ({ + color: theme.colors.lightRed, +})); + const UserCard = styled.div({ padding: '0.25em 0.5em', display: 'flex', @@ -68,6 +72,13 @@ export default function Navbar() { {isAuthenticated && !!user ? ( <> + {user.isAdmin && ( +
  • + + {t('admin')} + +
  • + )}
  • Dashboard
  • diff --git a/inertia/constants/index.ts b/inertia/constants/index.ts index 4b75c94..da392b4 100644 --- a/inertia/constants/index.ts +++ b/inertia/constants/index.ts @@ -1,2 +1,3 @@ export const LS_LANG_KEY = 'language'; export const GOOGLE_SEARCH_URL = 'https://google.com/search?q='; +export const DATE_FORMAT = 'DD MMM YYYY (HH:mm)'; diff --git a/inertia/lib/array.ts b/inertia/lib/array.ts index e06f15a..7ef442f 100644 --- a/inertia/lib/array.ts +++ b/inertia/lib/array.ts @@ -37,3 +37,6 @@ export function arrayMove( arrayCopy.splice(nextIndex, 0, removedElement); return arrayCopy; } + +export const sortByCreationDate = (arr: T[]) => + arr.sort((a, b) => (a.createdAt > b.createdAt ? 1 : -1)); diff --git a/inertia/pages/admin/dashboard.tsx b/inertia/pages/admin/dashboard.tsx new file mode 100644 index 0000000..35cf9a8 --- /dev/null +++ b/inertia/pages/admin/dashboard.tsx @@ -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) => ( + + + +); +export default ThemeProvider; + +function AdminDashboard({ + users, + totalCollections, + totalLinks, +}: AdminDashboardProps) { + const { t } = useTranslation(); + const theme = useTheme(); + return ( +
    + + + + # + {t('common:name')} + {t('common:email')} + + {t('common:collection.collections', { count: totalCollections })}{' '} + + ({totalCollections}) + + + + {t('common:link.links', { count: totalLinks })}{' '} + ({totalLinks}) + + {t('admin:role')} + {t('admin:created_at')} + {t('admin:updated_at')} + + + + {users.length !== 0 && + sortByCreationDate(users).map((user) => ( + + ))} + +
    +
    + ); +} + +function TableUserRow({ user }: { user: UserWithRelationCount }) { + const { t } = useTranslation(); + const theme = useTheme(); + const { id, fullname, avatarUrl, email, isAdmin, createdAt, updatedAt } = + user; + return ( + + {id} + + {avatarUrl && ( + + )} + {fullname ?? '-'} + + + {email} + + {user.count.collection} + {user.count.link} + + {isAdmin ? ( + + {t('admin:admin')} + + ) : ( + {t('admin:user')} + )} + + + {dayjs(createdAt.toString()).fromNow()} + {dayjs(createdAt.toString()).format(DATE_FORMAT)} + + + {dayjs(updatedAt.toString()).fromNow()} + {dayjs(updatedAt.toString()).format(DATE_FORMAT)} + + + ); +} + +type TableItem = { + children: ReactNode; + type: 'td' | 'th'; + fixed?: boolean; + column?: boolean; +}; + +function TableCell({ children, type, ...props }: TableItem) { + const child = {children}; + return type === 'td' ? {child} : {child}; +} diff --git a/inertia/styles/theme.ts b/inertia/styles/theme.ts index b932c29..d875897 100644 --- a/inertia/styles/theme.ts +++ b/inertia/styles/theme.ts @@ -47,6 +47,8 @@ export const lightTheme: Theme = { darkBlue, darkestBlue, + green: 'green', + lightRed, yellow: '#FF8A08', @@ -78,6 +80,8 @@ export const darkTheme: Theme = { darkBlue, darkestBlue, + green: '#09b909', + lightRed, yellow: '#ffc107', diff --git a/inertia/types/app.d.ts b/inertia/types/app.d.ts index 0756812..2f34400 100644 --- a/inertia/types/app.d.ts +++ b/inertia/types/app.d.ts @@ -10,3 +10,17 @@ type User = { isAdmin: boolean; collections: Collection[]; }; + +type UserWithRelationCount = { + id: number; + email: string; + fullname: string; + avatarUrl: string; + isAdmin: string; + createdAt: string; + updatedAt: string; + count: { + link: number; + collection: number; + }; +}; diff --git a/inertia/types/emotion.d.ts b/inertia/types/emotion.d.ts index 38f226c..4633f5b 100644 --- a/inertia/types/emotion.d.ts +++ b/inertia/types/emotion.d.ts @@ -20,6 +20,8 @@ declare module '@emotion/react' { darkBlue: string; darkestBlue: string; + green: string; + lightRed: string; yellow: string; diff --git a/package-lock.json b/package-lock.json index 408a0a9..df22fd3 100644 --- a/package-lock.json +++ b/package-lock.json @@ -25,6 +25,7 @@ "@izzyjs/route": "^1.1.0-0", "@vinejs/vine": "^2.0.0", "bentocache": "^1.0.0-beta.9", + "dayjs": "^1.11.11", "edge.js": "^6.0.2", "hex-rgb": "^5.0.0", "i18next": "^23.11.5", diff --git a/package.json b/package.json index 9f21d16..ce4614e 100644 --- a/package.json +++ b/package.json @@ -76,6 +76,7 @@ "@izzyjs/route": "^1.1.0-0", "@vinejs/vine": "^2.0.0", "bentocache": "^1.0.0-beta.9", + "dayjs": "^1.11.11", "edge.js": "^6.0.2", "hex-rgb": "^5.0.0", "i18next": "^23.11.5", diff --git a/start/kernel.ts b/start/kernel.ts index 6f08863..e1f4ff6 100644 --- a/start/kernel.ts +++ b/start/kernel.ts @@ -48,6 +48,7 @@ router.use([ * the routes or the routes group. */ export const middleware = router.named({ + admin: () => import('#middleware/admin_middleware'), guest: () => import('#middleware/guest_middleware'), auth: () => import('#middleware/auth_middleware'), }); diff --git a/start/routes.ts b/start/routes.ts index 5eef342..9184beb 100644 --- a/start/routes.ts +++ b/start/routes.ts @@ -4,3 +4,4 @@ import './routes/collection.js'; import './routes/favicon.js'; import './routes/link.js'; import './routes/search.js'; +import './routes/admin.js'; diff --git a/start/routes/admin.ts b/start/routes/admin.ts new file mode 100644 index 0000000..2543275 --- /dev/null +++ b/start/routes/admin.ts @@ -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');