feat: create new page for guest dashboard
This commit is contained in:
parent
c0723257e6
commit
ab3dcabbf9
19 changed files with 221 additions and 70 deletions
|
@ -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
src/app/(dashboard)/apps/page.module.css
Normal file
8
src/app/(dashboard)/apps/page.module.css
Normal file
|
@ -0,0 +1,8 @@
|
|||
.link {
|
||||
text-decoration: none;
|
||||
color: inherit;
|
||||
}
|
||||
|
||||
.link:hover {
|
||||
text-decoration: none;
|
||||
}
|
|
@ -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;
|
||||
};
|
||||
|
|
|
@ -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>
|
||||
|
|
|
@ -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)}
|
||||
|
|
|
@ -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"
|
||||
|
|
|
@ -0,0 +1,8 @@
|
|||
.link {
|
||||
text-decoration: none;
|
||||
color: inherit;
|
||||
}
|
||||
|
||||
.link:hover {
|
||||
text-decoration: none;
|
||||
}
|
37
src/app/components/GuestDashboardApps/GuestDashboardApps.tsx
Normal file
37
src/app/components/GuestDashboardApps/GuestDashboardApps.tsx
Normal 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>
|
||||
);
|
||||
});
|
||||
};
|
1
src/app/components/GuestDashboardApps/index.tsx
Normal file
1
src/app/components/GuestDashboardApps/index.tsx
Normal file
|
@ -0,0 +1 @@
|
|||
export { GuestDashboardApps } from './GuestDashboardApps';
|
|
@ -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) {
|
||||
|
|
49
src/client/components/AppTile/AppTile.tsx
Normal file
49
src/client/components/AppTile/AppTile.tsx
Normal 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>
|
||||
);
|
||||
};
|
|
@ -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
src/client/components/UnauthenticatedPage/index.ts
Normal file
1
src/client/components/UnauthenticatedPage/index.ts
Normal file
|
@ -0,0 +1 @@
|
|||
export { UnauthenticatedPage } from './UnauthenticatedPage';
|
3
src/client/components/ui/Button/Button.module.css
Normal file
3
src/client/components/ui/Button/Button.module.css
Normal file
|
@ -0,0 +1,3 @@
|
|||
.button {
|
||||
max-height: 40px;
|
||||
}
|
|
@ -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}
|
||||
|
|
|
@ -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
|
||||
*
|
||||
|
|
|
@ -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);
|
||||
|
|
Loading…
Add table
Reference in a new issue