feat: support navigator and trash page

* refactor: navigator

* feat: support trash
This commit is contained in:
Kilu.He 2023-07-14 20:33:22 +08:00 committed by GitHub
parent 098c085d96
commit c65584d23c
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
100 changed files with 2610 additions and 1587 deletions

View File

@ -52,6 +52,7 @@
"react-katex": "^3.0.1",
"react-redux": "^8.0.5",
"react-router-dom": "^6.8.0",
"react-transition-group": "^4.4.5",
"react18-input-otp": "^1.1.2",
"redux": "^4.2.1",
"rxjs": "^7.8.0",
@ -73,6 +74,7 @@
"@types/react-beautiful-dnd": "^13.1.3",
"@types/react-dom": "^18.0.6",
"@types/react-katex": "^3.0.0",
"@types/react-transition-group": "^4.4.6",
"@types/utf8": "^3.0.1",
"@types/uuid": "^9.0.1",
"@typescript-eslint/eslint-plugin": "^5.51.0",

View File

@ -106,6 +106,9 @@ dependencies:
react-router-dom:
specifier: ^6.8.0
version: 6.11.1(react-dom@18.2.0)(react@18.2.0)
react-transition-group:
specifier: ^4.4.5
version: 4.4.5(react-dom@18.2.0)(react@18.2.0)
react18-input-otp:
specifier: ^1.1.2
version: 1.1.3(react-dom@18.2.0)(react@18.2.0)
@ -165,6 +168,9 @@ devDependencies:
'@types/react-katex':
specifier: ^3.0.0
version: 3.0.0
'@types/react-transition-group':
specifier: ^4.4.6
version: 4.4.6
'@types/utf8':
specifier: ^3.0.1
version: 3.0.1
@ -1725,7 +1731,6 @@ packages:
resolution: {integrity: sha512-VnCdSxfcm08KjsJVQcfBmhEQAPnLB8G08hAxn39azX1qYBQ/5RVQuoHuKIcfKOdncuaUvEpFKFzEvbtIMsfVew==}
dependencies:
'@types/react': 18.2.6
dev: false
/@types/react@17.0.59:
resolution: {integrity: sha512-gSON5zWYIGyoBcycCE75E9+r6dCC2dHdsrVkOEiIYNU5+Q28HcBAuqvDuxHcCbMfHBHdeT5Tva/AFn3rnMKE4g==}

View File

@ -44,7 +44,8 @@ function flattenJSON(obj, prefix = '') {
const nestedKeys = flattenJSON(obj[key], `${prefix}${key}.`);
result = { ...result, ...nestedKeys };
} else {
result[`${prefix}${key}`] = obj[key];
result[`${prefix}${key}`] = obj[key].replaceAll('{', '{{').replaceAll('}', '}}');
}
}

View File

@ -14,6 +14,7 @@ import { ConfirmAccountPage } from '$app/views/ConfirmAccountPage';
import { ThemeProvider } from '@mui/material';
import { useUserSetting } from '$app/AppMain.hooks';
import { UserSettingControllerContext } from '$app/components/_shared/app-hooks/useUserSettingControllerContext';
import TrashPage from '$app/views/TrashPage';
function AppMain() {
const { muiTheme, userSettingController } = useUserSetting();
@ -29,6 +30,7 @@ function AppMain() {
<Route path={'/page/document/:id'} element={<DocumentPage />} />
<Route path={'/page/board/:id'} element={<BoardPage />} />
<Route path={'/page/grid/:id'} element={<GridPage />} />
<Route path={'/trash'} id={'trash'} element={<TrashPage />} />
</Route>
<Route path={'/auth/login'} element={<LoginPage />}></Route>
<Route path={'/auth/getStarted'} element={<GetStarted />}></Route>

View File

@ -0,0 +1,56 @@
import React, { useCallback, useState } from 'react';
import { List, MenuItem, Popover, Portal } from '@mui/material';
import { PopoverOrigin } from '@mui/material/Popover/Popover';
interface ButtonPopoverListProps {
isVisible: boolean;
children: React.ReactNode;
popoverOptions: {
key: string;
icon: React.ReactNode;
label: React.ReactNode | string;
onClick: () => void;
}[];
popoverOrigin: {
anchorOrigin: PopoverOrigin;
transformOrigin: PopoverOrigin;
};
}
function ButtonPopoverList({ popoverOrigin, isVisible, children, popoverOptions }: ButtonPopoverListProps) {
const [anchorEl, setAnchorEl] = useState<HTMLDivElement>();
const open = Boolean(anchorEl);
const visible = isVisible || open;
const handleClick = (event: React.MouseEvent<HTMLDivElement>) => {
setAnchorEl(event.currentTarget);
};
const handleClose = useCallback(() => {
setAnchorEl(undefined);
}, []);
return (
<>
{visible && <div onClick={handleClick}>{children}</div>}
<Portal>
<Popover open={open} {...popoverOrigin} anchorEl={anchorEl} onClose={handleClose}>
<List>
{popoverOptions.map((option) => (
<MenuItem
key={option.key}
onClick={() => {
option.onClick();
handleClose();
}}
>
<span className={'mr-2'}>{option.icon}</span>
<span>{option.label}</span>
</MenuItem>
))}
</List>
</Popover>
</Portal>
</>
);
}
export default ButtonPopoverList;

View File

@ -1,11 +1,10 @@
import { Outlet } from 'react-router-dom';
import { useAuth } from './auth.hooks';
import { Screen } from '../layout/Screen';
import Layout from '$app/components/layout/Layout';
import { useEffect, useState } from 'react';
import { GetStarted } from './GetStarted/GetStarted';
import { AppflowyLogo } from '../_shared/svg/AppflowyLogo';
export const ProtectedRoutes = () => {
const { currentUser, checkUser } = useAuth();
const [isLoading, setIsLoading] = useState(true);
@ -13,15 +12,14 @@ export const ProtectedRoutes = () => {
useEffect(() => {
void checkUser().then(async (result) => {
await new Promise(() =>
setTimeout(() => {
setIsLoading(false);
}, 1200)
setTimeout(() => {
setIsLoading(false);
}, 1200)
);
if (result.err) {
throw new Error(result.val.msg);
}
});
}, []);
@ -46,9 +44,9 @@ const StartLoading = () => {
const SplashScreen = ({ isAuthenticated }: { isAuthenticated: boolean }) => {
if (isAuthenticated) {
return (
<Screen>
<Layout>
<Outlet />
</Screen>
</Layout>
);
} else {
return <GetStarted></GetStarted>;

View File

@ -46,7 +46,7 @@ export const useAuth = () => {
if (authResult.ok) {
const userProfile = authResult.val;
// Get the workspace setting after user registered. The workspace setting
// contains the latest visiting view and the current workspace data.
// contains the latest visiting page and the current workspace data.
const openWorkspaceResult = await _openWorkspace();
if (openWorkspaceResult.ok) {

View File

@ -6,9 +6,9 @@ import { getBlock } from '$app/components/document/_shared/SubscribeNode.hooks';
import { useSubscribeDocument } from '$app/components/document/_shared/SubscribeDoc.hooks';
const headingBlockTopOffset: Record<number, number> = {
1: 7,
2: 5,
3: 4,
1: 6,
2: 4,
3: 3,
};
export function useBlockSideToolbar({ container }: { container: HTMLDivElement }) {
@ -32,7 +32,7 @@ export function useBlockSideToolbar({ container }: { container: HTMLDivElement }
});
return;
} else {
let top = 2;
let top = 0;
if (node.type === BlockType.HeadingBlock) {
const nodeData = node.data as HeadingBlockData;

View File

@ -30,7 +30,7 @@ export const Grid = ({ viewId }: { viewId: string }) => {
<GridToolbar />
</div>
{/* table component view with text area for td */}
{/* table component page with text area for td */}
<div className='flex flex-col gap-4'>
<table className='w-full table-fixed text-sm'>
<GridTableHeader controller={controller} />

View File

@ -1,39 +0,0 @@
import { HideMenuSvg } from '../_shared/svg/HideMenuSvg';
import { ShowMenuSvg } from '../_shared/svg/ShowMenuSvg';
import { useAppSelector } from '$app/stores/store';
import { ThemeMode } from '$app/interfaces';
import { AppflowyLogoLight } from '$app/components/_shared/svg/AppflowyLogoLight';
import { AppflowyLogoDark } from '$app/components/_shared/svg/AppflowyLogoDark';
export const AppLogo = ({
iconToShow,
onHideMenuClick,
onShowMenuClick,
}: {
iconToShow: 'hide' | 'show';
onHideMenuClick?: () => void;
onShowMenuClick?: () => void;
}) => {
const isDark = useAppSelector((state) => state.currentUser?.userSetting?.themeMode === ThemeMode.Dark);
return (
<div className={'mb-2 flex h-[60px] items-center justify-between px-6 text-text-title'}>
{isDark ? <AppflowyLogoDark /> : <AppflowyLogoLight />}
{iconToShow === 'hide' && (
<button onClick={onHideMenuClick} className={'h-5 w-5'}>
<i>
<HideMenuSvg></HideMenuSvg>
</i>
</button>
)}
{iconToShow === 'show' && (
<button onClick={onShowMenuClick} className={'h-5 w-5 text-text-title'}>
<i>
<ShowMenuSvg></ShowMenuSvg>
</i>
</button>
)}
</div>
);
};

View File

@ -0,0 +1,70 @@
import { useAppDispatch } from '$app/stores/store';
import { useCallback, useEffect, useMemo, useState } from 'react';
import { PageController } from '$app/stores/effects/workspace/page/page_controller';
import { useParams, useLocation } from 'react-router-dom';
import { Page, pagesActions } from '$app_reducers/pages/slice';
import { Log } from '$app/utils/log';
import { useTranslation } from 'react-i18next';
export function useLoadExpandedPages() {
const dispatch = useAppDispatch();
const { t } = useTranslation();
const params = useParams();
const location = useLocation();
const isTrash = useMemo(() => location.pathname.includes('trash'), [location.pathname]);
const currentPageId = params.id;
const [pagePath, setPagePath] = useState<
(
| Page
| {
name: string;
}
)[]
>([]);
const loadPage = useCallback(
async (id: string) => {
if (!id) return;
const controller = new PageController(id);
try {
const page = await controller.getPage();
const childPages = await controller.getChildPages();
dispatch(pagesActions.addChildPages({ id, childPages }));
dispatch(pagesActions.expandPage(id));
setPagePath((prev) => [page, ...prev]);
await loadPage(page.parentId);
} catch (e) {
Log.info(`${id} is workspace`);
}
},
[dispatch]
);
useEffect(() => {
setPagePath([]);
if (!currentPageId) {
return;
}
void (async () => {
await loadPage(currentPageId);
})();
}, [currentPageId, dispatch, loadPage]);
useEffect(() => {
if (isTrash) {
setPagePath([
{
name: t('trash.text'),
},
]);
}
}, [isTrash, t]);
return {
pagePath,
};
}

View File

@ -0,0 +1,43 @@
import React, { useCallback, useMemo } from 'react';
import { useLoadExpandedPages } from '$app/components/layout/Breadcrumb/Breadcrumb.hooks';
import Breadcrumbs from '@mui/material/Breadcrumbs';
import Link from '@mui/material/Link';
import Typography from '@mui/material/Typography';
import { Page } from '$app_reducers/pages/slice';
import { useNavigate } from 'react-router-dom';
import { pageTypeMap } from '$app/constants';
function Breadcrumb() {
const { pagePath } = useLoadExpandedPages();
const navigate = useNavigate();
const activePage = useMemo(() => pagePath[pagePath.length - 1], [pagePath]);
const parentPages = useMemo(() => pagePath.slice(0, pagePath.length - 1) as Page[], [pagePath]);
const navigateToPage = useCallback(
(page: Page) => {
const pageType = pageTypeMap[page.layout];
navigate(`/page/${pageType}/${page.id}`);
},
[navigate]
);
return (
<Breadcrumbs aria-label='breadcrumb'>
{parentPages?.map((page: Page) => (
<Link
key={page.id}
underline='hover'
color='inherit'
onClick={() => {
navigateToPage(page);
}}
>
{page.name}
</Link>
))}
<Typography color='text.primary'>{activePage?.name}</Typography>
</Breadcrumbs>
);
}
export default Breadcrumb;

View File

@ -0,0 +1,22 @@
import React from 'react';
import { IconButton } from '@mui/material';
import { ShowMenuSvg } from '$app/components/_shared/svg/ShowMenuSvg';
import { HideMenuSvg } from '$app/components/_shared/svg/HideMenuSvg';
import { useAppDispatch, useAppSelector } from '$app/stores/store';
import { sidebarActions } from '$app_reducers/sidebar/slice';
function CollapseMenuButton() {
const isCollapsed = useAppSelector((state) => state.sidebar.isCollapsed);
const dispatch = useAppDispatch();
const handleClick = () => {
dispatch(sidebarActions.toggleCollapse());
};
return (
<IconButton className={'h-6 w-6 p-2'} size={'small'} onClick={handleClick}>
{isCollapsed ? <ShowMenuSvg /> : <HideMenuSvg />}
</IconButton>
);
}
export default CollapseMenuButton;

View File

@ -1,61 +0,0 @@
import { ShowMenuSvg } from '../../_shared/svg/ShowMenuSvg';
import { useEffect, useState } from 'react';
import { useAppSelector } from '$app/stores/store';
import { useLocation } from 'react-router-dom';
import { ArrowLeft, ArrowRight } from '@mui/icons-material';
import { ArrowLeftSvg } from '$app/components/_shared/svg/ArrowLeftSvg';
import { ArrowRightSvg } from '$app/components/_shared/svg/ArrowRightSvg';
export const Breadcrumbs = ({ menuHidden, onShowMenuClick }: { menuHidden: boolean; onShowMenuClick: () => void }) => {
const [folderName, setFolderName] = useState('');
const [pageName, setPageName] = useState('');
const [activePageId, setActivePageId] = useState<string>('');
const currentLocation = useLocation();
const pagesStore = useAppSelector((state) => state.pages);
useEffect(() => {
const { pathname } = currentLocation;
const parts = pathname.split('/');
const pageId = parts[parts.length - 1];
setActivePageId(pageId);
}, [currentLocation]);
useEffect(() => {
const page = pagesStore.find((p) => p.id === activePageId);
// const folder = foldersStore.find((f) => f.id === page?.parentPageId);
// setFolderName(folder?.title ?? '');
setPageName(page?.title ?? '');
}, [pagesStore, activePageId]);
return (
<div className={'flex items-center'}>
<div className={'mr-4 flex items-center'}>
{menuHidden && (
<button onClick={() => onShowMenuClick()} className={'mr-2 h-5 w-5 text-text-title'}>
<ShowMenuSvg></ShowMenuSvg>
</button>
)}
<button
className={'h-6 w-6 rounded p-1 text-text-title hover:bg-fill-list-hover'}
onClick={() => history.back()}
>
<ArrowLeftSvg />
</button>
<button
className={'h-6 w-6 rounded p-1 text-text-title hover:bg-fill-list-hover'}
onClick={() => history.forward()}
>
<ArrowRightSvg />
</button>
</div>
<div className={'mr-8 flex items-center gap-4'}>
<span>{folderName}</span>
<span>/</span>
<span>{pageName}</span>
</div>
</div>
);
};

View File

@ -1,11 +0,0 @@
import { Breadcrumbs } from './Breadcrumbs';
import { PageOptions } from './PageOptions';
export const HeaderPanel = ({ menuHidden, onShowMenuClick }: { menuHidden: boolean; onShowMenuClick: () => void }) => {
return (
<div className={'flex h-[60px] items-center justify-between border-b border-line-divider px-8'}>
<Breadcrumbs menuHidden={menuHidden} onShowMenuClick={onShowMenuClick}></Breadcrumbs>
<PageOptions></PageOptions>
</div>
);
};

View File

@ -1,45 +0,0 @@
import React, { useCallback, useMemo } from 'react';
import { LogoutSvg } from '$app/components/_shared/svg/LogoutSvg';
import { useAuth } from '$app/components/auth/auth.hooks';
import MenuItem from '@mui/material/MenuItem';
import { useTranslation } from 'react-i18next';
function MoreMenu({ onClose }: { onClose: () => void }) {
const { t } = useTranslation();
const { logout } = useAuth();
const onSignOutClick = useCallback(async () => {
await logout();
onClose();
}, [onClose, logout]);
const items = useMemo(() => {
return [
{
title: t('button.signOut'),
icon: (
<i className={'block h-5 w-5 flex-shrink-0'}>
<LogoutSvg></LogoutSvg>
</i>
),
onClick: onSignOutClick,
},
];
}, [onSignOutClick, t]);
return (
<>
{items.map((item, index) => {
return (
<MenuItem key={index} onClick={item.onClick}>
<div className={'flex items-center gap-2'}>
{item.icon}
<span className={'flex-shrink-0'}>{item.title}</span>
</div>
</MenuItem>
);
})}
</>
);
}
export default MoreMenu;

View File

@ -1,20 +0,0 @@
import { useCallback, useState } from 'react';
import { useAuth } from '../../auth/auth.hooks';
export const usePageOptions = () => {
const [anchorEl, setAnchorEl] = useState<HTMLDivElement | HTMLButtonElement>();
const onOptionsClick = useCallback((el: HTMLDivElement | HTMLButtonElement) => {
setAnchorEl(el);
}, []);
const onClose = () => {
setAnchorEl(undefined);
};
return {
anchorEl,
onOptionsClick,
onClose,
};
};

View File

@ -1,73 +0,0 @@
import { Details2Svg } from '../../_shared/svg/Details2Svg';
import { usePageOptions } from './PageOptions.hooks';
import { Button, IconButton, List } from '@mui/material';
import Popover from '@mui/material/Popover';
import { useCallback, useState } from 'react';
import MoreMenu from '$app/components/layout/HeaderPanel/MoreMenu';
import { useTranslation } from 'react-i18next';
enum PageOptionsEnum {
Share = 'Share',
More = 'More',
}
export const PageOptions = () => {
const { t } = useTranslation();
const { anchorEl, onOptionsClick, onClose } = usePageOptions();
const open = Boolean(anchorEl);
const [option, setOption] = useState<PageOptionsEnum>();
const renderMenu = useCallback(() => {
switch (option) {
case PageOptionsEnum.Share:
return <div>Share</div>;
default:
return <MoreMenu onClose={onClose} />;
}
}, [onClose, option]);
return (
<>
<div className={'relative flex items-center gap-4'}>
<Button
variant={'contained'}
onClick={(e) => {
const el = e.currentTarget;
setOption(PageOptionsEnum.Share);
onOptionsClick(el);
}}
>
{t('shareAction.buttonText')}
</Button>
<IconButton
id='option-button'
size={'small'}
className={'h-8 w-8 rounded text-text-title hover:bg-fill-list-hover'}
onClick={(e) => {
const el = e.currentTarget;
setOption(PageOptionsEnum.More);
onOptionsClick(el);
}}
>
<Details2Svg></Details2Svg>
</IconButton>
</div>
<Popover
open={open}
anchorEl={anchorEl}
onClose={onClose}
anchorOrigin={{
vertical: 'bottom',
horizontal: 'right',
}}
transformOrigin={{
vertical: 'top',
horizontal: 'right',
}}
>
<List>{renderMenu()}</List>
</Popover>
</>
);
};

View File

@ -0,0 +1,47 @@
import React, { ReactNode, useEffect } from 'react';
import SideBar from '$app/components/layout/SideBar';
import TopBar from '$app/components/layout/TopBar';
import { useAppSelector } from '$app/stores/store';
import { FooterPanel } from '$app/components/layout/FooterPanel';
function Layout({ children }: { children: ReactNode }) {
const { isCollapsed, width } = useAppSelector((state) => state.sidebar);
useEffect(() => {
const onKeyDown = (e: KeyboardEvent) => {
if (e.key === 'Backspace' && e.target instanceof HTMLBodyElement) {
e.preventDefault();
}
};
window.addEventListener('keydown', onKeyDown);
return () => {
window.removeEventListener('keydown', onKeyDown);
};
}, []);
return (
<div className='flex h-screen w-[100%] text-sm text-text-title'>
<SideBar />
<div
className='flex flex-1 flex-col bg-bg-body'
style={{
width: isCollapsed ? 'auto' : `calc(100% - ${width}px)`,
}}
>
<TopBar />
<div
style={{
height: 'calc(100vh - 64px - 48px)',
}}
className={'overflow-y-auto overflow-x-hidden'}
>
{children}
</div>
<FooterPanel />
</div>
</div>
);
}
export default Layout;

View File

@ -1,42 +0,0 @@
import { ReactNode, useEffect, useState } from 'react';
import { HeaderPanel } from './HeaderPanel/HeaderPanel';
import { FooterPanel } from './FooterPanel';
import { ANIMATION_DURATION } from '../_shared/constants';
export const MainPanel = ({
left,
menuHidden,
onShowMenuClick,
children,
}: {
left: number;
menuHidden: boolean;
onShowMenuClick: () => void;
children: ReactNode;
}) => {
const [animation, setAnimation] = useState(false);
useEffect(() => {
if (!menuHidden) {
setTimeout(() => {
setAnimation(false);
}, ANIMATION_DURATION);
} else {
setAnimation(true);
}
}, [menuHidden]);
return (
<div
className={`absolute inset-0 flex h-full flex-1 flex-col bg-bg-body text-text-title`}
style={{
transition: menuHidden || animation ? `left ${ANIMATION_DURATION}ms ease-out` : 'none',
left: `${menuHidden ? 0 : left}px`,
}}
>
<HeaderPanel menuHidden={menuHidden} onShowMenuClick={onShowMenuClick}></HeaderPanel>
<div className={'min-h-0 flex-1 overflow-auto'}>{children}</div>
<FooterPanel></FooterPanel>
</div>
);
};

View File

@ -1,84 +0,0 @@
import React, { useMemo, useState } from 'react';
import { EditSvg } from '$app/components/_shared/svg/EditSvg';
import { TrashSvg } from '$app/components/_shared/svg/TrashSvg';
import { CopySvg } from '$app/components/_shared/svg/CopySvg';
import MenuItem from '@mui/material/MenuItem';
import { useTranslation } from 'react-i18next';
import RenameDialog from '$app/components/layout/NavigationPanel/RenameDialog';
import { IPage } from '$app_reducers/pages/slice';
function MoreMenu({
selectedPage,
onRename,
onDeleteClick,
onDuplicateClick,
}: {
selectedPage: IPage;
onRename: (name: string) => Promise<void>;
onDeleteClick: () => void;
onDuplicateClick: () => void;
}) {
const { t } = useTranslation();
const [renameDialogOpen, setRenameDialogOpen] = useState(false);
const items = useMemo(
() => [
{
icon: (
<i className={'h-[16px] w-[16px] text-text-title'}>
<EditSvg></EditSvg>
</i>
),
onClick: () => {
setRenameDialogOpen(true);
},
title: t('disclosureAction.rename'),
},
{
icon: (
<i className={'h-[16px] w-[16px] text-text-title'}>
<TrashSvg></TrashSvg>
</i>
),
onClick: onDeleteClick,
title: t('disclosureAction.delete'),
},
{
icon: (
<i className={'h-[16px] w-[16px] text-text-title'}>
<CopySvg></CopySvg>
</i>
),
onClick: onDuplicateClick,
title: t('disclosureAction.duplicate'),
},
],
[onDeleteClick, onDuplicateClick, t]
);
return (
<>
{items.map((item, index) => {
return (
<MenuItem key={index} onClick={item.onClick}>
<div className={'flex items-center gap-2'}>
{item.icon}
<span className={'flex-shrink-0'}>{item.title}</span>
</div>
</MenuItem>
);
})}
<RenameDialog
defaultValue={selectedPage.title}
open={renameDialogOpen}
onClose={() => setRenameDialogOpen(false)}
onOk={async (val: string) => {
await onRename(val);
setRenameDialogOpen(false);
}}
/>
</>
);
}
export default MoreMenu;

View File

@ -1,200 +0,0 @@
import { useCallback, useEffect, useState } from 'react';
import { useAppDispatch, useAppSelector } from '$app/stores/store';
import { IPage, pagesActions } from '$app_reducers/pages/slice';
import { ViewLayoutPB } from '@/services/backend';
import { WorkspaceBackendService } from '$app/stores/effects/folder/workspace/workspace_bd_svc';
import { useLocation, useNavigate } from 'react-router-dom';
import { INITIAL_FOLDER_HEIGHT, PAGE_ITEM_HEIGHT } from '../../_shared/constants';
import { ViewBackendService } from '$app/stores/effects/folder/view/view_bd_svc';
import { ViewObserver } from '$app/stores/effects/folder/view/view_observer';
export enum NavItemOptions {
More = 'More',
NewPage = 'NewPage',
}
export const useNavItem = (page: IPage) => {
const appDispatch = useAppDispatch();
const workspace = useAppSelector((state) => state.workspace);
const currentLocation = useLocation();
const [activePageId, setActivePageId] = useState<string>('');
const pages = useAppSelector((state) => state.pages);
const [anchorEl, setAnchorEl] = useState<HTMLElement>();
const menuOpen = Boolean(anchorEl);
const [menuOption, setMenuOption] = useState<NavItemOptions>();
const [selectedPage, setSelectedPage] = useState<IPage>();
const onClickMenuBtn = useCallback((page: IPage, option: NavItemOptions) => {
setSelectedPage(page);
setMenuOption(option);
}, []);
const navigate = useNavigate();
// backend
const service = new ViewBackendService(page.id);
const observer = new ViewObserver(page.id);
const loadInsidePages = async () => {
const result = await service.getChildViews();
if (!result.ok) return;
const views = result.val;
const updatedPages: IPage[] = views.map<IPage>((view) => ({
parentPageId: page.id,
id: view.id,
pageType: view.layout,
title: view.name,
showPagesInside: false,
}));
appDispatch(pagesActions.addInsidePages({ currentPageId: page.id, insidePages: updatedPages }));
};
useEffect(() => {
void loadInsidePages();
void observer.subscribe({
onChildViewsChanged: () => {
void loadInsidePages();
},
});
return () => {
// Unsubscribe when the component is unmounted.
void observer.unsubscribe();
};
}, []);
useEffect(() => {
const { pathname } = currentLocation;
const parts = pathname.split('/');
const pageId = parts[parts.length - 1];
setActivePageId(pageId);
}, [currentLocation]);
// recursively get all unfolded child pages
const getChildCount: (startPage: IPage) => number = (startPage: IPage) => {
let count = 0;
count = pages.filter((p) => p.parentPageId === startPage.id).length;
pages
.filter((p) => p.parentPageId === startPage.id)
.forEach((p) => {
if (p.showPagesInside) {
count += getChildCount(p);
}
});
return count;
};
const onUnfoldClick = () => {
appDispatch(pagesActions.toggleShowPages({ id: page.id }));
};
const changePageTitle = async (newTitle: string) => {
await service.update({ name: newTitle });
appDispatch(pagesActions.renamePage({ id: page.id, newTitle }));
setAnchorEl(undefined);
};
const deletePage = async () => {
await service.delete();
appDispatch(pagesActions.deletePage({ id: page.id }));
setAnchorEl(undefined);
};
const duplicatePage = async () => {
await service.duplicate();
setAnchorEl(undefined);
};
const onPageClick = (eventPage: IPage) => {
const pageTypeRoute = (() => {
switch (eventPage.pageType) {
case ViewLayoutPB.Document:
return 'document';
case ViewLayoutPB.Grid:
return 'grid';
case ViewLayoutPB.Board:
return 'board';
default:
return 'document';
}
})();
navigate(`/page/${pageTypeRoute}/${eventPage.id}`);
};
const onAddNewPage = async (pageType: ViewLayoutPB) => {
if (!workspace?.id) return;
let newPageName = '';
let pageTypeRoute = '';
switch (pageType) {
case ViewLayoutPB.Document:
newPageName = 'Document Page 1';
pageTypeRoute = 'document';
break;
case ViewLayoutPB.Grid:
newPageName = 'Grid Page 1';
pageTypeRoute = 'grid';
break;
case ViewLayoutPB.Board:
newPageName = 'Board Page 1';
pageTypeRoute = 'board';
break;
default:
newPageName = 'Document Page 1';
pageTypeRoute = 'document';
break;
}
const workspaceService = new WorkspaceBackendService(workspace.id);
const newViewResult = await workspaceService.createView({
name: newPageName,
layoutType: pageType,
parentViewId: page.id,
});
if (newViewResult.ok) {
const newView = newViewResult.val;
if (!page.showPagesInside) {
appDispatch(pagesActions.toggleShowPages({ id: page.id }));
}
appDispatch(
pagesActions.addPage({
parentPageId: page.id,
pageType,
title: newView.name,
id: newView.id,
showPagesInside: false,
})
);
setAnchorEl(undefined);
navigate(`/page/${pageTypeRoute}/${newView.id}`);
}
};
return {
onUnfoldClick,
changePageTitle,
deletePage,
duplicatePage,
onPageClick,
onAddNewPage,
activePageId,
menuOpen,
anchorEl,
setAnchorEl,
menuOption,
selectedPage,
onClickMenuBtn,
};
};

View File

@ -1,124 +0,0 @@
import { Details2Svg } from '../../_shared/svg/Details2Svg';
import AddSvg from '../../_shared/svg/AddSvg';
import { IPage } from '$app_reducers/pages/slice';
import { useMemo, useRef } from 'react';
import { DropDownShowSvg } from '../../_shared/svg/DropDownShowSvg';
import { ANIMATION_DURATION } from '../../_shared/constants';
import { NavItemOptions, useNavItem } from '$app/components/layout/NavigationPanel/NavItem.hooks';
import { useAppSelector } from '$app/stores/store';
import { ViewLayoutPB } from '@/services/backend';
import Popover from '@mui/material/Popover';
import { IconButton, List } from '@mui/material';
import MoreMenu from '$app/components/layout/NavigationPanel/MoreMenu';
import NewPageMenu from '$app/components/layout/NavigationPanel/NewPageMenu';
export const NavItem = ({ page }: { page: IPage }) => {
const pages = useAppSelector((state) => state.pages);
const {
onUnfoldClick,
changePageTitle,
deletePage,
duplicatePage,
onAddNewPage,
activePageId,
onPageClick,
onClickMenuBtn,
menuOpen,
menuOption,
setAnchorEl,
selectedPage,
anchorEl,
} = useNavItem(page);
const el = useRef<HTMLDivElement>(null);
return (
<>
<div ref={el}>
<div className={`transition-all`} style={{ transitionDuration: `${ANIMATION_DURATION}ms` }}>
<div className={`cursor-pointer px-1 py-1`}>
<div
className={`flex items-center justify-between rounded-lg px-2 py-1 hover:bg-fill-list-hover ${
activePageId === page.id ? 'bg-fill-list-hover' : ''
}`}
>
<div className={'flex h-full min-w-0 flex-1 items-center'}>
<button
onClick={() => onUnfoldClick()}
className={`mr-2 h-5 w-5 transition-transform duration-200 ${
page.showPagesInside ? 'rotate-180' : ''
}`}
>
<DropDownShowSvg></DropDownShowSvg>
</button>
<div
onClick={() => onPageClick(page)}
className={'mr-1 flex h-full min-w-0 flex-1 items-center text-left'}
>
<span className={'w-[100%] overflow-hidden overflow-ellipsis whitespace-nowrap'}>{page.title}</span>
</div>
</div>
<div className={'flex items-center'}>
<IconButton
className={'h-6 w-6'}
size={'small'}
onClick={(e) => {
setAnchorEl(e.currentTarget);
onClickMenuBtn(page, NavItemOptions.More);
}}
>
<Details2Svg></Details2Svg>
</IconButton>
<IconButton
className={'h-6 w-6'}
size={'small'}
onClick={(e) => {
setAnchorEl(e.currentTarget);
onClickMenuBtn(page, NavItemOptions.NewPage);
}}
>
<AddSvg></AddSvg>
</IconButton>
</div>
</div>
</div>
<div className={`${page.showPagesInside ? '' : 'hidden'} pl-4`}>
{useMemo(() => pages.filter((insidePage) => insidePage.parentPageId === page.id), [pages, page]).map(
(insidePage, insideIndex) => (
<NavItem key={insideIndex} page={insidePage}></NavItem>
)
)}
</div>
</div>
</div>
<Popover
open={menuOpen}
anchorEl={anchorEl}
onClose={() => setAnchorEl(undefined)}
anchorOrigin={{ vertical: 'bottom', horizontal: 'left' }}
transformOrigin={{ vertical: 'top', horizontal: 'left' }}
>
<List>
{menuOption === NavItemOptions.More && selectedPage && (
<MoreMenu
selectedPage={selectedPage}
onRename={changePageTitle}
onDeleteClick={() => deletePage()}
onDuplicateClick={() => duplicatePage()}
/>
)}
{menuOption === NavItemOptions.NewPage && (
<NewPageMenu
onDocumentClick={() => onAddNewPage(ViewLayoutPB.Document)}
onBoardClick={() => onAddNewPage(ViewLayoutPB.Board)}
onGridClick={() => onAddNewPage(ViewLayoutPB.Grid)}
/>
)}
</List>
</Popover>
</>
);
};

View File

@ -1,22 +0,0 @@
import { useAppSelector } from '$app/stores/store';
import { useState } from 'react';
export const useNavigationPanelHooks = function () {
const width = useAppSelector((state) => state.navigationWidth);
const [menuHidden, setMenuHidden] = useState(false);
const onHideMenuClick = () => {
setMenuHidden(true);
};
const onShowMenuClick = () => {
setMenuHidden(false);
};
return {
width,
menuHidden,
onHideMenuClick,
onShowMenuClick,
};
};

View File

@ -1,131 +0,0 @@
import { WorkspaceUser } from '../WorkspaceUser';
import { AppLogo } from '../AppLogo';
import { TrashButton } from './TrashButton';
import { NewViewButton } from './NewViewButton';
import { NavigationResizer } from './NavigationResizer';
import { IPage } from '$app_reducers/pages/slice';
import { useLocation, useNavigate } from 'react-router-dom';
import React, { useEffect, useRef, useState } from 'react';
import { useAppSelector } from '$app/stores/store';
import { NavItem } from '$app/components/layout/NavigationPanel/NavItem';
import { ANIMATION_DURATION, NAV_PANEL_MINIMUM_WIDTH, PAGE_ITEM_HEIGHT } from '../../_shared/constants';
export const NavigationPanel = ({
onHideMenuClick,
menuHidden,
width,
}: {
onHideMenuClick: () => void;
menuHidden: boolean;
width: number;
}) => {
const el = useRef<HTMLDivElement>(null);
const pages = useAppSelector((state) => state.pages);
const workspace = useAppSelector((state) => state.workspace);
const [activePageId, setActivePageId] = useState<string>('');
const currentLocation = useLocation();
const [maxHeight, setMaxHeight] = useState(0);
useEffect(() => {
const { pathname } = currentLocation;
const parts = pathname.split('/');
const pageId = parts[parts.length - 1];
setActivePageId(pageId);
}, [currentLocation]);
useEffect(() => {
setMaxHeight(pages.length * PAGE_ITEM_HEIGHT);
}, [pages]);
const scrollDown = () => {
setTimeout(() => {
el?.current?.scrollTo({ top: maxHeight, behavior: 'smooth' });
}, ANIMATION_DURATION);
};
return (
<>
<div
className={`absolute inset-0 flex flex-col justify-between bg-bg-base text-sm text-text-title`}
style={{
transition: `left ${ANIMATION_DURATION}ms ease-out`,
width: `${width}px`,
left: `${menuHidden ? -width : 0}px`,
}}
>
<AppLogo iconToShow={'hide'} onHideMenuClick={onHideMenuClick}></AppLogo>
<WorkspaceUser></WorkspaceUser>
<div className={'relative flex flex-1 flex-col'}>
<div className={'flex h-[100%] flex-col overflow-auto px-2'} ref={el}>
<WorkspaceApps pages={pages.filter((p) => p.parentPageId === workspace.id)} />
</div>
</div>
<div className={'flex max-h-[240px] flex-col'}>
<div className={'border-b border-line-divider px-2 pb-4'}>
{/*<PluginsButton></PluginsButton>*/}
{/*<DesignSpec></DesignSpec>*/}
{/*<AllIcons></AllIcons>*/}
{/*<TestBackendButton></TestBackendButton>*/}
{/*Trash Button*/}
<TrashButton></TrashButton>
</div>
{/*New Root View Button*/}
<NewViewButton scrollDown={scrollDown}></NewViewButton>
</div>
</div>
<NavigationResizer minWidth={NAV_PANEL_MINIMUM_WIDTH}></NavigationResizer>
</>
);
};
const WorkspaceApps: React.FC<{ pages: IPage[] }> = ({ pages }) => (
<>
{pages.map((page, index) => (
<NavItem key={index} page={page}></NavItem>
))}
</>
);
export const TestBackendButton = () => {
const navigate = useNavigate();
return (
<button
onClick={() => navigate('/page/api-test')}
className={'hover:bg-fill-active flex w-full items-center rounded-lg px-4 py-2'}
>
API Test
</button>
);
};
export const DesignSpec = () => {
const navigate = useNavigate();
return (
<button
onClick={() => navigate('page/colors')}
className={'hover:bg-fill-active flex w-full items-center rounded-lg px-4 py-2'}
>
Color Palette
</button>
);
};
export const AllIcons = () => {
const navigate = useNavigate();
return (
<button
onClick={() => navigate('page/all-icons')}
className={'hover:bg-fill-active flex w-full items-center rounded-lg px-4 py-2'}
>
All Icons
</button>
);
};

View File

@ -1,26 +0,0 @@
import { useResizer } from '../../_shared/useResizer';
import { useAppDispatch, useAppSelector } from '$app/stores/store';
import { useEffect } from 'react';
import { navigationWidthActions } from '$app_reducers/navigation-width/slice';
export const NavigationResizer = ({ minWidth }: { minWidth: number }) => {
const width = useAppSelector((state) => state.navigationWidth);
const appDispatch = useAppDispatch();
const { onMouseDown, movementX } = useResizer();
useEffect(() => {
if (width + movementX < minWidth) {
appDispatch(navigationWidthActions.changeWidth(minWidth));
} else {
appDispatch(navigationWidthActions.changeWidth(width + movementX));
}
}, [movementX]);
return (
<button
className={'fixed z-10 h-full w-[15px] cursor-ew-resize'}
style={{ left: `${width - 8}px`, userSelect: 'none' }}
onMouseDown={onMouseDown}
></button>
);
};

View File

@ -1,67 +0,0 @@
import React, { useMemo } from 'react';
import { DocumentSvg } from '$app/components/_shared/svg/DocumentSvg';
import { BoardSvg } from '$app/components/_shared/svg/BoardSvg';
import { GridSvg } from '$app/components/_shared/svg/GridSvg';
import MenuItem from '@mui/material/MenuItem';
import { useTranslation } from 'react-i18next';
function NewPageMenu({
onDocumentClick,
onGridClick,
onBoardClick,
}: {
onDocumentClick: () => void;
onGridClick: () => void;
onBoardClick: () => void;
}) {
const { t } = useTranslation();
const items = useMemo(
() => [
{
icon: (
<i className={'h-[16px] w-[16px] text-text-title'}>
<DocumentSvg></DocumentSvg>
</i>
),
onClick: onDocumentClick,
title: t('document.menuName'),
},
{
icon: (
<i className={'h-[16px] w-[16px] text-text-title'}>
<BoardSvg></BoardSvg>
</i>
),
onClick: onBoardClick,
title: t('board.menuName'),
},
{
icon: (
<i className={'h-[16px] w-[16px] text-text-title'}>
<GridSvg></GridSvg>
</i>
),
onClick: onGridClick,
title: t('grid.menuName'),
},
],
[onBoardClick, onDocumentClick, onGridClick, t]
);
return (
<>
{items.map((item, index) => {
return (
<MenuItem key={index} onClick={item.onClick}>
<div className={'flex items-center gap-2'}>
{item.icon}
<span className={'flex-shrink-0'}>{item.title}</span>
</div>
</MenuItem>
);
})}
</>
);
}
export default NewPageMenu;

View File

@ -1,45 +0,0 @@
import { useAppDispatch, useAppSelector } from '$app/stores/store';
import { WorkspaceBackendService } from '$app/stores/effects/folder/workspace/workspace_bd_svc';
import { ViewLayoutPB } from '@/services/backend';
import { pagesActions } from '$app_reducers/pages/slice';
import { useNavigate } from 'react-router-dom';
export const useNewRootView = () => {
const appDispatch = useAppDispatch();
const workspace = useAppSelector((state) => state.workspace);
const navigate = useNavigate();
const onNewRootView = async () => {
if (!workspace.id) return;
const workspaceBackendService = new WorkspaceBackendService(workspace.id);
// in future should show options for new page type
const defaultType = ViewLayoutPB.Document;
const defaultName = 'Document Page 1';
const defaultRoute = 'document';
const result = await workspaceBackendService.createView({
parentViewId: workspace.id,
layoutType: defaultType,
name: defaultName,
});
if (result.ok) {
const newView = result.val;
appDispatch(
pagesActions.addPage({
parentPageId: workspace.id,
id: newView.id,
title: newView.name,
showPagesInside: false,
pageType: defaultType,
})
);
navigate(`/page/${defaultRoute}/${newView.id}`);
}
};
return {
onNewRootView,
};
};

View File

@ -1,23 +0,0 @@
import AddSvg from '../../_shared/svg/AddSvg';
import { useNewRootView } from './NewViewButton.hooks';
export const NewViewButton = ({ scrollDown }: { scrollDown: () => void }) => {
const { onNewRootView } = useNewRootView();
return (
<button
onClick={() => {
void onNewRootView();
scrollDown();
}}
className={'flex h-[50px] w-full items-center px-6 hover:bg-fill-list-active'}
>
<div className={'mr-2 rounded-full bg-fill-default'}>
<div className={'h-[24px] w-[24px] text-content-on-fill'}>
<AddSvg></AddSvg>
</div>
</div>
<span>New View</span>
</button>
);
};

View File

@ -1,8 +0,0 @@
export const PluginsButton = () => {
return (
<button className={'flex w-full items-center rounded-lg px-4 py-2 hover:bg-fill-active'}>
<img className={'mr-2 h-[24px] w-[24px]'} src={'/images/home/page.svg'} alt={''} />
<span>Plugins</span>
</button>
);
};

View File

@ -1,13 +0,0 @@
import { DeleteForeverOutlined } from '@mui/icons-material';
import { TrashSvg } from '$app/components/_shared/svg/TrashSvg';
export const TrashButton = () => {
return (
<button className={'flex w-full items-center rounded-lg px-4 py-2 text-text-title hover:bg-fill-list-active'}>
<span className={'h-[23px] w-[23px]'}>
<TrashSvg />
</span>
<span className={'ml-2'}>Trash</span>
</button>
);
};

View File

@ -0,0 +1,77 @@
import React, { useMemo } from 'react';
import ButtonPopoverList from '$app/components/_shared/ButtonPopoverList';
import { IconButton } from '@mui/material';
import AddSvg from '$app/components/_shared/svg/AddSvg';
import { useTranslation } from 'react-i18next';
import { DocumentSvg } from '$app/components/_shared/svg/DocumentSvg';
import { GridSvg } from '$app/components/_shared/svg/GridSvg';
import { BoardSvg } from '$app/components/_shared/svg/BoardSvg';
import { ViewLayoutPB } from '@/services/backend';
function AddButton({ isVisible, onAddPage }: { isVisible: boolean; onAddPage: (layout: ViewLayoutPB) => void }) {
const { t } = useTranslation();
const options = useMemo(
() => [
{
key: 'add-document',
label: t('document.menuName'),
icon: (
<div className={'h-5 w-5'}>
<DocumentSvg />
</div>
),
onClick: () => {
onAddPage(ViewLayoutPB.Document);
},
},
{
key: 'add-grid',
label: t('grid.menuName'),
icon: (
<div className={'h-5 w-5'}>
<GridSvg />
</div>
),
onClick: () => {
onAddPage(ViewLayoutPB.Grid);
},
},
{
key: 'add-board',
label: t('board.menuName'),
icon: (
<div className={'h-5 w-5'}>
<BoardSvg />
</div>
),
onClick: () => {
onAddPage(ViewLayoutPB.Board);
},
},
],
[onAddPage, t]
);
return (
<ButtonPopoverList
popoverOrigin={{
anchorOrigin: {
vertical: 'bottom',
horizontal: 'left',
},
transformOrigin: {
vertical: 'top',
horizontal: 'left',
},
}}
popoverOptions={options}
isVisible={isVisible}
>
<IconButton className={'mr-2 h-6 w-6'}>
<AddSvg />
</IconButton>
</ButtonPopoverList>
);
}
export default AddButton;

View File

@ -0,0 +1,64 @@
import React, { useState } from 'react';
import DialogTitle from '@mui/material/DialogTitle';
import DialogContent from '@mui/material/DialogContent';
import Dialog from '@mui/material/Dialog';
import { useTranslation } from 'react-i18next';
import TextField from '@mui/material/TextField';
import { Button, DialogActions } from '@mui/material';
import { ViewLayoutPB } from '@/services/backend';
function DeleteDialog({
layout,
open,
onClose,
onOk,
}: {
layout: ViewLayoutPB;
open: boolean;
onClose: () => void;
onOk: () => Promise<void>;
}) {
const { t } = useTranslation();
const pageType = {
[ViewLayoutPB.Document]: t('document.menuName'),
[ViewLayoutPB.Grid]: t('grid.menuName'),
[ViewLayoutPB.Board]: t('board.menuName'),
[ViewLayoutPB.Calendar]: t('calendar.menuName'),
}[layout];
return (
<Dialog keepMounted={false} onMouseDown={(e) => e.stopPropagation()} open={open} onClose={onClose}>
<DialogContent className={'flex w-[540px] flex-col items-center justify-center'}>
<div className={'text-md m-2 font-bold'}>
{t('views.deleteContentTitle', {
pageType,
})}
</div>
<div className={'m-1 text-sm text-text-caption'}>
{t('views.deleteContentCaption', {
pageType,
})}
</div>
</DialogContent>
<DialogActions>
<Button variant={'outlined'} onClick={onClose}>
{t('button.Cancel')}
</Button>
<Button
variant={'contained'}
onClick={async () => {
try {
await onOk();
onClose();
} catch (e) {}
}}
>
{t('button.delete')}
</Button>
</DialogActions>
</Dialog>
);
}
export default DeleteDialog;

View File

@ -0,0 +1,112 @@
import React, { useMemo, useState } from 'react';
import { useTranslation } from 'react-i18next';
import { IconButton } from '@mui/material';
import ButtonPopoverList from '../../_shared/ButtonPopoverList';
import { MoreHoriz } from '@mui/icons-material';
import { TrashSvg } from '$app/components/_shared/svg/TrashSvg';
import { CopySvg } from '$app/components/_shared/svg/CopySvg';
import { EditSvg } from '$app/components/_shared/svg/EditSvg';
import RenameDialog from './RenameDialog';
import { Page } from '$app_reducers/pages/slice';
import DeleteDialog from '$app/components/layout/NestedPage/DeleteDialog';
function MoreButton({
isVisible,
onDelete,
onDuplicate,
onRename,
page,
}: {
isVisible: boolean;
onDelete: () => Promise<void>;
onDuplicate: () => Promise<void>;
onRename: (newName: string) => Promise<void>;
page: Page;
}) {
const [renameDialogOpen, setRenameDialogOpen] = useState(false);
const [deleteDialogOpen, setDeleteDialogOpen] = useState(false);
const { t } = useTranslation();
const options = useMemo(
() => [
{
label: t('disclosureAction.rename'),
key: 'rename',
icon: (
<div className={'h-5 w-5'}>
<EditSvg />
</div>
),
onClick: () => {
setRenameDialogOpen(true);
},
},
{
label: t('button.delete'),
key: 'delete',
onClick: () => {
setDeleteDialogOpen(true);
},
icon: (
<div className={'h-5 w-5'}>
<TrashSvg />
</div>
),
},
{
key: 'duplicate',
label: t('button.duplicate'),
onClick: onDuplicate,
icon: (
<div className={'h-5 w-5'}>
<CopySvg />
</div>
),
},
],
[onDuplicate, t]
);
return (
<>
<ButtonPopoverList
isVisible={isVisible}
popoverOptions={options}
popoverOrigin={{
anchorOrigin: {
vertical: 'bottom',
horizontal: 'left',
},
transformOrigin: {
vertical: 'top',
horizontal: 'left',
},
}}
>
<IconButton className={'h-6 w-6'}>
<MoreHoriz />
</IconButton>
</ButtonPopoverList>
<RenameDialog
defaultValue={page.name}
open={renameDialogOpen}
onClose={() => setRenameDialogOpen(false)}
onOk={async (newName: string) => {
await onRename(newName);
setRenameDialogOpen(false);
}}
/>
<DeleteDialog
layout={page.layout}
open={deleteDialogOpen}
onClose={() => setDeleteDialogOpen(false)}
onOk={async () => {
await onDelete();
setDeleteDialogOpen(false);
}}
/>
</>
);
}
export default MoreButton;

View File

@ -0,0 +1,146 @@
import { useCallback, useEffect, useMemo } from 'react';
import { PageController } from '$app/stores/effects/workspace/page/page_controller';
import { Page, pagesActions } from '$app_reducers/pages/slice';
import { useAppDispatch, useAppSelector } from '$app/stores/store';
import { ViewLayoutPB } from '@/services/backend';
import { useNavigate, useParams } from 'react-router-dom';
import { pageTypeMap } from '$app/constants';
import { useTranslation } from 'react-i18next';
export function useLoadChildPages(pageId: string) {
const dispatch = useAppDispatch();
const childPages = useAppSelector((state) => state.pages.childPages[pageId]);
const collapsed = useAppSelector((state) => !state.pages.expandedPages[pageId]);
const toggleCollapsed = useCallback(() => {
if (collapsed) {
dispatch(pagesActions.expandPage(pageId));
} else {
dispatch(pagesActions.collapsePage(pageId));
}
}, [dispatch, pageId, collapsed]);
const controller = useMemo(() => {
return new PageController(pageId);
}, [pageId]);
const onChildPagesChanged = useCallback(
(childPages: Page[]) => {
dispatch(
pagesActions.addChildPages({
id: pageId,
childPages,
})
);
},
[dispatch, pageId]
);
const onPageCollapsed = useCallback(async () => {
dispatch(pagesActions.removeChildPages(pageId));
await controller.unsubscribe();
}, [dispatch, pageId, controller]);
const onPageExpanded = useCallback(async () => {
const childPages = await controller.getChildPages();
dispatch(
pagesActions.addChildPages({
id: pageId,
childPages,
})
);
await controller.subscribe({
onChildPagesChanged,
});
}, [controller, dispatch, onChildPagesChanged, pageId]);
useEffect(() => {
if (collapsed) {
onPageCollapsed();
} else {
onPageExpanded();
}
}, [collapsed, onPageCollapsed, onPageExpanded]);
useEffect(() => {
return () => {
controller.dispose();
};
}, [controller]);
return {
toggleCollapsed,
collapsed,
childPages,
};
}
export function usePageActions(pageId: string) {
const page = useAppSelector((state) => state.pages.map[pageId]);
const { t } = useTranslation();
const dispatch = useAppDispatch();
const navigate = useNavigate();
const controller = useMemo(() => {
return new PageController(pageId);
}, [pageId]);
const onPageClick = useCallback(() => {
const pageType = pageTypeMap[page.layout];
navigate(`/page/${pageType}/${pageId}`);
}, [navigate, page.layout, pageId]);
const onAddPage = useCallback(
async (layout: ViewLayoutPB) => {
const newViewId = await controller.createPage({
layout,
name: t('document.title.placeholder'),
});
dispatch(pagesActions.expandPage(pageId));
const pageType = pageTypeMap[layout];
navigate(`/page/${pageType}/${newViewId}`);
},
[t, controller, dispatch, navigate, pageId]
);
const onDeletePage = useCallback(async () => {
await controller.deletePage();
}, [controller]);
const onDuplicatePage = useCallback(async () => {
await controller.duplicatePage();
}, [controller]);
const onRenamePage = useCallback(
async (name: string) => {
await controller.updatePage({
id: pageId,
name,
});
},
[controller, pageId]
);
useEffect(() => {
return () => {
controller.dispose();
};
}, [controller]);
return {
onAddPage,
onPageClick,
onRenamePage,
onDeletePage,
onDuplicatePage,
};
}
export function useSelectedPage(pageId: string) {
const id = useParams().id;
return id === pageId;
}

View File

@ -0,0 +1,75 @@
import React, { useState } from 'react';
import { ArrowRightSvg } from '$app/components/_shared/svg/ArrowRightSvg';
import MenuItem from '@mui/material/MenuItem';
import { useAppSelector } from '$app/stores/store';
import AddButton from './AddButton';
import MoreButton from './MoreButton';
import { ViewLayoutPB } from '@/services/backend';
import { useSelectedPage } from '$app/components/layout/NestedPage/NestedPage.hooks';
function NestedPageTitle({
pageId,
collapsed,
toggleCollapsed,
onAddPage,
onClick,
onDelete,
onDuplicate,
onRename,
}: {
pageId: string;
collapsed: boolean;
toggleCollapsed: () => void;
onAddPage: (layout: ViewLayoutPB) => void;
onClick: () => void;
onDelete: () => Promise<void>;
onDuplicate: () => Promise<void>;
onRename: (newName: string) => Promise<void>;
}) {
const page = useAppSelector((state) => {
return state.pages.map[pageId];
});
const [isHovering, setIsHovering] = useState(false);
const isSelected = useSelectedPage(pageId);
return (
<MenuItem
selected={isSelected}
onClick={onClick}
onMouseEnter={() => setIsHovering(true)}
onMouseLeave={() => setIsHovering(false)}
>
<div className={'flex h-6 w-[100%] items-center justify-between'}>
<div className={'flex flex-1 items-center justify-start overflow-hidden'}>
<button
onClick={(e) => {
e.stopPropagation();
toggleCollapsed();
}}
style={{
transform: collapsed ? 'rotate(0deg)' : 'rotate(-90deg)',
}}
className={'flex h-[100%] w-8 items-center justify-center p-2'}
>
<div className={'h-5 w-5'}>
<ArrowRightSvg />
</div>
</button>
<div className={'flex-1 overflow-hidden text-ellipsis whitespace-nowrap'}>{page.name}</div>
</div>
<div onClick={(e) => e.stopPropagation()} className={'min:w-14 flex items-center justify-end'}>
<AddButton isVisible={isHovering} onAddPage={onAddPage} />
<MoreButton
page={page}
isVisible={isHovering}
onDelete={onDelete}
onDuplicate={onDuplicate}
onRename={onRename}
/>
</div>
</div>
</MenuItem>
);
}
export default NestedPageTitle;

View File

@ -15,16 +15,18 @@ function RenameDialog({
defaultValue: string;
open: boolean;
onClose: () => void;
onOk: (val: string) => void;
onOk: (val: string) => Promise<void>;
}) {
const { t } = useTranslation();
const [value, setValue] = useState(defaultValue);
const [error, setError] = useState(false);
return (
<Dialog keepMounted={false} onMouseDown={(e) => e.stopPropagation()} open={open} onClose={onClose}>
<DialogTitle>{t('menuAppHeader.renameDialog')}</DialogTitle>
<DialogContent className={'flex w-[540px]'}>
<TextField
error={error}
autoFocus
value={value}
onChange={(e) => {
@ -38,8 +40,12 @@ function RenameDialog({
<DialogActions>
<Button onClick={onClose}>{t('button.Cancel')}</Button>
<Button
onClick={() => {
onOk(value);
onClick={async () => {
try {
await onOk(value);
} catch (e) {
setError(true);
}
}}
>
{t('button.OK')}

View File

@ -0,0 +1,39 @@
import React from 'react';
import Collapse from '@mui/material/Collapse';
import { TransitionGroup } from 'react-transition-group';
import NestedPageTitle from '$app/components/layout/NestedPage/NestedPageTitle';
import { useLoadChildPages, usePageActions } from '$app/components/layout/NestedPage/NestedPage.hooks';
function NestedPage({ pageId }: { pageId: string }) {
const { toggleCollapsed, collapsed, childPages } = useLoadChildPages(pageId);
const { onAddPage, onPageClick, onDeletePage, onDuplicatePage, onRenamePage } = usePageActions(pageId);
return (
<div>
<NestedPageTitle
onClick={() => {
onPageClick();
}}
onAddPage={onAddPage}
onDuplicate={onDuplicatePage}
onDelete={onDeletePage}
onRename={onRenamePage}
collapsed={collapsed}
toggleCollapsed={toggleCollapsed}
pageId={pageId}
/>
<div className={'pl-4 pt-[2px]'}>
<TransitionGroup>
{childPages?.map((pageId) => (
<Collapse key={pageId}>
<NestedPage key={pageId} pageId={pageId} />
</Collapse>
))}
</TransitionGroup>
</div>
</div>
);
}
export default NestedPage;

View File

@ -1,21 +0,0 @@
import React, { ReactNode } from 'react';
import { NavigationPanel } from './NavigationPanel/NavigationPanel';
import { MainPanel } from './MainPanel';
import { useNavigationPanelHooks } from './NavigationPanel/NavigationPanel.hooks';
import { useWorkspace } from './Workspace.hooks';
export const Screen = ({ children }: { children: ReactNode }) => {
useWorkspace();
const { width, onHideMenuClick, onShowMenuClick, menuHidden } = useNavigationPanelHooks();
return (
<div className='flex h-screen w-screen bg-bg-body text-text-title'>
<NavigationPanel onHideMenuClick={onHideMenuClick} width={width} menuHidden={menuHidden}></NavigationPanel>
<MainPanel left={width} menuHidden={menuHidden} onShowMenuClick={onShowMenuClick}>
{children}
</MainPanel>
</div>
);
};

View File

@ -0,0 +1,12 @@
import { useLocation, useParams } from 'react-router-dom';
export function useShareConfig() {
const params = useParams();
const id = params.id;
const showShareButton = !!id;
return {
showShareButton,
};
}

View File

@ -0,0 +1,14 @@
import React from 'react';
import { useTranslation } from 'react-i18next';
import Button from '@mui/material/Button';
import { useShareConfig } from '$app/components/layout/Share/Share.hooks';
function ShareButton() {
const { showShareButton } = useShareConfig();
const { t } = useTranslation();
if (!showShareButton) return null;
return <Button variant={'contained'}>{t('shareAction.buttonText')}</Button>;
}
export default ShareButton;

View File

@ -0,0 +1,54 @@
import React, { useCallback, useRef } from 'react';
import { useAppDispatch, useAppSelector } from '$app/stores/store';
import { sidebarActions } from '$app_reducers/sidebar/slice';
const minSidebarWidth = 200;
function Resizer() {
const dispatch = useAppDispatch();
const width = useAppSelector((state) => state.sidebar.width);
const startX = useRef(0);
const onResize = useCallback(
(e: MouseEvent) => {
const diff = e.clientX - startX.current;
const newWidth = width + diff;
if (newWidth < minSidebarWidth) {
return;
}
dispatch(sidebarActions.changeWidth(newWidth));
},
[dispatch, width]
);
const onResizeEnd = useCallback(() => {
dispatch(sidebarActions.stopResizing());
document.removeEventListener('mousemove', onResize);
document.removeEventListener('mouseup', onResizeEnd);
}, [onResize, dispatch]);
const onResizeStart = useCallback(
(e: React.MouseEvent) => {
startX.current = e.clientX;
dispatch(sidebarActions.startResizing());
document.addEventListener('mousemove', onResize);
document.addEventListener('mouseup', onResizeEnd);
},
[onResize, onResizeEnd, dispatch]
);
return (
<div
onMouseDown={onResizeStart}
style={{
left: `${width - 8}px`,
}}
className={'fixed top-0 z-10 h-screen cursor-col-resize'}
>
<div className={'h-full w-2 select-none bg-transparent'}></div>
</div>
);
}
export default React.memo(Resizer);

View File

@ -1,22 +1,22 @@
import React, { useState } from 'react';
import { useAppSelector } from '$app/stores/store';
import UserSetting from '$app/components/layout/UserSetting';
import { useState } from 'react';
import { Avatar } from '@mui/material';
import PersonOutline from '@mui/icons-material/PersonOutline';
import { Avatar, IconButton } from '@mui/material';
import ArrowDropDown from '@mui/icons-material/ArrowDropDown';
import UserSetting from '../UserSetting';
export const WorkspaceUser = () => {
function UserInfo() {
const currentUser = useAppSelector((state) => state.currentUser);
const [showUserSetting, setShowUserSetting] = useState(false);
return (
<div className={'flex items-center justify-between px-2 py-2'}>
<>
<div
onClick={(e) => {
e.stopPropagation();
setShowUserSetting(!showUserSetting);
}}
className={'flex cursor-pointer items-center pl-4 text-text-title'}
className={'flex cursor-pointer items-center px-6 text-text-title'}
>
<Avatar
sx={{
@ -28,13 +28,15 @@ export const WorkspaceUser = () => {
>
<PersonOutline />
</Avatar>
<span className={'ml-2'}>{currentUser.displayName}</span>
<button className={'ml-1 rounded hover:bg-fill-list-hover'}>
<span className={'ml-2 text-sm'}>{currentUser.displayName}</span>
<button className={'ml-2 rounded hover:bg-fill-list-hover'}>
<ArrowDropDown />
</button>
</div>
<UserSetting open={showUserSetting} onClose={() => setShowUserSetting(false)} />
</div>
</>
);
};
}
export default UserInfo;

View File

@ -0,0 +1,47 @@
import React from 'react';
import { useAppSelector } from '$app/stores/store';
import { ThemeMode } from '$app/interfaces';
import { AppflowyLogoDark } from '$app/components/_shared/svg/AppflowyLogoDark';
import { AppflowyLogoLight } from '$app/components/_shared/svg/AppflowyLogoLight';
import CollapseMenuButton from '$app/components/layout/CollapseMenuButton';
import Resizer from '$app/components/layout/SideBar/Resizer';
import UserInfo from '$app/components/layout/SideBar/UserInfo';
import WorkspaceManager from '$app/components/layout/WorkspaceManager';
function SideBar() {
const { isCollapsed, width, isResizing } = useAppSelector((state) => state.sidebar);
const isDark = useAppSelector((state) => state.currentUser?.userSetting?.themeMode === ThemeMode.Dark);
return (
<>
<div
style={{
width: isCollapsed ? 0 : width,
transition: isResizing ? 'none' : 'width 150ms cubic-bezier(0.4, 0, 0.2, 1)',
}}
className={'relative h-screen select-none overflow-hidden'}
>
<div className={'flex h-[100vh] flex-col overflow-hidden border-r border-line-divider bg-bg-base'}>
<div className={'flex h-[64px] justify-between px-6 py-5'}>
{isDark ? <AppflowyLogoDark /> : <AppflowyLogoLight />}
<CollapseMenuButton />
</div>
<div className={'flex h-[36px] items-center'}>
<UserInfo />
</div>
<div
style={{
height: 'calc(100% - 64px - 36px)',
}}
>
<WorkspaceManager />
</div>
</div>
</div>
<Resizer />
</>
);
}
export default SideBar;

View File

@ -0,0 +1,26 @@
import React from 'react';
import { useTranslation } from 'react-i18next';
import { ButtonGroup, Divider } from '@mui/material';
import Button from '@mui/material/Button';
function FontSizeConfig() {
const { t } = useTranslation();
return (
<>
<div className={'flex flex-col justify-center p-4'}>
<div className={'py-2 text-sm text-text-caption'}>{t('moreAction.fontSize')}</div>
<div className={'flex items-center justify-around pt-2'}>
<ButtonGroup variant='text' color={'inherit'}>
<Button>{t('moreAction.small')}</Button>
<Button color={'primary'}>{t('moreAction.medium')}</Button>
<Button>{t('moreAction.large')}</Button>
</ButtonGroup>
</div>
</div>
<Divider />
</>
);
}
export default FontSizeConfig;

View File

@ -0,0 +1,33 @@
import React, { useCallback, useMemo } from 'react';
import { useTranslation } from 'react-i18next';
import { Drawer, IconButton } from '@mui/material';
import { Details2Svg } from '$app/components/_shared/svg/Details2Svg';
import { LogoutOutlined } from '@mui/icons-material';
import Tooltip from '@mui/material/Tooltip';
import MoreOptions from '$app/components/layout/TopBar/MoreOptions';
import { useMoreOptionsConfig } from '$app/components/layout/TopBar/MoreOptions.hooks';
function MoreButton() {
const { t } = useTranslation();
const [open, setOpen] = React.useState(false);
const toggleDrawer = useCallback((open: boolean) => {
setOpen(open);
}, []);
const { showMoreButton } = useMoreOptionsConfig();
if (!showMoreButton) return null;
return (
<>
<Tooltip placement={'bottom-end'} title={t('moreAction.moreOptions')}>
<IconButton onClick={(e) => toggleDrawer(true)} className={'h-8 w-8 text-icon-primary'}>
<Details2Svg />
</IconButton>
</Tooltip>
<Drawer anchor={'right'} open={open} onClose={() => toggleDrawer(false)}>
<MoreOptions />
</Drawer>
</>
);
}
export default MoreButton;

View File

@ -0,0 +1,29 @@
import { useLocation } from 'react-router-dom';
import { useMemo } from 'react';
export function useMoreOptionsConfig() {
const location = useLocation();
const { type, pageType, id } = useMemo(() => {
const [_, type, pageType, id] = location.pathname.split('/');
return {
type,
pageType,
id,
};
}, [location.pathname]);
const showMoreButton = useMemo(() => {
return type === 'page';
}, [type]);
const showStyleOptions = useMemo(() => {
return type === 'page' && pageType === 'document';
}, [pageType, type]);
return {
showMoreButton,
showStyleOptions,
};
}

View File

@ -0,0 +1,15 @@
import React from 'react';
import { useTranslation } from 'react-i18next';
import FontSizeConfig from '$app/components/layout/TopBar/FontSizeConfig';
import { Divider } from '@mui/material';
import { useLocation } from 'react-router-dom';
import { useMoreOptionsConfig } from '$app/components/layout/TopBar/MoreOptions.hooks';
function MoreOptions() {
const { t } = useTranslation();
const { showStyleOptions } = useMoreOptionsConfig();
return <div className={'flex w-[220px] flex-col'}>{showStyleOptions && <FontSizeConfig />}</div>;
}
export default MoreOptions;

View File

@ -0,0 +1,34 @@
import React from 'react';
import CollapseMenuButton from '$app/components/layout/CollapseMenuButton';
import { useAppSelector } from '$app/stores/store';
import Breadcrumb from '$app/components/layout/Breadcrumb';
import ShareButton from '$app/components/layout/Share';
import MoreButton from '$app/components/layout/TopBar/MoreButton';
function TopBar() {
const sidebarIsCollapsed = useAppSelector((state) => state.sidebar.isCollapsed);
return (
<div className={'flex h-[64px] select-none border-b border-line-divider p-4'}>
{sidebarIsCollapsed && (
<div className={'mr-2 py-1'}>
<CollapseMenuButton />
</div>
)}
<div className={'flex flex-1 items-center justify-between'}>
<div className={'flex-1'}>
<Breadcrumb />
</div>
<div className={'flex items-center justify-end'}>
<div className={'mr-2'}>
<ShareButton />
</div>
<MoreButton />
</div>
</div>
</div>
);
}
export default TopBar;

View File

@ -1,7 +1,7 @@
import React, { useMemo } from 'react';
import { MenuItem } from './Menu';
import AppearanceSetting from '$app/components/layout/UserSetting/AppearanceSetting';
import LanguageSetting from '$app/components/layout/UserSetting/LanguageSetting';
import AppearanceSetting from './AppearanceSetting';
import LanguageSetting from './LanguageSetting';
import { UserSetting } from '$app/interfaces';

View File

@ -3,8 +3,8 @@ import Dialog from '@mui/material/Dialog';
import DialogContent from '@mui/material/DialogContent';
import DialogTitle from '@mui/material/DialogTitle';
import Slide, { SlideProps } from '@mui/material/Slide';
import UserSettingMenu, { MenuItem } from '$app/components/layout/UserSetting/Menu';
import UserSettingPanel from '$app/components/layout/UserSetting/SettingPanel';
import UserSettingMenu, { MenuItem } from './Menu';
import UserSettingPanel from './SettingPanel';
import { Theme, UserSetting } from '$app/interfaces';
import { useAppDispatch, useAppSelector } from '$app/stores/store';
import { currentUserActions } from '$app_reducers/current-user/slice';

View File

@ -1,69 +0,0 @@
import { foldersActions } from '$app_reducers/folders/slice';
import { useAppDispatch, useAppSelector } from '$app/stores/store';
import { IPage, pagesActions } from '$app_reducers/pages/slice';
import { workspaceActions } from '$app_reducers/workspace/slice';
import { UserBackendService } from '$app/stores/effects/user/user_bd_svc';
import { useEffect, useState } from 'react';
import { WorkspaceBackendService } from '$app/stores/effects/folder/workspace/workspace_bd_svc';
export const useWorkspace = () => {
const currentUser = useAppSelector((state) => state.currentUser);
const appDispatch = useAppDispatch();
const [userService, setUserService] = useState<UserBackendService | null>(null);
const [workspaceService, setWorkspaceService] = useState<WorkspaceBackendService | null>(null);
const [isReady, setIsReady] = useState(false);
useEffect(() => {
if (currentUser.id) {
setUserService(new UserBackendService(currentUser.id));
}
}, [currentUser]);
useEffect(() => {
if (!userService) return;
void (async () => {
try {
const workspaceSettingPB = await userService.getCurrentWorkspace();
const workspace = workspaceSettingPB.workspace;
appDispatch(workspaceActions.updateWorkspace({ id: workspace.id, name: workspace.name }));
appDispatch(foldersActions.clearFolders());
appDispatch(pagesActions.clearPages());
setWorkspaceService(new WorkspaceBackendService(workspace.id));
} catch (e1) {
// create workspace for first start
const workspace = await userService.createWorkspace({ name: 'New Workspace', desc: '' });
appDispatch(workspaceActions.updateWorkspace({ id: workspace.id, name: workspace.name }));
appDispatch(foldersActions.clearFolders());
appDispatch(pagesActions.clearPages());
}
})();
}, [userService]);
useEffect(() => {
if (!workspaceService) return;
void (async () => {
const rootViews = await workspaceService.getAllViews();
if (rootViews.ok) {
appDispatch(
pagesActions.addInsidePages({
currentPageId: workspaceService.workspaceId,
insidePages: rootViews.val.map<IPage>((v) => ({
id: v.id,
title: v.name,
pageType: v.layout,
showPagesInside: false,
parentPageId: workspaceService.workspaceId,
})),
})
);
setIsReady(true);
}
})();
}, [workspaceService]);
return {};
};

View File

@ -0,0 +1,64 @@
import React, { useMemo } from 'react';
import { WorkspaceItem } from '$app_reducers/workspace/slice';
import { IconButton } from '@mui/material';
import MoreIcon from '@mui/icons-material/MoreHoriz';
import SettingsIcon from '@mui/icons-material/Settings';
import { useTranslation } from 'react-i18next';
import { DeleteOutline } from '@mui/icons-material';
import ButtonPopoverList from '$app/components/_shared/ButtonPopoverList';
function MoreButton({
workspace,
isHovered,
onDelete,
}: {
isHovered: boolean;
workspace: WorkspaceItem;
onDelete: (id: string) => void;
}) {
const { t } = useTranslation();
const options = useMemo(() => {
return [
{
key: 'settings',
icon: <SettingsIcon />,
label: t('settings.title'),
onClick: () => {
//
},
},
{
key: 'delete',
icon: <DeleteOutline />,
label: t('button.delete'),
onClick: () => onDelete(workspace.id),
},
];
}, [onDelete, t, workspace.id]);
return (
<>
<ButtonPopoverList
isVisible={isHovered}
popoverOrigin={{
anchorOrigin: {
vertical: 'bottom',
horizontal: 'left',
},
transformOrigin: {
vertical: 'top',
horizontal: 'left',
},
}}
popoverOptions={options}
>
<IconButton>
<MoreIcon />
</IconButton>
</ButtonPopoverList>
</>
);
}
export default MoreButton;

View File

@ -0,0 +1,20 @@
import React from 'react';
import { useAppSelector } from '$app/stores/store';
import NestedPage from '$app/components/layout/NestedPage';
import { List } from '@mui/material';
function WorkspaceNestedPages({ workspaceId }: { workspaceId: string }) {
const pageIds = useAppSelector((state) => {
return state.pages.childPages[workspaceId];
});
return (
<List className={'h-[100%] overflow-y-auto overflow-x-hidden'}>
{pageIds?.map((pageId) => (
<NestedPage key={pageId} pageId={pageId} />
))}
</List>
);
}
export default WorkspaceNestedPages;

View File

@ -0,0 +1,44 @@
import React, { useEffect, useMemo } from 'react';
import AddSvg from '$app/components/_shared/svg/AddSvg';
import { useTranslation } from 'react-i18next';
import { WorkspaceController } from '$app/stores/effects/workspace/workspace_controller';
import { ViewLayoutPB } from '@/services/backend';
import { useNavigate } from 'react-router-dom';
function NewPageButton({ workspaceId }: { workspaceId: string }) {
const { t } = useTranslation();
const controller = useMemo(() => new WorkspaceController(workspaceId), [workspaceId]);
const navigate = useNavigate();
useEffect(() => {
return () => {
controller.dispose();
};
}, [controller]);
return (
<div className={'flex h-[60px] w-full items-center border-t border-line-divider px-6 py-5'}>
<button
onClick={async () => {
const { id } = await controller.createView({
name: t('document.title.placeholder'),
layout: ViewLayoutPB.Document,
parent_view_id: workspaceId,
});
navigate(`/page/document/${id}`);
}}
className={'flex items-center hover:text-fill-default'}
>
<div className={'mr-2 rounded-full bg-fill-default'}>
<div className={'h-[24px] w-[24px] text-content-on-fill'}>
<AddSvg />
</div>
</div>
{t('newPageText')}
</button>
</div>
);
}
export default NewPageButton;

View File

@ -0,0 +1,32 @@
import React from 'react';
import { TrashSvg } from '$app/components/_shared/svg/TrashSvg';
import MenuItem from '@mui/material/MenuItem';
import { useTranslation } from 'react-i18next';
import { useLocation, useNavigate } from 'react-router-dom';
function TrashButton() {
const { t } = useTranslation();
const navigate = useNavigate();
const currentPathType = useLocation().pathname.split('/')[1];
const navigateToTrash = () => {
navigate('/trash');
};
return (
<MenuItem
selected={currentPathType === 'trash'}
onClick={navigateToTrash}
style={{
borderRadius: '8px',
}}
className={'flex w-[100%] items-center'}
>
<div className='h-6 w-6'>
<TrashSvg />
</div>
<span className={'ml-2'}>{t('trash.text')}</span>
</MenuItem>
);
}
export default TrashButton;

View File

@ -0,0 +1,116 @@
import { useCallback, useEffect, useMemo } from 'react';
import { WorkspaceController } from '$app/stores/effects/workspace/workspace_controller';
import { useAppDispatch, useAppSelector } from '$app/stores/store';
import { workspaceActions, WorkspaceItem } from '$app_reducers/workspace/slice';
import { WorkspaceManagerController } from '$app/stores/effects/workspace/workspace_manager_controller';
import { Page, pagesActions } from '$app_reducers/pages/slice';
export function useLoadWorkspaces() {
const dispatch = useAppDispatch();
const { workspaces, currentWorkspace } = useAppSelector((state) => state.workspace);
const onWorkspacesChanged = useCallback(
(data: { workspaces: WorkspaceItem[]; currentWorkspace: WorkspaceItem }) => {
dispatch(workspaceActions.onWorkspacesChanged(data));
},
[dispatch]
);
const controller = useMemo(() => {
return new WorkspaceManagerController();
}, []);
useEffect(() => {
void (async () => {
const workspaces = await controller.getWorkspaces();
const currentWorkspace = await controller.getCurrentWorkspace();
await controller.subscribe({
onWorkspacesChanged,
});
dispatch(
workspaceActions.initWorkspaces({
workspaces,
currentWorkspace,
})
);
})();
return () => {
controller.dispose();
};
}, [controller, dispatch, onWorkspacesChanged]);
return {
workspaces,
currentWorkspace,
};
}
export function useLoadWorkspace(workspace: WorkspaceItem) {
const { id } = workspace;
const dispatch = useAppDispatch();
const controller = useMemo(() => {
return new WorkspaceController(id);
}, [id]);
const onWorkspaceChanged = useCallback(
(data: WorkspaceItem) => {
dispatch(workspaceActions.onWorkspaceChanged(data));
},
[dispatch]
);
const onWorkspaceDeleted = useCallback(() => {
dispatch(workspaceActions.onWorkspaceDeleted(id));
}, [dispatch, id]);
const openWorkspace = useCallback(async () => {
await controller.open();
}, [controller]);
const deleteWorkspace = useCallback(async () => {
await controller.delete();
}, [controller]);
const onChildPagesChanged = useCallback(
(childPages: Page[]) => {
dispatch(
pagesActions.addChildPages({
id,
childPages,
})
);
},
[dispatch, id]
);
useEffect(() => {
void (async () => {
const childPages = await controller.getChildPages();
dispatch(
pagesActions.addChildPages({
id,
childPages,
})
);
await controller.subscribe({
onWorkspaceChanged,
onWorkspaceDeleted,
onChildPagesChanged,
});
})();
return () => {
controller.dispose();
};
}, [controller, dispatch, id, onChildPagesChanged, onWorkspaceChanged, onWorkspaceDeleted]);
return {
openWorkspace,
controller,
deleteWorkspace,
};
}

View File

@ -0,0 +1,26 @@
import React from 'react';
import { WorkspaceItem } from '$app_reducers/workspace/slice';
import NestedViews from '$app/components/layout/WorkspaceManager/NestedPages';
import { useLoadWorkspace } from '$app/components/layout/WorkspaceManager/Workspace.hooks';
import WorkspaceTitle from '$app/components/layout/WorkspaceManager/WorkspaceTitle';
function Workspace({ workspace, opened }: { workspace: WorkspaceItem; opened: boolean }) {
const { openWorkspace, deleteWorkspace } = useLoadWorkspace(workspace);
return (
<div className={'flex flex-col'}>
<div
style={{
height: opened ? 'auto' : 0,
overflow: 'hidden',
transition: 'height 0.2s ease-in-out',
}}
>
{/*<WorkspaceTitle workspace={workspace} openWorkspace={openWorkspace} onDelete={onDelete} />*/}
<NestedViews workspaceId={workspace.id} />
</div>
</div>
);
}
export default Workspace;

View File

@ -0,0 +1,38 @@
import React, { useState } from 'react';
import MoreButton from '$app/components/layout/WorkspaceManager/MoreButton';
import MenuItem from '@mui/material/MenuItem';
import { WorkspaceItem } from '$app_reducers/workspace/slice';
function WorkspaceTitle({
workspace,
openWorkspace,
onDelete,
}: {
openWorkspace: () => void;
onDelete: (id: string) => void;
workspace: WorkspaceItem;
}) {
const [isHovered, setIsHovered] = useState(false);
return (
<MenuItem
onClick={() => openWorkspace()}
onMouseEnter={() => {
setIsHovered(true);
}}
onMouseLeave={() => {
setIsHovered(false);
}}
className={'hover:bg-fill-list-active'}
>
<div className={'flex w-[100%] items-center justify-between'}>
<div className={'flex-1 font-bold text-text-caption'}>{workspace.name}</div>
<div className='flex h-[23px] w-auto items-center justify-end'>
<MoreButton workspace={workspace} isHovered={isHovered} onDelete={onDelete} />
</div>
</div>
</MenuItem>
);
}
export default WorkspaceTitle;

View File

@ -0,0 +1,26 @@
import React from 'react';
import NewPageButton from '$app/components/layout/WorkspaceManager/NewPageButton';
import { useLoadWorkspaces } from '$app/components/layout/WorkspaceManager/Workspace.hooks';
import Workspace from './Workspace';
import { List } from '@mui/material';
import TrashButton from '$app/components/layout/WorkspaceManager/TrashButton';
function WorkspaceManager() {
const { workspaces, currentWorkspace } = useLoadWorkspaces();
return (
<div className={'flex h-[100%] flex-col justify-between'}>
<List className={'flex-1 overflow-y-auto overflow-x-hidden'}>
{workspaces.map((workspace) => (
<Workspace opened={currentWorkspace?.id === workspace.id} key={workspace.id} workspace={workspace} />
))}
</List>
<div className={'flex h-[48px] w-[100%] items-center px-2'}>
<TrashButton />
</div>
{currentWorkspace && <NewPageButton workspaceId={currentWorkspace.id} />}
</div>
);
}
export default WorkspaceManager;

View File

@ -27,20 +27,16 @@ import { TypeOptionController } from '../../stores/effects/database/field/type_o
import { makeSingleSelectTypeOptionContext } from '../../stores/effects/database/field/type_option/type_option_context';
import { SelectOptionBackendService } from '../../stores/effects/database/cell/select_option_bd_svc';
import { Log } from '$app/utils/log';
import { ViewBackendService } from '$app/stores/effects/folder/view/view_bd_svc';
import { WorkspaceBackendService } from '$app/stores/effects/folder/workspace/workspace_bd_svc';
import { WorkspaceController } from '../../stores/effects/workspace/workspace_controller';
// Create a database view for specific layout type
// Create a database page for specific layout type
// Do not use it production code. Just for testing
export async function createTestDatabaseView(layout: ViewLayoutPB): Promise<ViewPB> {
const workspaceSetting: WorkspaceSettingPB = await FolderEventGetCurrentWorkspace().then((result) => result.unwrap());
const wsSvc = new WorkspaceBackendService(workspaceSetting.workspace.id);
const viewRes = await wsSvc.createView({ name: 'New Grid', layoutType: layout });
if (viewRes.ok) {
return viewRes.val;
} else {
throw Error(viewRes.val.msg);
}
const wsSvc = new WorkspaceController(workspaceSetting.workspace.id);
const viewRes = await wsSvc.createView({ name: 'New Grid', layout });
return viewRes;
}
export async function openTestDatabase(viewId: string): Promise<DatabaseController> {
@ -56,9 +52,11 @@ export async function assertTextCell(
const cellController = await makeTextCellController(fieldId, rowInfo, databaseController).then((result) =>
result.unwrap()
);
cellController.subscribeChanged({
onCellChanged: (value) => {
const cellContent = value.unwrap();
if (cellContent !== expectedContent) {
throw Error('Text cell content is not match');
}
@ -76,6 +74,7 @@ export async function editTextCell(
const cellController = await makeTextCellController(fieldId, rowInfo, databaseController).then((result) =>
result.unwrap()
);
await cellController.saveCellData(content);
}
@ -87,6 +86,7 @@ export async function makeTextCellController(
const builder = await makeCellControllerBuilder(fieldId, rowInfo, FieldType.RichText, databaseController).then(
(result) => result.unwrap()
);
return Some(builder.build() as TextCellController);
}
@ -98,6 +98,7 @@ export async function makeNumberCellController(
const builder = await makeCellControllerBuilder(fieldId, rowInfo, FieldType.Number, databaseController).then(
(result) => result.unwrap()
);
return Some(builder.build() as NumberCellController);
}
@ -109,6 +110,7 @@ export async function makeSingleSelectCellController(
const builder = await makeCellControllerBuilder(fieldId, rowInfo, FieldType.SingleSelect, databaseController).then(
(result) => result.unwrap()
);
return Some(builder.build() as SelectOptionCellController);
}
@ -120,6 +122,7 @@ export async function makeMultiSelectCellController(
const builder = await makeCellControllerBuilder(fieldId, rowInfo, FieldType.MultiSelect, databaseController).then(
(result) => result.unwrap()
);
return Some(builder.build() as SelectOptionCellController);
}
@ -131,6 +134,7 @@ export async function makeDateCellController(
const builder = await makeCellControllerBuilder(fieldId, rowInfo, FieldType.DateTime, databaseController).then(
(result) => result.unwrap()
);
return Some(builder.build() as DateCellController);
}
@ -142,6 +146,7 @@ export async function makeCheckboxCellController(
const builder = await makeCellControllerBuilder(fieldId, rowInfo, FieldType.Checkbox, databaseController).then(
(result) => result.unwrap()
);
return Some(builder.build() as CheckboxCellController);
}
@ -153,6 +158,7 @@ export async function makeURLCellController(
const builder = await makeCellControllerBuilder(fieldId, rowInfo, FieldType.DateTime, databaseController).then(
(result) => result.unwrap()
);
return Some(builder.build() as URLCellController);
}
@ -167,8 +173,10 @@ export async function makeCellControllerBuilder(
const fieldController = databaseController.fieldController;
const rowController = new RowController(rowInfo, fieldController, rowCache);
const cellByFieldId = await rowController.loadCells();
for (const cellIdentifier of cellByFieldId.values()) {
const builder = new CellControllerBuilder(cellIdentifier, cellCache, fieldController);
if (cellIdentifier.fieldId === fieldId) {
return Some(builder);
}
@ -179,6 +187,7 @@ export async function makeCellControllerBuilder(
export function findFirstFieldInfoWithFieldType(rowInfo: RowInfo, fieldType: FieldType) {
const fieldInfo = rowInfo.fieldInfos.find((element) => element.field.field_type === fieldType);
if (fieldInfo === undefined) {
return None;
} else {
@ -189,6 +198,7 @@ export function findFirstFieldInfoWithFieldType(rowInfo: RowInfo, fieldType: Fie
export async function assertFieldName(viewId: string, fieldId: string, fieldType: FieldType, expected: string) {
const svc = new TypeOptionBackendService(viewId);
const typeOptionPB = await svc.getTypeOption(fieldId, fieldType).then((result) => result.unwrap());
if (typeOptionPB.field.name !== expected) {
throw Error('Expect field name:' + expected + 'but receive:' + typeOptionPB.field.name);
}
@ -197,6 +207,7 @@ export async function assertFieldName(viewId: string, fieldId: string, fieldType
export async function assertNumberOfFields(viewId: string, expected: number) {
const svc = new DatabaseBackendService(viewId);
const databasePB = await svc.openDatabase().then((result) => result.unwrap());
if (databasePB.fields.length !== expected) {
throw Error('Expect number of fields:' + expected + 'but receive:' + databasePB.fields.length);
}
@ -205,6 +216,7 @@ export async function assertNumberOfFields(viewId: string, expected: number) {
export async function assertNumberOfRows(viewId: string, expected: number) {
const svc = new DatabaseBackendService(viewId);
const databasePB = await svc.openDatabase().then((result) => result.unwrap());
if (databasePB.rows.length !== expected) {
throw Error('Expect number of rows:' + expected + 'but receive:' + databasePB.rows.length);
}
@ -212,9 +224,11 @@ export async function assertNumberOfRows(viewId: string, expected: number) {
export async function assertNumberOfRowsInGroup(viewId: string, groupId: string, expected: number) {
const svc = new DatabaseBackendService(viewId);
await svc.openDatabase();
const group = await svc.getGroup(groupId).then((result) => result.unwrap());
if (group.rows.length !== expected) {
throw Error('Expect number of rows in group:' + expected + 'but receive:' + group.rows.length);
}
@ -229,10 +243,13 @@ export async function createSingleSelectOptions(viewId: string, fieldInfo: Field
.then((result) => result.unwrap());
const backendSvc = new SelectOptionBackendService(viewId, fieldInfo.field.id);
for (const optionName of optionNames) {
const option = await backendSvc.createOption({ name: optionName }).then((result) => result.unwrap());
singleSelectTypeOptionPB.options.splice(0, 0, option);
}
await singleSelectTypeOptionContext.setTypeOption(singleSelectTypeOptionPB);
return singleSelectTypeOptionContext;
}

View File

@ -1,15 +1,11 @@
import { ViewLayoutPB, WorkspaceSettingPB } from '@/services/backend';
import { FolderEventGetCurrentWorkspace } from '@/services/backend/events/flowy-folder2';
import {WorkspaceBackendService} from "$app/stores/effects/folder/workspace/workspace_bd_svc";
import { WorkspaceController } from '../../stores/effects/workspace/workspace_controller';
export async function createTestDocument() {
const workspaceSetting: WorkspaceSettingPB = await FolderEventGetCurrentWorkspace().then((result) => result.unwrap());
const appService = new WorkspaceBackendService(workspaceSetting.workspace.id);
const result = await appService.createView({ name: 'New Document', layoutType: ViewLayoutPB.Document });
if (result.ok) {
return result.val;
}
else {
throw Error(result.val.msg);
}
const appService = new WorkspaceController(workspaceSetting.workspace.id);
const result = await appService.createView({ name: 'New Document', layout: ViewLayoutPB.Document });
return result;
}

View File

@ -1,7 +1,7 @@
import React from 'react';
import { UserBackendService } from '$app/stores/effects/user/user_bd_svc';
import { useAppSelector } from '$app/stores/store';
import { WorkspaceBackendService } from '$app/stores/effects/folder/workspace/workspace_bd_svc';
import { WorkspaceController } from '../../stores/effects/workspace/workspace_controller';
import { ViewLayoutPB, ViewPB } from '@/services/backend';
const testCreateFolder = async (userId?: number) => {
@ -9,36 +9,41 @@ const testCreateFolder = async (userId?: number) => {
console.log('user is not logged in');
return;
}
console.log('test create views');
const userBackendService: UserBackendService = new UserBackendService(userId);
const workspaces = await userBackendService.getWorkspaces();
if (workspaces.ok) {
console.log('workspaces: ', workspaces.val.toObject());
}
const currentWorkspace = await userBackendService.getCurrentWorkspace();
const workspaceService = new WorkspaceBackendService(currentWorkspace.workspace.id);
const workspaceService = new WorkspaceController(currentWorkspace.workspace.id);
const rootViews: ViewPB[] = [];
for (let i = 1; i <= 3; i++) {
const result = await workspaceService.createView({
name: `test board ${i}`,
desc: 'test description',
layoutType: ViewLayoutPB.Board,
layout: ViewLayoutPB.Board,
});
if (result.ok) {
rootViews.push(result.val);
}
rootViews.push(result);
}
for (let i = 1; i <= 3; i++) {
const result = await workspaceService.createView({
name: `test board 1 ${i}`,
desc: 'test description',
layoutType: ViewLayoutPB.Board,
parentViewId: rootViews[0].id,
layout: ViewLayoutPB.Board,
parent_view_id: rootViews[0].id,
});
}
const allApps = await workspaceService.getAllViews();
const allApps = await workspaceService.getChildPages();
console.log(allApps);
};

View File

@ -0,0 +1,44 @@
import React from 'react';
import DialogContent from '@mui/material/DialogContent';
import { Button, DialogActions } from '@mui/material';
import Dialog from '@mui/material/Dialog';
import { useTranslation } from 'react-i18next';
interface Props {
open: boolean;
title: string;
caption: string;
onOk: () => Promise<void>;
onClose: () => void;
}
function ConfirmDialog({ open, title, caption, onOk, onClose }: Props) {
const { t } = useTranslation();
return (
<Dialog keepMounted={false} onMouseDown={(e) => e.stopPropagation()} open={open} onClose={onClose}>
<DialogContent className={'flex w-[540px] flex-col items-center justify-center'}>
<div className={'text-md m-2 font-bold'}>{title}</div>
<div className={'m-1 text-sm text-text-caption'}>{caption}</div>
</DialogContent>
<DialogActions>
<Button variant={'outlined'} onClick={onClose}>
{t('button.Cancel')}
</Button>
<Button
variant={'contained'}
onClick={async () => {
try {
await onOk();
onClose();
} catch (e) {}
}}
>
{t('button.delete')}
</Button>
</DialogActions>
</Dialog>
);
}
export default ConfirmDialog;

View File

@ -0,0 +1,82 @@
import { useEffect, useMemo, useState } from 'react';
import { TrashController } from '$app/stores/effects/workspace/trash/controller';
import { TrashPB } from '@/services/backend';
export function useLoadTrash() {
const [trash, setTrash] = useState<TrashPB[]>([]);
const controller = useMemo(() => {
return new TrashController();
}, []);
useEffect(() => {
void (async () => {
const trash = await controller.getTrash();
setTrash(trash);
})();
}, [controller]);
useEffect(() => {
controller.subscribe({
onTrashChanged: (trash) => {
setTrash(trash);
},
});
return () => {
controller.dispose();
};
}, [controller]);
return {
trash,
};
}
export function useTrashActions() {
const [restoreAllDialogOpen, setRestoreAllDialogOpen] = useState(false);
const [deleteAllDialogOpen, setDeleteAllDialogOpen] = useState(false);
const controller = useMemo(() => {
return new TrashController();
}, []);
useEffect(() => {
return () => {
controller.dispose();
};
}, [controller]);
const onClickRestoreAll = () => {
setRestoreAllDialogOpen(true);
};
const onClickDeleteAll = () => {
setDeleteAllDialogOpen(true);
};
const closeDislog = () => {
setRestoreAllDialogOpen(false);
setDeleteAllDialogOpen(false);
};
return {
onPutback: async (id: string) => {
await controller.putback(id);
},
onDelete: async (ids: string[]) => {
await controller.delete(ids);
},
onDeleteAll: async () => {
await controller.deleteAll();
},
onRestoreAll: async () => {
await controller.restoreAll();
},
onClickRestoreAll,
onClickDeleteAll,
restoreAllDialogOpen,
deleteAllDialogOpen,
closeDislog,
};
}

View File

@ -0,0 +1,78 @@
import React, { useState } from 'react';
import { useTranslation } from 'react-i18next';
import Button from '@mui/material/Button';
import { DeleteOutline, RestoreOutlined } from '@mui/icons-material';
import { useLoadTrash, useTrashActions } from '$app/components/trash/Trash.hooks';
import { Divider, List } from '@mui/material';
import TrashItem from '$app/components/trash/TrashItem';
import ConfirmDialog from '$app/components/trash/ConfirmDialog';
function Trash() {
const { t } = useTranslation();
const { trash } = useLoadTrash();
const {
onPutback,
onDelete,
onClickRestoreAll,
onClickDeleteAll,
restoreAllDialogOpen,
deleteAllDialogOpen,
onRestoreAll,
onDeleteAll,
closeDislog,
} = useTrashActions();
const [hoverId, setHoverId] = useState('');
return (
<div className={'flex flex-col'}>
<div className={'flex items-center justify-between'}>
<div className={'text-2xl font-bold'}>{t('trash.text')}</div>
<div className={'flex items-center justify-end'}>
<Button color={'inherit'} onClick={(e) => onClickRestoreAll()}>
<RestoreOutlined />
<span className={'ml-1'}>{t('trash.restoreAll')}</span>
</Button>
<Button color={'error'} onClick={(e) => onClickDeleteAll()}>
<DeleteOutline />
<span className={'ml-1'}>{t('trash.deleteAll')}</span>
</Button>
</div>
</div>
<div className={'flex justify-around p-6 px-2 text-text-caption'}>
<div className={'w-[40%]'}>{t('trash.pageHeader.fileName')}</div>
<div className={'flex-1'}>{t('trash.pageHeader.lastModified')}</div>
<div className={'flex-1'}>{t('trash.pageHeader.created')}</div>
<div className={'w-[64px]'}></div>
</div>
<Divider />
<List>
{trash.map((item) => (
<TrashItem
item={item}
key={item.id}
onPutback={onPutback}
onDelete={onDelete}
hoverId={hoverId}
setHoverId={setHoverId}
/>
))}
</List>
<ConfirmDialog
open={restoreAllDialogOpen}
title={t('trash.confirmRestoreAll.title')}
caption={t('trash.confirmRestoreAll.caption')}
onOk={onRestoreAll}
onClose={closeDislog}
/>
<ConfirmDialog
open={deleteAllDialogOpen}
title={t('trash.confirmDeleteAll.title')}
caption={t('trash.confirmDeleteAll.caption')}
onOk={onDeleteAll}
onClose={closeDislog}
/>
</div>
);
}
export default Trash;

View File

@ -0,0 +1,64 @@
import React from 'react';
import dayjs from 'dayjs';
import { IconButton, ListItem } from '@mui/material';
import { DeleteOutline, RestoreOutlined } from '@mui/icons-material';
import { TrashPB } from '@/services/backend';
import Tooltip from '@mui/material/Tooltip';
import { useTranslation } from 'react-i18next';
function TrashItem({
item,
hoverId,
setHoverId,
onDelete,
onPutback,
}: {
setHoverId: (id: string) => void;
item: TrashPB;
hoverId: string;
onPutback: (id: string) => void;
onDelete: (ids: string[]) => void;
}) {
const { t } = useTranslation();
return (
<ListItem
onMouseEnter={(e) => {
setHoverId(item.id);
}}
onMouseLeave={(e) => {
setHoverId('');
}}
key={item.id}
style={{
paddingInline: 0,
}}
>
<div className={'flex w-[100%] items-center justify-around rounded-lg px-2 py-3 hover:bg-fill-list-hover'}>
<div className={'w-[40%] text-left'}>{item.name}</div>
<div className={'flex-1'}>{dayjs.unix(item.modified_time).format('MM/DD/YYYY hh:mm A')}</div>
<div className={'flex-1'}>{dayjs.unix(item.create_time).format('MM/DD/YYYY hh:mm A')}</div>
<div
style={{
visibility: hoverId === item.id ? 'visible' : 'hidden',
}}
className={'w-[64px]'}
>
<Tooltip placement={'top-start'} title={t('button.putback')}>
<IconButton onClick={(e) => onPutback(item.id)} className={'mr-2'}>
<RestoreOutlined />
</IconButton>
</Tooltip>
<Tooltip placement={'top-start'} title={t('button.delete')}>
<IconButton color={'error'} onClick={(e) => onDelete([item.id])}>
<DeleteOutline />
</IconButton>
</Tooltip>
</div>
</div>
</ListItem>
);
}
export default TrashItem;

View File

@ -0,0 +1,8 @@
import { ViewLayoutPB } from '@/services/backend';
export const pageTypeMap = {
[ViewLayoutPB.Document]: 'document',
[ViewLayoutPB.Board]: 'board',
[ViewLayoutPB.Grid]: 'grid',
[ViewLayoutPB.Calendar]: 'calendar',
};

View File

@ -1,49 +0,0 @@
import { UpdateViewPayloadPB, RepeatedViewIdPB, ViewPB, FlowyError, ViewIdPB } from '@/services/backend';
import {
FolderEventDeleteView,
FolderEventDuplicateView,
FolderEventReadView,
FolderEventUpdateView,
} from '@/services/backend/events/flowy-folder2';
import { Ok, Result } from 'ts-results';
export class ViewBackendService {
constructor(public readonly viewId: string) {}
getChildViews = async (): Promise<Result<ViewPB[], FlowyError>> => {
const payload = ViewIdPB.fromObject({ value: this.viewId });
const result = await FolderEventReadView(payload);
if (result.ok) {
return Ok(result.val.child_views);
} else {
return result;
}
};
update = (params: { name?: string; desc?: string }) => {
const payload = UpdateViewPayloadPB.fromObject({ view_id: this.viewId });
if (params.name !== undefined) {
payload.name = params.name;
}
if (params.desc !== undefined) {
payload.desc = params.desc;
}
return FolderEventUpdateView(payload);
};
delete = () => {
const payload = RepeatedViewIdPB.fromObject({ items: [this.viewId] });
return FolderEventDeleteView(payload);
};
duplicate = async () => {
const view = await FolderEventReadView(ViewIdPB.fromObject({ value: this.viewId }));
if (view.ok) {
return FolderEventDuplicateView(view.val);
} else {
return view;
}
};
}

View File

@ -1,101 +0,0 @@
import { Ok, Result } from 'ts-results';
import { DeletedViewPB, FolderNotification, ViewPB, FlowyError } from '@/services/backend';
import { ChangeNotifier } from '$app/utils/change_notifier';
import { FolderNotificationObserver } from '../notifications/observer';
type DeleteViewNotifyValue = Result<ViewPB, FlowyError>;
type UpdateViewNotifyValue = Result<ViewPB, FlowyError>;
type RestoreViewNotifyValue = Result<ViewPB, FlowyError>;
type MoveToTrashViewNotifyValue = Result<DeletedViewPB, FlowyError>;
export class ViewObserver {
private _deleteViewNotifier = new ChangeNotifier<DeleteViewNotifyValue>();
private _updateViewNotifier = new ChangeNotifier<UpdateViewNotifyValue>();
private _restoreViewNotifier = new ChangeNotifier<RestoreViewNotifyValue>();
private _moveToTrashNotifier = new ChangeNotifier<MoveToTrashViewNotifyValue>();
private _childViewsNotifier = new ChangeNotifier<void>();
private _listener?: FolderNotificationObserver;
constructor(public readonly viewId: string) {}
subscribe = async (callbacks: {
onViewUpdate?: (value: UpdateViewNotifyValue) => void;
onViewDelete?: (value: DeleteViewNotifyValue) => void;
onViewRestored?: (value: RestoreViewNotifyValue) => void;
onViewMoveToTrash?: (value: MoveToTrashViewNotifyValue) => void;
onChildViewsChanged?: () => void;
}) => {
if (callbacks.onViewDelete !== undefined) {
this._deleteViewNotifier.observer?.subscribe(callbacks.onViewDelete);
}
if (callbacks.onViewUpdate !== undefined) {
this._updateViewNotifier.observer?.subscribe(callbacks.onViewUpdate);
}
if (callbacks.onViewRestored !== undefined) {
this._restoreViewNotifier.observer?.subscribe(callbacks.onViewRestored);
}
if (callbacks.onViewMoveToTrash !== undefined) {
this._moveToTrashNotifier.observer?.subscribe(callbacks.onViewMoveToTrash);
}
if (callbacks.onChildViewsChanged !== undefined) {
this._childViewsNotifier.observer?.subscribe(callbacks.onChildViewsChanged);
}
this._listener = new FolderNotificationObserver({
viewId: this.viewId,
parserHandler: (notification, result) => {
switch (notification) {
case FolderNotification.DidUpdateView:
if (result.ok) {
this._updateViewNotifier.notify(Ok(ViewPB.deserializeBinary(result.val)));
} else {
this._updateViewNotifier.notify(result);
}
break;
case FolderNotification.DidDeleteView:
if (result.ok) {
this._deleteViewNotifier.notify(Ok(ViewPB.deserializeBinary(result.val)));
} else {
this._deleteViewNotifier.notify(result);
}
break;
case FolderNotification.DidRestoreView:
if (result.ok) {
this._restoreViewNotifier.notify(Ok(ViewPB.deserializeBinary(result.val)));
} else {
this._restoreViewNotifier.notify(result);
}
break;
case FolderNotification.DidMoveViewToTrash:
if (result.ok) {
this._moveToTrashNotifier.notify(Ok(DeletedViewPB.deserializeBinary(result.val)));
} else {
this._moveToTrashNotifier.notify(result);
}
break;
case FolderNotification.DidUpdateChildViews:
if (result.ok) {
this._childViewsNotifier?.notify();
}
break;
default:
break;
}
},
});
await this._listener.start();
};
unsubscribe = async () => {
this._deleteViewNotifier.unsubscribe();
this._updateViewNotifier.unsubscribe();
this._restoreViewNotifier.unsubscribe();
this._moveToTrashNotifier.unsubscribe();
this._childViewsNotifier.unsubscribe();
await this._listener?.stop();
};
}

View File

@ -1,71 +0,0 @@
import { Err, Ok, Result } from 'ts-results';
import {
FolderEventCreateView,
FolderEventMoveView,
FolderEventReadWorkspaceViews,
FolderEventReadAllWorkspaces,
ViewPB,
} from '@/services/backend/events/flowy-folder2';
import { CreateViewPayloadPB, FlowyError, MoveViewPayloadPB, ViewLayoutPB, WorkspaceIdPB } from '@/services/backend';
import assert from 'assert';
export class WorkspaceBackendService {
constructor(public readonly workspaceId: string) {}
createView = async (params: {
name: string;
desc?: string;
layoutType: ViewLayoutPB;
parentViewId?: string;
/// The initial data should be the JSON of the document
/// For example: {"document":{"type":"editor","children":[]}}
initialData?: string;
}) => {
const encoder = new TextEncoder();
const payload = CreateViewPayloadPB.fromObject({
parent_view_id: params.parentViewId ?? this.workspaceId,
name: params.name,
desc: params.desc || '',
layout: params.layoutType,
initial_data: encoder.encode(params.initialData || ''),
});
return FolderEventCreateView(payload);
};
getWorkspace = () => {
const payload = WorkspaceIdPB.fromObject({ value: this.workspaceId });
return FolderEventReadAllWorkspaces(payload).then((result) => {
if (result.ok) {
const workspaces = result.val.items;
if (workspaces.length === 0) {
return Err(FlowyError.fromObject({ msg: 'workspace not found' }));
} else {
assert(workspaces.length === 1);
return Ok(workspaces[0]);
}
} else {
return Err(result.val);
}
});
};
getAllViews: () => Promise<Result<ViewPB[], FlowyError>> = async () => {
const payload = WorkspaceIdPB.fromObject({ value: this.workspaceId });
const result = await FolderEventReadWorkspaceViews(payload);
if (result.ok) {
return Ok(result.val.items);
} else {
return result;
}
};
moveView = (params: { viewId: string; fromIndex: number; toIndex: number }) => {
const payload = MoveViewPayloadPB.fromObject({
view_id: params.viewId,
from: params.fromIndex,
to: params.toIndex,
});
return FolderEventMoveView(payload);
};
}

View File

@ -1,57 +0,0 @@
import { Ok, Result } from "ts-results";
import { FolderNotification, WorkspacePB, FlowyError, RepeatedViewPB, ViewPB } from "@/services/backend";
import { ChangeNotifier } from "$app/utils/change_notifier";
import { FolderNotificationObserver } from "../notifications/observer";
export type AppListNotifyValue = Result<ViewPB[], FlowyError>;
export type AppListNotifyCallback = (value: AppListNotifyValue) => void;
export type WorkspaceNotifyValue = Result<WorkspacePB, FlowyError>;
export type WorkspaceNotifyCallback = (value: WorkspaceNotifyValue) => void;
export class WorkspaceObserver {
private appListNotifier = new ChangeNotifier<AppListNotifyValue>();
private workspaceNotifier = new ChangeNotifier<WorkspaceNotifyValue>();
private listener?: FolderNotificationObserver;
constructor(public readonly workspaceId: string) {
}
subscribe = async (callbacks: {
onAppListChanged: AppListNotifyCallback;
onWorkspaceChanged: WorkspaceNotifyCallback;
}) => {
this.appListNotifier?.observer?.subscribe(callbacks.onAppListChanged);
this.workspaceNotifier?.observer?.subscribe(callbacks.onWorkspaceChanged);
this.listener = new FolderNotificationObserver({
viewId: this.workspaceId,
parserHandler: (notification, result) => {
switch (notification) {
case FolderNotification.DidUpdateWorkspace:
if (result.ok) {
this.workspaceNotifier?.notify(Ok(WorkspacePB.deserializeBinary(result.val)));
} else {
this.workspaceNotifier?.notify(result);
}
break;
case FolderNotification.DidUpdateWorkspaceViews:
if (result.ok) {
this.appListNotifier?.notify(Ok(RepeatedViewPB.deserializeBinary(result.val).items));
} else {
this.appListNotifier?.notify(result);
}
break;
default:
break;
}
}
});
await this.listener.start();
};
unsubscribe = async () => {
this.appListNotifier.unsubscribe();
this.workspaceNotifier.unsubscribe();
await this.listener?.stop();
};
}

View File

@ -1,16 +1,17 @@
import { OnNotificationError, AFNotificationObserver } from '@/services/backend/notifications';
import { FolderNotificationParser } from './parser';
import { FlowyError, FolderNotification } from '@/services/backend';
import { Result } from 'ts-results';
import { WorkspaceNotificationParser } from './parser';
export type ParserHandler = (notification: FolderNotification, payload: Result<Uint8Array, FlowyError>) => void;
export class FolderNotificationObserver extends AFNotificationObserver<FolderNotification> {
constructor(params: { viewId?: string; parserHandler: ParserHandler; onError?: OnNotificationError }) {
const parser = new FolderNotificationParser({
export class WorkspaceNotificationObserver extends AFNotificationObserver<FolderNotification> {
constructor(params: { id?: string; parserHandler: ParserHandler; onError?: OnNotificationError }) {
const parser = new WorkspaceNotificationParser({
callback: params.parserHandler,
id: params.viewId,
id: params.id,
});
super(parser);
}
}

View File

@ -2,14 +2,15 @@ import { NotificationParser, OnNotificationError } from '@/services/backend/noti
import { FlowyError, FolderNotification } from '@/services/backend';
import { Result } from 'ts-results';
declare type FolderNotificationCallback = (ty: FolderNotification, payload: Result<Uint8Array, FlowyError>) => void;
declare type WorkspaceNotificationCallback = (ty: FolderNotification, payload: Result<Uint8Array, FlowyError>) => void;
export class FolderNotificationParser extends NotificationParser<FolderNotification> {
constructor(params: { id?: string; callback: FolderNotificationCallback; onError?: OnNotificationError }) {
export class WorkspaceNotificationParser extends NotificationParser<FolderNotification> {
constructor(params: { id?: string; callback: WorkspaceNotificationCallback; onError?: OnNotificationError }) {
super(
params.callback,
(ty) => {
const notification = FolderNotification[ty];
if (isFolderNotification(notification)) {
return FolderNotification[notification];
} else {

View File

@ -0,0 +1,83 @@
import {
FolderEventReadView,
FolderEventCreateView,
FolderEventUpdateView,
FolderEventDeleteView,
FolderEventDuplicateView,
FolderEventCloseView,
FolderEventImportData,
ViewIdPB,
CreateViewPayloadPB,
UpdateViewPayloadPB,
RepeatedViewIdPB,
ViewPB,
ImportPB,
} from '@/services/backend/events/flowy-folder2';
import { Page } from '$app_reducers/pages/slice';
export class PageBackendService {
constructor() {
//
}
getPage = async (viewId: string) => {
const payload = new ViewIdPB({
value: viewId,
});
return FolderEventReadView(payload);
};
createPage = async (params: ReturnType<typeof CreateViewPayloadPB.prototype.toObject>) => {
const payload = CreateViewPayloadPB.fromObject(params);
return FolderEventCreateView(payload);
};
updatePage = async (page: { id: string } & Partial<Page>) => {
const payload = new UpdateViewPayloadPB();
payload.view_id = page.id;
if (page.name !== undefined) {
payload.name = page.name;
}
if (page.cover !== undefined) {
payload.cover_url = page.cover;
}
if (page.icon !== undefined) {
payload.icon_url = page.icon;
}
return FolderEventUpdateView(payload);
};
deletePage = async (viewId: string) => {
const payload = new RepeatedViewIdPB({
items: [viewId],
});
return FolderEventDeleteView(payload);
};
duplicatePage = async (params: ReturnType<typeof ViewPB.prototype.toObject>) => {
const payload = ViewPB.fromObject(params);
return FolderEventDuplicateView(payload);
};
closePage = async (viewId: string) => {
const payload = new ViewIdPB({
value: viewId,
});
return FolderEventCloseView(payload);
};
importData = async (params: ReturnType<typeof ImportPB.prototype.toObject>) => {
const payload = ImportPB.fromObject(params);
return FolderEventImportData(payload);
};
}

View File

@ -0,0 +1,112 @@
import { CreateViewPayloadPB, UpdateViewPayloadPB, ViewLayoutPB } from '@/services/backend';
import { PageBackendService } from '$app/stores/effects/workspace/page/page_bd_svc';
import { WorkspaceObserver } from '$app/stores/effects/workspace/workspace_observer';
import { Page, parserViewPBToPage } from '$app_reducers/pages/slice';
import { AsyncQueue } from '$app/utils/async_queue';
export class PageController {
private readonly backendService: PageBackendService = new PageBackendService();
private readonly observer: WorkspaceObserver = new WorkspaceObserver();
private onChangeQueue?: AsyncQueue;
constructor(private readonly id: string) {
//
}
dispose = () => {
this.observer.unsubscribe();
};
createPage = async (params: { name: string; layout: ViewLayoutPB }): Promise<string> => {
const result = await this.backendService.createPage({
name: params.name,
layout: params.layout,
parent_view_id: this.id,
});
if (result.ok) {
return result.val.id;
}
return Promise.reject(result.err);
};
getChildPages = async (): Promise<Page[]> => {
const result = await this.backendService.getPage(this.id);
if (result.ok) {
return result.val.child_views.map(parserViewPBToPage);
}
return [];
};
getPage = async (id?: string): Promise<Page> => {
const result = await this.backendService.getPage(id || this.id);
if (result.ok) {
return parserViewPBToPage(result.val);
}
return Promise.reject(result.err);
};
getParentPage = async (): Promise<Page> => {
const page = await this.getPage();
const parentPageId = page.parentId;
return this.getPage(parentPageId);
};
subscribe = async (callbacks: { onChildPagesChanged?: (childPages: Page[]) => void }) => {
const onChildPagesChanged = async () => {
const childPages = await this.getChildPages();
callbacks.onChildPagesChanged?.(childPages);
};
this.onChangeQueue = new AsyncQueue(onChildPagesChanged);
await this.observer.subscribeView(this.id, {
didUpdateChildViews: this.didUpdateChildPages,
});
};
unsubscribe = async () => {
await this.observer.unsubscribe();
};
updatePage = async (page: { id: string } & Partial<Page>) => {
const result = await this.backendService.updatePage(page);
if (result.ok) {
return result.val.toObject();
}
return Promise.reject(result.err);
};
deletePage = async () => {
const result = await this.backendService.deletePage(this.id);
if (result.ok) {
return result.val;
}
return Promise.reject(result.err);
};
duplicatePage = async () => {
const page = await this.getPage();
const result = await this.backendService.duplicatePage(page);
if (result.ok) {
return result.val;
}
return Promise.reject(result.err);
};
private didUpdateChildPages = (payload: Uint8Array) => {
this.onChangeQueue?.enqueue(Math.random());
};
}

View File

@ -0,0 +1,44 @@
import {
FolderEventReadTrash,
FolderEventPutbackTrash,
FolderEventDeleteAllTrash,
FolderEventRestoreAllTrash,
FolderEventDeleteTrash,
TrashIdPB,
RepeatedTrashIdPB,
} from '@/services/backend/events/flowy-folder2';
export class TrashBackendService {
constructor() {
//
}
getTrash = async () => {
return FolderEventReadTrash();
};
putback = async (id: string) => {
const payload = new TrashIdPB({
id,
});
return FolderEventPutbackTrash(payload);
};
delete = async (ids: string[]) => {
const items = ids.map((id) => new TrashIdPB({ id }));
const payload = new RepeatedTrashIdPB({
items,
});
return FolderEventDeleteTrash(payload);
};
deleteAll = async () => {
return FolderEventDeleteAllTrash();
};
restoreAll = async () => {
return FolderEventRestoreAllTrash();
};
}

View File

@ -0,0 +1,74 @@
import { TrashBackendService } from '$app/stores/effects/workspace/trash/bd_svc';
import { WorkspaceObserver } from '$app/stores/effects/workspace/workspace_observer';
import { RepeatedTrashPB, TrashPB } from '@/services/backend';
export class TrashController {
private readonly observer: WorkspaceObserver = new WorkspaceObserver();
private readonly backendService: TrashBackendService = new TrashBackendService();
subscribe = (callbacks: { onTrashChanged?: (trash: TrashPB[]) => void }) => {
const didUpdateTrash = (payload: Uint8Array) => {
const res = RepeatedTrashPB.deserializeBinary(payload);
callbacks.onTrashChanged?.(res.items);
};
this.observer.subscribeTrash({
didUpdateTrash,
});
};
dispose = () => {
this.observer.unsubscribe();
};
getTrash = async () => {
const res = await this.backendService.getTrash();
if (res.ok) {
return res.val.items;
}
return [];
};
putback = async (id: string) => {
const res = await this.backendService.putback(id);
if (res.ok) {
return res.val;
}
return Promise.reject(res.err);
};
delete = async (ids: string[]) => {
const res = await this.backendService.delete(ids);
if (res.ok) {
return res.val;
}
return Promise.reject(res.err);
};
deleteAll = async () => {
const res = await this.backendService.deleteAll();
if (res.ok) {
return res.val;
}
return Promise.reject(res.err);
};
restoreAll = async () => {
const res = await this.backendService.restoreAll();
if (res.ok) {
return res.val;
}
return Promise.reject(res.err);
};
}

View File

@ -0,0 +1,61 @@
import {
FolderEventCreateWorkspace,
FolderEventGetCurrentWorkspace,
CreateWorkspacePayloadPB,
FolderEventReadAllWorkspaces,
FolderEventOpenWorkspace,
FolderEventDeleteWorkspace,
WorkspaceIdPB,
FolderEventReadWorkspaceViews,
} from '@/services/backend/events/flowy-folder2';
export class WorkspaceBackendService {
constructor() {
//
}
createWorkspace = async (params: ReturnType<typeof CreateWorkspacePayloadPB.prototype.toObject>) => {
const { name, desc } = params;
const payload = CreateWorkspacePayloadPB.fromObject({
name,
desc,
});
return FolderEventCreateWorkspace(payload);
};
openWorkspace = async (workspaceId: string) => {
const payload = new WorkspaceIdPB({
value: workspaceId,
});
return FolderEventOpenWorkspace(payload);
};
deleteWorkspace = async (workspaceId: string) => {
const payload = new WorkspaceIdPB({
value: workspaceId,
});
return FolderEventDeleteWorkspace(payload);
};
getWorkspaces = async () => {
// if workspaceId is not provided, it will return all workspaces
const workspaceId = new WorkspaceIdPB();
return FolderEventReadAllWorkspaces(workspaceId);
};
getCurrentWorkspace = async () => {
return FolderEventGetCurrentWorkspace();
};
getChildPages = async (workspaceId: string) => {
const payload = new WorkspaceIdPB({
value: workspaceId,
});
return FolderEventReadWorkspaceViews(payload);
};
}

View File

@ -0,0 +1,99 @@
import { WorkspaceBackendService } from '$app/stores/effects/workspace/workspace_bd_svc';
import { WorkspaceObserver } from '$app/stores/effects/workspace/workspace_observer';
import { CreateViewPayloadPB } from '@/services/backend';
import { WorkspaceItem } from '$app_reducers/workspace/slice';
import { PageBackendService } from '$app/stores/effects/workspace/page/page_bd_svc';
import { Page, parserViewPBToPage } from '$app_reducers/pages/slice';
import { AsyncQueue } from '$app/utils/async_queue';
export class WorkspaceController {
private readonly observer: WorkspaceObserver = new WorkspaceObserver();
private readonly pageBackendService: PageBackendService;
private readonly backendService: WorkspaceBackendService;
private onWorkspaceChanged?: (data: WorkspaceItem) => void;
private onWorkspaceDeleted?: () => void;
private onChangeQueue?: AsyncQueue;
constructor(private readonly workspaceId: string) {
this.pageBackendService = new PageBackendService();
this.backendService = new WorkspaceBackendService();
}
dispose = () => {
this.observer.unsubscribe();
};
open = async () => {
const result = await this.backendService.openWorkspace(this.workspaceId);
if (result.ok) {
return result.val;
}
return Promise.reject(result.err);
};
delete = async () => {
const result = await this.backendService.deleteWorkspace(this.workspaceId);
if (result.ok) {
return result.val;
}
return Promise.reject(result.err);
};
subscribe = async (callbacks: {
onWorkspaceChanged?: (data: WorkspaceItem) => void;
onWorkspaceDeleted?: () => void;
onChildPagesChanged?: (childPages: Page[]) => void;
}) => {
this.onWorkspaceChanged = callbacks.onWorkspaceChanged;
this.onWorkspaceDeleted = callbacks.onWorkspaceDeleted;
const onChildPagesChanged = async () => {
const childPages = await this.getChildPages();
callbacks.onChildPagesChanged?.(childPages);
};
this.onChangeQueue = new AsyncQueue(onChildPagesChanged);
await this.observer.subscribeWorkspace(this.workspaceId, {
didUpdateWorkspace: this.didUpdateWorkspace,
didDeleteWorkspace: this.didDeleteWorkspace,
didUpdateChildViews: this.didUpdateChildPages,
});
};
createView = async (params: ReturnType<typeof CreateViewPayloadPB.prototype.toObject>) => {
const result = await this.pageBackendService.createPage(params);
if (result.ok) {
const view = result.val;
return view;
}
return Promise.reject(result.err);
};
getChildPages = async (): Promise<Page[]> => {
const result = await this.backendService.getChildPages(this.workspaceId);
if (result.ok) {
return result.val.items.map(parserViewPBToPage);
}
return [];
};
private didUpdateWorkspace = (payload: Uint8Array) => {
// this.onWorkspaceChanged?.(payload.toObject());
};
private didDeleteWorkspace = (payload: Uint8Array) => {
this.onWorkspaceDeleted?.();
};
private didUpdateChildPages = (payload: Uint8Array) => {
this.onChangeQueue?.enqueue(Math.random());
};
}

View File

@ -0,0 +1,72 @@
import { WorkspaceBackendService } from './workspace_bd_svc';
import { CreateWorkspacePayloadPB, RepeatedWorkspacePB } from '@/services/backend';
import { WorkspaceItem } from '$app_reducers/workspace/slice';
import { WorkspaceObserver } from '$app/stores/effects/workspace/workspace_observer';
export class WorkspaceManagerController {
private readonly observer: WorkspaceObserver;
private readonly backendService: WorkspaceBackendService = new WorkspaceBackendService();
private onWorkspacesChanged?: (data: { workspaces: WorkspaceItem[]; currentWorkspace: WorkspaceItem }) => void;
constructor() {
this.observer = new WorkspaceObserver();
}
subscribe = async (callbacks: {
onWorkspacesChanged?: (data: { workspaces: WorkspaceItem[]; currentWorkspace: WorkspaceItem }) => void;
}) => {
// this.observer.subscribeWorkspaces(this.didCreateWorkspace);
this.onWorkspacesChanged = callbacks.onWorkspacesChanged;
};
createWorkspace = async (params: ReturnType<typeof CreateWorkspacePayloadPB.prototype.toObject>) => {
const result = await this.backendService.createWorkspace(params);
if (result.ok) {
return result.val;
}
return Promise.reject(result.err);
};
getWorkspaces = async (): Promise<WorkspaceItem[]> => {
const result = await this.backendService.getWorkspaces();
if (result.ok) {
const items = result.val.items;
return items.map((item) => {
return {
id: item.id,
name: item.name,
};
});
}
return [];
};
getCurrentWorkspace = async (): Promise<WorkspaceItem | null> => {
const result = await this.backendService.getCurrentWorkspace();
if (result.ok) {
const workspace = result.val.workspace;
return {
id: workspace.id,
name: workspace.name,
};
}
return null;
};
dispose = async () => {
await this.observer.unsubscribe();
};
private didCreateWorkspace = (payload: Uint8Array) => {
const data = RepeatedWorkspacePB.deserializeBinary(payload);
// onWorkspacesChanged(data.toObject().items);
};
}

View File

@ -0,0 +1,99 @@
import { FolderNotification } from '@/services/backend';
import { WorkspaceNotificationObserver } from '$app/stores/effects/workspace/notifications/observer';
export class WorkspaceObserver {
private listener?: WorkspaceNotificationObserver;
constructor() {
//
}
subscribeWorkspaces = async (callback: (payload: Uint8Array) => void) => {
this.listener = new WorkspaceNotificationObserver({
parserHandler: (notification, result) => {
switch (notification) {
case FolderNotification.DidCreateWorkspace:
if (!result.ok) break;
callback(result.val);
break;
default:
break;
}
},
});
await this.listener.start();
};
subscribeWorkspace = async (
workspaceId: string,
callbacks: {
didUpdateChildViews: (payload: Uint8Array) => void;
didUpdateWorkspace: (payload: Uint8Array) => void;
didDeleteWorkspace: (payload: Uint8Array) => void;
}
) => {
this.listener = new WorkspaceNotificationObserver({
id: workspaceId,
parserHandler: (notification, result) => {
switch (notification) {
case FolderNotification.DidUpdateWorkspace:
if (!result.ok) break;
callbacks.didUpdateWorkspace(result.val);
break;
case FolderNotification.DidUpdateChildViews:
if (!result.ok) break;
callbacks.didUpdateChildViews(result.val);
break;
// case FolderNotification.DidDeleteWorkspace:
// if (!result.ok) break;
// callbacks.didDeleteWorkspace(result.val);
// break;
default:
break;
}
},
});
await this.listener.start();
};
subscribeView = async (
viewId: string,
callbacks: {
didUpdateChildViews: (payload: Uint8Array) => void;
}
) => {
this.listener = new WorkspaceNotificationObserver({
id: viewId,
parserHandler: (notification, result) => {
switch (notification) {
case FolderNotification.DidUpdateChildViews:
if (!result.ok) break;
callbacks.didUpdateChildViews(result.val);
break;
default:
break;
}
},
});
await this.listener.start();
};
subscribeTrash = async (callbacks: { didUpdateTrash: (payload: Uint8Array) => void }) => {
this.listener = new WorkspaceNotificationObserver({
parserHandler: (notification, result) => {
switch (notification) {
case FolderNotification.DidUpdateTrash:
if (!result.ok) break;
callbacks.didUpdateTrash(result.val);
break;
default:
break;
}
},
});
await this.listener.start();
};
unsubscribe = async () => {
await this.listener?.stop();
};
}

View File

@ -1,12 +0,0 @@
import { createSlice, PayloadAction } from '@reduxjs/toolkit';
export const activePageIdSlice = createSlice({
name: 'activePageId',
initialState: '',
reducers: {
setActivePageId(state, action: PayloadAction<string>) {
return action.payload;
},
},
});
export const activePageIdActions = activePageIdSlice.actions;

View File

@ -1,33 +0,0 @@
import { createSlice, PayloadAction } from '@reduxjs/toolkit';
export interface IFolder {
id: string;
title: string;
showPages?: boolean;
}
const initialState: IFolder[] = [];
export const foldersSlice = createSlice({
name: 'folders',
initialState: initialState,
reducers: {
addFolder(state, action: PayloadAction<IFolder>) {
state.push(action.payload);
},
renameFolder(state, action: PayloadAction<{ id: string; newTitle: string }>) {
return state.map((f) => (f.id === action.payload.id ? { ...f, title: action.payload.newTitle } : f));
},
deleteFolder(state, action: PayloadAction<{ id: string }>) {
return state.filter((f) => f.id !== action.payload.id);
},
clearFolders() {
return [];
},
setShowPages(state, action: PayloadAction<{ id: string; showPages: boolean }>) {
return state.map((f) => (f.id === action.payload.id ? { ...f, showPages: action.payload.showPages } : f));
},
},
});
export const foldersActions = foldersSlice.actions;

View File

@ -1,17 +0,0 @@
import { createSlice, PayloadAction } from '@reduxjs/toolkit';
export const NAVIGATION_MIN_WIDTH = 200;
const initialState = 250;
export const navigationWidthSlice = createSlice({
name: 'navigationWidth',
initialState: initialState,
reducers: {
changeWidth(state, action: PayloadAction<number>) {
return action.payload;
},
},
});
export const navigationWidthActions = navigationWidthSlice.actions;

View File

@ -1,43 +1,82 @@
import { ViewLayoutPB, ViewPB } from '@/services/backend';
import { createSlice, PayloadAction } from '@reduxjs/toolkit';
import { ViewLayoutPB } from '@/services/backend';
export interface IPage {
export interface Page {
id: string;
title: string;
pageType: ViewLayoutPB;
parentPageId: string;
showPagesInside: boolean;
parentId: string;
name: string;
layout: ViewLayoutPB;
icon?: string;
cover?: string;
}
const initialState: IPage[] = [];
export function parserViewPBToPage(view: ViewPB) {
return {
id: view.id,
name: view.name,
parentId: view.parent_view_id,
layout: view.layout,
cover: view.cover_url,
icon: view.icon_url,
};
}
export interface PageState {
map: Record<string, Page>;
childPages: Record<string, string[]>;
expandedPages: Record<string, boolean>;
}
export const initialState: PageState = {
map: {},
childPages: {},
expandedPages: {},
};
export const pagesSlice = createSlice({
name: 'pages',
initialState: initialState,
initialState,
reducers: {
addInsidePages(state, action: PayloadAction<{ insidePages: IPage[]; currentPageId: string }>) {
return state
.filter((page) => page.parentPageId !== action.payload.currentPageId)
.concat(action.payload.insidePages);
addChildPages(
state,
action: PayloadAction<{
childPages: Page[];
id: string;
}>
) {
const { childPages, id } = action.payload;
const pageMap: Record<string, Page> = {};
const children: string[] = [];
childPages.forEach((page) => {
pageMap[page.id] = page;
children.push(page.id);
});
state.map = {
...state.map,
...pageMap,
};
state.childPages[id] = children;
},
addPage(state, action: PayloadAction<IPage>) {
state.push(action.payload);
removeChildPages(state, action: PayloadAction<string>) {
const parentId = action.payload;
delete state.childPages[parentId];
},
toggleShowPages(state, action: PayloadAction<{ id: string }>) {
return state.map<IPage>((page: IPage) =>
page.id === action.payload.id ? { ...page, showPagesInside: !page.showPagesInside } : page
);
expandPage(state, action: PayloadAction<string>) {
const id = action.payload;
state.expandedPages[id] = true;
},
renamePage(state, action: PayloadAction<{ id: string; newTitle: string }>) {
return state.map<IPage>((page: IPage) =>
page.id === action.payload.id ? { ...page, title: action.payload.newTitle } : page
);
},
deletePage(state, action: PayloadAction<{ id: string }>) {
return state.filter((page) => page.id !== action.payload.id);
},
clearPages() {
return [];
collapsePage(state, action: PayloadAction<string>) {
const id = action.payload;
state.expandedPages[id] = false;
},
},
});

View File

@ -0,0 +1,34 @@
import { createSlice, PayloadAction } from '@reduxjs/toolkit';
interface SidebarState {
isCollapsed: boolean;
width: number;
isResizing: boolean;
}
const initialState: SidebarState = {
isCollapsed: false,
width: 250,
isResizing: false,
};
export const sidebarSlice = createSlice({
name: 'sidebar',
initialState: initialState,
reducers: {
toggleCollapse(state) {
state.isCollapsed = !state.isCollapsed;
},
changeWidth(state, action: PayloadAction<number>) {
state.width = action.payload;
},
startResizing(state) {
state.isResizing = true;
},
stopResizing(state) {
state.isResizing = false;
},
},
});
export const sidebarActions = sidebarSlice.actions;

View File

@ -1,17 +1,61 @@
import { createSlice, PayloadAction } from '@reduxjs/toolkit';
export interface IWorkspace {
id?: string;
name?: string;
export interface WorkspaceItem {
id: string;
name: string;
}
interface WorkspaceState {
workspaces: WorkspaceItem[];
currentWorkspace: WorkspaceItem | null;
}
const initialState: WorkspaceState = {
workspaces: [],
currentWorkspace: null,
};
export const workspaceSlice = createSlice({
name: 'workspace',
initialState: {} as IWorkspace,
initialState,
reducers: {
updateWorkspace: (state, action: PayloadAction<IWorkspace>) => {
initWorkspaces: (
state,
action: PayloadAction<{
workspaces: WorkspaceItem[];
currentWorkspace: WorkspaceItem | null;
}>
) => {
return action.payload;
},
onWorkspacesChanged: (
state,
action: PayloadAction<{
workspaces: WorkspaceItem[];
currentWorkspace: WorkspaceItem | null;
}>
) => {
return action.payload;
},
onWorkspaceChanged: (state, action: PayloadAction<WorkspaceItem>) => {
const { id } = action.payload;
const index = state.workspaces.findIndex((workspace) => workspace.id === id);
if (index !== -1) {
state.workspaces[index] = action.payload;
}
},
onWorkspaceDeleted: (state, action: PayloadAction<string>) => {
const id = action.payload;
const index = state.workspaces.findIndex((workspace) => workspace.id === id);
if (index !== -1) {
state.workspaces.splice(index, 1);
}
},
},
});

View File

@ -8,7 +8,6 @@ import {
addListener,
} from '@reduxjs/toolkit';
import { pagesSlice } from './reducers/pages/slice';
import { navigationWidthSlice } from './reducers/navigation-width/slice';
import { currentUserSlice } from './reducers/current-user/slice';
import { gridSlice } from './reducers/grid/slice';
import { workspaceSlice } from './reducers/workspace/slice';
@ -16,7 +15,7 @@ import { databaseSlice } from './reducers/database/slice';
import { documentReducers } from './reducers/document/slice';
import { boardSlice } from './reducers/board/slice';
import { errorSlice } from './reducers/error/slice';
import { activePageIdSlice } from '$app_reducers/active-page-id/slice';
import { sidebarSlice } from '$app_reducers/sidebar/slice';
const listenerMiddlewareInstance = createListenerMiddleware({
onError: () => console.error,
@ -25,14 +24,13 @@ const listenerMiddlewareInstance = createListenerMiddleware({
const store = configureStore({
reducer: {
[pagesSlice.name]: pagesSlice.reducer,
[activePageIdSlice.name]: activePageIdSlice.reducer,
[navigationWidthSlice.name]: navigationWidthSlice.reducer,
[currentUserSlice.name]: currentUserSlice.reducer,
[gridSlice.name]: gridSlice.reducer,
[databaseSlice.name]: databaseSlice.reducer,
[boardSlice.name]: boardSlice.reducer,
[workspaceSlice.name]: workspaceSlice.reducer,
[errorSlice.name]: errorSlice.reducer,
[sidebarSlice.name]: sidebarSlice.reducer,
...documentReducers,
},
middleware: (gDM) => gDM({ serializableCheck: false }).prepend(listenerMiddlewareInstance.middleware),

View File

@ -1,6 +1,6 @@
import { Log } from '$app/utils/log';
export class AsyncQueue<T> {
export class AsyncQueue<T = unknown> {
private queue: T[] = [];
private isProcessing = false;
private executeFunction: (item: T) => Promise<void>;
@ -20,6 +20,7 @@ export class AsyncQueue<T> {
}
const item = this.queue.shift();
this.isProcessing = true;
const executeFn = async (item: T) => {

View File

@ -7,14 +7,15 @@ export const BoardPage = () => {
const params = useParams();
const [viewId, setViewId] = useState('');
const pagesStore = useAppSelector((state) => state.pages);
const page = useAppSelector((state) => (params.id ? state.pages.map[params.id] : undefined));
const [title, setTitle] = useState('');
useEffect(() => {
if (params?.id?.length) {
setViewId(params.id);
setTitle(pagesStore.find((page) => page.id === params.id)?.title || '');
if (page) {
setViewId(page.id);
setTitle(page.name);
}
}, [params, pagesStore]);
}, [params, pagesStore, page]);
return (
<div className='flex h-full flex-col gap-8 px-8 pt-8'>

View File

@ -0,0 +1,12 @@
import React from 'react';
import Trash from '$app/components/trash/Trash';
function TrashPage() {
return (
<div className='flex h-full flex-col gap-8 px-8 pt-8'>
<Trash />
</div>
);
}
export default TrashPage;

View File

@ -12,7 +12,7 @@
}
[class$='-MuiButtonBase-root-MuiMenuItem-root'].MuiButtonBase-root:hover {
background-color: var(--fill-list-hover);
background-color: var(--fill-list-active);
}
.MuiPaper-root.MuiMenu-paper.MuiPopover-paper {
@ -24,7 +24,6 @@
}
.MuiButtonBase-root.MuiIconButton-root.MuiIconButton-sizeMedium {
color: var(--icon-primary);
border-radius: 4px;
}
@ -33,7 +32,6 @@
}
.MuiButtonBase-root.MuiIconButton-root {
color: var(--icon-primary);
border-radius: 4px;
padding: 2px;
}
@ -52,4 +50,14 @@
.MuiInput-input[class$='-MuiSelect-select-MuiInputBase-input-MuiInput-input']:focus {
background: transparent;
}
.MuiList-root .MuiMenuItem-root {
border-radius: 8px;
margin-left: 0.5em;
margin-right: 0.5em;
padding: 0.5em 1em;
}
.MuiDivider-root.MuiDivider-fullWidth {
border-color: var(--line-divider);
}

View File

@ -1,6 +1,6 @@
/**
* Do not edit directly
* Generated on Tue, 11 Jul 2023 06:48:47 GMT
* Generated on Wed, 12 Jul 2023 07:09:42 GMT
* Generated from $pnpm css:variables
*/

View File

@ -1,10 +1,10 @@
/**
* Do not edit directly
* Generated on Tue, 11 Jul 2023 06:48:47 GMT
* Generated on Wed, 12 Jul 2023 07:09:42 GMT
* Generated from $pnpm css:variables
*/
:root[data-dark-mode=false] {
:root {
--base-light-neutral-50: #f9fafd;
--base-light-neutral-100: #edeef2;
--base-light-neutral-200: #e2e4eb;

View File

@ -46,7 +46,7 @@ StyleDictionary.extend({
{
format: 'css/variables',
destination: 'light.variables.css',
selector: '[data-dark-mode=false]',
selector: '',
options: {
outputReferences: true
}

View File

@ -1,6 +1,6 @@
/**
* Do not edit directly
* Generated on Tue, 11 Jul 2023 06:48:47 GMT
* Generated on Wed, 12 Jul 2023 07:09:42 GMT
* Generated from $pnpm css:variables
*/

View File

@ -1,6 +1,6 @@
/**
* Do not edit directly
* Generated on Tue, 11 Jul 2023 06:48:47 GMT
* Generated on Wed, 12 Jul 2023 07:09:42 GMT
* Generated from $pnpm css:variables
*/

View File

@ -12,7 +12,7 @@ module.exports = {
theme: {
extend: {
colors,
boxShadow
boxShadow,
},
},
plugins: [],

View File

@ -80,6 +80,14 @@
"fileName": "File name",
"lastModified": "Last Modified",
"created": "Created"
},
"confirmDeleteAll": {
"title": "Are you sure to delete all pages in Trash?",
"caption": "This action cannot be undone."
},
"confirmRestoreAll": {
"title": "Are you sure to restore all pages in Trash?",
"caption": "This action cannot be undone."
}
},
"deletePagePrompt": {
@ -169,7 +177,8 @@
"edit": "Edit",
"delete": "Delete",
"duplicate": "Duplicate",
"done": "Done"
"done": "Done",
"putback": "Put Back"
},
"label": {
"welcome": "Welcome!",
@ -580,5 +589,9 @@
"fail": "Unable to copy"
}
},
"unSupportBlock": "The current version does not support this Block."
"unSupportBlock": "The current version does not support this Block.",
"views": {
"deleteContentTitle": "Are you sure want to delete the {pageType}?",
"deleteContentCaption": "if you delete this {pageType}, you can restore it from the trash."
}
}