| @@ -1,6 +1,6 @@ | |||
| 'use client' | |||
| import { FC, useRef } from 'react' | |||
| import React, { useEffect, useState } from 'react' | |||
| import type { FC } from 'react' | |||
| import React, { useEffect, useRef, useState } from 'react' | |||
| import { usePathname, useRouter, useSelectedLayoutSegments } from 'next/navigation' | |||
| import useSWR, { SWRConfig } from 'swr' | |||
| import Header from '../components/header' | |||
| @@ -50,7 +50,7 @@ const CommonLayout: FC<ICommonLayoutProps> = ({ children }) => { | |||
| if (!appList || !userProfile || !langeniusVersionInfo) | |||
| return <Loading type='app' /> | |||
| const curApp = appList?.data.find(opt => opt.id === appId) | |||
| const curAppId = segments[0] === 'app' && segments[2] | |||
| const currentDatasetId = segments[0] === 'datasets' && segments[2] | |||
| const currentDataset = datasetList?.data?.find(opt => opt.id === currentDatasetId) | |||
| @@ -70,12 +70,18 @@ const CommonLayout: FC<ICommonLayoutProps> = ({ children }) => { | |||
| return ( | |||
| <SWRConfig value={{ | |||
| shouldRetryOnError: false | |||
| shouldRetryOnError: false, | |||
| }}> | |||
| <AppContextProvider value={{ apps: appList.data, mutateApps, userProfile, mutateUserProfile, pageContainerRef }}> | |||
| <DatasetsContext.Provider value={{ datasets: datasetList?.data || [], mutateDatasets, currentDataset }}> | |||
| <div ref={pageContainerRef} className='relative flex flex-col h-full overflow-auto bg-gray-100'> | |||
| <Header isBordered={['/apps', '/datasets'].includes(pathname)} curApp={curApp as any} appItems={appList.data} userProfile={userProfile} onLogout={onLogout} langeniusVersionInfo={langeniusVersionInfo} /> | |||
| <Header | |||
| isBordered={['/apps', '/datasets'].includes(pathname)} | |||
| curAppId={curAppId || ''} | |||
| userProfile={userProfile} | |||
| onLogout={onLogout} | |||
| langeniusVersionInfo={langeniusVersionInfo} | |||
| /> | |||
| {children} | |||
| </div> | |||
| </DatasetsContext.Provider> | |||
| @@ -1,6 +1,9 @@ | |||
| 'use client' | |||
| import { useCallback, useState } from 'react' | |||
| import type { FC } from 'react' | |||
| import { useState } from 'react' | |||
| import useSWRInfinite from 'swr/infinite' | |||
| import { useTranslation } from 'react-i18next' | |||
| import { flatten } from 'lodash-es' | |||
| import { useRouter, useSelectedLayoutSegment } from 'next/navigation' | |||
| import classNames from 'classnames' | |||
| import { CircleStackIcon, PuzzlePieceIcon } from '@heroicons/react/24/outline' | |||
| @@ -9,11 +12,12 @@ import Link from 'next/link' | |||
| import AccountDropdown from './account-dropdown' | |||
| import Nav from './nav' | |||
| import s from './index.module.css' | |||
| import type { AppDetailResponse } from '@/models/app' | |||
| import type { AppListResponse } from '@/models/app' | |||
| import type { LangGeniusVersionResponse, UserProfileResponse } from '@/models/common' | |||
| import NewAppDialog from '@/app/(commonLayout)/apps/NewAppDialog' | |||
| import { WorkspaceProvider } from '@/context/workspace-context' | |||
| import { useDatasetsContext } from '@/context/datasets-context' | |||
| import { fetchAppList } from '@/service/apps' | |||
| const BuildAppsIcon = ({ isSelected }: { isSelected: boolean }) => ( | |||
| <svg className='mr-1' width="16" height="16" viewBox="0 0 16 16" fill="none" xmlns="http://www.w3.org/2000/svg"> | |||
| @@ -22,8 +26,7 @@ const BuildAppsIcon = ({ isSelected }: { isSelected: boolean }) => ( | |||
| ) | |||
| export type IHeaderProps = { | |||
| appItems: AppDetailResponse[] | |||
| curApp: AppDetailResponse | |||
| curAppId?: string | |||
| userProfile: UserProfileResponse | |||
| onLogout: () => void | |||
| langeniusVersionInfo: LangGeniusVersionResponse | |||
| @@ -38,15 +41,36 @@ const headerEnvClassName: { [k: string]: string } = { | |||
| DEVELOPMENT: 'bg-[#FEC84B] border-[#FDB022] text-[#93370D]', | |||
| TESTING: 'bg-[#A5F0FC] border-[#67E3F9] text-[#164C63]', | |||
| } | |||
| const Header: FC<IHeaderProps> = ({ appItems, curApp, userProfile, onLogout, langeniusVersionInfo, isBordered }) => { | |||
| const getKey = (pageIndex: number, previousPageData: AppListResponse) => { | |||
| if (!pageIndex || previousPageData.has_more) | |||
| return { url: 'apps', params: { page: pageIndex + 1, limit: 30 } } | |||
| return null | |||
| } | |||
| const Header: FC<IHeaderProps> = ({ | |||
| curAppId, | |||
| userProfile, | |||
| onLogout, | |||
| langeniusVersionInfo, | |||
| isBordered, | |||
| }) => { | |||
| const { t } = useTranslation() | |||
| const [showNewAppDialog, setShowNewAppDialog] = useState(false) | |||
| const { data: appsData, isLoading, setSize } = useSWRInfinite(curAppId ? getKey : () => null, fetchAppList, { revalidateFirstPage: false }) | |||
| const { datasets, currentDataset } = useDatasetsContext() | |||
| const router = useRouter() | |||
| const showEnvTag = langeniusVersionInfo.current_env === 'TESTING' || langeniusVersionInfo.current_env === 'DEVELOPMENT' | |||
| const selectedSegment = useSelectedLayoutSegment() | |||
| const isPluginsComingSoon = selectedSegment === 'plugins-coming-soon' | |||
| const isExplore = selectedSegment === 'explore' | |||
| const appItems = flatten(appsData?.map(appData => appData.data)) | |||
| const handleLoadmore = useCallback(() => { | |||
| if (isLoading) | |||
| return | |||
| setSize(size => size + 1) | |||
| }, [setSize, isLoading]) | |||
| return ( | |||
| <div className={classNames( | |||
| 'sticky top-0 left-0 right-0 z-20 flex bg-gray-100 grow-0 shrink-0 basis-auto h-14', | |||
| @@ -84,7 +108,7 @@ const Header: FC<IHeaderProps> = ({ appItems, curApp, userProfile, onLogout, lan | |||
| text={t('common.menus.apps')} | |||
| activeSegment={['apps', 'app']} | |||
| link='/apps' | |||
| curNav={curApp && { id: curApp.id, name: curApp.name, icon: curApp.icon, icon_background: curApp.icon_background }} | |||
| curNav={appItems.find(appItem => appItem.id === curAppId)} | |||
| navs={appItems.map(item => ({ | |||
| id: item.id, | |||
| name: item.name, | |||
| @@ -94,6 +118,7 @@ const Header: FC<IHeaderProps> = ({ appItems, curApp, userProfile, onLogout, lan | |||
| }))} | |||
| createText={t('common.menus.newApp')} | |||
| onCreate={() => setShowNewAppDialog(true)} | |||
| onLoadmore={handleLoadmore} | |||
| /> | |||
| <Link href="/plugins-coming-soon" className={classNames( | |||
| navClassName, 'group', | |||
| @@ -24,6 +24,7 @@ const Nav = ({ | |||
| navs, | |||
| createText, | |||
| onCreate, | |||
| onLoadmore, | |||
| }: INavProps) => { | |||
| const [hovered, setHovered] = useState(false) | |||
| const segment = useSelectedLayoutSegment() | |||
| @@ -62,6 +63,7 @@ const Nav = ({ | |||
| navs={navs} | |||
| createText={createText} | |||
| onCreate={onCreate} | |||
| onLoadmore={onLoadmore} | |||
| /> | |||
| </> | |||
| ) | |||
| @@ -1,8 +1,9 @@ | |||
| 'use client' | |||
| import { Fragment } from 'react' | |||
| import { useCallback } from 'react' | |||
| import { ChevronDownIcon, PlusIcon } from '@heroicons/react/24/solid' | |||
| import { Menu, Transition } from '@headlessui/react' | |||
| import { Menu } from '@headlessui/react' | |||
| import { useRouter } from 'next/navigation' | |||
| import { debounce } from 'lodash-es' | |||
| import Indicator from '../../indicator' | |||
| import AppIcon from '@/app/components/base/app-icon' | |||
| @@ -13,11 +14,12 @@ type NavItem = { | |||
| icon: string | |||
| icon_background: string | |||
| } | |||
| export interface INavSelectorProps { | |||
| export type INavSelectorProps = { | |||
| navs: NavItem[] | |||
| curNav?: Omit<NavItem, 'link'> | |||
| createText: string | |||
| onCreate: () => void | |||
| onLoadmore?: () => void | |||
| } | |||
| const itemClassName = ` | |||
| @@ -25,9 +27,18 @@ const itemClassName = ` | |||
| rounded-lg font-normal hover:bg-gray-100 cursor-pointer | |||
| ` | |||
| const NavSelector = ({ curNav, navs, createText, onCreate }: INavSelectorProps) => { | |||
| const NavSelector = ({ curNav, navs, createText, onCreate, onLoadmore }: INavSelectorProps) => { | |||
| const router = useRouter() | |||
| const handleScroll = useCallback(debounce((e) => { | |||
| if (typeof onLoadmore === 'function') { | |||
| const { clientHeight, scrollHeight, scrollTop } = e.target | |||
| if (clientHeight + scrollTop > scrollHeight - 50) | |||
| onLoadmore() | |||
| } | |||
| }, 50), []) | |||
| return ( | |||
| <div className=""> | |||
| <Menu as="div" className="relative inline-block text-left"> | |||
| @@ -46,59 +57,49 @@ const NavSelector = ({ curNav, navs, createText, onCreate }: INavSelectorProps) | |||
| /> | |||
| </Menu.Button> | |||
| </div> | |||
| <Transition | |||
| as={Fragment} | |||
| enter="transition ease-out duration-100" | |||
| enterFrom="transform opacity-0 scale-95" | |||
| enterTo="transform opacity-100 scale-100" | |||
| leave="transition ease-in duration-75" | |||
| leaveFrom="transform opacity-100 scale-100" | |||
| leaveTo="transform opacity-0 scale-95" | |||
| <Menu.Items | |||
| className=" | |||
| absolute -left-11 right-0 mt-1.5 w-60 max-w-80 | |||
| divide-y divide-gray-100 origin-top-right rounded-lg bg-white | |||
| shadow-[0_10px_15px_-3px_rgba(0,0,0,0.1),0_4px_6px_rgba(0,0,0,0.05)] | |||
| " | |||
| > | |||
| <Menu.Items | |||
| className=" | |||
| absolute -left-11 right-0 mt-1.5 w-60 max-w-80 | |||
| divide-y divide-gray-100 origin-top-right rounded-lg bg-white | |||
| shadow-[0_10px_15px_-3px_rgba(0,0,0,0.1),0_4px_6px_rgba(0,0,0,0.05)] | |||
| " | |||
| > | |||
| <div className="px-1 py-1 overflow-auto" style={{ maxHeight: '50vh' }}> | |||
| { | |||
| navs.map((nav) => ( | |||
| <Menu.Item key={nav.id}> | |||
| <div className={itemClassName} onClick={() => router.push(nav.link)}> | |||
| <div className='relative w-6 h-6 mr-2 bg-[#D5F5F6] rounded-[6px]'> | |||
| <AppIcon size='tiny' icon={nav.icon} background={nav.icon_background}/> | |||
| <div className='flex justify-center items-center absolute -right-0.5 -bottom-0.5 w-2.5 h-2.5 bg-white rounded'> | |||
| <Indicator /> | |||
| </div> | |||
| <div className="px-1 py-1 overflow-auto" style={{ maxHeight: '50vh' }} onScroll={handleScroll}> | |||
| { | |||
| navs.map(nav => ( | |||
| <Menu.Item key={nav.id}> | |||
| <div className={itemClassName} onClick={() => router.push(nav.link)}> | |||
| <div className='relative w-6 h-6 mr-2 bg-[#D5F5F6] rounded-[6px]'> | |||
| <AppIcon size='tiny' icon={nav.icon} background={nav.icon_background}/> | |||
| <div className='flex justify-center items-center absolute -right-0.5 -bottom-0.5 w-2.5 h-2.5 bg-white rounded'> | |||
| <Indicator /> | |||
| </div> | |||
| {nav.name} | |||
| </div> | |||
| </Menu.Item> | |||
| )) | |||
| } | |||
| </div> | |||
| <Menu.Item> | |||
| <div className='p-1' onClick={onCreate}> | |||
| {nav.name} | |||
| </div> | |||
| </Menu.Item> | |||
| )) | |||
| } | |||
| </div> | |||
| <Menu.Item> | |||
| <div className='p-1' onClick={onCreate}> | |||
| <div | |||
| className='flex items-center h-12 rounded-lg cursor-pointer hover:bg-gray-100' | |||
| > | |||
| <div | |||
| className='flex items-center h-12 rounded-lg cursor-pointer hover:bg-gray-100' | |||
| className=' | |||
| flex justify-center items-center | |||
| ml-4 mr-2 w-6 h-6 bg-gray-100 rounded-[6px] | |||
| border-[0.5px] border-gray-200 border-dashed | |||
| ' | |||
| > | |||
| <div | |||
| className=' | |||
| flex justify-center items-center | |||
| ml-4 mr-2 w-6 h-6 bg-gray-100 rounded-[6px] | |||
| border-[0.5px] border-gray-200 border-dashed | |||
| ' | |||
| > | |||
| <PlusIcon className='w-4 h-4 text-gray-500' /> | |||
| </div> | |||
| <div className='font-normal text-[14px] text-gray-700'>{createText}</div> | |||
| <PlusIcon className='w-4 h-4 text-gray-500' /> | |||
| </div> | |||
| <div className='font-normal text-[14px] text-gray-700'>{createText}</div> | |||
| </div> | |||
| </Menu.Item> | |||
| </Menu.Items> | |||
| </Transition> | |||
| </div> | |||
| </Menu.Item> | |||
| </Menu.Items> | |||
| </Menu> | |||
| </div> | |||
| ) | |||