feat: move dashboard page to RSC

This commit is contained in:
Nicolas Meienberger 2023-09-01 22:22:27 +02:00 committed by Nicolas Meienberger
parent bd1b7dfac9
commit 23a115b955
22 changed files with 2135 additions and 152 deletions

1
next-env.d.ts vendored
View file

@ -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.

View file

@ -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,

View file

@ -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",

File diff suppressed because it is too large Load diff

View 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>
);
};

View file

@ -0,0 +1 @@
export { Header } from './Header';

View 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>
);
};

View file

@ -0,0 +1 @@
export { NavBar } from './NavBar';

View 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>
</>
);
};

View file

@ -0,0 +1 @@
export { PageTitle } from './PageTitle';

View 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} />;
}

View file

@ -0,0 +1,3 @@
.title {
min-height: 50px;
}

View 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
View file

@ -0,0 +1,5 @@
@import '@tabler/core/dist/css/tabler.min.css';
body {
overflow-y: scroll;
}

50
src/app/layout.tsx Normal file
View 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
View 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');
}

View file

@ -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)}

View file

@ -1,3 +1,5 @@
'use client';
import { IconCircuitResistor, IconCpu, IconDatabase } from '@tabler/icons-react';
import React from 'react';
import { useTranslations } from 'next-intl';

View file

@ -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: {},
});
};

View file

@ -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;
};

View file

@ -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();
}

View file

@ -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"
]
}