Procházet zdrojové kódy

feat: move dashboard page to RSC

Nicolas Meienberger před 1 rokem
rodič
revize
23a115b955

+ 1 - 0
next-env.d.ts

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

+ 3 - 0
next.config.mjs

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

+ 2 - 4
package.json

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

Rozdílová data souboru nebyla zobrazena, protože soubor je příliš velký
+ 550 - 96
pnpm-lock.yaml


+ 80 - 0
src/app/(dashboard)/components/Header/Header.tsx

@@ -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 - 0
src/app/(dashboard)/components/Header/index.ts

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

+ 46 - 0
src/app/(dashboard)/components/NavBar/NavBar.tsx

@@ -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 - 0
src/app/(dashboard)/components/NavBar/index.ts

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

+ 37 - 0
src/app/(dashboard)/components/PageTitle/PageTitle.tsx

@@ -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 - 0
src/app/(dashboard)/components/PageTitle/index.ts

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

+ 18 - 0
src/app/(dashboard)/dashboard/page.tsx

@@ -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 - 0
src/app/(dashboard)/layout.module.scss

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

+ 42 - 0
src/app/(dashboard)/layout.tsx

@@ -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 - 0
src/app/global.css

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

+ 50 - 0
src/app/layout.tsx

@@ -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 - 0
src/app/page.tsx

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

+ 1 - 1
src/client/components/ui/NavBar/NavBar.tsx

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

+ 2 - 0
src/client/modules/Dashboard/containers/DashboardContainer.tsx

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

+ 0 - 14
src/pages/index.tsx

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

+ 27 - 0
src/server/common/session.helpers.ts

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

+ 1 - 1
src/server/services/system/system.service.ts

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

+ 9 - 3
tsconfig.json

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

Některé soubory nejsou zobrazeny, neboť je v těchto rozdílových datech změněno mnoho souborů