Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat(chat): add layout for shared auth logic, add marketplace route #2082

Merged
merged 5 commits into from
Sep 11, 2024
Merged
Show file tree
Hide file tree
Changes from 2 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions apps/chat/public/images/icons/arrow-left.svg
Gimir marked this conversation as resolved.
Show resolved Hide resolved
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
3 changes: 3 additions & 0 deletions apps/chat/public/images/icons/home.svg
Gimir marked this conversation as resolved.
Show resolved Hide resolved
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
114 changes: 114 additions & 0 deletions apps/chat/src/components/Header/MarketplaceHeader.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,114 @@
import { IconX } from '@tabler/icons-react';
import { useCallback } from 'react';

import classNames from 'classnames';

import { ApiUtils } from '@/src/utils/server/api';

import { useAppDispatch, useAppSelector } from '@/src/store/hooks';
import { SettingsSelectors } from '@/src/store/settings/settings.reducers';
import { UIActions, UISelectors } from '@/src/store/ui/ui.reducers';

import { SettingDialog } from '@/src/components/Settings/SettingDialog';

import MoveLeftIcon from '../../../public/images/icons/move-left.svg';
import MoveRightIcon from '../../../public/images/icons/move-right.svg';
import { User } from './User/User';

import { Feature } from '@epam/ai-dial-shared';
import cssEscape from 'css.escape';

const DEFAULT_HEADER_ICON_SIZE = 24;
const OVERLAY_HEADER_ICON_SIZE = 18;
Gimir marked this conversation as resolved.
Show resolved Hide resolved

const MarketplaceHeader = () => {
const showFilterbar = useAppSelector(
UISelectors.selectShowMarketplaceFilterbar,
);
const isUserSettingsOpen = useAppSelector(
UISelectors.selectIsUserSettingsOpen,
);
const isOverlay = useAppSelector(SettingsSelectors.selectIsOverlay);
const customLogo = useAppSelector(UISelectors.selectCustomLogo);

const isCustomLogoFeatureEnabled: boolean = useAppSelector((state) =>
SettingsSelectors.isFeatureEnabled(state, Feature.CustomLogo),
);

const customLogoUrl =
isCustomLogoFeatureEnabled &&
customLogo &&
`api/${ApiUtils.encodeApiUrl(customLogo)}`;

const dispatch = useAppDispatch();

const handleToggleFilterbar = useCallback(() => {
dispatch(UIActions.setShowMarketplaceFilterbar(!showFilterbar));
}, [dispatch, showFilterbar]);

const onClose = () => {
dispatch(UIActions.setIsUserSettingsOpen(false));
};

const headerIconSize = isOverlay
? OVERLAY_HEADER_ICON_SIZE
: DEFAULT_HEADER_ICON_SIZE;

return (
<div
className={classNames(
'z-40 flex w-full border-b border-tertiary bg-layer-3',
isOverlay ? 'min-h-[36px]' : 'min-h-[48px]',
)}
data-qa="header"
>
<div
className="flex h-full cursor-pointer items-center justify-center border-r border-tertiary px-3 md:px-5"
data-qa="marketplace-facet-panel-toggle"
onClick={handleToggleFilterbar}
>
{showFilterbar ? (
<>
<IconX
className="text-secondary md:hidden"
width={headerIconSize}
height={headerIconSize}
/>

<MoveLeftIcon
className="text-secondary hover:text-accent-secondary max-md:hidden"
width={headerIconSize}
height={headerIconSize}
/>
</>
) : (
<MoveRightIcon
className="text-secondary hover:text-accent-secondary"
width={headerIconSize}
height={headerIconSize}
/>
)}
</div>

<div className="flex grow justify-between">
<span
className={classNames(
'mx-auto min-w-[110px] bg-contain bg-center bg-no-repeat md:ml-5 lg:bg-left',
)}
style={{
backgroundImage: customLogoUrl
? `url(${cssEscape(customLogoUrl)})`
: `var(--app-logo)`,
}}
></span>
<div className="w-[48px] max-md:border-l max-md:border-tertiary md:w-auto">
<User />
</div>
</div>

<SettingDialog open={isUserSettingsOpen} onClose={onClose} />
</div>
);
};

export default MarketplaceHeader;
44 changes: 44 additions & 0 deletions apps/chat/src/components/Marketplace/Marketplace.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,44 @@
import { useEffect } from 'react';

import { useAppDispatch, useAppSelector } from '@/src/store/hooks';
import {
ModelsActions,
ModelsSelectors,
} from '@/src/store/models/models.reducers';

import { Spinner } from '@/src/components/Common/Spinner';

const Marketplace = () => {
const dispatch = useAppDispatch();

const isModelsLoading = useAppSelector(ModelsSelectors.selectModelsIsLoading);
const isModelsLoaded = useAppSelector(ModelsSelectors.selectIsModelsLoaded);
const models = useAppSelector(ModelsSelectors.selectModels);

useEffect(() => {
if (!isModelsLoaded && !isModelsLoading) {
dispatch(ModelsActions.getModels());
}
}, [isModelsLoaded, isModelsLoading, dispatch]);

return (
<div className="grow overflow-auto px-6 py-4 xl:px-16">
{isModelsLoading ? (
<Spinner size={60} className="mx-auto" />
) : (
<div className="grid grid-cols-1 gap-4 md:grid-cols-2 xl:grid-cols-3 xl:gap-6">
{models.map((model) => (
<div
key={model.id}
className="h-[92px] rounded border border-primary bg-transparent p-4 md:h-[203px] xl:h-[207px]"
>
{model.name}
</div>
))}
</div>
)}
</div>
);
};

export default Marketplace;
78 changes: 78 additions & 0 deletions apps/chat/src/components/Marketplace/MarketplaceFilterbar.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,78 @@
/* eslint-disable tailwindcss/no-custom-classname */
Gimir marked this conversation as resolved.
Show resolved Hide resolved
import { ComponentType } from 'react';

import { useTranslation } from 'next-i18next';
import { useRouter } from 'next/router';

import classnames from 'classnames';

import { Translation } from '@/src/types/translation';

import { useAppSelector } from '@/src/store/hooks';
import { UISelectors } from '@/src/store/ui/ui.reducers';

import ArrowIcon from '../../../public/images/icons/arrow-left.svg';
import HomeIcon from '../../../public/images/icons/home.svg';
Gimir marked this conversation as resolved.
Show resolved Hide resolved

interface ActionButtonProps {
isOpen: boolean;
onClick: () => void;
caption: string;
Icon: ComponentType;
}

const ActionButton = ({
isOpen,
onClick,
caption,
Icon,
}: ActionButtonProps) => {
return (
<div className="flex px-2 py-1">
<button
onClick={onClick}
className="flex min-h-9 shrink-0 grow cursor-pointer select-none items-center gap-3 rounded px-4 py-2 transition-colors duration-200 hover:bg-accent-primary-alpha hover:disabled:bg-transparent"
>
{/* @ts-expect-error-next-line */}
Gimir marked this conversation as resolved.
Show resolved Hide resolved
<Icon className="text-secondary" width={18} height={18} />
{isOpen ? caption : ''}
</button>
</div>
);
};

const MarketplaceFilterbar = () => {
const router = useRouter();
const { t } = useTranslation(Translation.SideBar);
const showFilterbar = useAppSelector(
UISelectors.selectShowMarketplaceFilterbar,
);

const onHomeClick = () => {
// filler
};

return (
<div
className={classnames(
showFilterbar ? 'w-[284px]' : 'invisible md:visible md:w-[64px]',
'group/sidebar absolute left-0 top-0 h-full flex-col gap-px divide-y divide-tertiary bg-layer-3 md:sticky',
)}
>
<ActionButton
isOpen={showFilterbar}
onClick={() => router.push('/')}
caption={t('Back to chats')}
Icon={ArrowIcon}
/>
<ActionButton
isOpen={showFilterbar}
onClick={onHomeClick}
caption={t('Home page')}
Icon={HomeIcon}
/>
</div>
);
};

export default MarketplaceFilterbar;
141 changes: 141 additions & 0 deletions apps/chat/src/pages/Layout.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,141 @@
import { SessionContextValue, signIn, useSession } from 'next-auth/react';
import React, { useEffect } from 'react';

import { useTranslation } from 'next-i18next';
import Head from 'next/head';

import { AuthWindowLocationLike } from '@/src/utils/auth/auth-window-location-like';
import { delay } from '@/src/utils/auth/delay';
import { timeoutAsync } from '@/src/utils/auth/timeout-async';

import { Translation } from '../types/translation';

import { AuthActions, AuthSelectors } from '../store/auth/auth.reducers';
import { useAppDispatch, useAppSelector } from '@/src/store/hooks';
import {
SettingsActions,
SettingsSelectors,
} from '@/src/store/settings/settings.reducers';
import { UIActions } from '@/src/store/ui/ui.reducers';

import { HomeProps } from '.';

export default function Layout({
children,
pageProps: { initialState },
}: {
children: React.ReactNode;
pageProps: HomeProps;
}) {
const session: SessionContextValue<boolean> = useSession();

const { t } = useTranslation(Translation.Chat);

const dispatch = useAppDispatch();

const isOverlay = useAppSelector(SettingsSelectors.selectIsOverlay);

const shouldLogin = useAppSelector(AuthSelectors.selectIsShouldLogin);
const authStatus = useAppSelector(AuthSelectors.selectStatus);

const isSignInInSameWindow = useAppSelector(
SettingsSelectors.selectIsSignInInSameWindow,
);

const shouldOverlayLogin = isOverlay && shouldLogin;

// EFFECTS --------------------------------------------
useEffect(() => {
if (!isOverlay && shouldLogin) {
signIn();
}
}, [isOverlay, shouldLogin]);

useEffect(() => {
dispatch(AuthActions.setSession(session));
}, [dispatch, session]);

// ON LOAD --------------------------------------------

useEffect(() => {
// Hack for ios 100vh issue
const handleSetProperVHPoints = () => {
document.documentElement.style.setProperty(
'--vh',
window.innerHeight * 0.01 + 'px',
);
dispatch(UIActions.resize());
};
handleSetProperVHPoints();
window.addEventListener('resize', handleSetProperVHPoints);

dispatch(SettingsActions.initApp());
}, [dispatch, initialState]);

const handleOverlayAuth = async () => {
const timeout = 30 * 1000;
let complete = false;
await Promise.race([
timeoutAsync(timeout),
(async () => {
const authWindowLocation = new AuthWindowLocationLike(
`api/auth/signin`,
isSignInInSameWindow,
);

await authWindowLocation.ready; // ready after redirects
const t = Math.max(100, timeout / 1000);
// wait for redirection to back
while (!complete) {
try {
if (authWindowLocation.href === window.location.href) {
complete = true;
authWindowLocation.destroy();
break;
}
} catch {
// Do nothing
}
await delay(t);
}
window.location.reload();

return;
})(),
]);
};

return (
<>
<Head>
<title className="whitespace-pre">
{initialState.settings.appName}
</title>
<meta name="description" content="ChatGPT but better." />
<meta
name="viewport"
content="height=device-height ,width=device-width, initial-scale=1, user-scalable=no"
/>
</Head>
{shouldOverlayLogin ? (
<div className="grid size-full min-h-[100px] place-items-center bg-layer-1 text-sm text-primary">
<button
onClick={handleOverlayAuth}
className="button button-secondary"
disabled={authStatus === 'loading'}
>
{t('Login')}
</button>
</div>
) : (
<main
// eslint-disable-next-line tailwindcss/enforces-shorthand
className="h-screen w-screen flex-col bg-layer-1 text-sm text-primary"
id="theme-main"
>
{children}
</main>
)}
</>
);
}
Loading