feat: move dashboard page to RSC
This commit is contained in:
parent
bd1b7dfac9
commit
23a115b955
22 changed files with 2135 additions and 152 deletions
1
next-env.d.ts
vendored
1
next-env.d.ts
vendored
|
@ -1,5 +1,6 @@
|
|||
/// <reference types="next" />
|
||||
/// <reference types="next/image-types/global" />
|
||||
/// <reference types="next/navigation-types/compat/navigation" />
|
||||
|
||||
// NOTE: This file should not be edited
|
||||
// see https://nextjs.org/docs/basic-features/typescript for more information.
|
||||
|
|
|
@ -4,6 +4,9 @@ const nextConfig = {
|
|||
output: 'standalone',
|
||||
reactStrictMode: true,
|
||||
transpilePackages: ['@runtipi/shared'],
|
||||
experimental: {
|
||||
serverComponentsExternalPackages: ['bullmq'],
|
||||
},
|
||||
serverRuntimeConfig: {
|
||||
INTERNAL_IP: process.env.INTERNAL_IP,
|
||||
TIPI_VERSION: process.env.TIPI_VERSION,
|
||||
|
|
|
@ -60,8 +60,8 @@
|
|||
"fs-extra": "^11.1.1",
|
||||
"isomorphic-fetch": "^3.0.0",
|
||||
"lodash.merge": "^4.6.2",
|
||||
"next": "13.4.7",
|
||||
"next-intl": "^2.15.1",
|
||||
"next": "13.4.19",
|
||||
"next-intl": "^2.20.0",
|
||||
"pg": "^8.11.1",
|
||||
"qrcode.react": "^3.1.0",
|
||||
"react": "18.2.0",
|
||||
|
@ -97,8 +97,6 @@
|
|||
"@testing-library/user-event": "^14.4.3",
|
||||
"@total-typescript/shoehorn": "^0.1.1",
|
||||
"@total-typescript/ts-reset": "^0.4.2",
|
||||
"@types/express": "^4.17.13",
|
||||
"@types/express-session": "^1.17.7",
|
||||
"@types/fs-extra": "^11.0.1",
|
||||
"@types/isomorphic-fetch": "^0.0.36",
|
||||
"@types/jest": "^29.5.2",
|
||||
|
|
1913
pnpm-lock.yaml
1913
pnpm-lock.yaml
File diff suppressed because it is too large
Load diff
80
src/app/(dashboard)/components/Header/Header.tsx
Normal file
80
src/app/(dashboard)/components/Header/Header.tsx
Normal file
|
@ -0,0 +1,80 @@
|
|||
'use client';
|
||||
|
||||
import React from 'react';
|
||||
import { IconBrandGithub, IconHeart, IconLogout, IconMoon, IconSun } from '@tabler/icons-react';
|
||||
import Image from 'next/image';
|
||||
import clsx from 'clsx';
|
||||
import Link from 'next/link';
|
||||
|
||||
import { Tooltip } from 'react-tooltip';
|
||||
import { useTranslations } from 'next-intl';
|
||||
import { useUIStore } from '@/client/state/uiStore';
|
||||
import { NavBar } from '../NavBar';
|
||||
|
||||
interface IProps {
|
||||
isUpdateAvailable?: boolean;
|
||||
}
|
||||
|
||||
export const Header: React.FC<IProps> = ({ isUpdateAvailable }) => {
|
||||
const { setDarkMode } = useUIStore();
|
||||
const t = useTranslations('header');
|
||||
|
||||
const onLogout = async () => {};
|
||||
|
||||
return (
|
||||
<header className="text-white navbar navbar-expand-md navbar-dark navbar-overlap d-print-none" data-bs-theme="dark">
|
||||
<div className="container-xl">
|
||||
<button className="navbar-toggler" type="button" data-bs-toggle="collapse" data-bs-target="#navbar-menu">
|
||||
<span className="navbar-toggler-icon" />
|
||||
</button>
|
||||
<Link href="/" passHref>
|
||||
<h1 className="navbar-brand d-none-navbar-horizontal pe-0 pe-md-3">
|
||||
<Image
|
||||
priority
|
||||
alt="Tipi logo"
|
||||
className={clsx('navbar-brand-image me-3')}
|
||||
width={100}
|
||||
height={100}
|
||||
src="/tipi.png"
|
||||
style={{
|
||||
width: '30px',
|
||||
maxWidth: '30px',
|
||||
height: 'auto',
|
||||
}}
|
||||
/>
|
||||
Tipi
|
||||
</h1>
|
||||
</Link>
|
||||
<div className="navbar-nav flex-row order-md-last">
|
||||
<div className="nav-item d-none d-xl-flex me-3">
|
||||
<div className="btn-list">
|
||||
<a href="https://github.com/meienberger/runtipi" target="_blank" rel="noreferrer" className="btn btn-dark">
|
||||
<IconBrandGithub data-testid="icon-github" className="me-1 icon" size={24} />
|
||||
{t('source-code')}
|
||||
</a>
|
||||
<a href="https://github.com/meienberger/runtipi?sponsor=1" target="_blank" rel="noreferrer" className="btn btn-dark">
|
||||
<IconHeart className="me-1 icon text-pink" size={24} />
|
||||
{t('sponsor')}
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
<div style={{ zIndex: 1 }} className="d-flex">
|
||||
<Tooltip anchorSelect=".darkMode">{t('dark-mode')}</Tooltip>
|
||||
<div onClick={() => setDarkMode(true)} role="button" aria-hidden="true" className="darkMode nav-link px-0 hide-theme-dark cursor-pointer" data-testid="dark-mode-toggle">
|
||||
<IconMoon data-testid="icon-moon" size={20} />
|
||||
</div>
|
||||
<Tooltip anchorSelect=".lightMode">{t('light-mode')}</Tooltip>
|
||||
<div onClick={() => setDarkMode(false)} aria-hidden="true" className="lightMode nav-link px-0 hide-theme-light cursor-pointer" data-testid="light-mode-toggle">
|
||||
<IconSun data-testid="icon-sun" size={20} />
|
||||
</div>
|
||||
<Tooltip anchorSelect=".logOut">{t('logout')}</Tooltip>
|
||||
<div onClick={() => onLogout} tabIndex={0} onKeyPress={() => onLogout()} role="button" className="logOut nav-link px-0 cursor-pointer" data-testid="logout-button">
|
||||
<IconLogout size={20} />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<NavBar isUpdateAvailable={isUpdateAvailable} />
|
||||
</div>
|
||||
</header>
|
||||
);
|
||||
};
|
1
src/app/(dashboard)/components/Header/index.ts
Normal file
1
src/app/(dashboard)/components/Header/index.ts
Normal file
|
@ -0,0 +1 @@
|
|||
export { Header } from './Header';
|
46
src/app/(dashboard)/components/NavBar/NavBar.tsx
Normal file
46
src/app/(dashboard)/components/NavBar/NavBar.tsx
Normal file
|
@ -0,0 +1,46 @@
|
|||
// "use client"
|
||||
import { IconApps, IconBrandAppstore, IconHome, IconSettings, Icon } from '@tabler/icons-react';
|
||||
import clsx from 'clsx';
|
||||
import { useTranslations } from 'next-intl';
|
||||
import Link from 'next/link';
|
||||
import { usePathname } from 'next/navigation';
|
||||
import React from 'react';
|
||||
|
||||
interface IProps {
|
||||
isUpdateAvailable?: boolean;
|
||||
}
|
||||
|
||||
export const NavBar: React.FC<IProps> = ({ isUpdateAvailable }) => {
|
||||
const t = useTranslations('header');
|
||||
const path = usePathname()?.split('/')[1];
|
||||
|
||||
const renderItem = (title: string, name: string, IconComponent: Icon) => {
|
||||
const isActive = path === name;
|
||||
const itemClass = clsx('nav-item', { active: isActive, 'border-primary': isActive, 'border-bottom-wide': isActive });
|
||||
|
||||
return (
|
||||
<li aria-label={title} data-testid={`nav-item-${name}`} className={itemClass}>
|
||||
<Link href={`/${name}`} className="nav-link" passHref>
|
||||
<span className="nav-link-icon d-md-none d-lg-inline-block">
|
||||
<IconComponent size={24} />
|
||||
</span>
|
||||
<span className="nav-link-title">{title}</span>
|
||||
</Link>
|
||||
</li>
|
||||
);
|
||||
};
|
||||
|
||||
return (
|
||||
<div id="navbar-menu" className="collapse navbar-collapse" style={{}}>
|
||||
<div className="d-flex flex-column flex-md-row flex-fill align-items-stretch align-items-md-center">
|
||||
<ul className="navbar-nav">
|
||||
{renderItem(t('dashboard'), 'dashboard', IconHome)}
|
||||
{renderItem(t('my-apps'), 'apps', IconApps)}
|
||||
{renderItem(t('app-store'), 'app-store', IconBrandAppstore)}
|
||||
{renderItem(t('settings'), 'settings', IconSettings)}
|
||||
</ul>
|
||||
{Boolean(isUpdateAvailable) && <span className="ms-2 badge bg-green d-none d-lg-block">{t('update-available')}</span>}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
1
src/app/(dashboard)/components/NavBar/index.ts
Normal file
1
src/app/(dashboard)/components/NavBar/index.ts
Normal file
|
@ -0,0 +1 @@
|
|||
export { NavBar } from './NavBar';
|
37
src/app/(dashboard)/components/PageTitle/PageTitle.tsx
Normal file
37
src/app/(dashboard)/components/PageTitle/PageTitle.tsx
Normal file
|
@ -0,0 +1,37 @@
|
|||
'use client';
|
||||
|
||||
import { MessageKey } from '@/server/utils/errors';
|
||||
import clsx from 'clsx';
|
||||
import { useTranslations } from 'next-intl';
|
||||
import Link from 'next/link';
|
||||
import { usePathname } from 'next/navigation';
|
||||
import React from 'react';
|
||||
|
||||
export const PageTitle = () => {
|
||||
const t = useTranslations();
|
||||
|
||||
const path = usePathname();
|
||||
|
||||
const pathArray = path?.substring(1).split('/') || [];
|
||||
|
||||
const renderBreadcrumbs = () => {
|
||||
return (
|
||||
<ol className="breadcrumb" aria-label="breadcrumbs">
|
||||
{pathArray.map((breadcrumb, index) => (
|
||||
<li key={breadcrumb} className={clsx('breadcrumb-item', { active: index === pathArray.length - 1 })}>
|
||||
<Link href={`/${pathArray.slice(0, index + 1).join('/')}`}>{breadcrumb}</Link>
|
||||
</li>
|
||||
))}
|
||||
</ol>
|
||||
);
|
||||
};
|
||||
|
||||
const title = t(`header.${pathArray[pathArray.length - 1]}` as MessageKey);
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="page-pretitle">{renderBreadcrumbs()}</div>
|
||||
<h2 className="page-title">{title}</h2>
|
||||
</>
|
||||
);
|
||||
};
|
1
src/app/(dashboard)/components/PageTitle/index.ts
Normal file
1
src/app/(dashboard)/components/PageTitle/index.ts
Normal file
|
@ -0,0 +1 @@
|
|||
export { PageTitle } from './PageTitle';
|
18
src/app/(dashboard)/dashboard/page.tsx
Normal file
18
src/app/(dashboard)/dashboard/page.tsx
Normal file
|
@ -0,0 +1,18 @@
|
|||
import { DashboardContainer } from '@/client/modules/Dashboard/containers';
|
||||
import { useUIStore } from '@/client/state/uiStore';
|
||||
import { SystemServiceClass } from '@/server/services/system';
|
||||
import { Metadata } from 'next';
|
||||
import React from 'react';
|
||||
|
||||
export async function generateMetadata(): Promise<Metadata> {
|
||||
const { translator } = useUIStore.getState();
|
||||
return {
|
||||
title: `${translator('dashboard.title')} - Tipi`,
|
||||
};
|
||||
}
|
||||
|
||||
export default async function Page() {
|
||||
const data = SystemServiceClass.systemInfo();
|
||||
|
||||
return <DashboardContainer data={data} isLoading={false} />;
|
||||
}
|
3
src/app/(dashboard)/layout.module.scss
Normal file
3
src/app/(dashboard)/layout.module.scss
Normal file
|
@ -0,0 +1,3 @@
|
|||
.title {
|
||||
min-height: 50px;
|
||||
}
|
42
src/app/(dashboard)/layout.tsx
Normal file
42
src/app/(dashboard)/layout.tsx
Normal file
|
@ -0,0 +1,42 @@
|
|||
import React from 'react';
|
||||
import { redirect } from 'next/navigation';
|
||||
import { getUserFromCookie } from '@/server/common/session.helpers';
|
||||
import { SystemServiceClass } from '@/server/services/system';
|
||||
import semver from 'semver';
|
||||
import clsx from 'clsx';
|
||||
import { Header } from './components/Header';
|
||||
import { PageTitle } from './components/PageTitle';
|
||||
import styles from './layout.module.scss';
|
||||
|
||||
export default async function DashboardLayout({ children }: { children: React.ReactNode }) {
|
||||
const user = await getUserFromCookie();
|
||||
|
||||
if (!user) {
|
||||
redirect('/login');
|
||||
}
|
||||
|
||||
const systemService = new SystemServiceClass();
|
||||
const { latest, current } = await systemService.getVersion();
|
||||
const isLatest = semver.gte(current, latest || '0.0.0');
|
||||
|
||||
return (
|
||||
<div className="page">
|
||||
<Header isUpdateAvailable={!isLatest} />
|
||||
<div className="page-wrapper">
|
||||
<div className="page-header d-print-none">
|
||||
<div className="container-xl">
|
||||
<div className={clsx(styles.title, 'align-items-stretch align-items-md-center d-flex flex-column flex-md-row ')}>
|
||||
<div className="me-3 text-white">
|
||||
<PageTitle />
|
||||
</div>
|
||||
<div className="flex-fill">{}</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="page-body">
|
||||
<div className="container-xl">{children}</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
5
src/app/global.css
Normal file
5
src/app/global.css
Normal file
|
@ -0,0 +1,5 @@
|
|||
@import '@tabler/core/dist/css/tabler.min.css';
|
||||
|
||||
body {
|
||||
overflow-y: scroll;
|
||||
}
|
50
src/app/layout.tsx
Normal file
50
src/app/layout.tsx
Normal file
|
@ -0,0 +1,50 @@
|
|||
import React from 'react';
|
||||
import type { Metadata } from 'next';
|
||||
|
||||
import { Inter } from 'next/font/google';
|
||||
import { cookies, headers } from 'next/headers';
|
||||
import { getLocaleFromString } from '@/shared/internationalization/locales';
|
||||
import merge from 'lodash.merge';
|
||||
import { NextIntlClientProvider, createTranslator } from 'next-intl';
|
||||
|
||||
import './global.css';
|
||||
import clsx from 'clsx';
|
||||
import { useUIStore } from '@/client/state/uiStore';
|
||||
|
||||
const inter = Inter({
|
||||
subsets: ['latin'],
|
||||
display: 'swap',
|
||||
});
|
||||
|
||||
export const metadata: Metadata = {
|
||||
title: 'Tipi',
|
||||
description: 'Tipi',
|
||||
};
|
||||
|
||||
export default async function RootLayout({ children }: { children: React.ReactNode }) {
|
||||
const cookieStore = cookies();
|
||||
const cookieLocale = cookieStore.get('tipi-locale');
|
||||
|
||||
const headersList = headers();
|
||||
const browserLocale = headersList.get('accept-language');
|
||||
|
||||
const locale = getLocaleFromString(String(cookieLocale?.value || browserLocale || 'en'));
|
||||
|
||||
const englishMessages = (await import(`../client/messages/en.json`)).default;
|
||||
const messages = (await import(`../client/messages/${locale}.json`)).default;
|
||||
const mergedMessages = merge(englishMessages, messages);
|
||||
|
||||
const translator = createTranslator({
|
||||
messages: mergedMessages,
|
||||
locale,
|
||||
});
|
||||
useUIStore.getState().setTranslator(translator);
|
||||
|
||||
return (
|
||||
<html lang={locale} className={clsx(inter.className, 'border-top-wide border-primary')}>
|
||||
<NextIntlClientProvider locale="en" messages={mergedMessages}>
|
||||
<body>{children}</body>
|
||||
</NextIntlClientProvider>
|
||||
</html>
|
||||
);
|
||||
}
|
21
src/app/page.tsx
Normal file
21
src/app/page.tsx
Normal file
|
@ -0,0 +1,21 @@
|
|||
import { getUserFromCookie } from '@/server/common/session.helpers';
|
||||
import { redirect } from 'next/navigation';
|
||||
import { db } from '@/server/db';
|
||||
import { AuthQueries } from '@/server/queries/auth/auth.queries';
|
||||
|
||||
export default async function RootPage() {
|
||||
const authQueries = new AuthQueries(db);
|
||||
const isConfigured = await authQueries.getFirstOperator();
|
||||
|
||||
if (!isConfigured) {
|
||||
redirect('/register');
|
||||
}
|
||||
|
||||
const user = await getUserFromCookie();
|
||||
|
||||
if (!user) {
|
||||
redirect('/login');
|
||||
}
|
||||
|
||||
redirect('/dashboard');
|
||||
}
|
|
@ -34,7 +34,7 @@ export const NavBar: React.FC<IProps> = ({ isUpdateAvailable }) => {
|
|||
<div id="navbar-menu" className="collapse navbar-collapse" style={{}}>
|
||||
<div className="d-flex flex-column flex-md-row flex-fill align-items-stretch align-items-md-center">
|
||||
<ul className="navbar-nav">
|
||||
{renderItem(t('dashboard'), '', IconHome)}
|
||||
{renderItem(t('dashboard'), 'dashboard', IconHome)}
|
||||
{renderItem(t('my-apps'), 'apps', IconApps)}
|
||||
{renderItem(t('app-store'), 'app-store', IconBrandAppstore)}
|
||||
{renderItem(t('settings'), 'settings', IconSettings)}
|
||||
|
|
|
@ -1,3 +1,5 @@
|
|||
'use client';
|
||||
|
||||
import { IconCircuitResistor, IconCpu, IconDatabase } from '@tabler/icons-react';
|
||||
import React from 'react';
|
||||
import { useTranslations } from 'next-intl';
|
||||
|
|
|
@ -1,14 +0,0 @@
|
|||
import { getAuthedPageProps, getMessagesPageProps } from '@/utils/page-helpers';
|
||||
import merge from 'lodash.merge';
|
||||
import { GetServerSideProps } from 'next';
|
||||
|
||||
export { DashboardPage as default } from '../client/modules/Dashboard/pages/DashboardPage';
|
||||
|
||||
export const getServerSideProps: GetServerSideProps = async (ctx) => {
|
||||
const authedProps = await getAuthedPageProps(ctx);
|
||||
const messagesProps = await getMessagesPageProps(ctx);
|
||||
|
||||
return merge(authedProps, messagesProps, {
|
||||
props: {},
|
||||
});
|
||||
};
|
|
@ -1,7 +1,10 @@
|
|||
import { setCookie } from 'cookies-next';
|
||||
import { NextApiRequest, NextApiResponse } from 'next';
|
||||
import { v4 } from 'uuid';
|
||||
import { cookies } from 'next/headers';
|
||||
import { TipiCache } from '../core/TipiCache/TipiCache';
|
||||
import { db } from '../db';
|
||||
import { AuthQueries } from '../queries/auth/auth.queries';
|
||||
|
||||
const COOKIE_MAX_AGE = 60 * 60 * 24; // 1 day
|
||||
const COOKIE_NAME = 'tipi.sid';
|
||||
|
@ -22,3 +25,27 @@ export const setSession = async (sessionId: string, userId: string, req: NextApi
|
|||
|
||||
await cache.close();
|
||||
};
|
||||
|
||||
export const getUserFromCookie = async () => {
|
||||
const authQueries = new AuthQueries(db);
|
||||
|
||||
const cookieStore = cookies();
|
||||
const sessionId = cookieStore.get('tipi.sid');
|
||||
|
||||
if (!sessionId) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const cache = new TipiCache('getUserFromCookie');
|
||||
const sessionKey = `session:${sessionId.value}`;
|
||||
const userId = await cache.get(sessionKey);
|
||||
await cache.close();
|
||||
|
||||
if (!userId) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const user = await authQueries.getUserDtoById(Number(userId));
|
||||
|
||||
return user;
|
||||
};
|
||||
|
|
|
@ -59,7 +59,7 @@ export class SystemServiceClass {
|
|||
return { current: TipiConfig.getConfig().version, latest: version, body };
|
||||
} catch (e) {
|
||||
Logger.error(e);
|
||||
return { current: TipiConfig.getConfig().version, latest: undefined };
|
||||
return { current: TipiConfig.getConfig().version, latest: TipiConfig.getConfig().version, body: '' };
|
||||
} finally {
|
||||
await cache.close();
|
||||
}
|
||||
|
|
|
@ -20,7 +20,7 @@
|
|||
],
|
||||
"@/tests/*": [
|
||||
"./tests/*"
|
||||
],
|
||||
]
|
||||
},
|
||||
"lib": [
|
||||
"dom",
|
||||
|
@ -46,7 +46,12 @@
|
|||
"jest",
|
||||
"@testing-library/jest-dom"
|
||||
],
|
||||
"experimentalDecorators": true
|
||||
"experimentalDecorators": true,
|
||||
"plugins": [
|
||||
{
|
||||
"name": "next"
|
||||
}
|
||||
]
|
||||
},
|
||||
"include": [
|
||||
"next-env.d.ts",
|
||||
|
@ -55,10 +60,11 @@
|
|||
"**/*.mjs",
|
||||
"**/*.js",
|
||||
"**/*.jsx",
|
||||
".next/types/**/*.ts"
|
||||
],
|
||||
"exclude": [
|
||||
"node_modules",
|
||||
"packages",
|
||||
"repos",
|
||||
"repos"
|
||||
]
|
||||
}
|
||||
|
|
Loading…
Reference in a new issue