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-katex": "^3.0.1",
|
||||||
"react-redux": "^8.0.5",
|
"react-redux": "^8.0.5",
|
||||||
"react-router-dom": "^6.8.0",
|
"react-router-dom": "^6.8.0",
|
||||||
|
"react-transition-group": "^4.4.5",
|
||||||
"react18-input-otp": "^1.1.2",
|
"react18-input-otp": "^1.1.2",
|
||||||
"redux": "^4.2.1",
|
"redux": "^4.2.1",
|
||||||
"rxjs": "^7.8.0",
|
"rxjs": "^7.8.0",
|
||||||
@ -73,6 +74,7 @@
|
|||||||
"@types/react-beautiful-dnd": "^13.1.3",
|
"@types/react-beautiful-dnd": "^13.1.3",
|
||||||
"@types/react-dom": "^18.0.6",
|
"@types/react-dom": "^18.0.6",
|
||||||
"@types/react-katex": "^3.0.0",
|
"@types/react-katex": "^3.0.0",
|
||||||
|
"@types/react-transition-group": "^4.4.6",
|
||||||
"@types/utf8": "^3.0.1",
|
"@types/utf8": "^3.0.1",
|
||||||
"@types/uuid": "^9.0.1",
|
"@types/uuid": "^9.0.1",
|
||||||
"@typescript-eslint/eslint-plugin": "^5.51.0",
|
"@typescript-eslint/eslint-plugin": "^5.51.0",
|
||||||
|
@ -106,6 +106,9 @@ dependencies:
|
|||||||
react-router-dom:
|
react-router-dom:
|
||||||
specifier: ^6.8.0
|
specifier: ^6.8.0
|
||||||
version: 6.11.1(react-dom@18.2.0)(react@18.2.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:
|
react18-input-otp:
|
||||||
specifier: ^1.1.2
|
specifier: ^1.1.2
|
||||||
version: 1.1.3(react-dom@18.2.0)(react@18.2.0)
|
version: 1.1.3(react-dom@18.2.0)(react@18.2.0)
|
||||||
@ -165,6 +168,9 @@ devDependencies:
|
|||||||
'@types/react-katex':
|
'@types/react-katex':
|
||||||
specifier: ^3.0.0
|
specifier: ^3.0.0
|
||||||
version: 3.0.0
|
version: 3.0.0
|
||||||
|
'@types/react-transition-group':
|
||||||
|
specifier: ^4.4.6
|
||||||
|
version: 4.4.6
|
||||||
'@types/utf8':
|
'@types/utf8':
|
||||||
specifier: ^3.0.1
|
specifier: ^3.0.1
|
||||||
version: 3.0.1
|
version: 3.0.1
|
||||||
@ -1725,7 +1731,6 @@ packages:
|
|||||||
resolution: {integrity: sha512-VnCdSxfcm08KjsJVQcfBmhEQAPnLB8G08hAxn39azX1qYBQ/5RVQuoHuKIcfKOdncuaUvEpFKFzEvbtIMsfVew==}
|
resolution: {integrity: sha512-VnCdSxfcm08KjsJVQcfBmhEQAPnLB8G08hAxn39azX1qYBQ/5RVQuoHuKIcfKOdncuaUvEpFKFzEvbtIMsfVew==}
|
||||||
dependencies:
|
dependencies:
|
||||||
'@types/react': 18.2.6
|
'@types/react': 18.2.6
|
||||||
dev: false
|
|
||||||
|
|
||||||
/@types/react@17.0.59:
|
/@types/react@17.0.59:
|
||||||
resolution: {integrity: sha512-gSON5zWYIGyoBcycCE75E9+r6dCC2dHdsrVkOEiIYNU5+Q28HcBAuqvDuxHcCbMfHBHdeT5Tva/AFn3rnMKE4g==}
|
resolution: {integrity: sha512-gSON5zWYIGyoBcycCE75E9+r6dCC2dHdsrVkOEiIYNU5+Q28HcBAuqvDuxHcCbMfHBHdeT5Tva/AFn3rnMKE4g==}
|
||||||
|
@ -44,7 +44,8 @@ function flattenJSON(obj, prefix = '') {
|
|||||||
const nestedKeys = flattenJSON(obj[key], `${prefix}${key}.`);
|
const nestedKeys = flattenJSON(obj[key], `${prefix}${key}.`);
|
||||||
result = { ...result, ...nestedKeys };
|
result = { ...result, ...nestedKeys };
|
||||||
} else {
|
} 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 { ThemeProvider } from '@mui/material';
|
||||||
import { useUserSetting } from '$app/AppMain.hooks';
|
import { useUserSetting } from '$app/AppMain.hooks';
|
||||||
import { UserSettingControllerContext } from '$app/components/_shared/app-hooks/useUserSettingControllerContext';
|
import { UserSettingControllerContext } from '$app/components/_shared/app-hooks/useUserSettingControllerContext';
|
||||||
|
import TrashPage from '$app/views/TrashPage';
|
||||||
|
|
||||||
function AppMain() {
|
function AppMain() {
|
||||||
const { muiTheme, userSettingController } = useUserSetting();
|
const { muiTheme, userSettingController } = useUserSetting();
|
||||||
@ -29,6 +30,7 @@ function AppMain() {
|
|||||||
<Route path={'/page/document/:id'} element={<DocumentPage />} />
|
<Route path={'/page/document/:id'} element={<DocumentPage />} />
|
||||||
<Route path={'/page/board/:id'} element={<BoardPage />} />
|
<Route path={'/page/board/:id'} element={<BoardPage />} />
|
||||||
<Route path={'/page/grid/:id'} element={<GridPage />} />
|
<Route path={'/page/grid/:id'} element={<GridPage />} />
|
||||||
|
<Route path={'/trash'} id={'trash'} element={<TrashPage />} />
|
||||||
</Route>
|
</Route>
|
||||||
<Route path={'/auth/login'} element={<LoginPage />}></Route>
|
<Route path={'/auth/login'} element={<LoginPage />}></Route>
|
||||||
<Route path={'/auth/getStarted'} element={<GetStarted />}></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 { Outlet } from 'react-router-dom';
|
||||||
import { useAuth } from './auth.hooks';
|
import { useAuth } from './auth.hooks';
|
||||||
import { Screen } from '../layout/Screen';
|
import Layout from '$app/components/layout/Layout';
|
||||||
import { useEffect, useState } from 'react';
|
import { useEffect, useState } from 'react';
|
||||||
import { GetStarted } from './GetStarted/GetStarted';
|
import { GetStarted } from './GetStarted/GetStarted';
|
||||||
import { AppflowyLogo } from '../_shared/svg/AppflowyLogo';
|
import { AppflowyLogo } from '../_shared/svg/AppflowyLogo';
|
||||||
|
|
||||||
|
|
||||||
export const ProtectedRoutes = () => {
|
export const ProtectedRoutes = () => {
|
||||||
const { currentUser, checkUser } = useAuth();
|
const { currentUser, checkUser } = useAuth();
|
||||||
const [isLoading, setIsLoading] = useState(true);
|
const [isLoading, setIsLoading] = useState(true);
|
||||||
@ -13,15 +12,14 @@ export const ProtectedRoutes = () => {
|
|||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
void checkUser().then(async (result) => {
|
void checkUser().then(async (result) => {
|
||||||
await new Promise(() =>
|
await new Promise(() =>
|
||||||
setTimeout(() => {
|
setTimeout(() => {
|
||||||
setIsLoading(false);
|
setIsLoading(false);
|
||||||
}, 1200)
|
}, 1200)
|
||||||
);
|
);
|
||||||
|
|
||||||
if (result.err) {
|
if (result.err) {
|
||||||
throw new Error(result.val.msg);
|
throw new Error(result.val.msg);
|
||||||
}
|
}
|
||||||
|
|
||||||
});
|
});
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
@ -46,9 +44,9 @@ const StartLoading = () => {
|
|||||||
const SplashScreen = ({ isAuthenticated }: { isAuthenticated: boolean }) => {
|
const SplashScreen = ({ isAuthenticated }: { isAuthenticated: boolean }) => {
|
||||||
if (isAuthenticated) {
|
if (isAuthenticated) {
|
||||||
return (
|
return (
|
||||||
<Screen>
|
<Layout>
|
||||||
<Outlet />
|
<Outlet />
|
||||||
</Screen>
|
</Layout>
|
||||||
);
|
);
|
||||||
} else {
|
} else {
|
||||||
return <GetStarted></GetStarted>;
|
return <GetStarted></GetStarted>;
|
||||||
|
@ -46,7 +46,7 @@ export const useAuth = () => {
|
|||||||
if (authResult.ok) {
|
if (authResult.ok) {
|
||||||
const userProfile = authResult.val;
|
const userProfile = authResult.val;
|
||||||
// Get the workspace setting after user registered. The workspace setting
|
// 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();
|
const openWorkspaceResult = await _openWorkspace();
|
||||||
|
|
||||||
if (openWorkspaceResult.ok) {
|
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';
|
import { useSubscribeDocument } from '$app/components/document/_shared/SubscribeDoc.hooks';
|
||||||
|
|
||||||
const headingBlockTopOffset: Record<number, number> = {
|
const headingBlockTopOffset: Record<number, number> = {
|
||||||
1: 7,
|
1: 6,
|
||||||
2: 5,
|
2: 4,
|
||||||
3: 4,
|
3: 3,
|
||||||
};
|
};
|
||||||
|
|
||||||
export function useBlockSideToolbar({ container }: { container: HTMLDivElement }) {
|
export function useBlockSideToolbar({ container }: { container: HTMLDivElement }) {
|
||||||
@ -32,7 +32,7 @@ export function useBlockSideToolbar({ container }: { container: HTMLDivElement }
|
|||||||
});
|
});
|
||||||
return;
|
return;
|
||||||
} else {
|
} else {
|
||||||
let top = 2;
|
let top = 0;
|
||||||
|
|
||||||
if (node.type === BlockType.HeadingBlock) {
|
if (node.type === BlockType.HeadingBlock) {
|
||||||
const nodeData = node.data as HeadingBlockData;
|
const nodeData = node.data as HeadingBlockData;
|
||||||
|
@ -30,7 +30,7 @@ export const Grid = ({ viewId }: { viewId: string }) => {
|
|||||||
<GridToolbar />
|
<GridToolbar />
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* table component view with text area for td */}
|
{/* table component page with text area for td */}
|
||||||
<div className='flex flex-col gap-4'>
|
<div className='flex flex-col gap-4'>
|
||||||
<table className='w-full table-fixed text-sm'>
|
<table className='w-full table-fixed text-sm'>
|
||||||
<GridTableHeader controller={controller} />
|
<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;
|
defaultValue: string;
|
||||||
open: boolean;
|
open: boolean;
|
||||||
onClose: () => void;
|
onClose: () => void;
|
||||||
onOk: (val: string) => void;
|
onOk: (val: string) => Promise<void>;
|
||||||
}) {
|
}) {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
const [value, setValue] = useState(defaultValue);
|
const [value, setValue] = useState(defaultValue);
|
||||||
|
const [error, setError] = useState(false);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Dialog keepMounted={false} onMouseDown={(e) => e.stopPropagation()} open={open} onClose={onClose}>
|
<Dialog keepMounted={false} onMouseDown={(e) => e.stopPropagation()} open={open} onClose={onClose}>
|
||||||
<DialogTitle>{t('menuAppHeader.renameDialog')}</DialogTitle>
|
<DialogTitle>{t('menuAppHeader.renameDialog')}</DialogTitle>
|
||||||
<DialogContent className={'flex w-[540px]'}>
|
<DialogContent className={'flex w-[540px]'}>
|
||||||
<TextField
|
<TextField
|
||||||
|
error={error}
|
||||||
autoFocus
|
autoFocus
|
||||||
value={value}
|
value={value}
|
||||||
onChange={(e) => {
|
onChange={(e) => {
|
||||||
@ -38,8 +40,12 @@ function RenameDialog({
|
|||||||
<DialogActions>
|
<DialogActions>
|
||||||
<Button onClick={onClose}>{t('button.Cancel')}</Button>
|
<Button onClick={onClose}>{t('button.Cancel')}</Button>
|
||||||
<Button
|
<Button
|
||||||
onClick={() => {
|
onClick={async () => {
|
||||||
onOk(value);
|
try {
|
||||||
|
await onOk(value);
|
||||||
|
} catch (e) {
|
||||||
|
setError(true);
|
||||||
|
}
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
{t('button.OK')}
|
{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 { useAppSelector } from '$app/stores/store';
|
||||||
import UserSetting from '$app/components/layout/UserSetting';
|
import { Avatar } from '@mui/material';
|
||||||
import { useState } from 'react';
|
|
||||||
import PersonOutline from '@mui/icons-material/PersonOutline';
|
import PersonOutline from '@mui/icons-material/PersonOutline';
|
||||||
import { Avatar, IconButton } from '@mui/material';
|
|
||||||
import ArrowDropDown from '@mui/icons-material/ArrowDropDown';
|
import ArrowDropDown from '@mui/icons-material/ArrowDropDown';
|
||||||
|
import UserSetting from '../UserSetting';
|
||||||
|
|
||||||
export const WorkspaceUser = () => {
|
function UserInfo() {
|
||||||
const currentUser = useAppSelector((state) => state.currentUser);
|
const currentUser = useAppSelector((state) => state.currentUser);
|
||||||
const [showUserSetting, setShowUserSetting] = useState(false);
|
const [showUserSetting, setShowUserSetting] = useState(false);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className={'flex items-center justify-between px-2 py-2'}>
|
<>
|
||||||
<div
|
<div
|
||||||
onClick={(e) => {
|
onClick={(e) => {
|
||||||
e.stopPropagation();
|
e.stopPropagation();
|
||||||
setShowUserSetting(!showUserSetting);
|
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
|
<Avatar
|
||||||
sx={{
|
sx={{
|
||||||
@ -28,13 +28,15 @@ export const WorkspaceUser = () => {
|
|||||||
>
|
>
|
||||||
<PersonOutline />
|
<PersonOutline />
|
||||||
</Avatar>
|
</Avatar>
|
||||||
<span className={'ml-2'}>{currentUser.displayName}</span>
|
<span className={'ml-2 text-sm'}>{currentUser.displayName}</span>
|
||||||
<button className={'ml-1 rounded hover:bg-fill-list-hover'}>
|
<button className={'ml-2 rounded hover:bg-fill-list-hover'}>
|
||||||
<ArrowDropDown />
|
<ArrowDropDown />
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<UserSetting open={showUserSetting} onClose={() => setShowUserSetting(false)} />
|
<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 React, { useMemo } from 'react';
|
||||||
import { MenuItem } from './Menu';
|
import { MenuItem } from './Menu';
|
||||||
import AppearanceSetting from '$app/components/layout/UserSetting/AppearanceSetting';
|
import AppearanceSetting from './AppearanceSetting';
|
||||||
import LanguageSetting from '$app/components/layout/UserSetting/LanguageSetting';
|
import LanguageSetting from './LanguageSetting';
|
||||||
|
|
||||||
import { UserSetting } from '$app/interfaces';
|
import { UserSetting } from '$app/interfaces';
|
||||||
|
|
||||||
|
@ -3,8 +3,8 @@ import Dialog from '@mui/material/Dialog';
|
|||||||
import DialogContent from '@mui/material/DialogContent';
|
import DialogContent from '@mui/material/DialogContent';
|
||||||
import DialogTitle from '@mui/material/DialogTitle';
|
import DialogTitle from '@mui/material/DialogTitle';
|
||||||
import Slide, { SlideProps } from '@mui/material/Slide';
|
import Slide, { SlideProps } from '@mui/material/Slide';
|
||||||
import UserSettingMenu, { MenuItem } from '$app/components/layout/UserSetting/Menu';
|
import UserSettingMenu, { MenuItem } from './Menu';
|
||||||
import UserSettingPanel from '$app/components/layout/UserSetting/SettingPanel';
|
import UserSettingPanel from './SettingPanel';
|
||||||
import { Theme, UserSetting } from '$app/interfaces';
|
import { Theme, UserSetting } from '$app/interfaces';
|
||||||
import { useAppDispatch, useAppSelector } from '$app/stores/store';
|
import { useAppDispatch, useAppSelector } from '$app/stores/store';
|
||||||
import { currentUserActions } from '$app_reducers/current-user/slice';
|
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 { makeSingleSelectTypeOptionContext } from '../../stores/effects/database/field/type_option/type_option_context';
|
||||||
import { SelectOptionBackendService } from '../../stores/effects/database/cell/select_option_bd_svc';
|
import { SelectOptionBackendService } from '../../stores/effects/database/cell/select_option_bd_svc';
|
||||||
import { Log } from '$app/utils/log';
|
import { Log } from '$app/utils/log';
|
||||||
import { ViewBackendService } from '$app/stores/effects/folder/view/view_bd_svc';
|
import { WorkspaceController } from '../../stores/effects/workspace/workspace_controller';
|
||||||
import { WorkspaceBackendService } from '$app/stores/effects/folder/workspace/workspace_bd_svc';
|
|
||||||
|
|
||||||
// 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
|
// Do not use it production code. Just for testing
|
||||||
export async function createTestDatabaseView(layout: ViewLayoutPB): Promise<ViewPB> {
|
export async function createTestDatabaseView(layout: ViewLayoutPB): Promise<ViewPB> {
|
||||||
const workspaceSetting: WorkspaceSettingPB = await FolderEventGetCurrentWorkspace().then((result) => result.unwrap());
|
const workspaceSetting: WorkspaceSettingPB = await FolderEventGetCurrentWorkspace().then((result) => result.unwrap());
|
||||||
const wsSvc = new WorkspaceBackendService(workspaceSetting.workspace.id);
|
const wsSvc = new WorkspaceController(workspaceSetting.workspace.id);
|
||||||
const viewRes = await wsSvc.createView({ name: 'New Grid', layoutType: layout });
|
const viewRes = await wsSvc.createView({ name: 'New Grid', layout });
|
||||||
if (viewRes.ok) {
|
|
||||||
return viewRes.val;
|
return viewRes;
|
||||||
} else {
|
|
||||||
throw Error(viewRes.val.msg);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function openTestDatabase(viewId: string): Promise<DatabaseController> {
|
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) =>
|
const cellController = await makeTextCellController(fieldId, rowInfo, databaseController).then((result) =>
|
||||||
result.unwrap()
|
result.unwrap()
|
||||||
);
|
);
|
||||||
|
|
||||||
cellController.subscribeChanged({
|
cellController.subscribeChanged({
|
||||||
onCellChanged: (value) => {
|
onCellChanged: (value) => {
|
||||||
const cellContent = value.unwrap();
|
const cellContent = value.unwrap();
|
||||||
|
|
||||||
if (cellContent !== expectedContent) {
|
if (cellContent !== expectedContent) {
|
||||||
throw Error('Text cell content is not match');
|
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) =>
|
const cellController = await makeTextCellController(fieldId, rowInfo, databaseController).then((result) =>
|
||||||
result.unwrap()
|
result.unwrap()
|
||||||
);
|
);
|
||||||
|
|
||||||
await cellController.saveCellData(content);
|
await cellController.saveCellData(content);
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -87,6 +86,7 @@ export async function makeTextCellController(
|
|||||||
const builder = await makeCellControllerBuilder(fieldId, rowInfo, FieldType.RichText, databaseController).then(
|
const builder = await makeCellControllerBuilder(fieldId, rowInfo, FieldType.RichText, databaseController).then(
|
||||||
(result) => result.unwrap()
|
(result) => result.unwrap()
|
||||||
);
|
);
|
||||||
|
|
||||||
return Some(builder.build() as TextCellController);
|
return Some(builder.build() as TextCellController);
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -98,6 +98,7 @@ export async function makeNumberCellController(
|
|||||||
const builder = await makeCellControllerBuilder(fieldId, rowInfo, FieldType.Number, databaseController).then(
|
const builder = await makeCellControllerBuilder(fieldId, rowInfo, FieldType.Number, databaseController).then(
|
||||||
(result) => result.unwrap()
|
(result) => result.unwrap()
|
||||||
);
|
);
|
||||||
|
|
||||||
return Some(builder.build() as NumberCellController);
|
return Some(builder.build() as NumberCellController);
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -109,6 +110,7 @@ export async function makeSingleSelectCellController(
|
|||||||
const builder = await makeCellControllerBuilder(fieldId, rowInfo, FieldType.SingleSelect, databaseController).then(
|
const builder = await makeCellControllerBuilder(fieldId, rowInfo, FieldType.SingleSelect, databaseController).then(
|
||||||
(result) => result.unwrap()
|
(result) => result.unwrap()
|
||||||
);
|
);
|
||||||
|
|
||||||
return Some(builder.build() as SelectOptionCellController);
|
return Some(builder.build() as SelectOptionCellController);
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -120,6 +122,7 @@ export async function makeMultiSelectCellController(
|
|||||||
const builder = await makeCellControllerBuilder(fieldId, rowInfo, FieldType.MultiSelect, databaseController).then(
|
const builder = await makeCellControllerBuilder(fieldId, rowInfo, FieldType.MultiSelect, databaseController).then(
|
||||||
(result) => result.unwrap()
|
(result) => result.unwrap()
|
||||||
);
|
);
|
||||||
|
|
||||||
return Some(builder.build() as SelectOptionCellController);
|
return Some(builder.build() as SelectOptionCellController);
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -131,6 +134,7 @@ export async function makeDateCellController(
|
|||||||
const builder = await makeCellControllerBuilder(fieldId, rowInfo, FieldType.DateTime, databaseController).then(
|
const builder = await makeCellControllerBuilder(fieldId, rowInfo, FieldType.DateTime, databaseController).then(
|
||||||
(result) => result.unwrap()
|
(result) => result.unwrap()
|
||||||
);
|
);
|
||||||
|
|
||||||
return Some(builder.build() as DateCellController);
|
return Some(builder.build() as DateCellController);
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -142,6 +146,7 @@ export async function makeCheckboxCellController(
|
|||||||
const builder = await makeCellControllerBuilder(fieldId, rowInfo, FieldType.Checkbox, databaseController).then(
|
const builder = await makeCellControllerBuilder(fieldId, rowInfo, FieldType.Checkbox, databaseController).then(
|
||||||
(result) => result.unwrap()
|
(result) => result.unwrap()
|
||||||
);
|
);
|
||||||
|
|
||||||
return Some(builder.build() as CheckboxCellController);
|
return Some(builder.build() as CheckboxCellController);
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -153,6 +158,7 @@ export async function makeURLCellController(
|
|||||||
const builder = await makeCellControllerBuilder(fieldId, rowInfo, FieldType.DateTime, databaseController).then(
|
const builder = await makeCellControllerBuilder(fieldId, rowInfo, FieldType.DateTime, databaseController).then(
|
||||||
(result) => result.unwrap()
|
(result) => result.unwrap()
|
||||||
);
|
);
|
||||||
|
|
||||||
return Some(builder.build() as URLCellController);
|
return Some(builder.build() as URLCellController);
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -167,8 +173,10 @@ export async function makeCellControllerBuilder(
|
|||||||
const fieldController = databaseController.fieldController;
|
const fieldController = databaseController.fieldController;
|
||||||
const rowController = new RowController(rowInfo, fieldController, rowCache);
|
const rowController = new RowController(rowInfo, fieldController, rowCache);
|
||||||
const cellByFieldId = await rowController.loadCells();
|
const cellByFieldId = await rowController.loadCells();
|
||||||
|
|
||||||
for (const cellIdentifier of cellByFieldId.values()) {
|
for (const cellIdentifier of cellByFieldId.values()) {
|
||||||
const builder = new CellControllerBuilder(cellIdentifier, cellCache, fieldController);
|
const builder = new CellControllerBuilder(cellIdentifier, cellCache, fieldController);
|
||||||
|
|
||||||
if (cellIdentifier.fieldId === fieldId) {
|
if (cellIdentifier.fieldId === fieldId) {
|
||||||
return Some(builder);
|
return Some(builder);
|
||||||
}
|
}
|
||||||
@ -179,6 +187,7 @@ export async function makeCellControllerBuilder(
|
|||||||
|
|
||||||
export function findFirstFieldInfoWithFieldType(rowInfo: RowInfo, fieldType: FieldType) {
|
export function findFirstFieldInfoWithFieldType(rowInfo: RowInfo, fieldType: FieldType) {
|
||||||
const fieldInfo = rowInfo.fieldInfos.find((element) => element.field.field_type === fieldType);
|
const fieldInfo = rowInfo.fieldInfos.find((element) => element.field.field_type === fieldType);
|
||||||
|
|
||||||
if (fieldInfo === undefined) {
|
if (fieldInfo === undefined) {
|
||||||
return None;
|
return None;
|
||||||
} else {
|
} else {
|
||||||
@ -189,6 +198,7 @@ export function findFirstFieldInfoWithFieldType(rowInfo: RowInfo, fieldType: Fie
|
|||||||
export async function assertFieldName(viewId: string, fieldId: string, fieldType: FieldType, expected: string) {
|
export async function assertFieldName(viewId: string, fieldId: string, fieldType: FieldType, expected: string) {
|
||||||
const svc = new TypeOptionBackendService(viewId);
|
const svc = new TypeOptionBackendService(viewId);
|
||||||
const typeOptionPB = await svc.getTypeOption(fieldId, fieldType).then((result) => result.unwrap());
|
const typeOptionPB = await svc.getTypeOption(fieldId, fieldType).then((result) => result.unwrap());
|
||||||
|
|
||||||
if (typeOptionPB.field.name !== expected) {
|
if (typeOptionPB.field.name !== expected) {
|
||||||
throw Error('Expect field name:' + expected + 'but receive:' + typeOptionPB.field.name);
|
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) {
|
export async function assertNumberOfFields(viewId: string, expected: number) {
|
||||||
const svc = new DatabaseBackendService(viewId);
|
const svc = new DatabaseBackendService(viewId);
|
||||||
const databasePB = await svc.openDatabase().then((result) => result.unwrap());
|
const databasePB = await svc.openDatabase().then((result) => result.unwrap());
|
||||||
|
|
||||||
if (databasePB.fields.length !== expected) {
|
if (databasePB.fields.length !== expected) {
|
||||||
throw Error('Expect number of fields:' + expected + 'but receive:' + databasePB.fields.length);
|
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) {
|
export async function assertNumberOfRows(viewId: string, expected: number) {
|
||||||
const svc = new DatabaseBackendService(viewId);
|
const svc = new DatabaseBackendService(viewId);
|
||||||
const databasePB = await svc.openDatabase().then((result) => result.unwrap());
|
const databasePB = await svc.openDatabase().then((result) => result.unwrap());
|
||||||
|
|
||||||
if (databasePB.rows.length !== expected) {
|
if (databasePB.rows.length !== expected) {
|
||||||
throw Error('Expect number of rows:' + expected + 'but receive:' + databasePB.rows.length);
|
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) {
|
export async function assertNumberOfRowsInGroup(viewId: string, groupId: string, expected: number) {
|
||||||
const svc = new DatabaseBackendService(viewId);
|
const svc = new DatabaseBackendService(viewId);
|
||||||
|
|
||||||
await svc.openDatabase();
|
await svc.openDatabase();
|
||||||
|
|
||||||
const group = await svc.getGroup(groupId).then((result) => result.unwrap());
|
const group = await svc.getGroup(groupId).then((result) => result.unwrap());
|
||||||
|
|
||||||
if (group.rows.length !== expected) {
|
if (group.rows.length !== expected) {
|
||||||
throw Error('Expect number of rows in group:' + expected + 'but receive:' + group.rows.length);
|
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());
|
.then((result) => result.unwrap());
|
||||||
|
|
||||||
const backendSvc = new SelectOptionBackendService(viewId, fieldInfo.field.id);
|
const backendSvc = new SelectOptionBackendService(viewId, fieldInfo.field.id);
|
||||||
|
|
||||||
for (const optionName of optionNames) {
|
for (const optionName of optionNames) {
|
||||||
const option = await backendSvc.createOption({ name: optionName }).then((result) => result.unwrap());
|
const option = await backendSvc.createOption({ name: optionName }).then((result) => result.unwrap());
|
||||||
|
|
||||||
singleSelectTypeOptionPB.options.splice(0, 0, option);
|
singleSelectTypeOptionPB.options.splice(0, 0, option);
|
||||||
}
|
}
|
||||||
|
|
||||||
await singleSelectTypeOptionContext.setTypeOption(singleSelectTypeOptionPB);
|
await singleSelectTypeOptionContext.setTypeOption(singleSelectTypeOptionPB);
|
||||||
return singleSelectTypeOptionContext;
|
return singleSelectTypeOptionContext;
|
||||||
}
|
}
|
||||||
|
@ -1,15 +1,11 @@
|
|||||||
import { ViewLayoutPB, WorkspaceSettingPB } from '@/services/backend';
|
import { ViewLayoutPB, WorkspaceSettingPB } from '@/services/backend';
|
||||||
import { FolderEventGetCurrentWorkspace } from '@/services/backend/events/flowy-folder2';
|
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() {
|
export async function createTestDocument() {
|
||||||
const workspaceSetting: WorkspaceSettingPB = await FolderEventGetCurrentWorkspace().then((result) => result.unwrap());
|
const workspaceSetting: WorkspaceSettingPB = await FolderEventGetCurrentWorkspace().then((result) => result.unwrap());
|
||||||
const appService = new WorkspaceBackendService(workspaceSetting.workspace.id);
|
const appService = new WorkspaceController(workspaceSetting.workspace.id);
|
||||||
const result = await appService.createView({ name: 'New Document', layoutType: ViewLayoutPB.Document });
|
const result = await appService.createView({ name: 'New Document', layout: ViewLayoutPB.Document });
|
||||||
if (result.ok) {
|
|
||||||
return result.val;
|
return result;
|
||||||
}
|
|
||||||
else {
|
|
||||||
throw Error(result.val.msg);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
@ -1,7 +1,7 @@
|
|||||||
import React from 'react';
|
import React from 'react';
|
||||||
import { UserBackendService } from '$app/stores/effects/user/user_bd_svc';
|
import { UserBackendService } from '$app/stores/effects/user/user_bd_svc';
|
||||||
import { useAppSelector } from '$app/stores/store';
|
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';
|
import { ViewLayoutPB, ViewPB } from '@/services/backend';
|
||||||
|
|
||||||
const testCreateFolder = async (userId?: number) => {
|
const testCreateFolder = async (userId?: number) => {
|
||||||
@ -9,36 +9,41 @@ const testCreateFolder = async (userId?: number) => {
|
|||||||
console.log('user is not logged in');
|
console.log('user is not logged in');
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
console.log('test create views');
|
console.log('test create views');
|
||||||
const userBackendService: UserBackendService = new UserBackendService(userId);
|
const userBackendService: UserBackendService = new UserBackendService(userId);
|
||||||
const workspaces = await userBackendService.getWorkspaces();
|
const workspaces = await userBackendService.getWorkspaces();
|
||||||
|
|
||||||
if (workspaces.ok) {
|
if (workspaces.ok) {
|
||||||
console.log('workspaces: ', workspaces.val.toObject());
|
console.log('workspaces: ', workspaces.val.toObject());
|
||||||
}
|
}
|
||||||
|
|
||||||
const currentWorkspace = await userBackendService.getCurrentWorkspace();
|
const currentWorkspace = await userBackendService.getCurrentWorkspace();
|
||||||
|
|
||||||
const workspaceService = new WorkspaceBackendService(currentWorkspace.workspace.id);
|
const workspaceService = new WorkspaceController(currentWorkspace.workspace.id);
|
||||||
const rootViews: ViewPB[] = [];
|
const rootViews: ViewPB[] = [];
|
||||||
|
|
||||||
for (let i = 1; i <= 3; i++) {
|
for (let i = 1; i <= 3; i++) {
|
||||||
const result = await workspaceService.createView({
|
const result = await workspaceService.createView({
|
||||||
name: `test board ${i}`,
|
name: `test board ${i}`,
|
||||||
desc: 'test description',
|
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++) {
|
for (let i = 1; i <= 3; i++) {
|
||||||
const result = await workspaceService.createView({
|
const result = await workspaceService.createView({
|
||||||
name: `test board 1 ${i}`,
|
name: `test board 1 ${i}`,
|
||||||
desc: 'test description',
|
desc: 'test description',
|
||||||
layoutType: ViewLayoutPB.Board,
|
layout: ViewLayoutPB.Board,
|
||||||
parentViewId: rootViews[0].id,
|
parent_view_id: rootViews[0].id,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
const allApps = await workspaceService.getAllViews();
|
const allApps = await workspaceService.getChildPages();
|
||||||
|
|
||||||
console.log(allApps);
|
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 { OnNotificationError, AFNotificationObserver } from '@/services/backend/notifications';
|
||||||
import { FolderNotificationParser } from './parser';
|
|
||||||
import { FlowyError, FolderNotification } from '@/services/backend';
|
import { FlowyError, FolderNotification } from '@/services/backend';
|
||||||
import { Result } from 'ts-results';
|
import { Result } from 'ts-results';
|
||||||
|
import { WorkspaceNotificationParser } from './parser';
|
||||||
|
|
||||||
export type ParserHandler = (notification: FolderNotification, payload: Result<Uint8Array, FlowyError>) => void;
|
export type ParserHandler = (notification: FolderNotification, payload: Result<Uint8Array, FlowyError>) => void;
|
||||||
|
|
||||||
export class FolderNotificationObserver extends AFNotificationObserver<FolderNotification> {
|
export class WorkspaceNotificationObserver extends AFNotificationObserver<FolderNotification> {
|
||||||
constructor(params: { viewId?: string; parserHandler: ParserHandler; onError?: OnNotificationError }) {
|
constructor(params: { id?: string; parserHandler: ParserHandler; onError?: OnNotificationError }) {
|
||||||
const parser = new FolderNotificationParser({
|
const parser = new WorkspaceNotificationParser({
|
||||||
callback: params.parserHandler,
|
callback: params.parserHandler,
|
||||||
id: params.viewId,
|
id: params.id,
|
||||||
});
|
});
|
||||||
|
|
||||||
super(parser);
|
super(parser);
|
||||||
}
|
}
|
||||||
}
|
}
|
@ -2,14 +2,15 @@ import { NotificationParser, OnNotificationError } from '@/services/backend/noti
|
|||||||
import { FlowyError, FolderNotification } from '@/services/backend';
|
import { FlowyError, FolderNotification } from '@/services/backend';
|
||||||
import { Result } from 'ts-results';
|
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> {
|
export class WorkspaceNotificationParser extends NotificationParser<FolderNotification> {
|
||||||
constructor(params: { id?: string; callback: FolderNotificationCallback; onError?: OnNotificationError }) {
|
constructor(params: { id?: string; callback: WorkspaceNotificationCallback; onError?: OnNotificationError }) {
|
||||||
super(
|
super(
|
||||||
params.callback,
|
params.callback,
|
||||||
(ty) => {
|
(ty) => {
|
||||||
const notification = FolderNotification[ty];
|
const notification = FolderNotification[ty];
|
||||||
|
|
||||||
if (isFolderNotification(notification)) {
|
if (isFolderNotification(notification)) {
|
||||||
return FolderNotification[notification];
|
return FolderNotification[notification];
|
||||||
} else {
|
} 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 { createSlice, PayloadAction } from '@reduxjs/toolkit';
|
||||||
import { ViewLayoutPB } from '@/services/backend';
|
|
||||||
|
|
||||||
export interface IPage {
|
export interface Page {
|
||||||
id: string;
|
id: string;
|
||||||
title: string;
|
parentId: string;
|
||||||
pageType: ViewLayoutPB;
|
name: string;
|
||||||
parentPageId: string;
|
layout: ViewLayoutPB;
|
||||||
showPagesInside: boolean;
|
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({
|
export const pagesSlice = createSlice({
|
||||||
name: 'pages',
|
name: 'pages',
|
||||||
initialState: initialState,
|
initialState,
|
||||||
reducers: {
|
reducers: {
|
||||||
addInsidePages(state, action: PayloadAction<{ insidePages: IPage[]; currentPageId: string }>) {
|
addChildPages(
|
||||||
return state
|
state,
|
||||||
.filter((page) => page.parentPageId !== action.payload.currentPageId)
|
action: PayloadAction<{
|
||||||
.concat(action.payload.insidePages);
|
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) =>
|
expandPage(state, action: PayloadAction<string>) {
|
||||||
page.id === action.payload.id ? { ...page, showPagesInside: !page.showPagesInside } : page
|
const id = action.payload;
|
||||||
);
|
|
||||||
|
state.expandedPages[id] = true;
|
||||||
},
|
},
|
||||||
renamePage(state, action: PayloadAction<{ id: string; newTitle: string }>) {
|
|
||||||
return state.map<IPage>((page: IPage) =>
|
collapsePage(state, action: PayloadAction<string>) {
|
||||||
page.id === action.payload.id ? { ...page, title: action.payload.newTitle } : page
|
const id = action.payload;
|
||||||
);
|
|
||||||
},
|
state.expandedPages[id] = false;
|
||||||
deletePage(state, action: PayloadAction<{ id: string }>) {
|
|
||||||
return state.filter((page) => page.id !== action.payload.id);
|
|
||||||
},
|
|
||||||
clearPages() {
|
|
||||||
return [];
|
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
@ -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';
|
import { createSlice, PayloadAction } from '@reduxjs/toolkit';
|
||||||
|
|
||||||
export interface IWorkspace {
|
export interface WorkspaceItem {
|
||||||
id?: string;
|
id: string;
|
||||||
name?: string;
|
name: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
interface WorkspaceState {
|
||||||
|
workspaces: WorkspaceItem[];
|
||||||
|
currentWorkspace: WorkspaceItem | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const initialState: WorkspaceState = {
|
||||||
|
workspaces: [],
|
||||||
|
currentWorkspace: null,
|
||||||
|
};
|
||||||
|
|
||||||
export const workspaceSlice = createSlice({
|
export const workspaceSlice = createSlice({
|
||||||
name: 'workspace',
|
name: 'workspace',
|
||||||
initialState: {} as IWorkspace,
|
initialState,
|
||||||
reducers: {
|
reducers: {
|
||||||
updateWorkspace: (state, action: PayloadAction<IWorkspace>) => {
|
initWorkspaces: (
|
||||||
|
state,
|
||||||
|
action: PayloadAction<{
|
||||||
|
workspaces: WorkspaceItem[];
|
||||||
|
currentWorkspace: WorkspaceItem | null;
|
||||||
|
}>
|
||||||
|
) => {
|
||||||
return action.payload;
|
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,
|
addListener,
|
||||||
} from '@reduxjs/toolkit';
|
} from '@reduxjs/toolkit';
|
||||||
import { pagesSlice } from './reducers/pages/slice';
|
import { pagesSlice } from './reducers/pages/slice';
|
||||||
import { navigationWidthSlice } from './reducers/navigation-width/slice';
|
|
||||||
import { currentUserSlice } from './reducers/current-user/slice';
|
import { currentUserSlice } from './reducers/current-user/slice';
|
||||||
import { gridSlice } from './reducers/grid/slice';
|
import { gridSlice } from './reducers/grid/slice';
|
||||||
import { workspaceSlice } from './reducers/workspace/slice';
|
import { workspaceSlice } from './reducers/workspace/slice';
|
||||||
@ -16,7 +15,7 @@ import { databaseSlice } from './reducers/database/slice';
|
|||||||
import { documentReducers } from './reducers/document/slice';
|
import { documentReducers } from './reducers/document/slice';
|
||||||
import { boardSlice } from './reducers/board/slice';
|
import { boardSlice } from './reducers/board/slice';
|
||||||
import { errorSlice } from './reducers/error/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({
|
const listenerMiddlewareInstance = createListenerMiddleware({
|
||||||
onError: () => console.error,
|
onError: () => console.error,
|
||||||
@ -25,14 +24,13 @@ const listenerMiddlewareInstance = createListenerMiddleware({
|
|||||||
const store = configureStore({
|
const store = configureStore({
|
||||||
reducer: {
|
reducer: {
|
||||||
[pagesSlice.name]: pagesSlice.reducer,
|
[pagesSlice.name]: pagesSlice.reducer,
|
||||||
[activePageIdSlice.name]: activePageIdSlice.reducer,
|
|
||||||
[navigationWidthSlice.name]: navigationWidthSlice.reducer,
|
|
||||||
[currentUserSlice.name]: currentUserSlice.reducer,
|
[currentUserSlice.name]: currentUserSlice.reducer,
|
||||||
[gridSlice.name]: gridSlice.reducer,
|
[gridSlice.name]: gridSlice.reducer,
|
||||||
[databaseSlice.name]: databaseSlice.reducer,
|
[databaseSlice.name]: databaseSlice.reducer,
|
||||||
[boardSlice.name]: boardSlice.reducer,
|
[boardSlice.name]: boardSlice.reducer,
|
||||||
[workspaceSlice.name]: workspaceSlice.reducer,
|
[workspaceSlice.name]: workspaceSlice.reducer,
|
||||||
[errorSlice.name]: errorSlice.reducer,
|
[errorSlice.name]: errorSlice.reducer,
|
||||||
|
[sidebarSlice.name]: sidebarSlice.reducer,
|
||||||
...documentReducers,
|
...documentReducers,
|
||||||
},
|
},
|
||||||
middleware: (gDM) => gDM({ serializableCheck: false }).prepend(listenerMiddlewareInstance.middleware),
|
middleware: (gDM) => gDM({ serializableCheck: false }).prepend(listenerMiddlewareInstance.middleware),
|
||||||
|
@ -1,6 +1,6 @@
|
|||||||
import { Log } from '$app/utils/log';
|
import { Log } from '$app/utils/log';
|
||||||
|
|
||||||
export class AsyncQueue<T> {
|
export class AsyncQueue<T = unknown> {
|
||||||
private queue: T[] = [];
|
private queue: T[] = [];
|
||||||
private isProcessing = false;
|
private isProcessing = false;
|
||||||
private executeFunction: (item: T) => Promise<void>;
|
private executeFunction: (item: T) => Promise<void>;
|
||||||
@ -20,6 +20,7 @@ export class AsyncQueue<T> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const item = this.queue.shift();
|
const item = this.queue.shift();
|
||||||
|
|
||||||
this.isProcessing = true;
|
this.isProcessing = true;
|
||||||
|
|
||||||
const executeFn = async (item: T) => {
|
const executeFn = async (item: T) => {
|
||||||
|
@ -7,14 +7,15 @@ export const BoardPage = () => {
|
|||||||
const params = useParams();
|
const params = useParams();
|
||||||
const [viewId, setViewId] = useState('');
|
const [viewId, setViewId] = useState('');
|
||||||
const pagesStore = useAppSelector((state) => state.pages);
|
const pagesStore = useAppSelector((state) => state.pages);
|
||||||
|
const page = useAppSelector((state) => (params.id ? state.pages.map[params.id] : undefined));
|
||||||
const [title, setTitle] = useState('');
|
const [title, setTitle] = useState('');
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (params?.id?.length) {
|
if (page) {
|
||||||
setViewId(params.id);
|
setViewId(page.id);
|
||||||
setTitle(pagesStore.find((page) => page.id === params.id)?.title || '');
|
setTitle(page.name);
|
||||||
}
|
}
|
||||||
}, [params, pagesStore]);
|
}, [params, pagesStore, page]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className='flex h-full flex-col gap-8 px-8 pt-8'>
|
<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 {
|
[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 {
|
.MuiPaper-root.MuiMenu-paper.MuiPopover-paper {
|
||||||
@ -24,7 +24,6 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
.MuiButtonBase-root.MuiIconButton-root.MuiIconButton-sizeMedium {
|
.MuiButtonBase-root.MuiIconButton-root.MuiIconButton-sizeMedium {
|
||||||
color: var(--icon-primary);
|
|
||||||
border-radius: 4px;
|
border-radius: 4px;
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -33,7 +32,6 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
.MuiButtonBase-root.MuiIconButton-root {
|
.MuiButtonBase-root.MuiIconButton-root {
|
||||||
color: var(--icon-primary);
|
|
||||||
border-radius: 4px;
|
border-radius: 4px;
|
||||||
padding: 2px;
|
padding: 2px;
|
||||||
}
|
}
|
||||||
@ -52,4 +50,14 @@
|
|||||||
|
|
||||||
.MuiInput-input[class$='-MuiSelect-select-MuiInputBase-input-MuiInput-input']:focus {
|
.MuiInput-input[class$='-MuiSelect-select-MuiInputBase-input-MuiInput-input']:focus {
|
||||||
background: transparent;
|
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
|
* 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
|
* Generated from $pnpm css:variables
|
||||||
*/
|
*/
|
||||||
|
|
||||||
|
@ -1,10 +1,10 @@
|
|||||||
/**
|
/**
|
||||||
* Do not edit directly
|
* 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
|
* Generated from $pnpm css:variables
|
||||||
*/
|
*/
|
||||||
|
|
||||||
:root[data-dark-mode=false] {
|
:root {
|
||||||
--base-light-neutral-50: #f9fafd;
|
--base-light-neutral-50: #f9fafd;
|
||||||
--base-light-neutral-100: #edeef2;
|
--base-light-neutral-100: #edeef2;
|
||||||
--base-light-neutral-200: #e2e4eb;
|
--base-light-neutral-200: #e2e4eb;
|
||||||
|
@ -46,7 +46,7 @@ StyleDictionary.extend({
|
|||||||
{
|
{
|
||||||
format: 'css/variables',
|
format: 'css/variables',
|
||||||
destination: 'light.variables.css',
|
destination: 'light.variables.css',
|
||||||
selector: '[data-dark-mode=false]',
|
selector: '',
|
||||||
options: {
|
options: {
|
||||||
outputReferences: true
|
outputReferences: true
|
||||||
}
|
}
|
||||||
|
@ -1,6 +1,6 @@
|
|||||||
/**
|
/**
|
||||||
* Do not edit directly
|
* 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
|
* Generated from $pnpm css:variables
|
||||||
*/
|
*/
|
||||||
|
|
||||||
|
@ -1,6 +1,6 @@
|
|||||||
/**
|
/**
|
||||||
* Do not edit directly
|
* 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
|
* Generated from $pnpm css:variables
|
||||||
*/
|
*/
|
||||||
|
|
||||||
|
@ -12,7 +12,7 @@ module.exports = {
|
|||||||
theme: {
|
theme: {
|
||||||
extend: {
|
extend: {
|
||||||
colors,
|
colors,
|
||||||
boxShadow
|
boxShadow,
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
plugins: [],
|
plugins: [],
|
||||||
|
@ -80,6 +80,14 @@
|
|||||||
"fileName": "File name",
|
"fileName": "File name",
|
||||||
"lastModified": "Last Modified",
|
"lastModified": "Last Modified",
|
||||||
"created": "Created"
|
"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": {
|
"deletePagePrompt": {
|
||||||
@ -169,7 +177,8 @@
|
|||||||
"edit": "Edit",
|
"edit": "Edit",
|
||||||
"delete": "Delete",
|
"delete": "Delete",
|
||||||
"duplicate": "Duplicate",
|
"duplicate": "Duplicate",
|
||||||
"done": "Done"
|
"done": "Done",
|
||||||
|
"putback": "Put Back"
|
||||||
},
|
},
|
||||||
"label": {
|
"label": {
|
||||||
"welcome": "Welcome!",
|
"welcome": "Welcome!",
|
||||||
@ -580,5 +589,9 @@
|
|||||||
"fail": "Unable to copy"
|
"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