feat: create new page for guest dashboard

This commit is contained in:
Nicolas Meienberger 2023-11-02 07:58:51 +01:00 committed by Nicolas Meienberger
parent c0723257e6
commit ab3dcabbf9
19 changed files with 221 additions and 70 deletions

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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