From b96ad5756813c7ba6cce9c4b9d24eeee88271b80 Mon Sep 17 00:00:00 2001 From: EYHN Date: Thu, 29 Aug 2024 04:01:35 +0000 Subject: [PATCH] feat(core): import template (#8000) --- .../src/modules/workspace/entities/list.ts | 6 + .../components/card/workspace-card/index.tsx | 111 ------ .../card/workspace-card/styles.css.ts | 94 ----- .../components/workspace-list/index.css.ts | 8 - .../src/components/workspace-list/index.tsx | 71 ---- .../component/src/ui/menu/desktop/root.tsx | 16 +- .../src/ui/menu/desktop/styles.css.ts | 34 ++ .../component/src/ui/menu/mobile/root.tsx | 49 +-- .../frontend/component/src/ui/modal/modal.tsx | 21 +- packages/frontend/core/src/atoms/index.ts | 2 - .../core/src/commands/affine-creation.tsx | 11 +- .../page-properties/tags-inline-editor.css.ts | 5 +- .../core/src/components/app-sidebar/index.tsx | 8 +- .../src/components/over-capacity/index.tsx | 66 ++++ .../pure/workspace-slider-bar/index.tsx | 10 - .../workspace-card/index.tsx | 313 ---------------- .../src/components/root-app-sidebar/index.tsx | 9 +- .../components/workspace-selector/index.tsx | 166 +++++++-- .../add-workspace/index.css.ts | 0 .../add-workspace/index.tsx | 0 .../user-with-workspace-list/index.css.ts | 0 .../user-with-workspace-list/index.tsx | 58 +-- .../user-account/index.css.ts | 0 .../user-account/index.tsx | 3 +- .../workspace-list/index.css.ts | 4 + .../workspace-list/index.tsx | 146 ++++---- .../workspace-card/index.tsx | 335 ++++++++++++++++++ .../workspace-card/styles.css.ts | 57 ++- .../hooks/affine/use-doc-engine-status.tsx | 21 -- .../core/src/hooks/use-navigate-helper.ts | 28 +- .../hooks/use-register-workspace-commands.ts | 6 +- .../core/src/hooks/use-workspace-info.ts | 10 +- .../core/src/layouts/workspace-layout.tsx | 8 +- .../create-workspace/entities/dialog.ts | 33 ++ .../src/modules/create-workspace/index.ts | 12 + .../create-workspace/services/dialog.ts | 7 + .../src/modules/create-workspace/types.ts | 7 + .../create-workspace/views/dialog.css.ts | 77 ++++ .../modules/create-workspace/views/dialog.tsx | 272 ++++++++++++++ .../import-template/entities/dialog.ts | 19 + .../import-template/entities/downloader.ts | 51 +++ .../core/src/modules/import-template/index.ts | 22 ++ .../import-template/services/dialog.ts | 7 + .../import-template/services/downloader.ts | 7 + .../import-template/services/import.ts | 47 +++ .../import-template/store/downloader.ts | 21 ++ .../import-template/views/dialog.css.ts | 48 +++ .../modules/import-template/views/dialog.tsx | 249 +++++++++++++ packages/frontend/core/src/modules/index.ts | 4 + .../theme-editor/views/custom-theme.tsx | 3 + .../workbench/view/route-container.tsx | 5 +- .../modules/workbench/view/workbench-root.tsx | 40 +-- .../core/src/pages/import-template.tsx | 21 ++ packages/frontend/core/src/pages/index.tsx | 53 +-- packages/frontend/core/src/pages/root.tsx | 12 + .../core/src/pages/workspace/index.tsx | 2 - .../core/src/providers/modal-provider.tsx | 67 ++-- packages/frontend/core/src/router.tsx | 14 +- packages/frontend/electron/renderer/app.tsx | 2 - packages/frontend/i18n/src/resources/en.json | 7 +- packages/frontend/mobile/src/app.tsx | 2 - .../mobile/src/pages/workspace/layout.tsx | 7 +- packages/frontend/web/src/app.tsx | 2 - .../e2e/local-first-delete-workspace.spec.ts | 1 + .../e2e/local-first-workspace-list.spec.ts | 6 +- tests/kit/utils/properties.ts | 4 + tests/kit/utils/sidebar.ts | 2 +- 67 files changed, 1835 insertions(+), 974 deletions(-) delete mode 100644 packages/frontend/component/src/components/card/workspace-card/index.tsx delete mode 100644 packages/frontend/component/src/components/card/workspace-card/styles.css.ts delete mode 100644 packages/frontend/component/src/components/workspace-list/index.css.ts delete mode 100644 packages/frontend/component/src/components/workspace-list/index.tsx create mode 100644 packages/frontend/component/src/ui/menu/desktop/styles.css.ts create mode 100644 packages/frontend/core/src/components/over-capacity/index.tsx delete mode 100644 packages/frontend/core/src/components/pure/workspace-slider-bar/index.tsx delete mode 100644 packages/frontend/core/src/components/pure/workspace-slider-bar/workspace-card/index.tsx rename packages/frontend/core/src/components/{pure/workspace-slider-bar => workspace-selector}/user-with-workspace-list/add-workspace/index.css.ts (100%) rename packages/frontend/core/src/components/{pure/workspace-slider-bar => workspace-selector}/user-with-workspace-list/add-workspace/index.tsx (100%) rename packages/frontend/core/src/components/{pure/workspace-slider-bar => workspace-selector}/user-with-workspace-list/index.css.ts (100%) rename packages/frontend/core/src/components/{pure/workspace-slider-bar => workspace-selector}/user-with-workspace-list/index.tsx (70%) rename packages/frontend/core/src/components/{pure/workspace-slider-bar => workspace-selector}/user-with-workspace-list/user-account/index.css.ts (100%) rename packages/frontend/core/src/components/{pure/workspace-slider-bar => workspace-selector}/user-with-workspace-list/user-account/index.tsx (80%) rename packages/frontend/core/src/components/{pure/workspace-slider-bar => workspace-selector}/user-with-workspace-list/workspace-list/index.css.ts (90%) rename packages/frontend/core/src/components/{pure/workspace-slider-bar => workspace-selector}/user-with-workspace-list/workspace-list/index.tsx (61%) create mode 100644 packages/frontend/core/src/components/workspace-selector/workspace-card/index.tsx rename packages/frontend/core/src/components/{pure/workspace-slider-bar => workspace-selector}/workspace-card/styles.css.ts (70%) delete mode 100644 packages/frontend/core/src/hooks/affine/use-doc-engine-status.tsx create mode 100644 packages/frontend/core/src/modules/create-workspace/entities/dialog.ts create mode 100644 packages/frontend/core/src/modules/create-workspace/index.ts create mode 100644 packages/frontend/core/src/modules/create-workspace/services/dialog.ts create mode 100644 packages/frontend/core/src/modules/create-workspace/types.ts create mode 100644 packages/frontend/core/src/modules/create-workspace/views/dialog.css.ts create mode 100644 packages/frontend/core/src/modules/create-workspace/views/dialog.tsx create mode 100644 packages/frontend/core/src/modules/import-template/entities/dialog.ts create mode 100644 packages/frontend/core/src/modules/import-template/entities/downloader.ts create mode 100644 packages/frontend/core/src/modules/import-template/index.ts create mode 100644 packages/frontend/core/src/modules/import-template/services/dialog.ts create mode 100644 packages/frontend/core/src/modules/import-template/services/downloader.ts create mode 100644 packages/frontend/core/src/modules/import-template/services/import.ts create mode 100644 packages/frontend/core/src/modules/import-template/store/downloader.ts create mode 100644 packages/frontend/core/src/modules/import-template/views/dialog.css.ts create mode 100644 packages/frontend/core/src/modules/import-template/views/dialog.tsx create mode 100644 packages/frontend/core/src/pages/import-template.tsx create mode 100644 packages/frontend/core/src/pages/root.tsx diff --git a/packages/common/infra/src/modules/workspace/entities/list.ts b/packages/common/infra/src/modules/workspace/entities/list.ts index 52f01c3333..e965262998 100644 --- a/packages/common/infra/src/modules/workspace/entities/list.ts +++ b/packages/common/infra/src/modules/workspace/entities/list.ts @@ -17,6 +17,12 @@ export class WorkspaceList extends Entity { .flat() .map(isLoadings => isLoadings.some(isLoading => isLoading)); + workspace$(id: string) { + return this.workspaces$.map(workspaces => + workspaces.find(workspace => workspace.id === id) + ); + } + constructor(private readonly providers: WorkspaceFlavourProvider[]) { super(); } diff --git a/packages/frontend/component/src/components/card/workspace-card/index.tsx b/packages/frontend/component/src/components/card/workspace-card/index.tsx deleted file mode 100644 index 69f4c8c9b8..0000000000 --- a/packages/frontend/component/src/components/card/workspace-card/index.tsx +++ /dev/null @@ -1,111 +0,0 @@ -import { WorkspaceAvatar } from '@affine/component/workspace-avatar'; -import { UNTITLED_WORKSPACE_NAME } from '@affine/env/constant'; -import { WorkspaceFlavour } from '@affine/env/workspace'; -import { CollaborationIcon, SettingsIcon } from '@blocksuite/icons/rc'; -import type { WorkspaceMetadata } from '@toeverything/infra'; -import { type MouseEvent, useCallback } from 'react'; - -import { Button } from '../../../ui/button'; -import { Skeleton } from '../../../ui/skeleton'; -import * as styles from './styles.css'; - -export interface WorkspaceTypeProps { - flavour: WorkspaceFlavour; - isOwner: boolean; -} - -export interface WorkspaceCardProps { - currentWorkspaceId?: string | null; - meta: WorkspaceMetadata; - onClick: (metadata: WorkspaceMetadata) => void; - onSettingClick: (metadata: WorkspaceMetadata) => void; - onEnableCloudClick?: (meta: WorkspaceMetadata) => void; - isOwner?: boolean; - openingId?: string | null; - enableCloudText?: string; - name?: string; -} - -export const WorkspaceCardSkeleton = () => { - return ( -
-
- - -
-
- ); -}; - -export const WorkspaceCard = ({ - onClick, - onSettingClick, - onEnableCloudClick, - openingId, - currentWorkspaceId, - meta, - isOwner = true, - enableCloudText = 'Enable Cloud', - name, -}: WorkspaceCardProps) => { - const isLocal = meta.flavour === WorkspaceFlavour.LOCAL; - const displayName = name ?? UNTITLED_WORKSPACE_NAME; - - const onEnableCloud = useCallback( - (e: MouseEvent) => { - e.stopPropagation(); - onEnableCloudClick?.(meta); - }, - [meta, onEnableCloudClick] - ); - return ( -
{ - onClick(meta); - }, [onClick, meta])} - > - -
-
{displayName}
- -
- {isLocal ? ( - - ) : null} - {isOwner ? null : } -
{ - e.stopPropagation(); - onSettingClick(meta); - }} - > - -
-
-
-
- ); -}; diff --git a/packages/frontend/component/src/components/card/workspace-card/styles.css.ts b/packages/frontend/component/src/components/card/workspace-card/styles.css.ts deleted file mode 100644 index 0521152290..0000000000 --- a/packages/frontend/component/src/components/card/workspace-card/styles.css.ts +++ /dev/null @@ -1,94 +0,0 @@ -import { cssVar } from '@toeverything/theme'; -import { style } from '@vanilla-extract/css'; - -import { displayFlex, textEllipsis } from '../../../styles'; - -export const card = style({ - width: '100%', - cursor: 'pointer', - padding: '8px 12px', - borderRadius: 4, - // border: `1px solid ${borderColor}`, - boxShadow: 'inset 0 0 0 1px transparent', - ...displayFlex('flex-start', 'flex-start'), - transition: 'background .2s', - position: 'relative', - color: cssVar('textSecondaryColor'), - background: 'transparent', - display: 'flex', - alignItems: 'center', - gap: 12, - - selectors: { - '&:hover': { - background: cssVar('hoverColor'), - }, - '&[data-active="true"]': { - boxShadow: 'inset 0 0 0 1px ' + cssVar('brandColor'), - }, - }, -}); - -export const workspaceInfo = style({ - width: 0, - flex: 1, - display: 'flex', - alignItems: 'center', - justifyContent: 'space-between', - gap: 2, -}); - -export const workspaceTitle = style({ - width: 0, - flex: 1, - fontSize: cssVar('fontSm'), - fontWeight: 500, - lineHeight: '22px', - maxWidth: '190px', - color: cssVar('textPrimaryColor'), - ...textEllipsis(1), -}); - -export const actionButtons = style({ - display: 'flex', - alignItems: 'center', -}); - -export const settingButtonWrapper = style({}); - -export const settingButton = style({ - transition: 'all 0.13s ease', - width: 0, - height: 20, - overflow: 'hidden', - marginLeft: 0, - display: 'flex', - alignItems: 'center', - justifyContent: 'center', - placeItems: 'center', - - borderRadius: 4, - boxShadow: 'none', - background: 'transparent', - cursor: 'pointer', - - selectors: { - [`.${card}:hover &`]: { - width: 20, - marginLeft: 8, - boxShadow: cssVar('shadow1'), - background: cssVar('white80'), - }, - }, -}); - -export const showOnCardHover = style({ - visibility: 'hidden', - opacity: 0, - selectors: { - [`.${card}:hover &`]: { - visibility: 'visible', - opacity: 1, - }, - }, -}); diff --git a/packages/frontend/component/src/components/workspace-list/index.css.ts b/packages/frontend/component/src/components/workspace-list/index.css.ts deleted file mode 100644 index 8cbed13dc0..0000000000 --- a/packages/frontend/component/src/components/workspace-list/index.css.ts +++ /dev/null @@ -1,8 +0,0 @@ -import { style } from '@vanilla-extract/css'; -export const workspaceItemStyle = style({ - '@media': { - 'screen and (max-width: 720px)': { - width: '100%', - }, - }, -}); diff --git a/packages/frontend/component/src/components/workspace-list/index.tsx b/packages/frontend/component/src/components/workspace-list/index.tsx deleted file mode 100644 index fca83d7c03..0000000000 --- a/packages/frontend/component/src/components/workspace-list/index.tsx +++ /dev/null @@ -1,71 +0,0 @@ -import { WorkspaceFlavour } from '@affine/env/workspace'; -import type { WorkspaceMetadata } from '@toeverything/infra'; -import { Suspense } from 'react'; - -import { - WorkspaceCard, - WorkspaceCardSkeleton, -} from '../../components/card/workspace-card'; -import { workspaceItemStyle } from './index.css'; - -export interface WorkspaceListProps { - disabled?: boolean; - currentWorkspaceId?: string | null; - items: WorkspaceMetadata[]; - openingId?: string | null; - onClick: (workspace: WorkspaceMetadata) => void; - onSettingClick: (workspace: WorkspaceMetadata) => void; - onEnableCloudClick?: (meta: WorkspaceMetadata) => void; - useIsWorkspaceOwner: ( - workspaceMetadata: WorkspaceMetadata - ) => boolean | undefined; - useWorkspaceName: ( - workspaceMetadata: WorkspaceMetadata - ) => string | undefined; -} - -interface SortableWorkspaceItemProps extends Omit { - item: WorkspaceMetadata; -} - -const SortableWorkspaceItem = ({ - item, - openingId, - useIsWorkspaceOwner, - useWorkspaceName, - currentWorkspaceId, - onClick, - onSettingClick, - onEnableCloudClick, -}: SortableWorkspaceItemProps) => { - const isOwner = useIsWorkspaceOwner?.(item); - const name = useWorkspaceName?.(item); - return ( -
- -
- ); -}; - -export const WorkspaceList = (props: WorkspaceListProps) => { - const workspaceList = props.items; - - return workspaceList - .filter( - w => w.flavour !== WorkspaceFlavour.AFFINE_CLOUD || w.initialized === true - ) - .map(item => ( - } key={item.id}> - - - )); -}; diff --git a/packages/frontend/component/src/ui/menu/desktop/root.tsx b/packages/frontend/component/src/ui/menu/desktop/root.tsx index aa8c77c6f6..0e486a4f81 100644 --- a/packages/frontend/component/src/ui/menu/desktop/root.tsx +++ b/packages/frontend/component/src/ui/menu/desktop/root.tsx @@ -1,39 +1,41 @@ import * as DropdownMenu from '@radix-ui/react-dropdown-menu'; import clsx from 'clsx'; -import { Fragment } from 'react'; import type { MenuProps } from '../menu.types'; import * as styles from '../styles.css'; +import * as desktopStyles from './styles.css'; export const DesktopMenu = ({ children, items, portalOptions, rootOptions, - noPortal, contentOptions: { className = '', style: contentStyle = {}, ...otherContentOptions } = {}, }: MenuProps) => { - const Wrapper = noPortal ? Fragment : DropdownMenu.Portal; - const wrapperProps = noPortal ? {} : portalOptions; return ( {children} - + {items} - + ); }; diff --git a/packages/frontend/component/src/ui/menu/desktop/styles.css.ts b/packages/frontend/component/src/ui/menu/desktop/styles.css.ts new file mode 100644 index 0000000000..1495cdebc3 --- /dev/null +++ b/packages/frontend/component/src/ui/menu/desktop/styles.css.ts @@ -0,0 +1,34 @@ +import { keyframes, style } from '@vanilla-extract/css'; + +const slideDown = keyframes({ + from: { + opacity: 0, + transform: 'translateY(-10px)', + }, + to: { + opacity: 1, + transform: 'translateY(0)', + }, +}); + +const slideUp = keyframes({ + to: { + opacity: 0, + transform: 'translateY(-10px)', + }, + from: { + opacity: 1, + transform: 'translateY(0)', + }, +}); + +export const contentAnimation = style({ + animation: `${slideDown} 150ms cubic-bezier(0.42, 0, 0.58, 1)`, + selectors: { + '&[data-state="closed"]': { + pointerEvents: 'none', + animation: `${slideUp} 150ms cubic-bezier(0.42, 0, 0.58, 1)`, + animationFillMode: 'forwards', + }, + }, +}); diff --git a/packages/frontend/component/src/ui/menu/mobile/root.tsx b/packages/frontend/component/src/ui/menu/mobile/root.tsx index ecc3de7782..a401409e86 100644 --- a/packages/frontend/component/src/ui/menu/mobile/root.tsx +++ b/packages/frontend/component/src/ui/menu/mobile/root.tsx @@ -1,18 +1,11 @@ import { ArrowLeftSmallIcon } from '@blocksuite/icons/rc'; import { Slot } from '@radix-ui/react-slot'; import clsx from 'clsx'; -import { - Fragment, - useCallback, - useContext, - useEffect, - useRef, - useState, -} from 'react'; +import { useCallback, useContext, useEffect, useRef, useState } from 'react'; import { observeResize } from '../../../utils'; import { Button } from '../../button'; -import { Modal, type ModalProps } from '../../modal'; +import { Modal } from '../../modal'; import type { MenuProps } from '../menu.types'; import type { SubMenuContent } from './context'; import { MobileMenuContext } from './context'; @@ -22,7 +15,6 @@ import { MobileMenuSubRaw } from './sub'; export const MobileMenu = ({ children, items, - noPortal, contentOptions: { className, onPointerDownOutside, @@ -56,25 +48,6 @@ export const MobileMenu = ({ [onPointerDownOutside, rootOptions] ); - const Wrapper = noPortal ? Fragment : Modal; - const wrapperProps = noPortal - ? {} - : ({ - open: finalOpen, - onOpenChange, - width: '100%', - animation: 'slideBottom', - withoutCloseButton: true, - contentOptions: { - className: clsx(className, styles.mobileMenuModal), - ...otherContentOptions, - }, - contentWrapperStyle: { - alignItems: 'end', - paddingBottom: 10, - }, - } satisfies ModalProps); - const onItemClick = useCallback((e: any) => { e.preventDefault(); setOpen(prev => !prev); @@ -127,7 +100,21 @@ export const MobileMenu = ({ - +
))}
-
+
); diff --git a/packages/frontend/component/src/ui/modal/modal.tsx b/packages/frontend/component/src/ui/modal/modal.tsx index 264847d2cd..e883c9e6c9 100644 --- a/packages/frontend/component/src/ui/modal/modal.tsx +++ b/packages/frontend/component/src/ui/modal/modal.tsx @@ -153,14 +153,19 @@ export const ModalInner = forwardRef( ); useEffect(() => { - const container = createContainer(); - setContainer(container); - return () => { - setTimeout(() => { - container.remove(); - }, 1000) as unknown as number; - }; - }, []); + if (open) { + const container = createContainer(); + setContainer(container); + return () => { + setTimeout(() => { + container.remove(); + }, 1000) as unknown as number; + }; + } else { + setContainer(null); + return; + } + }, [open]); const handlePointerDownOutSide = useCallback( (e: PointerDownOutsideEvent) => { diff --git a/packages/frontend/core/src/atoms/index.ts b/packages/frontend/core/src/atoms/index.ts index 3faeb7092a..1ca54b96df 100644 --- a/packages/frontend/core/src/atoms/index.ts +++ b/packages/frontend/core/src/atoms/index.ts @@ -1,12 +1,10 @@ import { atom } from 'jotai'; import type { AuthProps } from '../components/affine/auth'; -import type { CreateWorkspaceMode } from '../components/affine/create-workspace-modal'; import type { SettingProps } from '../components/affine/setting-modal'; import type { ActiveTab } from '../components/affine/setting-modal/types'; // modal atoms export const openWorkspacesModalAtom = atom(false); -export const openCreateWorkspaceModalAtom = atom(false); export const openSignOutModalAtom = atom(false); export const openQuotaModalAtom = atom(false); export const openStarAFFiNEModalAtom = atom(false); diff --git a/packages/frontend/core/src/commands/affine-creation.tsx b/packages/frontend/core/src/commands/affine-creation.tsx index c7ff51926e..7e3ad99903 100644 --- a/packages/frontend/core/src/commands/affine-creation.tsx +++ b/packages/frontend/core/src/commands/affine-creation.tsx @@ -1,20 +1,19 @@ import type { useI18n } from '@affine/i18n'; import { ImportIcon, PlusIcon } from '@blocksuite/icons/rc'; -import type { createStore } from 'jotai'; -import { openCreateWorkspaceModalAtom } from '../atoms'; import type { usePageHelper } from '../components/blocksuite/block-suite-page-list/utils'; import { track } from '../mixpanel'; +import type { CreateWorkspaceDialogService } from '../modules/create-workspace'; import { registerAffineCommand } from './registry'; export function registerAffineCreationCommands({ - store, pageHelper, t, + createWorkspaceDialogService, }: { t: ReturnType; - store: ReturnType; pageHelper: ReturnType; + createWorkspaceDialogService: CreateWorkspaceDialogService; }) { const unsubs: Array<() => void> = []; unsubs.push( @@ -62,7 +61,7 @@ export function registerAffineCreationCommands({ run() { track.$.cmdk.workspace.createWorkspace(); - store.set(openCreateWorkspaceModalAtom, 'new'); + createWorkspaceDialogService.dialog.open('new'); }, }) ); @@ -80,7 +79,7 @@ export function registerAffineCreationCommands({ control: 'import', }); - store.set(openCreateWorkspaceModalAtom, 'add'); + createWorkspaceDialogService.dialog.open('add'); }, }) ); diff --git a/packages/frontend/core/src/components/affine/page-properties/tags-inline-editor.css.ts b/packages/frontend/core/src/components/affine/page-properties/tags-inline-editor.css.ts index 7187c49b1b..56592b853f 100644 --- a/packages/frontend/core/src/components/affine/page-properties/tags-inline-editor.css.ts +++ b/packages/frontend/core/src/components/affine/page-properties/tags-inline-editor.css.ts @@ -25,8 +25,9 @@ export const inlineTagsContainer = style({ export const tagsMenu = style({ padding: 0, - transform: - 'translate(-3.5px, calc(-3.5px + var(--radix-popper-anchor-height) * -1))', + position: 'relative', + top: 'calc(-3.5px + var(--radix-popper-anchor-height) * -1)', + left: '-3.5px', width: 'calc(var(--radix-popper-anchor-width) + 16px)', overflow: 'hidden', }); diff --git a/packages/frontend/core/src/components/app-sidebar/index.tsx b/packages/frontend/core/src/components/app-sidebar/index.tsx index e8dc76833a..244926d1dc 100644 --- a/packages/frontend/core/src/components/app-sidebar/index.tsx +++ b/packages/frontend/core/src/components/app-sidebar/index.tsx @@ -6,7 +6,7 @@ import { debounce } from 'lodash-es'; import type { PropsWithChildren, ReactElement } from 'react'; import { useEffect } from 'react'; -import { WorkspaceSelector } from '../workspace-selector'; +import { WorkspaceNavigator } from '../workspace-selector'; import { fallbackHeaderStyle, fallbackStyle } from './fallback.css'; import { floatingMaxWidth, @@ -139,7 +139,11 @@ export const AppSidebarFallback = (): ReactElement | null => {
{currentWorkspace ? ( - + ) : ( <> diff --git a/packages/frontend/core/src/components/over-capacity/index.tsx b/packages/frontend/core/src/components/over-capacity/index.tsx new file mode 100644 index 0000000000..a5181da966 --- /dev/null +++ b/packages/frontend/core/src/components/over-capacity/index.tsx @@ -0,0 +1,66 @@ +import { notify } from '@affine/component'; +import { openSettingModalAtom } from '@affine/core/atoms'; +import { WorkspacePermissionService } from '@affine/core/modules/permissions'; +import { useI18n } from '@affine/i18n'; +import { useLiveData, useService, WorkspaceService } from '@toeverything/infra'; +import { useSetAtom } from 'jotai'; +import { debounce } from 'lodash-es'; +import { useCallback, useEffect } from 'react'; + +/** + * TODO(eyhn): refactor this + */ +export const OverCapacityNotification = () => { + const t = useI18n(); + const currentWorkspace = useService(WorkspaceService).workspace; + const permissionService = useService(WorkspacePermissionService); + const isOwner = useLiveData(permissionService.permission.isOwner$); + useEffect(() => { + // revalidate permission + permissionService.permission.revalidate(); + }, [permissionService]); + + const setSettingModalAtom = useSetAtom(openSettingModalAtom); + const jumpToPricePlan = useCallback(() => { + setSettingModalAtom({ + open: true, + activeTab: 'plans', + scrollAnchor: 'cloudPricingPlan', + }); + }, [setSettingModalAtom]); + + // debounce sync engine status + useEffect(() => { + const disposableOverCapacity = + currentWorkspace.engine.blob.isStorageOverCapacity$.subscribe( + debounce((isStorageOverCapacity: boolean) => { + const isOver = isStorageOverCapacity; + if (!isOver) { + return; + } + if (isOwner) { + notify.warning({ + title: t['com.affine.payment.storage-limit.title'](), + message: + t['com.affine.payment.storage-limit.description.owner'](), + action: { + label: t['com.affine.payment.storage-limit.view'](), + onClick: jumpToPricePlan, + }, + }); + } else { + notify.warning({ + title: t['com.affine.payment.storage-limit.title'](), + message: + t['com.affine.payment.storage-limit.description.member'](), + }); + } + }) + ); + return () => { + disposableOverCapacity?.unsubscribe(); + }; + }, [currentWorkspace, isOwner, jumpToPricePlan, t]); + + return null; +}; diff --git a/packages/frontend/core/src/components/pure/workspace-slider-bar/index.tsx b/packages/frontend/core/src/components/pure/workspace-slider-bar/index.tsx deleted file mode 100644 index 12a458fc0e..0000000000 --- a/packages/frontend/core/src/components/pure/workspace-slider-bar/index.tsx +++ /dev/null @@ -1,10 +0,0 @@ -import type { DocCollection } from '@blocksuite/store'; - -export type FavoriteListProps = { - docCollection: DocCollection; -}; - -export type CollectionsListProps = { - docCollection: DocCollection; - onCreate?: () => void; -}; diff --git a/packages/frontend/core/src/components/pure/workspace-slider-bar/workspace-card/index.tsx b/packages/frontend/core/src/components/pure/workspace-slider-bar/workspace-card/index.tsx deleted file mode 100644 index e1b71c690f..0000000000 --- a/packages/frontend/core/src/components/pure/workspace-slider-bar/workspace-card/index.tsx +++ /dev/null @@ -1,313 +0,0 @@ -import { notify, Tooltip } from '@affine/component'; -import { Loading } from '@affine/component/ui/loading'; -import { WorkspaceAvatar } from '@affine/component/workspace-avatar'; -import { openSettingModalAtom } from '@affine/core/atoms'; -import { useDocEngineStatus } from '@affine/core/hooks/affine/use-doc-engine-status'; -import { useWorkspaceInfo } from '@affine/core/hooks/use-workspace-info'; -import { WorkspacePermissionService } from '@affine/core/modules/permissions'; -import { UNTITLED_WORKSPACE_NAME } from '@affine/env/constant'; -import { WorkspaceFlavour } from '@affine/env/workspace'; -import { useI18n } from '@affine/i18n'; -import { - CloudWorkspaceIcon, - InformationFillDuotoneIcon, - LocalWorkspaceIcon, - NoNetworkIcon, - UnsyncIcon, -} from '@blocksuite/icons/rc'; -import { useLiveData, useService, WorkspaceService } from '@toeverything/infra'; -import { cssVar } from '@toeverything/theme'; -import { useSetAtom } from 'jotai'; -import { debounce } from 'lodash-es'; -import type { HTMLAttributes } from 'react'; -import { forwardRef, useCallback, useEffect, useMemo, useState } from 'react'; - -import { useSystemOnline } from '../../../../hooks/use-system-online'; -import * as styles from './styles.css'; - -// FIXME: -// 2. Refactor the code to improve readability -const CloudWorkspaceStatus = () => { - return ( - <> - - Cloud - - ); -}; - -const SyncingWorkspaceStatus = ({ progress }: { progress?: number }) => { - return ( - <> - - Syncing... - - ); -}; - -const UnSyncWorkspaceStatus = () => { - return ( - <> - - Wait for upload - - ); -}; - -const LocalWorkspaceStatus = () => { - return ( - <> - {!environment.isDesktop ? ( - - ) : ( - - )} - Local - - ); -}; - -const OfflineStatus = () => { - return ( - <> - - Offline - - ); -}; - -const useSyncEngineSyncProgress = () => { - const t = useI18n(); - const isOnline = useSystemOnline(); - const { syncing, progress, retrying, errorMessage } = useDocEngineStatus(); - const [isOverCapacity, setIsOverCapacity] = useState(false); - - const currentWorkspace = useService(WorkspaceService).workspace; - const permissionService = useService(WorkspacePermissionService); - const isOwner = useLiveData(permissionService.permission.isOwner$); - useEffect(() => { - // revalidate permission - permissionService.permission.revalidate(); - }, [permissionService]); - - const setSettingModalAtom = useSetAtom(openSettingModalAtom); - const jumpToPricePlan = useCallback(() => { - setSettingModalAtom({ - open: true, - activeTab: 'plans', - scrollAnchor: 'cloudPricingPlan', - }); - }, [setSettingModalAtom]); - - // debounce sync engine status - useEffect(() => { - const disposableOverCapacity = - currentWorkspace.engine.blob.isStorageOverCapacity$.subscribe( - debounce((isStorageOverCapacity: boolean) => { - const isOver = isStorageOverCapacity; - if (!isOver) { - setIsOverCapacity(false); - return; - } - setIsOverCapacity(true); - if (isOwner) { - notify.warning({ - title: t['com.affine.payment.storage-limit.title'](), - message: - t['com.affine.payment.storage-limit.description.owner'](), - action: { - label: t['com.affine.payment.storage-limit.view'](), - onClick: jumpToPricePlan, - }, - }); - } else { - notify.warning({ - title: t['com.affine.payment.storage-limit.title'](), - message: - t['com.affine.payment.storage-limit.description.member'](), - }); - } - }) - ); - return () => { - disposableOverCapacity?.unsubscribe(); - }; - }, [currentWorkspace, isOwner, jumpToPricePlan, t]); - - const content = useMemo(() => { - // TODO(@eyhn): add i18n - if (currentWorkspace.flavour === WorkspaceFlavour.LOCAL) { - if (!environment.isDesktop) { - return 'This is a local demo workspace.'; - } - return 'Saved locally'; - } - if (!isOnline) { - return 'Disconnected, please check your network connection'; - } - if (isOverCapacity) { - return 'Sync failed due to insufficient cloud storage space.'; - } - if (retrying && errorMessage) { - return `${errorMessage}, reconnecting.`; - } - if (retrying) { - return 'Sync disconnected due to unexpected issues, reconnecting.'; - } - if (syncing) { - return ( - `Syncing with AFFiNE Cloud` + - (progress ? ` (${Math.floor(progress * 100)}%)` : '') - ); - } - - return 'Synced with AFFiNE Cloud'; - }, [ - currentWorkspace.flavour, - errorMessage, - isOnline, - isOverCapacity, - progress, - retrying, - syncing, - ]); - - const CloudWorkspaceSyncStatus = useCallback(() => { - if (syncing) { - return SyncingWorkspaceStatus({ - progress: progress ? Math.max(progress, 0.2) : undefined, - }); - } else if (retrying) { - return UnSyncWorkspaceStatus(); - } else { - return CloudWorkspaceStatus(); - } - }, [progress, retrying, syncing]); - - return { - message: content, - icon: - currentWorkspace.flavour === WorkspaceFlavour.AFFINE_CLOUD ? ( - !isOnline ? ( - - ) : ( - - ) - ) : ( - - ), - active: - currentWorkspace.flavour === WorkspaceFlavour.AFFINE_CLOUD && - ((syncing && progress !== undefined) || retrying) && // active if syncing or retrying - !isOverCapacity, // not active if isOffline or OverCapacity - }; -}; -const usePauseAnimation = (timeToResume = 5000) => { - const [paused, setPaused] = useState(false); - - const resume = useCallback(() => { - setPaused(false); - }, []); - - const pause = useCallback(() => { - setPaused(true); - if (timeToResume > 0) { - setTimeout(resume, timeToResume); - } - }, [resume, timeToResume]); - - return { paused, pause }; -}; - -const WorkspaceInfo = ({ name }: { name: string }) => { - const { message, active } = useSyncEngineSyncProgress(); - const currentWorkspace = useService(WorkspaceService).workspace; - const isCloud = currentWorkspace.flavour === WorkspaceFlavour.AFFINE_CLOUD; - const { progress } = useDocEngineStatus(); - const { paused, pause } = usePauseAnimation(); - - // to make sure that animation will play first time - const [delayActive, setDelayActive] = useState(false); - useEffect(() => { - if (paused) { - return; - } - const delayOpen = 0; - const delayClose = 200; - let timer: ReturnType; - if (active) { - timer = setTimeout(() => { - setDelayActive(active); - }, delayOpen); - } else { - timer = setTimeout(() => { - setDelayActive(active); - pause(); - }, delayClose); - } - return () => clearTimeout(timer); - }, [active, pause, paused]); - - return ( -
-
-
-
- {name} -
-
- {isCloud ? : } -
-
- - {/* when syncing/offline/... */} -
- -
- -
-
-
-
-
- ); -}; - -export const WorkspaceCard = forwardRef< - HTMLDivElement, - HTMLAttributes ->(({ ...props }, ref) => { - const currentWorkspace = useService(WorkspaceService).workspace; - - const information = useWorkspaceInfo(currentWorkspace.meta); - - const name = information?.name ?? UNTITLED_WORKSPACE_NAME; - - return ( -
- - -
- ); -}); - -WorkspaceCard.displayName = 'WorkspaceCard'; diff --git a/packages/frontend/core/src/components/root-app-sidebar/index.tsx b/packages/frontend/core/src/components/root-app-sidebar/index.tsx index 62dcdffb44..9b7a2c6caf 100644 --- a/packages/frontend/core/src/components/root-app-sidebar/index.tsx +++ b/packages/frontend/core/src/components/root-app-sidebar/index.tsx @@ -45,7 +45,7 @@ import { } from '../app-sidebar'; import { ExternalMenuLinkItem } from '../app-sidebar/menu-item/external-menu-link-item'; import { usePageHelper } from '../blocksuite/block-suite-page-list/utils'; -import { WorkspaceSelector } from '../workspace-selector'; +import { WorkspaceNavigator } from '../workspace-selector'; import ImportPage from './import-page'; import { quickSearch, @@ -118,6 +118,7 @@ export const RootAppSidebar = (): ReactElement => { }, [pageHelper, settings.newDocDefaultMode] ); + useEffect(() => { if (environment.isDesktop) { return events?.applicationMenu.onNewPageAction(() => onClickNewPage()); @@ -143,7 +144,11 @@ export const RootAppSidebar = (): ReactElement => {
- +
diff --git a/packages/frontend/core/src/components/workspace-selector/index.tsx b/packages/frontend/core/src/components/workspace-selector/index.tsx index 939c71b0d0..21746e542f 100644 --- a/packages/frontend/core/src/components/workspace-selector/index.tsx +++ b/packages/frontend/core/src/components/workspace-selector/index.tsx @@ -1,51 +1,173 @@ -import { Menu } from '@affine/component'; +import { Menu, type MenuProps } from '@affine/component'; +import { useNavigateHelper } from '@affine/core/hooks/use-navigate-helper'; import { track } from '@affine/core/mixpanel'; -import { useService, WorkspacesService } from '@toeverything/infra'; -import { useAtom } from 'jotai'; -import { useCallback, useEffect } from 'react'; +import type { CreateWorkspaceCallbackPayload } from '@affine/core/modules/create-workspace'; +import { WorkspaceSubPath } from '@affine/core/shared'; +import { + GlobalContextService, + useLiveData, + useServices, + type WorkspaceMetadata, + WorkspacesService, +} from '@toeverything/infra'; +import { useCallback, useEffect, useState } from 'react'; -import { openWorkspaceListModalAtom } from '../../atoms'; -import { UserWithWorkspaceList } from '../pure/workspace-slider-bar/user-with-workspace-list'; -import { WorkspaceCard } from '../pure/workspace-slider-bar/workspace-card'; +import { UserWithWorkspaceList } from './user-with-workspace-list'; +import { WorkspaceCard } from './workspace-card'; -export const WorkspaceSelector = () => { - const [isUserWorkspaceListOpened, setOpenUserWorkspaceList] = useAtom( - openWorkspaceListModalAtom - ); +interface WorkspaceSelectorProps { + open?: boolean; + workspaceMetadata?: WorkspaceMetadata; + onSelectWorkspace?: (workspaceMetadata: WorkspaceMetadata) => void; + onCreatedWorkspace?: (payload: CreateWorkspaceCallbackPayload) => void; + showSettingsButton?: boolean; + showEnableCloudButton?: boolean; + showArrowDownIcon?: boolean; + showSyncStatus?: boolean; + disable?: boolean; + menuContentOptions?: MenuProps['contentOptions']; + className?: string; +} + +export const WorkspaceSelector = ({ + workspaceMetadata: outerWorkspaceMetadata, + onSelectWorkspace, + onCreatedWorkspace, + showSettingsButton, + showArrowDownIcon, + disable, + open: outerOpen, + showEnableCloudButton, + showSyncStatus, + className, + menuContentOptions, +}: WorkspaceSelectorProps) => { + const { workspacesService, globalContextService } = useServices({ + GlobalContextService, + WorkspacesService, + }); + const [innerOpen, setOpened] = useState(false); + const open = outerOpen ?? innerOpen; const closeUserWorkspaceList = useCallback(() => { - setOpenUserWorkspaceList(false); - }, [setOpenUserWorkspaceList]); + setOpened(false); + }, []); const openUserWorkspaceList = useCallback(() => { track.$.navigationPanel.workspaceList.open(); - setOpenUserWorkspaceList(true); - }, [setOpenUserWorkspaceList]); + setOpened(true); + }, []); - const workspaceManager = useService(WorkspacesService); + const currentWorkspaceId = useLiveData( + globalContextService.globalContext.workspaceId.$ + ); + const currentWorkspaceMetadata = useLiveData( + currentWorkspaceId + ? workspacesService.list.workspace$(currentWorkspaceId) + : null + ); + const workspaceMetadata = outerWorkspaceMetadata ?? currentWorkspaceMetadata; // revalidate workspace list when open workspace list useEffect(() => { - if (isUserWorkspaceListOpened) { - workspaceManager.list.revalidate(); + if (open) { + workspacesService.list.revalidate(); } - }, [workspaceManager, isUserWorkspaceListOpened]); + }, [workspacesService, open]); return ( } + items={ + + } contentOptions={{ // hide trigger sideOffset: -58, onInteractOutside: closeUserWorkspaceList, onEscapeKeyDown: closeUserWorkspaceList, + ...menuContentOptions, style: { width: '300px', + ...menuContentOptions?.style, }, }} > - + {workspaceMetadata ? ( + + ) : ( + + )} ); }; + +export const WorkspaceNavigator = ({ + onSelectWorkspace, + onCreatedWorkspace, + ...props +}: WorkspaceSelectorProps) => { + const { jumpToSubPath, jumpToPage } = useNavigateHelper(); + + const handleClickWorkspace = useCallback( + (workspaceMetadata: WorkspaceMetadata) => { + onSelectWorkspace?.(workspaceMetadata); + if (document.startViewTransition) { + document.startViewTransition(() => { + jumpToSubPath(workspaceMetadata.id, WorkspaceSubPath.ALL); + return new Promise(resolve => + setTimeout(resolve, 150) + ); /* start transition after 150ms */ + }); + } else { + jumpToSubPath(workspaceMetadata.id, WorkspaceSubPath.ALL); + } + }, + [onSelectWorkspace, jumpToSubPath] + ); + const handleCreatedWorkspace = useCallback( + (payload: CreateWorkspaceCallbackPayload) => { + onCreatedWorkspace?.(payload); + if (document.startViewTransition) { + document.startViewTransition(() => { + if (payload.defaultDocId) { + jumpToPage(payload.meta.id, payload.defaultDocId); + } else { + jumpToSubPath(payload.meta.id, WorkspaceSubPath.ALL); + } + return new Promise(resolve => + setTimeout(resolve, 150) + ); /* start transition after 150ms */ + }); + } else { + if (payload.defaultDocId) { + jumpToPage(payload.meta.id, payload.defaultDocId); + } else { + jumpToSubPath(payload.meta.id, WorkspaceSubPath.ALL); + } + } + }, + [jumpToPage, jumpToSubPath, onCreatedWorkspace] + ); + return ( + + ); +}; diff --git a/packages/frontend/core/src/components/pure/workspace-slider-bar/user-with-workspace-list/add-workspace/index.css.ts b/packages/frontend/core/src/components/workspace-selector/user-with-workspace-list/add-workspace/index.css.ts similarity index 100% rename from packages/frontend/core/src/components/pure/workspace-slider-bar/user-with-workspace-list/add-workspace/index.css.ts rename to packages/frontend/core/src/components/workspace-selector/user-with-workspace-list/add-workspace/index.css.ts diff --git a/packages/frontend/core/src/components/pure/workspace-slider-bar/user-with-workspace-list/add-workspace/index.tsx b/packages/frontend/core/src/components/workspace-selector/user-with-workspace-list/add-workspace/index.tsx similarity index 100% rename from packages/frontend/core/src/components/pure/workspace-slider-bar/user-with-workspace-list/add-workspace/index.tsx rename to packages/frontend/core/src/components/workspace-selector/user-with-workspace-list/add-workspace/index.tsx diff --git a/packages/frontend/core/src/components/pure/workspace-slider-bar/user-with-workspace-list/index.css.ts b/packages/frontend/core/src/components/workspace-selector/user-with-workspace-list/index.css.ts similarity index 100% rename from packages/frontend/core/src/components/pure/workspace-slider-bar/user-with-workspace-list/index.css.ts rename to packages/frontend/core/src/components/workspace-selector/user-with-workspace-list/index.css.ts diff --git a/packages/frontend/core/src/components/pure/workspace-slider-bar/user-with-workspace-list/index.tsx b/packages/frontend/core/src/components/workspace-selector/user-with-workspace-list/index.tsx similarity index 70% rename from packages/frontend/core/src/components/pure/workspace-slider-bar/user-with-workspace-list/index.tsx rename to packages/frontend/core/src/components/workspace-selector/user-with-workspace-list/index.tsx index 5733fe1597..88b700c54f 100644 --- a/packages/frontend/core/src/components/pure/workspace-slider-bar/user-with-workspace-list/index.tsx +++ b/packages/frontend/core/src/components/workspace-selector/user-with-workspace-list/index.tsx @@ -1,19 +1,21 @@ -import { Loading } from '@affine/component'; import { Divider } from '@affine/component/ui/divider'; import { MenuItem } from '@affine/component/ui/menu'; +import { authAtom } from '@affine/core/atoms'; import { track } from '@affine/core/mixpanel'; import { AuthService } from '@affine/core/modules/cloud'; +import { CreateWorkspaceDialogService } from '@affine/core/modules/create-workspace'; +import type { CreateWorkspaceCallbackPayload } from '@affine/core/modules/create-workspace/types'; import { useI18n } from '@affine/i18n'; import { Logo1Icon } from '@blocksuite/icons/rc'; import { useLiveData, useService, + type WorkspaceMetadata, WorkspacesService, } from '@toeverything/infra'; import { useSetAtom } from 'jotai'; -import { Suspense, useCallback } from 'react'; +import { useCallback } from 'react'; -import { authAtom, openCreateWorkspaceModalAtom } from '../../../../atoms'; import { AddWorkspace } from './add-workspace'; import * as styles from './index.css'; import { UserAccountItem } from './user-account'; @@ -56,27 +58,26 @@ export const SignInItem = () => { ); }; -const UserWithWorkspaceListLoading = () => { - return ( -
- -
- ); -}; - interface UserWithWorkspaceListProps { onEventEnd?: () => void; + onClickWorkspace?: (workspace: WorkspaceMetadata) => void; + onCreatedWorkspace?: (payload: CreateWorkspaceCallbackPayload) => void; + showSettingsButton?: boolean; + showEnableCloudButton?: boolean; } const UserWithWorkspaceListInner = ({ onEventEnd, + onClickWorkspace, + onCreatedWorkspace, + showSettingsButton, + showEnableCloudButton, }: UserWithWorkspaceListProps) => { + const createWorkspaceDialogService = useService(CreateWorkspaceDialogService); const session = useLiveData(useService(AuthService).session.session$); const isAuthenticated = session.status === 'authenticated'; - const setOpenCreateWorkspaceModal = useSetAtom(openCreateWorkspaceModalAtom); - const setOpenSignIn = useSetAtom(authAtom); const openSignInModal = useCallback(() => { @@ -91,23 +92,31 @@ const UserWithWorkspaceListInner = ({ return openSignInModal(); } track.$.navigationPanel.workspaceList.createWorkspace(); - setOpenCreateWorkspaceModal('new'); + createWorkspaceDialogService.dialog.open('new', payload => { + if (payload) { + onCreatedWorkspace?.(payload); + } + }); onEventEnd?.(); }, [ + createWorkspaceDialogService.dialog, isAuthenticated, + onCreatedWorkspace, onEventEnd, openSignInModal, - setOpenCreateWorkspaceModal, ]); - track.$.navigationPanel.workspaceList.createWorkspace(); const onAddWorkspace = useCallback(() => { track.$.navigationPanel.workspaceList.createWorkspace({ control: 'import', }); - setOpenCreateWorkspaceModal('add'); + createWorkspaceDialogService.dialog.open('add', payload => { + if (payload) { + onCreatedWorkspace?.(payload); + } + }); onEventEnd?.(); - }, [onEventEnd, setOpenCreateWorkspaceModal]); + }, [createWorkspaceDialogService.dialog, onCreatedWorkspace, onEventEnd]); const workspaceManager = useService(WorkspacesService); const workspaces = useLiveData(workspaceManager.list.workspaces$); @@ -123,7 +132,12 @@ const UserWithWorkspaceListInner = ({ )} - + {workspaces.length > 0 ? : null} { - return ( - }> - - - ); + return ; }; diff --git a/packages/frontend/core/src/components/pure/workspace-slider-bar/user-with-workspace-list/user-account/index.css.ts b/packages/frontend/core/src/components/workspace-selector/user-with-workspace-list/user-account/index.css.ts similarity index 100% rename from packages/frontend/core/src/components/pure/workspace-slider-bar/user-with-workspace-list/user-account/index.css.ts rename to packages/frontend/core/src/components/workspace-selector/user-with-workspace-list/user-account/index.css.ts diff --git a/packages/frontend/core/src/components/pure/workspace-slider-bar/user-with-workspace-list/user-account/index.tsx b/packages/frontend/core/src/components/workspace-selector/user-with-workspace-list/user-account/index.tsx similarity index 80% rename from packages/frontend/core/src/components/pure/workspace-slider-bar/user-with-workspace-list/user-account/index.tsx rename to packages/frontend/core/src/components/workspace-selector/user-with-workspace-list/user-account/index.tsx index 94616d703c..a784cea283 100644 --- a/packages/frontend/core/src/components/pure/workspace-slider-bar/user-with-workspace-list/user-account/index.tsx +++ b/packages/frontend/core/src/components/workspace-selector/user-with-workspace-list/user-account/index.tsx @@ -1,4 +1,5 @@ -import { UserPlanButton } from '../../../../affine/auth/user-plan-button'; +import { UserPlanButton } from '@affine/core/components/affine/auth/user-plan-button'; + import * as styles from './index.css'; export const UserAccountItem = ({ diff --git a/packages/frontend/core/src/components/pure/workspace-slider-bar/user-with-workspace-list/workspace-list/index.css.ts b/packages/frontend/core/src/components/workspace-selector/user-with-workspace-list/workspace-list/index.css.ts similarity index 90% rename from packages/frontend/core/src/components/pure/workspace-slider-bar/user-with-workspace-list/workspace-list/index.css.ts rename to packages/frontend/core/src/components/workspace-selector/user-with-workspace-list/workspace-list/index.css.ts index 3bd7664102..3d8aa7fb9e 100644 --- a/packages/frontend/core/src/components/pure/workspace-slider-bar/user-with-workspace-list/workspace-list/index.css.ts +++ b/packages/frontend/core/src/components/workspace-selector/user-with-workspace-list/workspace-list/index.css.ts @@ -29,3 +29,7 @@ export const scrollbar = style({ transform: 'translateX(8px)', width: '4px', }); +export const workspaceCard = style({ + height: '44px', + padding: '0 12px', +}); diff --git a/packages/frontend/core/src/components/pure/workspace-slider-bar/user-with-workspace-list/workspace-list/index.tsx b/packages/frontend/core/src/components/workspace-selector/user-with-workspace-list/workspace-list/index.tsx similarity index 61% rename from packages/frontend/core/src/components/pure/workspace-slider-bar/user-with-workspace-list/workspace-list/index.tsx rename to packages/frontend/core/src/components/workspace-selector/user-with-workspace-list/workspace-list/index.tsx index 6b59f24bbf..fae0fc42e1 100644 --- a/packages/frontend/core/src/components/pure/workspace-slider-bar/user-with-workspace-list/workspace-list/index.tsx +++ b/packages/frontend/core/src/components/workspace-selector/user-with-workspace-list/workspace-list/index.tsx @@ -1,18 +1,13 @@ import { ScrollableContainer } from '@affine/component'; import { Divider } from '@affine/component/ui/divider'; -import { WorkspaceList } from '@affine/component/workspace-list'; +import { openSettingModalAtom } from '@affine/core/atoms'; import { useEnableCloud } from '@affine/core/hooks/affine/use-enable-cloud'; -import { - useWorkspaceInfo, - useWorkspaceName, -} from '@affine/core/hooks/use-workspace-info'; import { AuthService } from '@affine/core/modules/cloud'; import { WorkspaceFlavour } from '@affine/env/workspace'; import { useI18n } from '@affine/i18n'; import { CloudWorkspaceIcon, LocalWorkspaceIcon } from '@blocksuite/icons/rc'; import type { WorkspaceMetadata } from '@toeverything/infra'; import { - GlobalContextService, useLiveData, useService, WorkspacesService, @@ -20,39 +15,23 @@ import { import { useSetAtom } from 'jotai'; import { useCallback, useMemo } from 'react'; -import { - openCreateWorkspaceModalAtom, - openSettingModalAtom, -} from '../../../../../atoms'; -import { WorkspaceSubPath } from '../../../../../shared'; -import { useNavigateHelper } from '../.././../../../hooks/use-navigate-helper'; +import { WorkspaceCard } from '../../workspace-card'; import * as styles from './index.css'; -function useIsWorkspaceOwner(meta: WorkspaceMetadata) { - const info = useWorkspaceInfo(meta); - - return info?.isOwner; -} - interface WorkspaceModalProps { - disabled?: boolean; workspaces: WorkspaceMetadata[]; - currentWorkspaceId?: string | null; - openingId?: string | null; onClickWorkspace: (workspaceMetadata: WorkspaceMetadata) => void; - onClickWorkspaceSetting: (workspaceMetadata: WorkspaceMetadata) => void; + onClickWorkspaceSetting?: (workspaceMetadata: WorkspaceMetadata) => void; onClickEnableCloud?: (meta: WorkspaceMetadata) => void; onNewWorkspace: () => void; onAddWorkspace: () => void; } const CloudWorkSpaceList = ({ - disabled, workspaces, onClickWorkspace, onClickWorkspaceSetting, - currentWorkspaceId, -}: WorkspaceModalProps) => { +}: Omit) => { const t = useI18n(); if (workspaces.length === 0) { return null; @@ -68,27 +47,20 @@ const CloudWorkSpaceList = ({ {t['com.affine.workspaceList.workspaceListType.cloud']()}
); }; const LocalWorkspaces = ({ - disabled, workspaces, onClickWorkspace, onClickWorkspaceSetting, onClickEnableCloud, - openingId, - currentWorkspaceId, -}: WorkspaceModalProps) => { +}: Omit) => { const t = useI18n(); if (workspaces.length === 0) { return null; @@ -104,15 +76,10 @@ const LocalWorkspaces = ({ {t['com.affine.workspaceList.workspaceListType.local']()} ); @@ -120,20 +87,20 @@ const LocalWorkspaces = ({ export const AFFiNEWorkspaceList = ({ onEventEnd, + onClickWorkspace, + showEnableCloudButton, + showSettingsButton, }: { + onClickWorkspace?: (workspaceMetadata: WorkspaceMetadata) => void; onEventEnd?: () => void; + showSettingsButton?: boolean; + showEnableCloudButton?: boolean; }) => { const workspacesService = useService(WorkspacesService); const workspaces = useLiveData(workspacesService.list.workspaces$); - const currentWorkspaceId = useLiveData( - useService(GlobalContextService).globalContext.workspaceId.$ - ); - const setOpenCreateWorkspaceModal = useSetAtom(openCreateWorkspaceModalAtom); const confirmEnableCloud = useEnableCloud(); - const { jumpToSubPath } = useNavigateHelper(); - const setOpenSettingModalAtom = useSetAtom(openSettingModalAtom); const session = useService(AuthService).session; @@ -181,34 +148,14 @@ export const AFFiNEWorkspaceList = ({ [confirmEnableCloud, workspacesService] ); - const onClickWorkspace = useCallback( + const handleClickWorkspace = useCallback( (workspaceMetadata: WorkspaceMetadata) => { - if (document.startViewTransition) { - document.startViewTransition(() => { - jumpToSubPath(workspaceMetadata.id, WorkspaceSubPath.ALL); - onEventEnd?.(); - return new Promise(resolve => - setTimeout(resolve, 150) - ); /* start transition after 150ms */ - }); - } else { - jumpToSubPath(workspaceMetadata.id, WorkspaceSubPath.ALL); - onEventEnd?.(); - } + onClickWorkspace?.(workspaceMetadata); + onEventEnd?.(); }, - [jumpToSubPath, onEventEnd] + [onClickWorkspace, onEventEnd] ); - const onNewWorkspace = useCallback(() => { - setOpenCreateWorkspaceModal('new'); - onEventEnd?.(); - }, [onEventEnd, setOpenCreateWorkspaceModal]); - - const onAddWorkspace = useCallback(() => { - setOpenCreateWorkspaceModal('add'); - onEventEnd?.(); - }, [onEventEnd, setOpenCreateWorkspaceModal]); - return ( {localWorkspaces.length > 0 && cloudWorkspaces.length > 0 ? ( @@ -231,13 +177,55 @@ export const AFFiNEWorkspaceList = ({ ) : null} ); }; + +interface WorkspaceListProps { + items: WorkspaceMetadata[]; + onClick: (workspace: WorkspaceMetadata) => void; + onSettingClick?: (workspace: WorkspaceMetadata) => void; + onEnableCloudClick?: (meta: WorkspaceMetadata) => void; +} + +interface SortableWorkspaceItemProps extends Omit { + workspaceMetadata: WorkspaceMetadata; +} + +const SortableWorkspaceItem = ({ + workspaceMetadata, + onClick, + onSettingClick, + onEnableCloudClick, +}: SortableWorkspaceItemProps) => { + const handleClick = useCallback(() => { + onClick(workspaceMetadata); + }, [onClick, workspaceMetadata]); + + return ( + + ); +}; + +export const WorkspaceList = (props: WorkspaceListProps) => { + const workspaceList = props.items; + + return workspaceList.map(item => ( + + )); +}; diff --git a/packages/frontend/core/src/components/workspace-selector/workspace-card/index.tsx b/packages/frontend/core/src/components/workspace-selector/workspace-card/index.tsx new file mode 100644 index 0000000000..e1c5824d65 --- /dev/null +++ b/packages/frontend/core/src/components/workspace-selector/workspace-card/index.tsx @@ -0,0 +1,335 @@ +import { Button, Skeleton, Tooltip } from '@affine/component'; +import { Loading } from '@affine/component/ui/loading'; +import { WorkspaceAvatar } from '@affine/component/workspace-avatar'; +import { useSystemOnline } from '@affine/core/hooks/use-system-online'; +import { useWorkspace } from '@affine/core/hooks/use-workspace'; +import { useWorkspaceInfo } from '@affine/core/hooks/use-workspace-info'; +import { UNTITLED_WORKSPACE_NAME } from '@affine/env/constant'; +import { WorkspaceFlavour } from '@affine/env/workspace'; +import { + ArrowDownSmallIcon, + CloudWorkspaceIcon, + CollaborationIcon, + InformationFillDuotoneIcon, + LocalWorkspaceIcon, + NoNetworkIcon, + SettingsIcon, + UnsyncIcon, +} from '@blocksuite/icons/rc'; +import { + useLiveData, + useService, + type WorkspaceMetadata, + type WorkspaceProfileInfo, + WorkspaceService, +} from '@toeverything/infra'; +import { cssVar } from '@toeverything/theme'; +import clsx from 'clsx'; +import type { HTMLAttributes } from 'react'; +import { forwardRef, useCallback, useEffect, useState } from 'react'; + +import * as styles from './styles.css'; + +const CloudWorkspaceStatus = () => { + return ( + <> + + Cloud + + ); +}; + +const SyncingWorkspaceStatus = ({ progress }: { progress?: number }) => { + return ( + <> + + Syncing... + + ); +}; + +const UnSyncWorkspaceStatus = () => { + return ( + <> + + Wait for upload + + ); +}; + +const LocalWorkspaceStatus = () => { + return ( + <> + {!environment.isDesktop ? ( + + ) : ( + + )} + Local + + ); +}; + +const OfflineStatus = () => { + return ( + <> + + Offline + + ); +}; + +const useSyncEngineSyncProgress = (meta: WorkspaceMetadata) => { + const isOnline = useSystemOnline(); + const workspace = useWorkspace(meta); + + const engineState = useLiveData( + workspace?.engine.docEngineState$.throttleTime(100) + ); + + if (!engineState || !workspace) { + return null; + } + + const progress = + (engineState.total - engineState.syncing) / engineState.total; + const syncing = engineState.syncing > 0 || engineState.retrying; + + let content; + // TODO(@eyhn): add i18n + if (workspace.flavour === WorkspaceFlavour.LOCAL) { + if (!environment.isDesktop) { + content = 'This is a local demo workspace.'; + } else { + content = 'Saved locally'; + } + } else if (!isOnline) { + content = 'Disconnected, please check your network connection'; + } else if (engineState.retrying && engineState.errorMessage) { + content = `${engineState.errorMessage}, reconnecting.`; + } else if (engineState.retrying) { + content = 'Sync disconnected due to unexpected issues, reconnecting.'; + } else if (syncing) { + content = + `Syncing with AFFiNE Cloud` + + (progress ? ` (${Math.floor(progress * 100)}%)` : ''); + } else { + content = 'Synced with AFFiNE Cloud'; + } + + const CloudWorkspaceSyncStatus = () => { + if (syncing) { + return SyncingWorkspaceStatus({ + progress: progress ? Math.max(progress, 0.2) : undefined, + }); + } else if (engineState.retrying) { + return UnSyncWorkspaceStatus(); + } else { + return CloudWorkspaceStatus(); + } + }; + + return { + message: content, + icon: + workspace.flavour === WorkspaceFlavour.AFFINE_CLOUD ? ( + !isOnline ? ( + + ) : ( + + ) + ) : ( + + ), + progress, + active: + workspace.flavour === WorkspaceFlavour.AFFINE_CLOUD && + ((syncing && progress !== undefined) || engineState.retrying), // active if syncing or retrying, + }; +}; + +const usePauseAnimation = (timeToResume = 5000) => { + const [paused, setPaused] = useState(false); + + const resume = useCallback(() => { + setPaused(false); + }, []); + + const pause = useCallback(() => { + setPaused(true); + if (timeToResume > 0) { + setTimeout(resume, timeToResume); + } + }, [resume, timeToResume]); + + return { paused, pause }; +}; + +const WorkspaceSyncInfo = ({ + workspaceMetadata, + workspaceProfile, +}: { + workspaceMetadata: WorkspaceMetadata; + workspaceProfile: WorkspaceProfileInfo; +}) => { + const syncStatus = useSyncEngineSyncProgress(workspaceMetadata); + const currentWorkspace = useService(WorkspaceService).workspace; + const isCloud = currentWorkspace.flavour === WorkspaceFlavour.AFFINE_CLOUD; + const { paused, pause } = usePauseAnimation(); + + // to make sure that animation will play first time + const [delayActive, setDelayActive] = useState(false); + useEffect(() => { + if (paused || !syncStatus) { + return; + } + const delayOpen = 0; + const delayClose = 200; + let timer: ReturnType; + if (syncStatus.active) { + timer = setTimeout(() => { + setDelayActive(syncStatus.active); + }, delayOpen); + } else { + timer = setTimeout(() => { + setDelayActive(syncStatus.active); + pause(); + }, delayClose); + } + return () => clearTimeout(timer); + }, [pause, paused, syncStatus]); + + if (!workspaceProfile) { + return null; + } + + return ( +
+
+
+
+ {workspaceProfile.name} +
+
+ {isCloud ? : } +
+
+ + {/* when syncing/offline/... */} + {syncStatus && ( +
+ +
+ +
+
+
+ )} +
+
+ ); +}; + +export const WorkspaceCard = forwardRef< + HTMLDivElement, + HTMLAttributes & { + workspaceMetadata: WorkspaceMetadata; + showSyncStatus?: boolean; + showArrowDownIcon?: boolean; + avatarSize?: number; + disable?: boolean; + onClickOpenSettings?: (workspaceMetadata: WorkspaceMetadata) => void; + onClickEnableCloud?: (workspaceMetadata: WorkspaceMetadata) => void; + } +>( + ( + { + workspaceMetadata, + showSyncStatus, + showArrowDownIcon, + avatarSize = 32, + onClickOpenSettings, + onClickEnableCloud, + className, + disable, + ...props + }, + ref + ) => { + const information = useWorkspaceInfo(workspaceMetadata); + + const name = information?.name ?? UNTITLED_WORKSPACE_NAME; + + return ( +
+ {information ? ( + + ) : ( + + )} +
+ {information ? ( + showSyncStatus ? ( + + ) : ( + {information.name} + ) + ) : ( + + )} +
+ {onClickEnableCloud && + workspaceMetadata.flavour === WorkspaceFlavour.LOCAL ? ( + + ) : null} + {information?.isOwner ? null : } + {onClickOpenSettings && ( +
{ + e.stopPropagation(); + onClickOpenSettings(workspaceMetadata); + }} + > + +
+ )} + {showArrowDownIcon && } +
+ ); + } +); + +WorkspaceCard.displayName = 'WorkspaceCard'; diff --git a/packages/frontend/core/src/components/pure/workspace-slider-bar/workspace-card/styles.css.ts b/packages/frontend/core/src/components/workspace-selector/workspace-card/styles.css.ts similarity index 70% rename from packages/frontend/core/src/components/pure/workspace-slider-bar/workspace-card/styles.css.ts rename to packages/frontend/core/src/components/workspace-selector/workspace-card/styles.css.ts index ddfd99431f..76baba9b47 100644 --- a/packages/frontend/core/src/components/pure/workspace-slider-bar/workspace-card/styles.css.ts +++ b/packages/frontend/core/src/components/workspace-selector/workspace-card/styles.css.ts @@ -24,6 +24,15 @@ export const container = style({ }, }); +export const disable = style({ + pointerEvents: 'none', + opacity: 0.8, + ':hover': { + cursor: 'default', + background: 'none', + }, +}); + export const workspaceInfoSlider = style({ height: 42, overflow: 'hidden', @@ -59,7 +68,6 @@ export const workspaceInfo = style({ }, }, }); - export const workspaceName = style({ fontSize: cssVar('fontSm'), lineHeight: '22px', @@ -68,6 +76,8 @@ export const workspaceName = style({ overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap', + width: '100%', + display: 'inline-block', }); export const workspaceStatus = style({ @@ -105,3 +115,48 @@ export const workspaceInfoTooltip = style({ padding: '0 8px', minHeight: 20, }); + +export const workspaceTitleContainer = style({ + display: 'flex', + flexDirection: 'column', + justifyContent: 'center', + flex: 1, + overflow: 'hidden', +}); + +export const settingButton = style({ + transition: 'all 0.13s ease', + width: 0, + height: 20, + overflow: 'hidden', + marginLeft: 0, + display: 'flex', + alignItems: 'center', + justifyContent: 'center', + placeItems: 'center', + + borderRadius: 4, + boxShadow: 'none', + background: 'transparent', + cursor: 'pointer', + + selectors: { + [`.${container}:hover &`]: { + width: 20, + marginLeft: 8, + boxShadow: cssVar('shadow1'), + background: cssVar('white80'), + }, + }, +}); + +export const showOnCardHover = style({ + visibility: 'hidden', + opacity: 0, + selectors: { + [`.${container}:hover &`]: { + visibility: 'visible', + opacity: 1, + }, + }, +}); diff --git a/packages/frontend/core/src/hooks/affine/use-doc-engine-status.tsx b/packages/frontend/core/src/hooks/affine/use-doc-engine-status.tsx deleted file mode 100644 index 3b972c7b92..0000000000 --- a/packages/frontend/core/src/hooks/affine/use-doc-engine-status.tsx +++ /dev/null @@ -1,21 +0,0 @@ -import { useLiveData, useService, WorkspaceService } from '@toeverything/infra'; -import { useMemo } from 'react'; - -export function useDocEngineStatus() { - const workspace = useService(WorkspaceService).workspace; - - const engineState = useLiveData( - workspace.engine.docEngineState$.throttleTime(100) - ); - const progress = - (engineState.total - engineState.syncing) / engineState.total; - - return useMemo( - () => ({ - ...engineState, - progress, - syncing: engineState.syncing > 0 || engineState.retrying, - }), - [engineState, progress] - ); -} diff --git a/packages/frontend/core/src/hooks/use-navigate-helper.ts b/packages/frontend/core/src/hooks/use-navigate-helper.ts index 95ba5c6d1a..c9c692f408 100644 --- a/packages/frontend/core/src/hooks/use-navigate-helper.ts +++ b/packages/frontend/core/src/hooks/use-navigate-helper.ts @@ -1,28 +1,28 @@ import type { WorkspaceSubPath } from '@affine/core/shared'; -import { useCallback, useContext, useMemo } from 'react'; -import type { NavigateOptions, To } from 'react-router-dom'; +import { createContext, useCallback, useContext, useMemo } from 'react'; +import type { NavigateFunction, NavigateOptions } from 'react-router-dom'; -import { NavigateContext, router } from '../router'; +/** + * In workbench, we use nested react-router, so default `useNavigate` can't get correct navigate function in workbench. + * We use this context to provide navigate function for whole app. + */ +export const NavigateContext = createContext(null); export enum RouteLogic { REPLACE = 'replace', PUSH = 'push', } -function defaultNavigate(to: To, option?: { replace?: boolean }) { - setTimeout(() => { - router?.navigate(to, option).catch(err => { - console.error('Failed to navigate', err); - }); - }, 100); -} - // TODO(@eyhn): add a name -> path helper in the results /** - * @deprecated use `WorkbenchService` instead + * Use this for over workbench navigate, for navigate in workbench, use `WorkbenchService`. */ export function useNavigateHelper() { - const navigate = useContext(NavigateContext) ?? defaultNavigate; + const navigate = useContext(NavigateContext); + + if (!navigate) { + throw new Error('useNavigateHelper must be used within a NavigateProvider'); + } const jumpToPage = useCallback( ( @@ -147,7 +147,7 @@ export function useNavigateHelper() { const searchParams = new URLSearchParams(); if (redirectUri) { - searchParams.set('redirect_uri', encodeURIComponent(redirectUri)); + searchParams.set('redirect_uri', redirectUri); } if (params) { diff --git a/packages/frontend/core/src/hooks/use-register-workspace-commands.ts b/packages/frontend/core/src/hooks/use-register-workspace-commands.ts index 763593088c..e56c32ea95 100644 --- a/packages/frontend/core/src/hooks/use-register-workspace-commands.ts +++ b/packages/frontend/core/src/hooks/use-register-workspace-commands.ts @@ -16,6 +16,7 @@ import { registerAffineUpdatesCommands, } from '../commands'; import { usePageHelper } from '../components/blocksuite/block-suite-page-list/utils'; +import { CreateWorkspaceDialogService } from '../modules/create-workspace'; import { EditorSettingService } from '../modules/editor-settting'; import { CMDKQuickSearchService } from '../modules/quicksearch/services/cmdk'; import { useLanguageHelper } from './affine/use-language-helper'; @@ -69,6 +70,7 @@ export function useRegisterWorkspaceCommands() { const [editor] = useActiveBlocksuiteEditor(); const cmdkQuickSearchService = useService(CMDKQuickSearchService); const editorSettingService = useService(EditorSettingService); + const createWorkspaceDialogService = useService(CreateWorkspaceDialogService); useEffect(() => { const unsub = registerCMDKCommand(cmdkQuickSearchService, editor); @@ -131,7 +133,7 @@ export function useRegisterWorkspaceCommands() { // register AffineCreationCommands useEffect(() => { const unsub = registerAffineCreationCommands({ - store, + createWorkspaceDialogService, pageHelper: pageHelper, t, }); @@ -139,7 +141,7 @@ export function useRegisterWorkspaceCommands() { return () => { unsub(); }; - }, [store, pageHelper, t]); + }, [store, pageHelper, t, createWorkspaceDialogService]); // register AffineHelpCommands useEffect(() => { diff --git a/packages/frontend/core/src/hooks/use-workspace-info.ts b/packages/frontend/core/src/hooks/use-workspace-info.ts index 17333ef8f1..05bfcf1876 100644 --- a/packages/frontend/core/src/hooks/use-workspace-info.ts +++ b/packages/frontend/core/src/hooks/use-workspace-info.ts @@ -6,19 +6,19 @@ import { } from '@toeverything/infra'; import { useEffect } from 'react'; -export function useWorkspaceInfo(meta: WorkspaceMetadata) { +export function useWorkspaceInfo(meta?: WorkspaceMetadata) { const workspacesService = useService(WorkspacesService); - const profile = workspacesService.getProfile(meta); + const profile = meta ? workspacesService.getProfile(meta) : undefined; useEffect(() => { - profile.revalidate(); + profile?.revalidate(); }, [meta, profile]); - return useLiveData(profile.profile$); + return useLiveData(profile?.profile$); } -export function useWorkspaceName(meta: WorkspaceMetadata) { +export function useWorkspaceName(meta?: WorkspaceMetadata) { const information = useWorkspaceInfo(meta); return information?.name; diff --git a/packages/frontend/core/src/layouts/workspace-layout.tsx b/packages/frontend/core/src/layouts/workspace-layout.tsx index 08b8064c70..08db80f54a 100644 --- a/packages/frontend/core/src/layouts/workspace-layout.tsx +++ b/packages/frontend/core/src/layouts/workspace-layout.tsx @@ -38,6 +38,7 @@ import { appSidebarResizingAtom, SidebarSwitch, } from '../components/app-sidebar'; +import { OverCapacityNotification } from '../components/over-capacity'; import { AIIsland } from '../components/pure/ai-island'; import { RootAppSidebar } from '../components/root-app-sidebar'; import { MainContainer } from '../components/workspace'; @@ -50,10 +51,7 @@ import { NavigationButtons } from '../modules/navigation'; import { useRegisterNavigationCommands } from '../modules/navigation/view/use-register-navigation-commands'; import { QuickSearchContainer } from '../modules/quicksearch'; import { WorkbenchService } from '../modules/workbench'; -import { - AllWorkspaceModals, - CurrentWorkspaceModals, -} from '../providers/modal-provider'; +import { CurrentWorkspaceModals } from '../providers/modal-provider'; import { SWRConfigProvider } from '../providers/swr-config-provider'; import * as styles from './styles.css'; @@ -63,7 +61,6 @@ export const WorkspaceLayout = function WorkspaceLayout({ return ( {/* load all workspaces is costly, do not block the whole UI */} - {children} {/* should show after workspace loaded */} @@ -173,6 +170,7 @@ export const WorkspaceLayoutProviders = ({ children }: PropsWithChildren) => { {children} + ); }; diff --git a/packages/frontend/core/src/modules/create-workspace/entities/dialog.ts b/packages/frontend/core/src/modules/create-workspace/entities/dialog.ts new file mode 100644 index 0000000000..6d17967a1b --- /dev/null +++ b/packages/frontend/core/src/modules/create-workspace/entities/dialog.ts @@ -0,0 +1,33 @@ +import { Entity, LiveData } from '@toeverything/infra'; + +import type { CreateWorkspaceCallbackPayload } from '../types'; + +export class CreateWorkspaceDialog extends Entity { + readonly mode$ = new LiveData<'new' | 'add'>('new'); + readonly isOpen$ = new LiveData(false); + readonly callback$ = new LiveData< + (data: CreateWorkspaceCallbackPayload | undefined) => void + >(() => {}); + + open( + mode: 'new' | 'add', + callback?: (data: CreateWorkspaceCallbackPayload | undefined) => void + ) { + this.callback(undefined); + this.mode$.next(mode); + this.isOpen$.next(true); + if (callback) { + this.callback$.next(callback); + } + } + + callback(payload: CreateWorkspaceCallbackPayload | undefined) { + this.callback$.value(payload); + this.callback$.next(() => {}); + } + + close() { + this.isOpen$.next(false); + this.callback(undefined); + } +} diff --git a/packages/frontend/core/src/modules/create-workspace/index.ts b/packages/frontend/core/src/modules/create-workspace/index.ts new file mode 100644 index 0000000000..e55eaa7cbc --- /dev/null +++ b/packages/frontend/core/src/modules/create-workspace/index.ts @@ -0,0 +1,12 @@ +import type { Framework } from '@toeverything/infra'; + +import { CreateWorkspaceDialog } from './entities/dialog'; +import { CreateWorkspaceDialogService } from './services/dialog'; + +export { CreateWorkspaceDialogService } from './services/dialog'; +export type { CreateWorkspaceCallbackPayload } from './types'; +export { CreateWorkspaceDialogProvider } from './views/dialog'; + +export function configureCreateWorkspaceModule(framework: Framework) { + framework.service(CreateWorkspaceDialogService).entity(CreateWorkspaceDialog); +} diff --git a/packages/frontend/core/src/modules/create-workspace/services/dialog.ts b/packages/frontend/core/src/modules/create-workspace/services/dialog.ts new file mode 100644 index 0000000000..78e067ce15 --- /dev/null +++ b/packages/frontend/core/src/modules/create-workspace/services/dialog.ts @@ -0,0 +1,7 @@ +import { Service } from '@toeverything/infra'; + +import { CreateWorkspaceDialog } from '../entities/dialog'; + +export class CreateWorkspaceDialogService extends Service { + dialog = this.framework.createEntity(CreateWorkspaceDialog); +} diff --git a/packages/frontend/core/src/modules/create-workspace/types.ts b/packages/frontend/core/src/modules/create-workspace/types.ts new file mode 100644 index 0000000000..b6f94e01ca --- /dev/null +++ b/packages/frontend/core/src/modules/create-workspace/types.ts @@ -0,0 +1,7 @@ +import type { WorkspaceMetadata } from '@toeverything/infra'; + +export type CreateWorkspaceMode = 'add' | 'new'; +export type CreateWorkspaceCallbackPayload = { + meta: WorkspaceMetadata; + defaultDocId?: string; +}; diff --git a/packages/frontend/core/src/modules/create-workspace/views/dialog.css.ts b/packages/frontend/core/src/modules/create-workspace/views/dialog.css.ts new file mode 100644 index 0000000000..5b0ede3fe8 --- /dev/null +++ b/packages/frontend/core/src/modules/create-workspace/views/dialog.css.ts @@ -0,0 +1,77 @@ +import { cssVar } from '@toeverything/theme'; +import { style } from '@vanilla-extract/css'; + +export const header = style({ + position: 'relative', + marginTop: '44px', +}); + +export const subTitle = style({ + fontSize: cssVar('fontSm'), + color: cssVar('textPrimaryColor'), + fontWeight: 600, +}); + +export const avatarWrapper = style({ + display: 'flex', + margin: '10px 0', +}); + +export const workspaceNameWrapper = style({ + display: 'flex', + flexDirection: 'column', + gap: '8px', + padding: '12px 0', +}); +export const affineCloudWrapper = style({ + display: 'flex', + flexDirection: 'column', + gap: '6px', + paddingTop: '10px', +}); + +export const card = style({ + padding: '12px', + display: 'flex', + alignItems: 'center', + borderRadius: '8px', + backgroundColor: cssVar('backgroundSecondaryColor'), + minHeight: '114px', + position: 'relative', +}); + +export const cardText = style({ + display: 'flex', + flexDirection: 'column', + justifyContent: 'center', + width: '100%', + gap: '12px', +}); + +export const cardTitle = style({ + fontSize: cssVar('fontBase'), + color: cssVar('textPrimaryColor'), + display: 'flex', + justifyContent: 'space-between', +}); +export const cardDescription = style({ + fontSize: cssVar('fontXs'), + color: cssVar('textSecondaryColor'), + maxWidth: '288px', +}); + +export const cloudTips = style({ + fontSize: cssVar('fontXs'), + color: cssVar('textSecondaryColor'), +}); + +export const cloudSvgContainer = style({ + width: '146px', + display: 'flex', + justifyContent: 'flex-end', + alignItems: 'center', + position: 'absolute', + bottom: '0', + right: '0', + pointerEvents: 'none', +}); diff --git a/packages/frontend/core/src/modules/create-workspace/views/dialog.tsx b/packages/frontend/core/src/modules/create-workspace/views/dialog.tsx new file mode 100644 index 0000000000..fd5b0ef926 --- /dev/null +++ b/packages/frontend/core/src/modules/create-workspace/views/dialog.tsx @@ -0,0 +1,272 @@ +import { Avatar, ConfirmModal, Input, Switch, toast } from '@affine/component'; +import type { ConfirmModalProps } from '@affine/component/ui/modal'; +import { authAtom } from '@affine/core/atoms'; +import { CloudSvg } from '@affine/core/components/affine/share-page-modal/cloud-svg'; +import { useAsyncCallback } from '@affine/core/hooks/affine-async-hooks'; +import { track } from '@affine/core/mixpanel'; +import { DebugLogger } from '@affine/debug'; +import { apis } from '@affine/electron-api'; +import { WorkspaceFlavour } from '@affine/env/workspace'; +import { useI18n } from '@affine/i18n'; +import { + initEmptyPage, + useLiveData, + useService, + WorkspacesService, +} from '@toeverything/infra'; +import { useSetAtom } from 'jotai'; +import type { KeyboardEvent } from 'react'; +import { useCallback, useLayoutEffect, useState } from 'react'; + +import { buildShowcaseWorkspace } from '../../../bootstrap/first-app-data'; +import { AuthService } from '../../../modules/cloud'; +import { _addLocalWorkspace } from '../../../modules/workspace-engine'; +import { CreateWorkspaceDialogService } from '../services/dialog'; +import * as styles from './dialog.css'; + +const logger = new DebugLogger('CreateWorkspaceModal'); + +interface NameWorkspaceContentProps extends ConfirmModalProps { + loading: boolean; + onConfirmName: ( + name: string, + workspaceFlavour: WorkspaceFlavour, + avatar?: File + ) => void; +} + +const shouldEnableCloud = !runtimeConfig.allowLocalWorkspace; + +const NameWorkspaceContent = ({ + loading, + onConfirmName, + ...props +}: NameWorkspaceContentProps) => { + const t = useI18n(); + const [workspaceName, setWorkspaceName] = useState(''); + const [enable, setEnable] = useState(shouldEnableCloud); + const session = useService(AuthService).session; + const loginStatus = useLiveData(session.status$); + + const setOpenSignIn = useSetAtom(authAtom); + + const openSignInModal = useCallback(() => { + setOpenSignIn(state => ({ + ...state, + openModal: true, + })); + }, [setOpenSignIn]); + + const onSwitchChange = useCallback( + (checked: boolean) => { + if (loginStatus !== 'authenticated') { + return openSignInModal(); + } + return setEnable(checked); + }, + [loginStatus, openSignInModal] + ); + + const handleCreateWorkspace = useCallback(() => { + onConfirmName( + workspaceName, + enable ? WorkspaceFlavour.AFFINE_CLOUD : WorkspaceFlavour.LOCAL + ); + }, [enable, onConfirmName, workspaceName]); + + const handleKeyDown = useCallback( + (event: KeyboardEvent) => { + if (event.key === 'Enter' && workspaceName) { + handleCreateWorkspace(); + } + }, + [handleCreateWorkspace, workspaceName] + ); + + // Currently, when we create a new workspace and upload an avatar at the same time, + // an error occurs after the creation is successful: get blob 404 not found + return ( + +
+ +
+ +
+
+ {t['com.affine.nameWorkspace.subtitle.workspace-name']()} +
+ +
+
+
{t['AFFiNE Cloud']()}
+
+
+
+ {t['com.affine.nameWorkspace.affine-cloud.title']()} + +
+
+ {t['com.affine.nameWorkspace.affine-cloud.description']()} +
+
+
+ +
+
+ {shouldEnableCloud ? ( + + {t['com.affine.nameWorkspace.affine-cloud.web-tips']()} + + ) : null} +
+
+ ); +}; + +const CreateWorkspaceDialog = () => { + const createWorkspaceDialogService = useService(CreateWorkspaceDialogService); + const mode = useLiveData(createWorkspaceDialogService.dialog.mode$); + const t = useI18n(); + const workspacesService = useService(WorkspacesService); + const [loading, setLoading] = useState(false); + + // TODO(@Peng): maybe refactor using xstate? + useLayoutEffect(() => { + let canceled = false; + // if mode changed, reset step + if (mode === 'add') { + // a hack for now + // when adding a workspace, we will immediately let user select a db file + // after it is done, it will effectively add a new workspace to app-data folder + // so after that, we will be able to load it via importLocalWorkspace + (async () => { + if (!apis) { + return; + } + logger.info('load db file'); + const result = await apis.dialog.loadDBFile(); + if (result.workspaceId && !canceled) { + _addLocalWorkspace(result.workspaceId); + workspacesService.list.revalidate(); + createWorkspaceDialogService.dialog.callback({ + meta: { + flavour: WorkspaceFlavour.LOCAL, + id: result.workspaceId, + }, + }); + } else if (result.error || result.canceled) { + if (result.error) { + toast(t[result.error]()); + } + createWorkspaceDialogService.dialog.callback(undefined); + createWorkspaceDialogService.dialog.close(); + } + })().catch(err => { + console.error(err); + }); + } + return () => { + canceled = true; + }; + }, [createWorkspaceDialogService, mode, t, workspacesService]); + + const onConfirmName = useAsyncCallback( + async (name: string, workspaceFlavour: WorkspaceFlavour) => { + track.$.$.$.createWorkspace({ flavour: workspaceFlavour }); + if (loading) return; + setLoading(true); + + // this will be the last step for web for now + // fix me later + if (runtimeConfig.enablePreloading) { + const { meta, defaultDocId } = await buildShowcaseWorkspace( + workspacesService, + workspaceFlavour, + name + ); + createWorkspaceDialogService.dialog.callback({ meta, defaultDocId }); + } else { + let defaultDocId: string | undefined = undefined; + const meta = await workspacesService.create( + workspaceFlavour, + async workspace => { + workspace.meta.initialize(); + workspace.meta.setName(name); + const page = workspace.createDoc(); + defaultDocId = page.id; + initEmptyPage(page); + } + ); + createWorkspaceDialogService.dialog.callback({ meta, defaultDocId }); + } + + createWorkspaceDialogService.dialog.close(); + setLoading(false); + }, + [createWorkspaceDialogService.dialog, loading, workspacesService] + ); + + const onOpenChange = useCallback( + (open: boolean) => { + if (!open) { + createWorkspaceDialogService.dialog.close(); + } + }, + [createWorkspaceDialogService] + ); + + if (mode === 'new') { + return ( + + ); + } else { + return null; + } +}; + +export const CreateWorkspaceDialogProvider = () => { + const createWorkspaceDialogService = useService(CreateWorkspaceDialogService); + const isOpen = useLiveData(createWorkspaceDialogService.dialog.isOpen$); + + return isOpen ? : null; +}; diff --git a/packages/frontend/core/src/modules/import-template/entities/dialog.ts b/packages/frontend/core/src/modules/import-template/entities/dialog.ts new file mode 100644 index 0000000000..a1253c983b --- /dev/null +++ b/packages/frontend/core/src/modules/import-template/entities/dialog.ts @@ -0,0 +1,19 @@ +import { Entity, LiveData } from '@toeverything/infra'; + +export class ImportTemplateDialog extends Entity { + readonly isOpen$ = new LiveData(false); + readonly template$ = new LiveData<{ + workspaceId: string; + docId: string; + templateName: string; + } | null>(null); + + open(workspaceId: string, docId: string, templateName: string) { + this.template$.next({ workspaceId, docId, templateName }); + this.isOpen$.next(true); + } + + close() { + this.isOpen$.next(false); + } +} diff --git a/packages/frontend/core/src/modules/import-template/entities/downloader.ts b/packages/frontend/core/src/modules/import-template/entities/downloader.ts new file mode 100644 index 0000000000..7ddb334904 --- /dev/null +++ b/packages/frontend/core/src/modules/import-template/entities/downloader.ts @@ -0,0 +1,51 @@ +import { + backoffRetry, + catchErrorInto, + effect, + Entity, + fromPromise, + LiveData, + onComplete, + onStart, +} from '@toeverything/infra'; +import { EMPTY, mergeMap, switchMap } from 'rxjs'; + +import { isBackendError, isNetworkError } from '../../cloud'; +import type { TemplateDownloaderStore } from '../store/downloader'; + +export class TemplateDownloader extends Entity { + constructor(private readonly store: TemplateDownloaderStore) { + super(); + } + + readonly isDownloading$ = new LiveData(false); + readonly data$ = new LiveData(null); + readonly error$ = new LiveData(null); + + readonly download = effect( + switchMap( + ({ workspaceId, docId }: { workspaceId: string; docId: string }) => { + return fromPromise(() => this.store.download(workspaceId, docId)).pipe( + mergeMap(({ data }) => { + this.data$.next(data); + return EMPTY; + }), + backoffRetry({ + when: isNetworkError, + count: Infinity, + }), + backoffRetry({ + when: isBackendError, + }), + catchErrorInto(this.error$), + onStart(() => { + this.isDownloading$.next(true); + this.data$.next(null); + this.error$.next(null); + }), + onComplete(() => this.isDownloading$.next(false)) + ); + } + ) + ); +} diff --git a/packages/frontend/core/src/modules/import-template/index.ts b/packages/frontend/core/src/modules/import-template/index.ts new file mode 100644 index 0000000000..be7ab4708e --- /dev/null +++ b/packages/frontend/core/src/modules/import-template/index.ts @@ -0,0 +1,22 @@ +import { type Framework, WorkspacesService } from '@toeverything/infra'; + +import { FetchService } from '../cloud'; +import { ImportTemplateDialog } from './entities/dialog'; +import { TemplateDownloader } from './entities/downloader'; +import { ImportTemplateDialogService } from './services/dialog'; +import { TemplateDownloaderService } from './services/downloader'; +import { ImportTemplateService } from './services/import'; +import { TemplateDownloaderStore } from './store/downloader'; + +export { ImportTemplateDialogService } from './services/dialog'; +export { ImportTemplateDialogProvider } from './views/dialog'; + +export function configureImportTemplateModule(framework: Framework) { + framework + .service(ImportTemplateDialogService) + .entity(ImportTemplateDialog) + .service(TemplateDownloaderService) + .entity(TemplateDownloader, [TemplateDownloaderStore]) + .store(TemplateDownloaderStore, [FetchService]) + .service(ImportTemplateService, [WorkspacesService]); +} diff --git a/packages/frontend/core/src/modules/import-template/services/dialog.ts b/packages/frontend/core/src/modules/import-template/services/dialog.ts new file mode 100644 index 0000000000..ea6960a4bf --- /dev/null +++ b/packages/frontend/core/src/modules/import-template/services/dialog.ts @@ -0,0 +1,7 @@ +import { Service } from '@toeverything/infra'; + +import { ImportTemplateDialog } from '../entities/dialog'; + +export class ImportTemplateDialogService extends Service { + dialog = this.framework.createEntity(ImportTemplateDialog); +} diff --git a/packages/frontend/core/src/modules/import-template/services/downloader.ts b/packages/frontend/core/src/modules/import-template/services/downloader.ts new file mode 100644 index 0000000000..82549e10e1 --- /dev/null +++ b/packages/frontend/core/src/modules/import-template/services/downloader.ts @@ -0,0 +1,7 @@ +import { Service } from '@toeverything/infra'; + +import { TemplateDownloader } from '../entities/downloader'; + +export class TemplateDownloaderService extends Service { + downloader = this.framework.createEntity(TemplateDownloader); +} diff --git a/packages/frontend/core/src/modules/import-template/services/import.ts b/packages/frontend/core/src/modules/import-template/services/import.ts new file mode 100644 index 0000000000..35c070014b --- /dev/null +++ b/packages/frontend/core/src/modules/import-template/services/import.ts @@ -0,0 +1,47 @@ +import type { WorkspaceFlavour } from '@affine/env/workspace'; +import type { WorkspaceMetadata, WorkspacesService } from '@toeverything/infra'; +import { Service } from '@toeverything/infra'; + +export class ImportTemplateService extends Service { + constructor(private readonly workspacesService: WorkspacesService) { + super(); + } + + async importToWorkspace( + workspaceMetadata: WorkspaceMetadata, + docBinary: Uint8Array + ) { + const { workspace, dispose: disposeWorkspace } = + this.workspacesService.open({ + metadata: workspaceMetadata, + }); + await workspace.engine.waitForRootDocReady(); + const newDoc = workspace.docCollection.createDoc({}); + await workspace.engine.doc.storage.behavior.doc.set( + newDoc.spaceDoc.guid, + docBinary + ); + disposeWorkspace(); + return newDoc.id; + } + + async importToNewWorkspace( + flavour: WorkspaceFlavour, + workspaceName: string, + docBinary: Uint8Array + ) { + // eslint-disable-next-line @typescript-eslint/no-non-null-assertion + let docId: string = null!; + const { id: workspaceId } = await this.workspacesService.create( + flavour, + async (docCollection, _, docStorage) => { + docCollection.meta.initialize(); + docCollection.meta.setName(workspaceName); + const doc = docCollection.createDoc(); + docId = doc.id; + await docStorage.doc.set(doc.spaceDoc.guid, docBinary); + } + ); + return { workspaceId, docId }; + } +} diff --git a/packages/frontend/core/src/modules/import-template/store/downloader.ts b/packages/frontend/core/src/modules/import-template/store/downloader.ts new file mode 100644 index 0000000000..f87a4963e2 --- /dev/null +++ b/packages/frontend/core/src/modules/import-template/store/downloader.ts @@ -0,0 +1,21 @@ +import { Store } from '@toeverything/infra'; + +import type { FetchService } from '../../cloud'; + +export class TemplateDownloaderStore extends Store { + constructor(private readonly fetchService: FetchService) { + super(); + } + + async download(workspaceId: string, docId: string) { + const response = await this.fetchService.fetch( + `/api/workspaces/${workspaceId}/docs/${docId}`, + { + priority: 'high', + } as any + ); + const arrayBuffer = await response.arrayBuffer(); + + return { data: new Uint8Array(arrayBuffer) }; + } +} diff --git a/packages/frontend/core/src/modules/import-template/views/dialog.css.ts b/packages/frontend/core/src/modules/import-template/views/dialog.css.ts new file mode 100644 index 0000000000..8526bbb27a --- /dev/null +++ b/packages/frontend/core/src/modules/import-template/views/dialog.css.ts @@ -0,0 +1,48 @@ +import { cssVarV2 } from '@toeverything/theme/v2'; +import { style } from '@vanilla-extract/css'; + +export const dialogContainer = style({ + display: 'flex', + flexDirection: 'column', + justifyContent: 'center', + alignItems: 'center', + color: cssVarV2('text/primary'), + padding: '16px', +}); + +export const mainIcon = style({ + width: 36, + height: 36, + color: cssVarV2('icon/primary'), +}); + +export const mainTitle = style({ + fontSize: '18px', + lineHeight: '26px', + textAlign: 'center', + marginTop: '16px', + fontWeight: 600, +}); + +export const desc = style({ + textAlign: 'center', + color: cssVarV2('text/secondary'), + marginBottom: '20px', +}); + +export const mainButton = style({ + width: '100%', + fontSize: '14px', + height: '42px', +}); + +export const modal = style({ + maxWidth: '400px', +}); + +export const workspaceSelector = style({ + margin: '0 -16px', + width: 'calc(100% + 32px)', + border: `1px solid ${cssVarV2('layer/insideBorder/border')}`, + padding: '0 16px', +}); diff --git a/packages/frontend/core/src/modules/import-template/views/dialog.tsx b/packages/frontend/core/src/modules/import-template/views/dialog.tsx new file mode 100644 index 0000000000..9ce6aaa897 --- /dev/null +++ b/packages/frontend/core/src/modules/import-template/views/dialog.tsx @@ -0,0 +1,249 @@ +import { Button, Modal } from '@affine/component'; +import { WorkspaceSelector } from '@affine/core/components/workspace-selector'; +import { useAsyncCallback } from '@affine/core/hooks/affine-async-hooks'; +import { useNavigateHelper } from '@affine/core/hooks/use-navigate-helper'; +import { useWorkspaceName } from '@affine/core/hooks/use-workspace-info'; +import { WorkspaceFlavour } from '@affine/env/workspace'; +import { useI18n } from '@affine/i18n'; +import { AllDocsIcon } from '@blocksuite/icons/rc'; +import { + useLiveData, + useService, + type WorkspaceMetadata, + WorkspacesService, +} from '@toeverything/infra'; +import { cssVar } from '@toeverything/theme'; +import { useCallback, useEffect, useState } from 'react'; + +import { AuthService } from '../../cloud'; +import type { CreateWorkspaceCallbackPayload } from '../../create-workspace'; +import { ImportTemplateDialogService } from '../services/dialog'; +import { TemplateDownloaderService } from '../services/downloader'; +import { ImportTemplateService } from '../services/import'; +import * as styles from './dialog.css'; + +const Dialog = ({ + workspaceId, + docId, + templateName, + onClose, +}: { + workspaceId: string; + docId: string; + templateName: string; + onClose?: () => void; +}) => { + const t = useI18n(); + const session = useService(AuthService).session; + const notLogin = useLiveData(session.status$) === 'unauthenticated'; + const isSessionRevalidating = useLiveData(session.isRevalidating$); + + const [importing, setImporting] = useState(false); + const [importingError, setImportingError] = useState(null); + const workspacesService = useService(WorkspacesService); + const templateDownloaderService = useService(TemplateDownloaderService); + const importTemplateService = useService(ImportTemplateService); + const templateDownloader = templateDownloaderService.downloader; + const isDownloading = useLiveData(templateDownloader.isDownloading$); + const downloadError = useLiveData(templateDownloader.error$); + const workspaces = useLiveData(workspacesService.list.workspaces$); + const [rawSelectedWorkspace, setSelectedWorkspace] = + useState(null); + const selectedWorkspace = + rawSelectedWorkspace ?? + workspaces.find(w => w.flavour === WorkspaceFlavour.AFFINE_CLOUD) ?? + workspaces.at(0); + const selectedWorkspaceName = useWorkspaceName(selectedWorkspace); + const { openPage, jumpToSignIn } = useNavigateHelper(); + + const noWorkspace = workspaces.length === 0; + + useEffect(() => { + workspacesService.list.revalidate(); + }, [workspacesService]); + + useEffect(() => { + session.revalidate(); + }, [session]); + + useEffect(() => { + if (!isSessionRevalidating && notLogin) { + jumpToSignIn( + '/template/import?workspaceId=' + + workspaceId + + '&docId=' + + docId + + '&name=' + + templateName + ); + onClose?.(); + } + }, [ + docId, + isSessionRevalidating, + jumpToSignIn, + notLogin, + onClose, + templateName, + workspaceId, + ]); + + useEffect(() => { + templateDownloader.download({ workspaceId, docId }); + }, [docId, templateDownloader, workspaceId]); + + const handleSelectedWorkspace = useCallback( + (workspaceMetadata: WorkspaceMetadata) => { + return setSelectedWorkspace(workspaceMetadata); + }, + [] + ); + + const handleCreatedWorkspace = useCallback( + (payload: CreateWorkspaceCallbackPayload) => { + return setSelectedWorkspace(payload.meta); + }, + [] + ); + + const handleImportToSelectedWorkspace = useAsyncCallback(async () => { + if (templateDownloader.data$.value && selectedWorkspace) { + setImporting(true); + try { + const docId = await importTemplateService.importToWorkspace( + selectedWorkspace, + templateDownloader.data$.value + ); + openPage(selectedWorkspace.id, docId); + onClose?.(); + } catch (err) { + setImportingError(err); + } finally { + setImporting(false); + } + } + }, [ + importTemplateService, + onClose, + openPage, + selectedWorkspace, + templateDownloader.data$.value, + ]); + + const handleImportToNewWorkspace = useAsyncCallback(async () => { + if (!templateDownloader.data$.value) { + return; + } + setImporting(true); + try { + const { workspaceId, docId } = + await importTemplateService.importToNewWorkspace( + WorkspaceFlavour.AFFINE_CLOUD, + 'Workspace', + templateDownloader.data$.value + ); + openPage(workspaceId, docId); + onClose?.(); + } catch (err) { + setImportingError(err); + } finally { + setImporting(false); + } + }, [ + importTemplateService, + onClose, + openPage, + templateDownloader.data$.value, + ]); + + const disabled = isDownloading || importing || notLogin; + + return ( + <> +
+ +
+ {t['com.affine.import-template.dialog.createDocWithTemplate']({ + templateName, + })} +
+ {noWorkspace ? ( +

A new workspace will be created.

+ ) : ( + <> +

Choose a workspace.

+ + + )} +
+ {importingError && ( + + {t['com.affine.import-template.dialog.errorImport']()} + + )} + {downloadError ? ( + + {t['com.affine.import-template.dialog.errorLoad']()} + + ) : selectedWorkspace ? ( + + ) : ( + + )} + + ); +}; + +export const ImportTemplateDialogProvider = () => { + const importTemplateDialogService = useService(ImportTemplateDialogService); + const isOpen = useLiveData(importTemplateDialogService.dialog.isOpen$); + const template = useLiveData(importTemplateDialogService.dialog.template$); + + return ( + importTemplateDialogService.dialog.close()} + > + {template && ( + importTemplateDialogService.dialog.close()} + /> + )} + + ); +}; diff --git a/packages/frontend/core/src/modules/index.ts b/packages/frontend/core/src/modules/index.ts index 63b017261f..a5ce024441 100644 --- a/packages/frontend/core/src/modules/index.ts +++ b/packages/frontend/core/src/modules/index.ts @@ -3,6 +3,7 @@ import { configureInfraModules, type Framework } from '@toeverything/infra'; import { configureCloudModule } from './cloud'; import { configureCollectionModule } from './collection'; +import { configureCreateWorkspaceModule } from './create-workspace'; import { configureDocLinksModule } from './doc-link'; import { configureDocsSearchModule } from './docs-search'; import { configureEditorModule } from './editor'; @@ -10,6 +11,7 @@ import { configureEditorSettingModule } from './editor-settting'; import { configureExplorerModule } from './explorer'; import { configureFavoriteModule } from './favorite'; import { configureFindInPageModule } from './find-in-page'; +import { configureImportTemplateModule } from './import-template'; import { configureNavigationModule } from './navigation'; import { configureOrganizeModule } from './organize'; import { configurePeekViewModule } from './peek-view'; @@ -45,4 +47,6 @@ export function configureCommonModules(framework: Framework) { configureEditorModule(framework); configureSystemFontFamilyModule(framework); configureEditorSettingModule(framework); + configureImportTemplateModule(framework); + configureCreateWorkspaceModule(framework); } diff --git a/packages/frontend/core/src/modules/theme-editor/views/custom-theme.tsx b/packages/frontend/core/src/modules/theme-editor/views/custom-theme.tsx index b981d85ee3..aca604b6de 100644 --- a/packages/frontend/core/src/modules/theme-editor/views/custom-theme.tsx +++ b/packages/frontend/core/src/modules/theme-editor/views/custom-theme.tsx @@ -23,7 +23,10 @@ export const useCustomTheme = (target: HTMLElement) => { const valueMap = themeObj[mode]; // remove previous style + // TOOD(@CatsJuice): find better way to remove previous style target.style.cssText = ''; + // recover color scheme set by next-themes + target.style.colorScheme = mode; Object.entries(valueMap).forEach(([key, value]) => { value && target.style.setProperty(key, value); diff --git a/packages/frontend/core/src/modules/workbench/view/route-container.tsx b/packages/frontend/core/src/modules/workbench/view/route-container.tsx index dc230badc1..19cd08d13b 100644 --- a/packages/frontend/core/src/modules/workbench/view/route-container.tsx +++ b/packages/frontend/core/src/modules/workbench/view/route-container.tsx @@ -3,6 +3,7 @@ import { RightSidebarIcon } from '@blocksuite/icons/rc'; import { useLiveData, useService } from '@toeverything/infra'; import { useAtomValue } from 'jotai'; import { Suspense, useCallback } from 'react'; +import { Outlet } from 'react-router-dom'; import { AffineErrorBoundary } from '../../../components/affine/affine-error-boundary'; import { appSidebarOpenAtom } from '../../../components/app-sidebar/index.jotai'; @@ -40,7 +41,7 @@ const ToggleButton = ({ ); }; -export const RouteContainer = ({ route }: Props) => { +export const RouteContainer = () => { const viewPosition = useViewPosition(); const leftSidebarOpen = useAtomValue(appSidebarOpenAtom); const workbench = useService(WorkbenchService).workbench; @@ -74,7 +75,7 @@ export const RouteContainer = ({ route }: Props) => { - + diff --git a/packages/frontend/core/src/modules/workbench/view/workbench-root.tsx b/packages/frontend/core/src/modules/workbench/view/workbench-root.tsx index 9ce7b81717..7618109bcf 100644 --- a/packages/frontend/core/src/modules/workbench/view/workbench-root.tsx +++ b/packages/frontend/core/src/modules/workbench/view/workbench-root.tsx @@ -8,15 +8,8 @@ import { useService, } from '@toeverything/infra'; import { useAtom, useAtomValue } from 'jotai'; -import { - lazy as reactLazy, - memo, - useCallback, - useEffect, - useRef, - useState, -} from 'react'; -import { useLocation } from 'react-router-dom'; +import { memo, useCallback, useEffect, useMemo, useRef, useState } from 'react'; +import { type RouteObject, useLocation } from 'react-router-dom'; import type { View } from '../entities/view'; import { WorkbenchService } from '../services/workbench'; @@ -33,24 +26,6 @@ const useAdapter = environment.isDesktop ? useBindWorkbenchToDesktopRouter : useBindWorkbenchToBrowserRouter; -const warpedRoutes = viewRoutes.map(({ path, lazy }) => { - const Component = reactLazy(() => - lazy().then(m => ({ - default: m.Component as React.ComponentType, - })) - ); - const route = { - Component, - }; - - return { - path, - Component: () => { - return ; - }, - }; -}); - export const WorkbenchRoot = memo(() => { const workbench = useService(WorkbenchService).workbench; @@ -118,9 +93,18 @@ const WorkbenchView = ({ view, index }: { view: View; index: number }) => { return; }, [handleOnFocus]); + const routes: RouteObject[] = useMemo(() => { + return [ + { + element: , + children: viewRoutes, + }, + ] satisfies RouteObject[]; + }, []); + return (
- +
); }; diff --git a/packages/frontend/core/src/pages/import-template.tsx b/packages/frontend/core/src/pages/import-template.tsx new file mode 100644 index 0000000000..41e6e40101 --- /dev/null +++ b/packages/frontend/core/src/pages/import-template.tsx @@ -0,0 +1,21 @@ +import { useService } from '@toeverything/infra'; +import { useEffect } from 'react'; +import { useSearchParams } from 'react-router-dom'; + +import { useNavigateHelper } from '../hooks/use-navigate-helper'; +import { ImportTemplateDialogService } from '../modules/import-template'; + +export const Component = () => { + const importTemplateDialogService = useService(ImportTemplateDialogService); + const [searchParams] = useSearchParams(); + const { jumpToIndex } = useNavigateHelper(); + useEffect(() => { + importTemplateDialogService.dialog.open( + searchParams.get('workspaceId') ?? '', + searchParams.get('docId') ?? '', + searchParams.get('name') ?? '' + ); + }, [importTemplateDialogService.dialog, jumpToIndex, searchParams]); + // no ui for this route, just open the dialog + return null; +}; diff --git a/packages/frontend/core/src/pages/index.tsx b/packages/frontend/core/src/pages/index.tsx index e01f08a50f..19a255f08e 100644 --- a/packages/frontend/core/src/pages/index.tsx +++ b/packages/frontend/core/src/pages/index.tsx @@ -1,4 +1,3 @@ -import { Menu } from '@affine/component/ui/menu'; import { apis } from '@affine/electron-api'; import { WorkspaceFlavour } from '@affine/env/workspace'; import { @@ -7,7 +6,6 @@ import { WorkspacesService, } from '@toeverything/infra'; import { - lazy, useCallback, useEffect, useLayoutEffect, @@ -21,17 +19,11 @@ import { createFirstAppData, } from '../bootstrap/first-app-data'; import { AppFallback } from '../components/affine/app-container'; -import { UserWithWorkspaceList } from '../components/pure/workspace-slider-bar/user-with-workspace-list'; +import { WorkspaceNavigator } from '../components/workspace-selector'; import { useNavigateHelper } from '../hooks/use-navigate-helper'; import { AuthService } from '../modules/cloud'; import { WorkspaceSubPath } from '../shared'; -const AllWorkspaceModals = lazy(() => - import('../providers/modal-provider').then(({ AllWorkspaceModals }) => ({ - default: AllWorkspaceModals, - })) -); - export const loader: LoaderFunction = async () => { return null; }; @@ -41,6 +33,7 @@ export const Component = () => { const [navigating, setNavigating] = useState(true); const [creating, setCreating] = useState(false); const authService = useService(AuthService); + const loggedIn = useLiveData( authService.session.status$.map(s => s === 'authenticated') ); @@ -151,35 +144,19 @@ export const Component = () => { // TODO(@eyhn): We need a no workspace page return ( - <> -
+ - } - noPortal - contentOptions={{ - style: { - width: 300, - transform: 'translate(-50%, -50%)', - borderRadius: '8px', - boxShadow: 'var(--affine-shadow-2)', - backgroundColor: 'var(--affine-background-overlay-panel-color)', - padding: '16px 12px', - }, - }} - > -
-
-
- - + /> + ); }; diff --git a/packages/frontend/core/src/pages/root.tsx b/packages/frontend/core/src/pages/root.tsx new file mode 100644 index 0000000000..007836e1c1 --- /dev/null +++ b/packages/frontend/core/src/pages/root.tsx @@ -0,0 +1,12 @@ +import { Outlet } from 'react-router-dom'; + +import { AllWorkspaceModals } from '../providers/modal-provider'; + +export const RootWrapper = () => { + return ( + <> + + + + ); +}; diff --git a/packages/frontend/core/src/pages/workspace/index.tsx b/packages/frontend/core/src/pages/workspace/index.tsx index c10ebf847e..d2fe0bcacb 100644 --- a/packages/frontend/core/src/pages/workspace/index.tsx +++ b/packages/frontend/core/src/pages/workspace/index.tsx @@ -16,7 +16,6 @@ import { matchPath, useLocation, useParams } from 'react-router-dom'; import { AffineErrorBoundary } from '../../components/affine/affine-error-boundary'; import { WorkspaceLayout } from '../../layouts/workspace-layout'; import { WorkbenchRoot } from '../../modules/workbench'; -import { AllWorkspaceModals } from '../../providers/modal-provider'; import { performanceRenderLogger } from '../../shared'; import { PageNotFound } from '../404'; import { SharePage } from './share/share-page'; @@ -206,7 +205,6 @@ const WorkspacePage = ({ meta }: { meta: WorkspaceMetadata }) => { return ( - ); } diff --git a/packages/frontend/core/src/providers/modal-provider.tsx b/packages/frontend/core/src/providers/modal-provider.tsx index 6c8945f0ce..2ca0330f22 100644 --- a/packages/frontend/core/src/providers/modal-provider.tsx +++ b/packages/frontend/core/src/providers/modal-provider.tsx @@ -1,10 +1,10 @@ -import { notify } from '@affine/component'; +import { NotificationCenter, notify } from '@affine/component'; import { events } from '@affine/electron-api'; import { WorkspaceFlavour } from '@affine/env/workspace'; import { + GlobalContextService, useLiveData, useService, - useServiceOptional, WorkspaceService, WorkspacesService, } from '@toeverything/infra'; @@ -13,15 +13,9 @@ import type { ReactElement } from 'react'; import { useCallback, useEffect } from 'react'; import type { SettingAtom } from '../atoms'; -import { - authAtom, - openCreateWorkspaceModalAtom, - openSettingModalAtom, - openSignOutModalAtom, -} from '../atoms'; +import { authAtom, openSettingModalAtom, openSignOutModalAtom } from '../atoms'; import { AuthModal as Auth } from '../components/affine/auth'; import { AiLoginRequiredModal } from '../components/affine/auth/ai-login-required'; -import { CreateWorkspaceModal } from '../components/affine/create-workspace-modal'; import { HistoryTipsModal } from '../components/affine/history-tips-modal'; import { IssueFeedbackModal } from '../components/affine/issue-feedback-modal'; import { @@ -36,7 +30,9 @@ import { useTrashModalHelper } from '../hooks/affine/use-trash-modal-helper'; import { useAsyncCallback } from '../hooks/affine-async-hooks'; import { useNavigateHelper } from '../hooks/use-navigate-helper'; import { AuthService } from '../modules/cloud/services/auth'; +import { CreateWorkspaceDialogProvider } from '../modules/create-workspace'; import { FindInPageModal } from '../modules/find-in-page/view/find-in-page-modal'; +import { ImportTemplateDialogProvider } from '../modules/import-template'; import { PeekViewManagerModal } from '../modules/peek-view'; import { WorkspaceSubPath } from '../shared'; @@ -181,9 +177,16 @@ export const SignOutConfirmModal = () => { const { openPage } = useNavigateHelper(); const authService = useService(AuthService); const [open, setOpen] = useAtom(openSignOutModalAtom); - const currentWorkspace = useServiceOptional(WorkspaceService)?.workspace; - const workspaces = useLiveData( - useService(WorkspacesService).list.workspaces$ + const globalContextService = useService(GlobalContextService); + const currentWorkspaceId = useLiveData( + globalContextService.globalContext.workspaceId.$ + ); + const workspacesService = useService(WorkspacesService); + const workspaces = useLiveData(workspacesService.list.workspaces$); + const currentWorkspaceMetadata = useLiveData( + currentWorkspaceId + ? workspacesService.list.workspace$(currentWorkspaceId) + : undefined ); const onConfirm = useAsyncCallback(async () => { @@ -199,7 +202,7 @@ export const SignOutConfirmModal = () => { } // if current workspace is affine cloud, switch to local workspace - if (currentWorkspace?.flavour === WorkspaceFlavour.AFFINE_CLOUD) { + if (currentWorkspaceMetadata?.flavour === WorkspaceFlavour.AFFINE_CLOUD) { const localWorkspace = workspaces.find( w => w.flavour === WorkspaceFlavour.LOCAL ); @@ -207,7 +210,13 @@ export const SignOutConfirmModal = () => { openPage(localWorkspace.id, WorkspaceSubPath.ALL); } } - }, [authService, currentWorkspace, openPage, setOpen, workspaces]); + }, [ + authService, + currentWorkspaceMetadata?.flavour, + openPage, + setOpen, + workspaces, + ]); return ( @@ -215,35 +224,11 @@ export const SignOutConfirmModal = () => { }; export const AllWorkspaceModals = (): ReactElement => { - const [isOpenCreateWorkspaceModal, setOpenCreateWorkspaceModal] = useAtom( - openCreateWorkspaceModalAtom - ); - - const { jumpToSubPath, jumpToPage } = useNavigateHelper(); - return ( <> - { - setOpenCreateWorkspaceModal(false); - }, [setOpenCreateWorkspaceModal])} - onCreate={useCallback( - (id, defaultDocId) => { - setOpenCreateWorkspaceModal(false); - // if jumping immediately, the page may stuck in loading state - // not sure why yet .. here is a workaround - setTimeout(() => { - if (!defaultDocId) { - jumpToSubPath(id, WorkspaceSubPath.ALL); - } else { - jumpToPage(id, defaultDocId); - } - }); - }, - [jumpToPage, jumpToSubPath, setOpenCreateWorkspaceModal] - )} - /> + + + diff --git a/packages/frontend/core/src/router.tsx b/packages/frontend/core/src/router.tsx index ec8b42ff1e..3fe3d45678 100644 --- a/packages/frontend/core/src/router.tsx +++ b/packages/frontend/core/src/router.tsx @@ -1,15 +1,15 @@ import { wrapCreateBrowserRouter } from '@sentry/react'; -import { createContext, useEffect, useState } from 'react'; -import type { NavigateFunction, RouteObject } from 'react-router-dom'; +import { useEffect, useState } from 'react'; +import type { RouteObject } from 'react-router-dom'; import { createBrowserRouter as reactRouterCreateBrowserRouter, - Outlet, redirect, // eslint-disable-next-line @typescript-eslint/no-restricted-imports useNavigate, } from 'react-router-dom'; -export const NavigateContext = createContext(null); +import { NavigateContext } from './hooks/use-navigate-helper'; +import { RootWrapper } from './pages/root'; export function RootRouter() { const navigate = useNavigate(); @@ -22,7 +22,7 @@ export function RootRouter() { return ( ready && ( - + ) ); @@ -114,6 +114,10 @@ export const topLevelRoutes = [ path: '/theme-editor', lazy: () => import('./pages/theme-editor'), }, + { + path: '/template/import', + lazy: () => import('./pages/import-template'), + }, { path: '*', lazy: () => import('./pages/404'), diff --git a/packages/frontend/electron/renderer/app.tsx b/packages/frontend/electron/renderer/app.tsx index 33b30f4617..f3a68287c3 100644 --- a/packages/frontend/electron/renderer/app.tsx +++ b/packages/frontend/electron/renderer/app.tsx @@ -1,7 +1,6 @@ import '@affine/component/theme/global.css'; import '@affine/component/theme/theme.css'; -import { NotificationCenter } from '@affine/component'; import { AffineContext } from '@affine/component/context'; import { GlobalLoading } from '@affine/component/global-loading'; import { AppFallback } from '@affine/core/components/affine/app-container'; @@ -114,7 +113,6 @@ export function App() { - } router={router} diff --git a/packages/frontend/i18n/src/resources/en.json b/packages/frontend/i18n/src/resources/en.json index 7db4dd6be9..387167801b 100644 --- a/packages/frontend/i18n/src/resources/en.json +++ b/packages/frontend/i18n/src/resources/en.json @@ -1551,5 +1551,10 @@ "will be moved to Trash": "{{title}} will be moved to trash", "will delete member": "will delete member", "com.affine.app-sidebar.star-us": "Star us", - "com.affine.app-sidebar.learn-more": "Learn more" + "com.affine.app-sidebar.learn-more": "Learn more", + "com.affine.import-template.dialog.errorImport": "Failed to import template, please try again.", + "com.affine.import-template.dialog.errorLoad": "Failed to load template, please try again.", + "com.affine.import-template.dialog.createDocToWorkspace": "Create doc to \"{{workspace}}\"", + "com.affine.import-template.dialog.createDocToNewWorkspace": "Create into a New Workspace", + "com.affine.import-template.dialog.createDocWithTemplate": "Create doc with \"{{templateName}}\" template" } diff --git a/packages/frontend/mobile/src/app.tsx b/packages/frontend/mobile/src/app.tsx index 8e46841299..d80c1b50e2 100644 --- a/packages/frontend/mobile/src/app.tsx +++ b/packages/frontend/mobile/src/app.tsx @@ -1,7 +1,6 @@ import '@affine/component/theme/global.css'; import '@affine/component/theme/theme.css'; -import { NotificationCenter } from '@affine/component'; import { AffineContext } from '@affine/component/context'; import { AppFallback } from '@affine/core/components/affine/app-container'; import { configureCommonModules } from '@affine/core/modules'; @@ -78,7 +77,6 @@ export function App() { - } router={router} diff --git a/packages/frontend/mobile/src/pages/workspace/layout.tsx b/packages/frontend/mobile/src/pages/workspace/layout.tsx index 5a29217f50..7a0eaaa771 100644 --- a/packages/frontend/mobile/src/pages/workspace/layout.tsx +++ b/packages/frontend/mobile/src/pages/workspace/layout.tsx @@ -1,10 +1,7 @@ import { AffineErrorBoundary } from '@affine/core/components/affine/affine-error-boundary'; import { AppFallback } from '@affine/core/components/affine/app-container'; import { WorkspaceLayoutProviders } from '@affine/core/layouts/workspace-layout'; -import { - AllWorkspaceModals, - CurrentWorkspaceModals, -} from '@affine/core/providers/modal-provider'; +import { CurrentWorkspaceModals } from '@affine/core/providers/modal-provider'; import { SWRConfigProvider } from '@affine/core/providers/swr-config-provider'; import type { Workspace, WorkspaceMetadata } from '@toeverything/infra'; import { @@ -73,7 +70,6 @@ export const WorkspaceLayout = ({ return ( - ); } @@ -82,7 +78,6 @@ export const WorkspaceLayout = ({ - {children} diff --git a/packages/frontend/web/src/app.tsx b/packages/frontend/web/src/app.tsx index 0e0896801b..511dfd1adc 100644 --- a/packages/frontend/web/src/app.tsx +++ b/packages/frontend/web/src/app.tsx @@ -1,7 +1,6 @@ import '@affine/component/theme/global.css'; import '@affine/component/theme/theme.css'; -import { NotificationCenter } from '@affine/component'; import { AffineContext } from '@affine/component/context'; import { GlobalLoading } from '@affine/component/global-loading'; import { AppFallback } from '@affine/core/components/affine/app-container'; @@ -100,7 +99,6 @@ export function App() { - } router={router} diff --git a/tests/affine-local/e2e/local-first-delete-workspace.spec.ts b/tests/affine-local/e2e/local-first-delete-workspace.spec.ts index f3df55d38e..bf3e0c4bcd 100644 --- a/tests/affine-local/e2e/local-first-delete-workspace.spec.ts +++ b/tests/affine-local/e2e/local-first-delete-workspace.spec.ts @@ -13,6 +13,7 @@ test('Create new workspace, then delete it', async ({ page, workspace }) => { await waitForEditorLoad(page); await clickSideBarCurrentWorkspaceBanner(page); await page.getByTestId('new-workspace').click(); + await page.waitForTimeout(1000); await page .getByTestId('create-workspace-input') .pressSequentially('Test Workspace', { delay: 50 }); diff --git a/tests/affine-local/e2e/local-first-workspace-list.spec.ts b/tests/affine-local/e2e/local-first-workspace-list.spec.ts index 6366379738..f813f1d280 100644 --- a/tests/affine-local/e2e/local-first-workspace-list.spec.ts +++ b/tests/affine-local/e2e/local-first-workspace-list.spec.ts @@ -83,7 +83,7 @@ test.skip('create multi workspace in the workspace list', async ({ await page.reload(); await openWorkspaceListModal(page); - await page.getByTestId('draggable-item').nth(1).click(); + await page.getByTestId('workspace-card').nth(1).click(); await page.waitForTimeout(500); const currentWorkspace = await workspace.current(); @@ -92,8 +92,8 @@ test.skip('create multi workspace in the workspace list', async ({ await openWorkspaceListModal(page); await page.waitForTimeout(1000); - const sourceElement = page.getByTestId('draggable-item').nth(2); - const targetElement = page.getByTestId('draggable-item').nth(1); + const sourceElement = page.getByTestId('workspace-card').nth(2); + const targetElement = page.getByTestId('workspace-card').nth(1); const sourceBox = await sourceElement.boundingBox(); const targetBox = await targetElement.boundingBox(); diff --git a/tests/kit/utils/properties.ts b/tests/kit/utils/properties.ts index 66c30eb3b9..0cf43b57c8 100644 --- a/tests/kit/utils/properties.ts +++ b/tests/kit/utils/properties.ts @@ -173,7 +173,11 @@ export const selectVisibilitySelector = async ( }) .click(); + await page.waitForTimeout(500); + await page.keyboard.press('Escape'); + + await page.waitForTimeout(500); }; export const changePropertyVisibility = async ( diff --git a/tests/kit/utils/sidebar.ts b/tests/kit/utils/sidebar.ts index 7ac33a9a9d..1bff4e5dbd 100644 --- a/tests/kit/utils/sidebar.ts +++ b/tests/kit/utils/sidebar.ts @@ -9,7 +9,7 @@ export async function clickSideBarAllPageButton(page: Page) { } export async function clickSideBarCurrentWorkspaceBanner(page: Page) { - return page.getByTestId('current-workspace').click(); + return page.getByTestId('current-workspace-card').click(); } export async function clickSideBarUseAvatar(page: Page) {