mirror of
https://github.com/AppFlowy-IO/AppFlowy.git
synced 2024-08-30 18:12:39 +00:00
feat: support navigator and trash page
* refactor: navigator * feat: support trash
This commit is contained in:
parent
098c085d96
commit
c65584d23c
@ -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",
|
||||
|
@ -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==}
|
||||
|
@ -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('}', '}}');
|
||||
}
|
||||
}
|
||||
|
||||
|
@ -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>
|
||||
|
@ -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;
|
@ -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>;
|
||||
|
@ -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) {
|
||||
|
@ -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;
|
||||
|
@ -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} />
|
||||
|
@ -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>
|
||||
);
|
||||
};
|
@ -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,
|
||||
};
|
||||
}
|
@ -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;
|
@ -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;
|
@ -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>
|
||||
);
|
||||
};
|
@ -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>
|
||||
);
|
||||
};
|
@ -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;
|
@ -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,
|
||||
};
|
||||
};
|
@ -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>
|
||||
</>
|
||||
);
|
||||
};
|
@ -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;
|
@ -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>
|
||||
);
|
||||
};
|
@ -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;
|
@ -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,
|
||||
};
|
||||
};
|
@ -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>
|
||||
</>
|
||||
);
|
||||
};
|
@ -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,
|
||||
};
|
||||
};
|
@ -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>
|
||||
);
|
||||
};
|
@ -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>
|
||||
);
|
||||
};
|
@ -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;
|
@ -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,
|
||||
};
|
||||
};
|
@ -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>
|
||||
);
|
||||
};
|
@ -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>
|
||||
);
|
||||
};
|
@ -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>
|
||||
);
|
||||
};
|
@ -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;
|
@ -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;
|
@ -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;
|
@ -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;
|
||||
}
|
@ -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;
|
@ -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')}
|
@ -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;
|
@ -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>
|
||||
);
|
||||
};
|
@ -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,
|
||||
};
|
||||
}
|
@ -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;
|
@ -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);
|
@ -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;
|
@ -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;
|
@ -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;
|
@ -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;
|
@ -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,
|
||||
};
|
||||
}
|
@ -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;
|
@ -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;
|
@ -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';
|
||||
|
||||
|
@ -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';
|
||||
|
@ -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 {};
|
||||
};
|
@ -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;
|
@ -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;
|
@ -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;
|
@ -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;
|
@ -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,
|
||||
};
|
||||
}
|
@ -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;
|
@ -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;
|
@ -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;
|
@ -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;
|
||||
}
|
||||
|
@ -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;
|
||||
}
|
||||
|
@ -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);
|
||||
};
|
||||
|
||||
|
@ -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;
|
@ -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,
|
||||
};
|
||||
}
|
@ -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;
|
@ -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;
|
@ -0,0 +1,8 @@
|
||||
import { ViewLayoutPB } from '@/services/backend';
|
||||
|
||||
export const pageTypeMap = {
|
||||
[ViewLayoutPB.Document]: 'document',
|
||||
[ViewLayoutPB.Board]: 'board',
|
||||
[ViewLayoutPB.Grid]: 'grid',
|
||||
[ViewLayoutPB.Calendar]: 'calendar',
|
||||
};
|
@ -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;
|
||||
}
|
||||
};
|
||||
}
|
@ -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();
|
||||
};
|
||||
}
|
@ -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);
|
||||
};
|
||||
}
|
@ -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();
|
||||
};
|
||||
}
|
@ -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);
|
||||
}
|
||||
}
|
@ -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 {
|
@ -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);
|
||||
};
|
||||
}
|
@ -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());
|
||||
};
|
||||
}
|
@ -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();
|
||||
};
|
||||
}
|
@ -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);
|
||||
};
|
||||
}
|
@ -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);
|
||||
};
|
||||
}
|
@ -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());
|
||||
};
|
||||
}
|
@ -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);
|
||||
};
|
||||
}
|
@ -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();
|
||||
};
|
||||
}
|
@ -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;
|
@ -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;
|
@ -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;
|
@ -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;
|
||||
},
|
||||
},
|
||||
});
|
||||
|
@ -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;
|
@ -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);
|
||||
}
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
|
@ -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),
|
||||
|
@ -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) => {
|
||||
|
@ -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'>
|
||||
|
12
frontend/appflowy_tauri/src/appflowy_app/views/TrashPage.tsx
Normal file
12
frontend/appflowy_tauri/src/appflowy_app/views/TrashPage.tsx
Normal 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;
|
@ -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);
|
||||
}
|
@ -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
|
||||
*/
|
||||
|
||||
|
@ -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;
|
||||
|
@ -46,7 +46,7 @@ StyleDictionary.extend({
|
||||
{
|
||||
format: 'css/variables',
|
||||
destination: 'light.variables.css',
|
||||
selector: '[data-dark-mode=false]',
|
||||
selector: '',
|
||||
options: {
|
||||
outputReferences: true
|
||||
}
|
||||
|
@ -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
|
||||
*/
|
||||
|
||||
|
@ -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
|
||||
*/
|
||||
|
||||
|
@ -12,7 +12,7 @@ module.exports = {
|
||||
theme: {
|
||||
extend: {
|
||||
colors,
|
||||
boxShadow
|
||||
boxShadow,
|
||||
},
|
||||
},
|
||||
plugins: [],
|
||||
|
@ -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."
|
||||
}
|
||||
}
|
||||
|
Loading…
Reference in New Issue
Block a user