mirror of
https://github.com/Sonny93/my-links.git
synced 2025-12-08 22:53:25 +00:00
feat: admin dashboard
This commit is contained in:
6
package-lock.json
generated
6
package-lock.json
generated
@@ -12,6 +12,7 @@
|
||||
"@types/react-toggle": "^4.0.5",
|
||||
"accept-language": "^3.0.18",
|
||||
"clsx": "^2.0.0",
|
||||
"dayjs": "^1.11.10",
|
||||
"framer-motion": "^10.16.16",
|
||||
"i18next": "^23.7.11",
|
||||
"next": "^14.0.4",
|
||||
@@ -4623,6 +4624,11 @@
|
||||
"integrity": "sha512-sdQSFB7+llfUcQHUQO3+B8ERRj0Oa4w9POWMI/puGtuf7gFywGmkaLCElnudfTiKZV+NvHqL0ifzdrI8Ro7ESA==",
|
||||
"dev": true
|
||||
},
|
||||
"node_modules/dayjs": {
|
||||
"version": "1.11.10",
|
||||
"resolved": "https://registry.npmjs.org/dayjs/-/dayjs-1.11.10.tgz",
|
||||
"integrity": "sha512-vjAczensTgRcqDERK0SR2XMwsF/tSvnvlv6VcF2GIhg6Sx4yOIt/irsr1RDJsKiIyBzJDpCoXiWWq28MqH2cnQ=="
|
||||
},
|
||||
"node_modules/debug": {
|
||||
"version": "4.3.4",
|
||||
"resolved": "https://registry.npmjs.org/debug/-/debug-4.3.4.tgz",
|
||||
|
||||
@@ -17,6 +17,7 @@
|
||||
"@types/react-toggle": "^4.0.5",
|
||||
"accept-language": "^3.0.18",
|
||||
"clsx": "^2.0.0",
|
||||
"dayjs": "^1.11.10",
|
||||
"framer-motion": "^10.16.16",
|
||||
"i18next": "^23.7.11",
|
||||
"next": "^14.0.4",
|
||||
@@ -57,4 +58,4 @@
|
||||
"lint-staged": {
|
||||
"*.js": "eslint --cache --fix"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,2 @@
|
||||
-- AlterTable
|
||||
ALTER TABLE `user` ADD COLUMN `is_admin` BOOLEAN NOT NULL DEFAULT false;
|
||||
@@ -17,6 +17,7 @@ model User {
|
||||
email String @unique
|
||||
name String?
|
||||
image String?
|
||||
is_admin Boolean @default(false)
|
||||
|
||||
categories Category[]
|
||||
links Link[]
|
||||
|
||||
9
public/locales/en/admin.json
Normal file
9
public/locales/en/admin.json
Normal file
@@ -0,0 +1,9 @@
|
||||
{
|
||||
"role": "Role",
|
||||
"created_at": "Created at",
|
||||
"updated_at": "Updated at",
|
||||
"admin": "Administrator",
|
||||
"user": "User",
|
||||
"users": "Users",
|
||||
"stats": "Statistics"
|
||||
}
|
||||
9
public/locales/fr/admin.json
Normal file
9
public/locales/fr/admin.json
Normal file
@@ -0,0 +1,9 @@
|
||||
{
|
||||
"role": "Rôle",
|
||||
"created_at": "Création",
|
||||
"updated_at": "Mise à jour",
|
||||
"admin": "Administrateur",
|
||||
"user": "Utilisateur",
|
||||
"users": "Utilisateurs",
|
||||
"stats": "Statistiques"
|
||||
}
|
||||
@@ -1,4 +1,5 @@
|
||||
import PATHS from 'constants/paths';
|
||||
import useUser from 'hooks/useUser';
|
||||
import { useSession } from 'next-auth/react';
|
||||
import { useTranslation } from 'next-i18next';
|
||||
import LinkTag from 'next/link';
|
||||
@@ -6,11 +7,12 @@ import RoundedImage from '../RoundedImage/RoundedImage';
|
||||
import styles from './navbar.module.scss';
|
||||
|
||||
export default function Navbar() {
|
||||
const { data, status } = useSession();
|
||||
const { status } = useSession();
|
||||
const { user } = useUser();
|
||||
const { t } = useTranslation();
|
||||
|
||||
const avatarLabel = t('common:avatar', {
|
||||
name: data?.user?.name,
|
||||
name: user?.name,
|
||||
});
|
||||
|
||||
return (
|
||||
@@ -27,12 +29,17 @@ export default function Navbar() {
|
||||
</li>
|
||||
{status === 'authenticated' ? (
|
||||
<>
|
||||
{user?.is_admin && (
|
||||
<li>
|
||||
<LinkTag href={PATHS.ADMIN}>Admin</LinkTag>
|
||||
</li>
|
||||
)}
|
||||
<li className={styles['user']}>
|
||||
<RoundedImage
|
||||
src={data.user.image}
|
||||
src={user?.image}
|
||||
alt={avatarLabel}
|
||||
/>
|
||||
{data.user.name}
|
||||
{user?.name}
|
||||
</li>
|
||||
<li>
|
||||
<LinkTag href={PATHS.LOGOUT}>{t('common:logout')}</LinkTag>
|
||||
|
||||
@@ -7,6 +7,10 @@ export default function RoundedImage({
|
||||
height = 24,
|
||||
alt,
|
||||
}: (typeof Image)['defaultProps']) {
|
||||
if (!src) {
|
||||
console.warn('No src provided');
|
||||
return <></>;
|
||||
}
|
||||
return (
|
||||
<div className={styles['rounded-image']}>
|
||||
<Image
|
||||
|
||||
@@ -1,3 +1,9 @@
|
||||
.rounded-image {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.rounded-image img {
|
||||
border-radius: 50%;
|
||||
}
|
||||
|
||||
@@ -1,28 +1,36 @@
|
||||
import RoundedImage from 'components/RoundedImage/RoundedImage';
|
||||
import SettingsModal from 'components/Settings/SettingsModal';
|
||||
import { useSession } from 'next-auth/react';
|
||||
import PATHS from 'constants/paths';
|
||||
import useUser from 'hooks/useUser';
|
||||
import { useTranslation } from 'next-i18next';
|
||||
import Image from 'next/image';
|
||||
import Link from 'next/link';
|
||||
import { MdOutlineAdminPanelSettings } from 'react-icons/md';
|
||||
import styles from './user-card.module.scss';
|
||||
|
||||
export default function UserCard() {
|
||||
const { data } = useSession({ required: true });
|
||||
const { user } = useUser();
|
||||
const { t } = useTranslation();
|
||||
|
||||
const avatarLabel = t('common:avatar', {
|
||||
name: data.user.name,
|
||||
name: user.name,
|
||||
});
|
||||
return (
|
||||
<div className={styles['user-card-wrapper']}>
|
||||
<div className={styles['user-card']}>
|
||||
<Image
|
||||
src={data.user.image}
|
||||
<RoundedImage
|
||||
src={user.image}
|
||||
width={28}
|
||||
height={28}
|
||||
alt={avatarLabel}
|
||||
title={avatarLabel}
|
||||
/>
|
||||
{data.user.name}
|
||||
{user.name}
|
||||
</div>
|
||||
{user.is_admin && (
|
||||
<Link href={PATHS.ADMIN}>
|
||||
<MdOutlineAdminPanelSettings />
|
||||
</Link>
|
||||
)}
|
||||
<SettingsModal />
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -4,6 +4,7 @@ const PATHS = {
|
||||
HOME: '/',
|
||||
PRIVACY: '/privacy',
|
||||
TERMS: '/terms',
|
||||
ADMIN: '/admin',
|
||||
CATEGORY: {
|
||||
CREATE: '/category/create',
|
||||
EDIT: '/category/edit',
|
||||
|
||||
8
src/hooks/useUser.tsx
Normal file
8
src/hooks/useUser.tsx
Normal file
@@ -0,0 +1,8 @@
|
||||
import { User } from '@prisma/client';
|
||||
import { Session } from 'next-auth';
|
||||
import { useSession } from 'next-auth/react';
|
||||
|
||||
export default function useUser() {
|
||||
const { data } = useSession();
|
||||
return data as Session & { user?: User };
|
||||
}
|
||||
@@ -3,6 +3,7 @@ import home from '../../public/locales/en/home.json';
|
||||
import login from '../../public/locales/en/login.json';
|
||||
import privacy from '../../public/locales/en/privacy.json';
|
||||
import terms from '../../public/locales/en/terms.json';
|
||||
import admin from '../../public/locales/en/admin.json';
|
||||
|
||||
const resources = {
|
||||
common,
|
||||
@@ -10,6 +11,7 @@ const resources = {
|
||||
home,
|
||||
privacy,
|
||||
terms,
|
||||
admin,
|
||||
} as const;
|
||||
|
||||
export default resources;
|
||||
|
||||
18
src/lib/user/getUsers.ts
Normal file
18
src/lib/user/getUsers.ts
Normal file
@@ -0,0 +1,18 @@
|
||||
import { Session } from 'next-auth';
|
||||
import prisma from 'utils/prisma';
|
||||
|
||||
export default async function getUsers(session: Session) {
|
||||
if (!session?.user) {
|
||||
return null;
|
||||
}
|
||||
return await prisma.user.findMany({
|
||||
include: {
|
||||
_count: {
|
||||
select: {
|
||||
categories: true,
|
||||
links: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
}
|
||||
@@ -1,6 +1,9 @@
|
||||
import ErrorBoundary from 'components/ErrorBoundary/ErrorBoundary';
|
||||
import * as Keys from 'constants/keys';
|
||||
import PATHS from 'constants/paths';
|
||||
import dayjs from 'dayjs';
|
||||
import 'dayjs/locale/en';
|
||||
import 'dayjs/locale/fr';
|
||||
import { SessionProvider } from 'next-auth/react';
|
||||
import { appWithTranslation, useTranslation } from 'next-i18next';
|
||||
import { DefaultSeo } from 'next-seo';
|
||||
@@ -10,12 +13,15 @@ import 'nprogress/nprogress.css';
|
||||
import { useEffect } from 'react';
|
||||
import { useHotkeys } from 'react-hotkeys-hook';
|
||||
import 'styles/globals.scss';
|
||||
import 'styles/table.scss';
|
||||
import config from '../../config';
|
||||
import nextI18nextConfig from '../../next-i18next.config';
|
||||
|
||||
function MyApp({ Component, pageProps: { session, ...pageProps } }) {
|
||||
const router = useRouter();
|
||||
const { i18n } = useTranslation();
|
||||
// TODO: use dynamic locale import
|
||||
dayjs.locale(i18n.language);
|
||||
|
||||
useHotkeys(Keys.CLOSE_SEARCH_KEY, () => router.push(PATHS.HOME), {
|
||||
enabled: router.pathname !== PATHS.HOME,
|
||||
|
||||
140
src/pages/admin.tsx
Normal file
140
src/pages/admin.tsx
Normal file
@@ -0,0 +1,140 @@
|
||||
import { User } from '@prisma/client';
|
||||
import clsx from 'clsx';
|
||||
import Navbar from 'components/Navbar/Navbar';
|
||||
import PageTransition from 'components/PageTransition';
|
||||
import RoundedImage from 'components/RoundedImage/RoundedImage';
|
||||
import dayjs from 'dayjs';
|
||||
import relativeTime from 'dayjs/plugin/relativeTime';
|
||||
import { getServerSideTranslation } from 'i18n';
|
||||
import getUsers from 'lib/user/getUsers';
|
||||
import { useTranslation } from 'next-i18next';
|
||||
import { ReactNode } from 'react';
|
||||
import { Tab, TabList, TabPanel, Tabs } from 'react-tabs';
|
||||
import 'react-tabs/style/react-tabs.css';
|
||||
import styles from 'styles/admin.module.scss';
|
||||
import { withAuthentication } from 'utils/session';
|
||||
|
||||
dayjs.extend(relativeTime);
|
||||
|
||||
type UserExtended = User & { _count: { categories: number; links: number } };
|
||||
|
||||
export default function AdminDashboard({ users }: { users: UserExtended[] }) {
|
||||
const { t } = useTranslation('common');
|
||||
return (
|
||||
<PageTransition className={styles['admin']}>
|
||||
<Navbar />
|
||||
<Tabs>
|
||||
<TabList>
|
||||
<Tab>{t('common:users')}</Tab>
|
||||
<Tab>{t('common:stats')}</Tab>
|
||||
</TabList>
|
||||
|
||||
<TabPanel>
|
||||
<p>
|
||||
{users.length} {t('admin:users')}
|
||||
</p>
|
||||
<table>
|
||||
<thead>
|
||||
<tr>
|
||||
<TH>#</TH>
|
||||
<TH>{t('common:name')}</TH>
|
||||
<TH>{t('common:email')}</TH>
|
||||
<TH>{t('common:category.categories')}</TH>
|
||||
<TH>{t('common:link.links')}</TH>
|
||||
<TH>{t('admin:role')}</TH>
|
||||
<TH>{t('admin:created_at')}</TH>
|
||||
<TH>{t('admin:updated_at')}</TH>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{users.length !== 0 &&
|
||||
users.map(
|
||||
({
|
||||
id,
|
||||
image,
|
||||
name,
|
||||
email,
|
||||
_count,
|
||||
is_admin,
|
||||
createdAt,
|
||||
updatedAt,
|
||||
}) => (
|
||||
<tr key={id}>
|
||||
<TD>{id}</TD>
|
||||
<TD fixed>
|
||||
{image && (
|
||||
<RoundedImage
|
||||
src={image}
|
||||
width={24}
|
||||
height={24}
|
||||
alt={name}
|
||||
title={name}
|
||||
/>
|
||||
)}
|
||||
{name ?? '-'}
|
||||
</TD>
|
||||
<TD>{email}</TD>
|
||||
<TD>{_count.categories}</TD>
|
||||
<TD>{_count.links}</TD>
|
||||
<TD>
|
||||
{is_admin ? (
|
||||
<span style={{ color: 'red' }}>
|
||||
{t('admin:admin')}
|
||||
</span>
|
||||
) : (
|
||||
<span style={{ color: 'green' }}>
|
||||
{t('admin:user')}
|
||||
</span>
|
||||
)}
|
||||
</TD>
|
||||
<TD>{dayjs(createdAt).fromNow()}</TD>
|
||||
<TD>{dayjs(updatedAt).fromNow()}</TD>
|
||||
</tr>
|
||||
),
|
||||
)}
|
||||
</tbody>
|
||||
</table>
|
||||
</TabPanel>
|
||||
<TabPanel>{/* // stats */}</TabPanel>
|
||||
</Tabs>
|
||||
</PageTransition>
|
||||
);
|
||||
}
|
||||
|
||||
type TableItem = { children: ReactNode; fixed?: boolean };
|
||||
function TH({ children, fixed = false }: TableItem) {
|
||||
return (
|
||||
<th>
|
||||
<div className={clsx('cell', fixed && 'fixed')}>{children}</div>
|
||||
</th>
|
||||
);
|
||||
}
|
||||
|
||||
function TD({ children, fixed = false }: TableItem) {
|
||||
return (
|
||||
<td>
|
||||
<div className={clsx('cell', fixed && 'fixed')}>{children}</div>
|
||||
</td>
|
||||
);
|
||||
}
|
||||
|
||||
export const getServerSideProps = withAuthentication(
|
||||
async ({ session, locale, user }) => {
|
||||
if (!user.is_admin) {
|
||||
return {
|
||||
redirect: {
|
||||
destination: '/',
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
const users = await getUsers(session);
|
||||
return {
|
||||
props: {
|
||||
session,
|
||||
...(await getServerSideTranslation(locale, ['admin'])),
|
||||
users: JSON.parse(JSON.stringify(users)),
|
||||
},
|
||||
};
|
||||
},
|
||||
);
|
||||
@@ -39,7 +39,11 @@ export const authOptions = {
|
||||
const user = await prisma.user.findFirst({
|
||||
where: { email: session.user.email },
|
||||
});
|
||||
return user ? session : undefined;
|
||||
if (user) {
|
||||
session.user = JSON.parse(JSON.stringify(user));
|
||||
return session;
|
||||
}
|
||||
return user;
|
||||
},
|
||||
async signIn({ account: accountParam, user }) {
|
||||
if (!checkProvider(accountParam.provider)) {
|
||||
|
||||
@@ -36,7 +36,6 @@ export default function Terms() {
|
||||
<h3>{t('terms:personal_data.collect.title')}</h3>
|
||||
<p>
|
||||
<Trans
|
||||
// @ts-ignore
|
||||
i18nKey='terms:personal_data.collect.description'
|
||||
components={{ a: <LinkTag href={PATHS.PRIVACY} /> }}
|
||||
/>
|
||||
|
||||
5
src/styles/admin.module.scss
Normal file
5
src/styles/admin.module.scss
Normal file
@@ -0,0 +1,5 @@
|
||||
.admin {
|
||||
width: 100%;
|
||||
padding: 1em;
|
||||
overflow: auto;
|
||||
}
|
||||
32
src/styles/table.scss
Normal file
32
src/styles/table.scss
Normal file
@@ -0,0 +1,32 @@
|
||||
@import 'colors.scss';
|
||||
|
||||
table {
|
||||
height: auto;
|
||||
width: 100%;
|
||||
border-collapse: collapse;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
td,
|
||||
th {
|
||||
padding: 1em;
|
||||
}
|
||||
|
||||
th {
|
||||
background-color: $lightest-blue;
|
||||
}
|
||||
|
||||
tr:nth-child(even) {
|
||||
background-color: #fff;
|
||||
}
|
||||
|
||||
table .cell {
|
||||
width: 100%;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.25em;
|
||||
|
||||
&:not(.fixed) {
|
||||
justify-content: center;
|
||||
}
|
||||
}
|
||||
@@ -1,19 +1,36 @@
|
||||
import { User } from '@prisma/client';
|
||||
import PATHS from 'constants/paths';
|
||||
import { getServerSideTranslation } from 'i18n';
|
||||
import getUser from 'lib/user/getUser';
|
||||
import {
|
||||
GetServerSidePropsContext,
|
||||
NextApiRequest,
|
||||
NextApiResponse,
|
||||
} from 'next';
|
||||
import { Session } from 'next-auth';
|
||||
import { getServerSession } from 'next-auth/next';
|
||||
|
||||
import PATHS from 'constants/paths';
|
||||
import getUser from 'lib/user/getUser';
|
||||
import { authOptions } from 'pages/api/auth/[...nextauth]';
|
||||
|
||||
export async function getSession(req: NextApiRequest, res: NextApiResponse) {
|
||||
return await getServerSession(req, res, authOptions);
|
||||
}
|
||||
|
||||
export function withAuthentication(serverSidePropsFunc) {
|
||||
type AuthenticationContext = GetServerSidePropsContext & {
|
||||
session: Session;
|
||||
user: User;
|
||||
};
|
||||
type AuthenticationReturnType = {
|
||||
redirect?: { destination: string };
|
||||
props?: Awaited<ReturnType<typeof getServerSideTranslation>> & {
|
||||
session: Session;
|
||||
};
|
||||
};
|
||||
|
||||
export function withAuthentication(
|
||||
serverSidePropsFunc: (
|
||||
context: AuthenticationContext,
|
||||
) => Promise<AuthenticationReturnType>,
|
||||
): (context: GetServerSidePropsContext) => Promise<AuthenticationReturnType> {
|
||||
return async (context: GetServerSidePropsContext) => {
|
||||
const { req, res } = context;
|
||||
|
||||
|
||||
Reference in New Issue
Block a user