Prechádzať zdrojové kódy

feat: create new page for guest dashboard

Nicolas Meienberger 1 rok pred
rodič
commit
ab3dcabbf9

+ 0 - 52
src/app/(dashboard)/apps/components/AppTile.tsx

@@ -1,52 +0,0 @@
-'use client';
-
-import Link from 'next/link';
-import React from 'react';
-import { IconDownload } from '@tabler/icons-react';
-import { Tooltip } from 'react-tooltip';
-import type { AppStatus as AppStatusEnum } from '@/server/db/schema';
-import { useTranslations } from 'next-intl';
-import type { AppInfo } from '@runtipi/shared';
-import { AppLogo } from '@/components/AppLogo';
-import { AppStatus } from '@/components/AppStatus';
-import styles from './AppTile.module.scss';
-import { limitText } from '../../app-store/helpers/table.helpers';
-
-type AppTileInfo = Pick<AppInfo, 'id' | 'name' | 'description' | 'short_desc'>;
-
-export const AppTile: React.FC<{ app: AppTileInfo; status: AppStatusEnum; updateAvailable: boolean }> = ({ app, status, updateAvailable }) => {
-  const t = useTranslations('apps');
-
-  return (
-    <div data-testid={`app-tile-${app.id}`} className="col-sm-6 col-lg-4">
-      <div className="card card-sm card-link">
-        <Link href={`/apps/${app.id}`} className="nav-link" passHref>
-          <div className="card-body">
-            <div className="d-flex align-items-center">
-              <span className="me-3">
-                <AppLogo alt={`${app.name} logo`} id={app.id} size={60} />
-              </span>
-              <div>
-                <div className="d-flex h-3 align-items-center">
-                  <span className="h4 me-2 mb-1 fw-bolder">{app.name}</span>
-                  <div className={styles.statusContainer}>
-                    <AppStatus lite status={status} />
-                  </div>
-                </div>
-                <div className="text-muted">{limitText(app.short_desc, 50)}</div>
-              </div>
-            </div>
-          </div>
-          {updateAvailable && (
-            <>
-              <Tooltip anchorSelect=".updateAvailable">{t('update-available')}</Tooltip>
-              <div className="updateAvailable ribbon bg-green ribbon-top">
-                <IconDownload size={20} />
-              </div>
-            </>
-          )}
-        </Link>
-      </div>
-    </div>
-  );
-};

+ 8 - 0
src/app/(dashboard)/apps/page.module.css

@@ -0,0 +1,8 @@
+.link {
+  text-decoration: none;
+  color: inherit;
+}
+
+.link:hover {
+  text-decoration: none;
+}

+ 10 - 2
src/app/(dashboard)/apps/page.tsx

@@ -3,8 +3,11 @@ import { db } from '@/server/db';
 import React from 'react';
 import { Metadata } from 'next';
 import { getTranslatorFromCookie } from '@/lib/get-translator';
-import { AppTile } from './components/AppTile';
+import { AppTile } from '@/components/AppTile';
+import Link from 'next/link';
+import clsx from 'clsx';
 import { EmptyPage } from '../../components/EmptyPage';
+import styles from './page.module.css';
 
 export async function generateMetadata(): Promise<Metadata> {
   const translator = await getTranslatorFromCookie();
@@ -21,7 +24,12 @@ export default async function Page() {
   const renderApp = (app: (typeof installedApps)[number]) => {
     const updateAvailable = Number(app.version) < Number(app.latestVersion);
 
-    if (app.info?.available) return <AppTile key={app.id} app={app.info} status={app.status} updateAvailable={updateAvailable} />;
+    if (app.info?.available)
+      return (
+        <Link href={`/apps/${app.id}`} className={clsx('col-sm-6 col-lg-4', styles.link)} passHref>
+          <AppTile key={app.id} app={app.info} status={app.status} updateAvailable={updateAvailable} />
+        </Link>
+      );
 
     return null;
   };

+ 22 - 13
src/app/(dashboard)/components/Header/Header.tsx

@@ -1,7 +1,7 @@
 'use client';
 
 import React from 'react';
-import { IconBrandGithub, IconHeart, IconLogout, IconMoon, IconSun } from '@tabler/icons-react';
+import { IconBrandGithub, IconHeart, IconLogin, IconLogout, IconMoon, IconSun } from '@tabler/icons-react';
 import Image from 'next/image';
 import clsx from 'clsx';
 import Link from 'next/link';
@@ -12,17 +12,33 @@ import { useUIStore } from '@/client/state/uiStore';
 import { useAction } from 'next-safe-action/hook';
 import { logoutAction } from '@/actions/logout/logout-action';
 import Script from 'next/script';
+import { useRouter } from 'next/navigation';
 import { NavBar } from '../NavBar';
 
 interface IProps {
   isUpdateAvailable?: boolean;
+  authenticated?: boolean;
 }
 
-export const Header: React.FC<IProps> = ({ isUpdateAvailable }) => {
+export const Header: React.FC<IProps> = ({ isUpdateAvailable, authenticated = true }) => {
   const { setDarkMode } = useUIStore();
   const t = useTranslations('header');
 
-  const logoutMutation = useAction(logoutAction);
+  const router = useRouter();
+
+  const logoutMutation = useAction(logoutAction, {
+    onSuccess: () => {
+      router.push('/');
+    },
+  });
+
+  const logHandler = () => {
+    if (authenticated) {
+      logoutMutation.execute();
+    } else {
+      router.push('/login');
+    }
+  };
 
   return (
     <header className="text-white navbar navbar-expand-md navbar-dark navbar-overlap d-print-none" data-bs-theme="dark">
@@ -71,16 +87,9 @@ export const Header: React.FC<IProps> = ({ isUpdateAvailable }) => {
             <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={() => logoutMutation.execute()}
-              tabIndex={0}
-              onKeyPress={() => logoutMutation.execute()}
-              role="button"
-              className="logOut nav-link px-0 cursor-pointer"
-              data-testid="logout-button"
-            >
-              <IconLogout size={20} />
+            <Tooltip anchorSelect=".logOut">{authenticated ? t('logout') : t('login')}</Tooltip>
+            <div onClick={() => logHandler()} tabIndex={0} onKeyPress={() => logHandler()} role="button" className="logOut nav-link px-0 cursor-pointer" data-testid="logout-button">
+              {authenticated ? <IconLogout size={20} /> : <IconLogin size={20} />}
             </div>
           </div>
         </div>

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

@@ -30,7 +30,7 @@ export const NavBar: React.FC<IProps> = ({ isUpdateAvailable }) => {
   };
 
   return (
-    <div id="navbar-menu" className="collapse navbar-collapse" style={{}}>
+    <div id="navbar-menu" className="collapse navbar-collapse">
       <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)}

+ 1 - 0
src/app/components/EmptyPage/EmptyPage.tsx

@@ -24,6 +24,7 @@ export const EmptyPage: React.FC<IProps> = ({ title, subtitle, redirectPath, act
     <div className="card empty">
       <Image
         src="/empty.svg"
+        priority
         alt="Empty box"
         height="80"
         width="80"

+ 8 - 0
src/app/components/GuestDashboardApps/GuestDashboardApps.module.css

@@ -0,0 +1,8 @@
+.link {
+  text-decoration: none;
+  color: inherit;
+}
+
+.link:hover {
+  text-decoration: none;
+}

+ 37 - 0
src/app/components/GuestDashboardApps/GuestDashboardApps.tsx

@@ -0,0 +1,37 @@
+import { AppTile } from '@/components/AppTile';
+import type { AppService } from '@/server/services/apps/apps.service';
+import Link from 'next/link';
+
+import React from 'react';
+import styles from './GuestDashboardApps.module.css';
+
+type Props = {
+  apps: Awaited<ReturnType<AppService['getGuestDashboardApps']>>;
+  hostname?: string;
+};
+
+export const GuestDashboardApps = (props: Props) => {
+  const { apps, hostname } = props;
+
+  const getUrl = (app: (typeof apps)[number]) => {
+    if (app.domain && app.exposed) {
+      return `https://${app.domain}`;
+    }
+
+    const { https } = app.info;
+    const protocol = https ? 'https' : 'http';
+    return `${protocol}://${hostname}:${app.info.port}${app.info.url_suffix || ''}`;
+  };
+
+  return apps.map((app) => {
+    const url = getUrl(app);
+
+    return (
+      <div key={app.id} className="col-sm-6 col-lg-4">
+        <Link passHref href={url} target="_blank" rel="noopener noreferrer" className={styles.link}>
+          <AppTile key={app.id} app={app.info} status={app.status} updateAvailable={false} />
+        </Link>
+      </div>
+    );
+  });
+};

+ 1 - 0
src/app/components/GuestDashboardApps/index.tsx

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

+ 33 - 0
src/app/page.tsx

@@ -1,10 +1,43 @@
+import React from 'react';
 import { getUserFromCookie } from '@/server/common/session.helpers';
 import { redirect } from 'next/navigation';
 import { db } from '@/server/db';
+import { AppServiceClass } from '@/server/services/apps/apps.service';
+import { getConfig } from '@/server/core/TipiConfig';
 import { AuthQueries } from '@/server/queries/auth/auth.queries';
+import { UnauthenticatedPage } from '@/components/UnauthenticatedPage';
+import { headers } from 'next/headers';
+import { GuestDashboardApps } from './components/GuestDashboardApps';
+import { EmptyPage } from './components/EmptyPage';
+
+export const dynamic = 'force-dynamic';
 
 export default async function RootPage() {
+  const appService = new AppServiceClass(db);
+  const { guestDashboard } = getConfig();
+
+  const headersList = headers();
+  const host = headersList.get('host');
+  const hostname = host?.split(':')[0];
+
+  if (guestDashboard) {
+    const apps = await appService.getGuestDashboardApps();
+
+    return (
+      <UnauthenticatedPage title="guest-dashboard" subtitle="runtipi">
+        {apps.length === 0 ? (
+          <EmptyPage title="guest-dashboard-no-apps" subtitle="guest-dashboard-no-apps-subtitle" />
+        ) : (
+          <div className="row row-cards">
+            <GuestDashboardApps apps={apps} hostname={hostname} />
+          </div>
+        )}
+      </UnauthenticatedPage>
+    );
+  }
+
   const authQueries = new AuthQueries(db);
+
   const isConfigured = await authQueries.getFirstOperator();
 
   if (!isConfigured) {

+ 0 - 0
src/app/(dashboard)/apps/components/AppTile.module.scss → src/client/components/AppTile/AppTile.module.scss


+ 49 - 0
src/client/components/AppTile/AppTile.tsx

@@ -0,0 +1,49 @@
+'use client';
+
+import React from 'react';
+import { IconDownload } from '@tabler/icons-react';
+import { Tooltip } from 'react-tooltip';
+import type { AppStatus as AppStatusEnum } from '@/server/db/schema';
+import { useTranslations } from 'next-intl';
+import type { AppInfo } from '@runtipi/shared';
+import { AppLogo } from '@/components/AppLogo';
+import { AppStatus } from '@/components/AppStatus';
+import { limitText } from '@/lib/helpers/text-helpers';
+import styles from './AppTile.module.scss';
+
+type AppTileInfo = Pick<AppInfo, 'id' | 'name' | 'description' | 'short_desc'>;
+
+export const AppTile: React.FC<{ app: AppTileInfo; status: AppStatusEnum; updateAvailable: boolean }> = ({ app, status, updateAvailable }) => {
+  const t = useTranslations('apps');
+
+  return (
+    <div data-testid={`app-tile-${app.id}`}>
+      <div className="card card-sm card-link">
+        <div className="card-body">
+          <div className="d-flex align-items-center">
+            <span className="me-3">
+              <AppLogo alt={`${app.name} logo`} id={app.id} size={60} />
+            </span>
+            <div>
+              <div className="d-flex h-3 align-items-center">
+                <span className="h4 me-2 mb-1 fw-bolder">{app.name}</span>
+                <div className={styles.statusContainer}>
+                  <AppStatus lite status={status} />
+                </div>
+              </div>
+              <div className="text-muted">{limitText(app.short_desc, 50)}</div>
+            </div>
+          </div>
+        </div>
+        {updateAvailable && (
+          <>
+            <Tooltip anchorSelect=".updateAvailable">{t('update-available')}</Tooltip>
+            <div className="updateAvailable ribbon bg-green ribbon-top">
+              <IconDownload size={20} />
+            </div>
+          </>
+        )}
+      </div>
+    </div>
+  );
+};

+ 0 - 0
src/app/(dashboard)/apps/components/index.tsx → src/client/components/AppTile/index.tsx


+ 39 - 0
src/client/components/UnauthenticatedPage/UnauthenticatedPage.tsx

@@ -0,0 +1,39 @@
+'use client';
+
+import { MessageKey } from '@/server/utils/errors';
+import clsx from 'clsx';
+import { useTranslations } from 'next-intl';
+import React from 'react';
+import { Header } from 'src/app/(dashboard)/components/Header';
+
+type Props = {
+  children: React.ReactNode;
+  title: MessageKey;
+  subtitle?: MessageKey;
+};
+
+export const UnauthenticatedPage = (props: Props) => {
+  const { children, title, subtitle } = props;
+  const t = useTranslations();
+
+  return (
+    <div className="page">
+      <Header authenticated={false} />
+      <div className="page-wrapper">
+        <div className="page-header d-print-none">
+          <div className="container-xl">
+            <div className={clsx('row g-2 align-items-center')}>
+              <div className="col text-white">
+                {subtitle && <div className="page-pretitle">{t(subtitle)}</div>}
+                <h2 className="page-title mt-1">{t(title)}</h2>
+              </div>
+            </div>
+          </div>
+        </div>
+        <div className="page-body">
+          <div className="container-xl">{children}</div>
+        </div>
+      </div>
+    </div>
+  );
+};

+ 1 - 0
src/client/components/UnauthenticatedPage/index.ts

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

+ 3 - 0
src/client/components/ui/Button/Button.module.css

@@ -0,0 +1,3 @@
+.button {
+  max-height: 40px;
+}

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

@@ -12,7 +12,7 @@ interface IProps {
 }
 
 export const Button = React.forwardRef<HTMLButtonElement, IProps>(({ type, className, children, loading, disabled, onClick, width, ...rest }, ref) => {
-  const styles = { width: width ? `${width}px` : 'auto' };
+  const styles = { width: width ? `${width}px` : 'auto', height: '36px' };
   return (
     <button style={styles} onClick={onClick} disabled={disabled || loading} ref={ref} className={clsx('btn', className, { disabled: disabled || loading })} type={type} {...rest}>
       {loading ? <span className="spinner-border spinner-border-sm mx-2" role="status" data-testid="loader" aria-hidden="true" /> : children}

+ 7 - 0
src/server/queries/apps/apps.queries.ts

@@ -64,6 +64,13 @@ export class AppQueries {
     return this.db.query.appTable.findMany({ orderBy: asc(appTable.id) });
   }
 
+  /**
+   * Returns all apps that are running and visible on guest dashboard sorted by id ascending
+   */
+  public async getGuestDashboardApps() {
+    return this.db.query.appTable.findMany({ where: and(eq(appTable.status, 'running'), eq(appTable.isVisibleOnGuestDashboard, true)), orderBy: asc(appTable.id) });
+  }
+
   /**
    * Given a domain, return all apps that have this domain, are exposed and not the given id
    *

+ 0 - 1
src/server/services/apps/apps.service.ts

@@ -387,7 +387,6 @@ export class AppServiceClass {
   public getGuestDashboardApps = async () => {
     const apps = await this.queries.getGuestDashboardApps();
 
-    console.log(apps);
     return apps
       .map((app) => {
         const info = getAppInfo(app.id, app.status);