feat(core): import template (#8000)

This commit is contained in:
EYHN
2024-08-29 04:01:35 +00:00
parent 4ec45a247e
commit b96ad57568
67 changed files with 1835 additions and 974 deletions
@@ -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',
});
@@ -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 => {
<div className={fallbackStyle}>
<div className={fallbackHeaderStyle}>
{currentWorkspace ? (
<WorkspaceSelector />
<WorkspaceNavigator
showSettingsButton
showSyncStatus
showEnableCloudButton
/>
) : (
<>
<Skeleton variant="circular" width={40} height={40} />
@@ -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;
};
@@ -1,10 +0,0 @@
import type { DocCollection } from '@blocksuite/store';
export type FavoriteListProps = {
docCollection: DocCollection;
};
export type CollectionsListProps = {
docCollection: DocCollection;
onCreate?: () => void;
};
@@ -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 (
<>
<CloudWorkspaceIcon />
Cloud
</>
);
};
const SyncingWorkspaceStatus = ({ progress }: { progress?: number }) => {
return (
<>
<Loading progress={progress} speed={progress ? 0 : undefined} />
Syncing...
</>
);
};
const UnSyncWorkspaceStatus = () => {
return (
<>
<UnsyncIcon />
Wait for upload
</>
);
};
const LocalWorkspaceStatus = () => {
return (
<>
{!environment.isDesktop ? (
<InformationFillDuotoneIcon style={{ color: cssVar('errorColor') }} />
) : (
<LocalWorkspaceIcon />
)}
Local
</>
);
};
const OfflineStatus = () => {
return (
<>
<NoNetworkIcon />
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 ? (
<OfflineStatus />
) : (
<CloudWorkspaceSyncStatus />
)
) : (
<LocalWorkspaceStatus />
),
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<typeof setTimeout>;
if (active) {
timer = setTimeout(() => {
setDelayActive(active);
}, delayOpen);
} else {
timer = setTimeout(() => {
setDelayActive(active);
pause();
}, delayClose);
}
return () => clearTimeout(timer);
}, [active, pause, paused]);
return (
<div className={styles.workspaceInfoSlider} data-active={delayActive}>
<div className={styles.workspaceInfoSlide}>
<div className={styles.workspaceInfo} data-type="normal">
<div className={styles.workspaceName} data-testid="workspace-name">
{name}
</div>
<div className={styles.workspaceStatus}>
{isCloud ? <CloudWorkspaceStatus /> : <LocalWorkspaceStatus />}
</div>
</div>
{/* when syncing/offline/... */}
<div className={styles.workspaceInfo} data-type="events">
<Tooltip
content={message}
options={{ className: styles.workspaceInfoTooltip }}
>
<div className={styles.workspaceActiveStatus}>
<SyncingWorkspaceStatus progress={progress} />
</div>
</Tooltip>
</div>
</div>
</div>
);
};
export const WorkspaceCard = forwardRef<
HTMLDivElement,
HTMLAttributes<HTMLDivElement>
>(({ ...props }, ref) => {
const currentWorkspace = useService(WorkspaceService).workspace;
const information = useWorkspaceInfo(currentWorkspace.meta);
const name = information?.name ?? UNTITLED_WORKSPACE_NAME;
return (
<div
className={styles.container}
role="button"
tabIndex={0}
data-testid="current-workspace"
id="current-workspace"
ref={ref}
{...props}
>
<WorkspaceAvatar
key={currentWorkspace.id}
meta={currentWorkspace.meta}
rounded={3}
data-testid="workspace-avatar"
size={32}
name={name}
colorfulFallback
/>
<WorkspaceInfo name={name} />
</div>
);
});
WorkspaceCard.displayName = 'WorkspaceCard';
@@ -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 => {
<SidebarContainer>
<div className={workspaceAndUserWrapper}>
<div className={workspaceWrapper}>
<WorkspaceSelector />
<WorkspaceNavigator
showEnableCloudButton
showSettingsButton
showSyncStatus
/>
</div>
<UserInfo />
</div>
@@ -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 (
<Menu
rootOptions={{
open: isUserWorkspaceListOpened,
open,
}}
items={<UserWithWorkspaceList onEventEnd={closeUserWorkspaceList} />}
items={
<UserWithWorkspaceList
onEventEnd={closeUserWorkspaceList}
onClickWorkspace={onSelectWorkspace}
onCreatedWorkspace={onCreatedWorkspace}
showEnableCloudButton={showEnableCloudButton}
showSettingsButton={showSettingsButton}
/>
}
contentOptions={{
// hide trigger
sideOffset: -58,
onInteractOutside: closeUserWorkspaceList,
onEscapeKeyDown: closeUserWorkspaceList,
...menuContentOptions,
style: {
width: '300px',
...menuContentOptions?.style,
},
}}
>
<WorkspaceCard onClick={openUserWorkspaceList} />
{workspaceMetadata ? (
<WorkspaceCard
workspaceMetadata={workspaceMetadata}
onClick={openUserWorkspaceList}
showSyncStatus={showSyncStatus}
className={className}
showArrowDownIcon={showArrowDownIcon}
disable={disable}
data-testid="current-workspace-card"
/>
) : (
<span></span>
)}
</Menu>
);
};
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 (
<WorkspaceSelector
onSelectWorkspace={handleClickWorkspace}
onCreatedWorkspace={handleCreatedWorkspace}
{...props}
/>
);
};
@@ -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 (
<div className={styles.loadingWrapper}>
<Loading size={24} />
</div>
);
};
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 = ({
<SignInItem />
)}
<Divider size="thinner" />
<AFFiNEWorkspaceList onEventEnd={onEventEnd} />
<AFFiNEWorkspaceList
onEventEnd={onEventEnd}
onClickWorkspace={onClickWorkspace}
showEnableCloudButton={showEnableCloudButton}
showSettingsButton={showSettingsButton}
/>
{workspaces.length > 0 ? <Divider size="thinner" /> : null}
<AddWorkspace
onAddWorkspace={onAddWorkspace}
@@ -134,9 +148,5 @@ const UserWithWorkspaceListInner = ({
};
export const UserWithWorkspaceList = (props: UserWithWorkspaceListProps) => {
return (
<Suspense fallback={<UserWithWorkspaceListLoading />}>
<UserWithWorkspaceListInner {...props} />
</Suspense>
);
return <UserWithWorkspaceListInner {...props} />;
};
@@ -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 = ({
@@ -29,3 +29,7 @@ export const scrollbar = style({
transform: 'translateX(8px)',
width: '4px',
});
export const workspaceCard = style({
height: '44px',
padding: '0 12px',
});
@@ -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<WorkspaceModalProps, 'onNewWorkspace' | 'onAddWorkspace'>) => {
const t = useI18n();
if (workspaces.length === 0) {
return null;
@@ -68,27 +47,20 @@ const CloudWorkSpaceList = ({
{t['com.affine.workspaceList.workspaceListType.cloud']()}
</div>
<WorkspaceList
disabled={disabled}
items={workspaces}
currentWorkspaceId={currentWorkspaceId}
onClick={onClickWorkspace}
onSettingClick={onClickWorkspaceSetting}
useIsWorkspaceOwner={useIsWorkspaceOwner}
useWorkspaceName={useWorkspaceName}
/>
</div>
);
};
const LocalWorkspaces = ({
disabled,
workspaces,
onClickWorkspace,
onClickWorkspaceSetting,
onClickEnableCloud,
openingId,
currentWorkspaceId,
}: WorkspaceModalProps) => {
}: Omit<WorkspaceModalProps, 'onNewWorkspace' | 'onAddWorkspace'>) => {
const t = useI18n();
if (workspaces.length === 0) {
return null;
@@ -104,15 +76,10 @@ const LocalWorkspaces = ({
{t['com.affine.workspaceList.workspaceListType.local']()}
</div>
<WorkspaceList
openingId={openingId}
disabled={disabled}
items={workspaces}
currentWorkspaceId={currentWorkspaceId}
onClick={onClickWorkspace}
onSettingClick={onClickWorkspaceSetting}
onEnableCloudClick={onClickEnableCloud}
useIsWorkspaceOwner={useIsWorkspaceOwner}
useWorkspaceName={useWorkspaceName}
/>
</div>
);
@@ -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 (
<ScrollableContainer
className={styles.workspaceListsWrapper}
@@ -218,11 +165,10 @@ export const AFFiNEWorkspaceList = ({
<div>
<CloudWorkSpaceList
workspaces={cloudWorkspaces}
onClickWorkspace={onClickWorkspace}
onClickWorkspaceSetting={onClickWorkspaceSetting}
onNewWorkspace={onNewWorkspace}
onAddWorkspace={onAddWorkspace}
currentWorkspaceId={currentWorkspaceId}
onClickWorkspace={handleClickWorkspace}
onClickWorkspaceSetting={
showSettingsButton ? onClickWorkspaceSetting : undefined
}
/>
{localWorkspaces.length > 0 && cloudWorkspaces.length > 0 ? (
<Divider size="thinner" />
@@ -231,13 +177,55 @@ export const AFFiNEWorkspaceList = ({
) : null}
<LocalWorkspaces
workspaces={localWorkspaces}
onClickWorkspace={onClickWorkspace}
onClickWorkspaceSetting={onClickWorkspaceSetting}
onClickEnableCloud={onClickEnableCloud}
onNewWorkspace={onNewWorkspace}
onAddWorkspace={onAddWorkspace}
currentWorkspaceId={currentWorkspaceId}
onClickWorkspace={handleClickWorkspace}
onClickWorkspaceSetting={
showSettingsButton ? onClickWorkspaceSetting : undefined
}
onClickEnableCloud={
showEnableCloudButton ? onClickEnableCloud : undefined
}
/>
</ScrollableContainer>
);
};
interface WorkspaceListProps {
items: WorkspaceMetadata[];
onClick: (workspace: WorkspaceMetadata) => void;
onSettingClick?: (workspace: WorkspaceMetadata) => void;
onEnableCloudClick?: (meta: WorkspaceMetadata) => void;
}
interface SortableWorkspaceItemProps extends Omit<WorkspaceListProps, 'items'> {
workspaceMetadata: WorkspaceMetadata;
}
const SortableWorkspaceItem = ({
workspaceMetadata,
onClick,
onSettingClick,
onEnableCloudClick,
}: SortableWorkspaceItemProps) => {
const handleClick = useCallback(() => {
onClick(workspaceMetadata);
}, [onClick, workspaceMetadata]);
return (
<WorkspaceCard
className={styles.workspaceCard}
workspaceMetadata={workspaceMetadata}
onClick={handleClick}
avatarSize={28}
onClickOpenSettings={onSettingClick}
onClickEnableCloud={onEnableCloudClick}
/>
);
};
export const WorkspaceList = (props: WorkspaceListProps) => {
const workspaceList = props.items;
return workspaceList.map(item => (
<SortableWorkspaceItem key={item.id} {...props} workspaceMetadata={item} />
));
};
@@ -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 (
<>
<CloudWorkspaceIcon />
Cloud
</>
);
};
const SyncingWorkspaceStatus = ({ progress }: { progress?: number }) => {
return (
<>
<Loading progress={progress} speed={progress ? 0 : undefined} />
Syncing...
</>
);
};
const UnSyncWorkspaceStatus = () => {
return (
<>
<UnsyncIcon />
Wait for upload
</>
);
};
const LocalWorkspaceStatus = () => {
return (
<>
{!environment.isDesktop ? (
<InformationFillDuotoneIcon style={{ color: cssVar('errorColor') }} />
) : (
<LocalWorkspaceIcon />
)}
Local
</>
);
};
const OfflineStatus = () => {
return (
<>
<NoNetworkIcon />
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 ? (
<OfflineStatus />
) : (
<CloudWorkspaceSyncStatus />
)
) : (
<LocalWorkspaceStatus />
),
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<typeof setTimeout>;
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 (
<div className={styles.workspaceInfoSlider} data-active={delayActive}>
<div className={styles.workspaceInfoSlide}>
<div className={styles.workspaceInfo} data-type="normal">
<div className={styles.workspaceName} data-testid="workspace-name">
{workspaceProfile.name}
</div>
<div className={styles.workspaceStatus}>
{isCloud ? <CloudWorkspaceStatus /> : <LocalWorkspaceStatus />}
</div>
</div>
{/* when syncing/offline/... */}
{syncStatus && (
<div className={styles.workspaceInfo} data-type="events">
<Tooltip
content={syncStatus.message}
options={{ className: styles.workspaceInfoTooltip }}
>
<div className={styles.workspaceActiveStatus}>
<SyncingWorkspaceStatus progress={syncStatus.progress} />
</div>
</Tooltip>
</div>
)}
</div>
</div>
);
};
export const WorkspaceCard = forwardRef<
HTMLDivElement,
HTMLAttributes<HTMLDivElement> & {
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 (
<div
className={clsx(
styles.container,
disable ? styles.disable : null,
className
)}
role="button"
tabIndex={0}
data-testid="workspace-card"
ref={ref}
{...props}
>
{information ? (
<WorkspaceAvatar
meta={workspaceMetadata}
rounded={3}
data-testid="workspace-avatar"
size={avatarSize}
name={name}
colorfulFallback
/>
) : (
<Skeleton width={avatarSize} height={avatarSize} />
)}
<div className={styles.workspaceTitleContainer}>
{information ? (
showSyncStatus ? (
<WorkspaceSyncInfo
workspaceProfile={information}
workspaceMetadata={workspaceMetadata}
/>
) : (
<span className={styles.workspaceName}>{information.name}</span>
)
) : (
<Skeleton width={100} />
)}
</div>
{onClickEnableCloud &&
workspaceMetadata.flavour === WorkspaceFlavour.LOCAL ? (
<Button
className={styles.showOnCardHover}
onClick={e => {
e.stopPropagation();
onClickEnableCloud(workspaceMetadata);
}}
>
Enable Cloud
</Button>
) : null}
{information?.isOwner ? null : <CollaborationIcon />}
{onClickOpenSettings && (
<div
className={styles.settingButton}
onClick={e => {
e.stopPropagation();
onClickOpenSettings(workspaceMetadata);
}}
>
<SettingsIcon width={16} height={16} />
</div>
)}
{showArrowDownIcon && <ArrowDownSmallIcon />}
</div>
);
}
);
WorkspaceCard.displayName = 'WorkspaceCard';
@@ -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,
},
},
});