feat(core): import template (#8000)

This commit is contained in:
EYHN 2024-08-29 04:01:35 +00:00
parent 4ec45a247e
commit b96ad57568
No known key found for this signature in database
GPG Key ID: 46C9E26A75AB276C
67 changed files with 1835 additions and 974 deletions

View File

@ -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();
}

View File

@ -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 (
<div>
<div className={styles.card} data-testid="workspace-card">
<Skeleton variant="circular" width={28} height={28} />
<Skeleton
variant="rectangular"
height={43}
width={220}
style={{ marginLeft: '12px' }}
/>
</div>
</div>
);
};
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 (
<div
className={styles.card}
data-active={meta.id === currentWorkspaceId}
data-testid="workspace-card"
onClick={useCallback(() => {
onClick(meta);
}, [onClick, meta])}
>
<WorkspaceAvatar
key={meta.id}
meta={meta}
rounded={3}
size={28}
name={name}
colorfulFallback
/>
<div className={styles.workspaceInfo}>
<div className={styles.workspaceTitle}>{displayName}</div>
<div className={styles.actionButtons}>
{isLocal ? (
<Button
loading={!!openingId && openingId === meta.id}
disabled={!!openingId}
className={styles.showOnCardHover}
onClick={onEnableCloud}
>
{enableCloudText}
</Button>
) : null}
{isOwner ? null : <CollaborationIcon />}
<div
className={styles.settingButton}
onClick={e => {
e.stopPropagation();
onSettingClick(meta);
}}
>
<SettingsIcon width={16} height={16} />
</div>
</div>
</div>
</div>
);
};

View File

@ -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,
},
},
});

View File

@ -1,8 +0,0 @@
import { style } from '@vanilla-extract/css';
export const workspaceItemStyle = style({
'@media': {
'screen and (max-width: 720px)': {
width: '100%',
},
},
});

View File

@ -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<WorkspaceListProps, 'items'> {
item: WorkspaceMetadata;
}
const SortableWorkspaceItem = ({
item,
openingId,
useIsWorkspaceOwner,
useWorkspaceName,
currentWorkspaceId,
onClick,
onSettingClick,
onEnableCloudClick,
}: SortableWorkspaceItemProps) => {
const isOwner = useIsWorkspaceOwner?.(item);
const name = useWorkspaceName?.(item);
return (
<div className={workspaceItemStyle} data-testid="draggable-item">
<WorkspaceCard
currentWorkspaceId={currentWorkspaceId}
meta={item}
onClick={onClick}
onSettingClick={onSettingClick}
onEnableCloudClick={onEnableCloudClick}
openingId={openingId}
isOwner={isOwner}
name={name}
/>
</div>
);
};
export const WorkspaceList = (props: WorkspaceListProps) => {
const workspaceList = props.items;
return workspaceList
.filter(
w => w.flavour !== WorkspaceFlavour.AFFINE_CLOUD || w.initialized === true
)
.map(item => (
<Suspense fallback={<WorkspaceCardSkeleton />} key={item.id}>
<SortableWorkspaceItem key={item.id} {...props} item={item} />
</Suspense>
));
};

View File

@ -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 (
<DropdownMenu.Root {...rootOptions}>
<DropdownMenu.Trigger asChild>{children}</DropdownMenu.Trigger>
<Wrapper {...wrapperProps}>
<DropdownMenu.Portal {...portalOptions}>
<DropdownMenu.Content
className={clsx(styles.menuContent, className)}
className={clsx(
styles.menuContent,
desktopStyles.contentAnimation,
className
)}
sideOffset={5}
align="start"
style={{ zIndex: 'var(--affine-z-index-popover)', ...contentStyle }}
{...otherContentOptions}
side="bottom"
>
{items}
</DropdownMenu.Content>
</Wrapper>
</DropdownMenu.Portal>
</DropdownMenu.Root>
);
};

View File

@ -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',
},
},
});

View File

@ -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 = ({
<MobileMenuContext.Provider
value={{ subMenus, setSubMenus, setOpen: onOpenChange }}
>
<Wrapper {...wrapperProps}>
<Modal
open={finalOpen}
onOpenChange={onOpenChange}
width="100%"
animation="slideBottom"
withoutCloseButton={true}
contentOptions={{
className: clsx(className, styles.mobileMenuModal),
...otherContentOptions,
}}
contentWrapperStyle={{
alignItems: 'end',
paddingBottom: 10,
}}
>
<div
ref={sliderRef}
className={styles.slider}
@ -159,7 +146,7 @@ export const MobileMenu = ({
</div>
))}
</div>
</Wrapper>
</Modal>
</MobileMenuContext.Provider>
</>
);

View File

@ -153,14 +153,19 @@ export const ModalInner = forwardRef<HTMLDivElement, ModalProps>(
);
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) => {

View File

@ -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<CreateWorkspaceMode>(false);
export const openSignOutModalAtom = atom(false);
export const openQuotaModalAtom = atom(false);
export const openStarAFFiNEModalAtom = atom(false);

View File

@ -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<typeof useI18n>;
store: ReturnType<typeof createStore>;
pageHelper: ReturnType<typeof usePageHelper>;
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');
},
})
);

View File

@ -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',
});

View File

@ -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} />

View File

@ -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;
};

View File

@ -1,10 +0,0 @@
import type { DocCollection } from '@blocksuite/store';
export type FavoriteListProps = {
docCollection: DocCollection;
};
export type CollectionsListProps = {
docCollection: DocCollection;
onCreate?: () => void;
};

View File

@ -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';

View File

@ -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>

View File

@ -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}
/>
);
};

View File

@ -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} />;
};

View File

@ -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 = ({

View File

@ -29,3 +29,7 @@ export const scrollbar = style({
transform: 'translateX(8px)',
width: '4px',
});
export const workspaceCard = style({
height: '44px',
padding: '0 12px',
});

View File

@ -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} />
));
};

View File

@ -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';

View File

@ -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,
},
},
});

View File

@ -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]
);
}

View File

@ -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<NavigateFunction | null>(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) {

View File

@ -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(() => {

View File

@ -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;

View File

@ -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 (
<SWRConfigProvider>
{/* load all workspaces is costly, do not block the whole UI */}
<AllWorkspaceModals />
<CurrentWorkspaceModals />
<WorkspaceLayoutInner>{children}</WorkspaceLayoutInner>
{/* should show after workspace loaded */}
@ -173,6 +170,7 @@ export const WorkspaceLayoutProviders = ({ children }: PropsWithChildren) => {
{children}
<QuickSearchContainer />
<SyncAwareness />
<OverCapacityNotification />
</>
);
};

View File

@ -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);
}
}

View File

@ -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);
}

View File

@ -0,0 +1,7 @@
import { Service } from '@toeverything/infra';
import { CreateWorkspaceDialog } from '../entities/dialog';
export class CreateWorkspaceDialogService extends Service {
dialog = this.framework.createEntity(CreateWorkspaceDialog);
}

View File

@ -0,0 +1,7 @@
import type { WorkspaceMetadata } from '@toeverything/infra';
export type CreateWorkspaceMode = 'add' | 'new';
export type CreateWorkspaceCallbackPayload = {
meta: WorkspaceMetadata;
defaultDocId?: string;
};

View File

@ -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',
});

View File

@ -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<HTMLInputElement>) => {
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 (
<ConfirmModal
defaultOpen={true}
title={t['com.affine.nameWorkspace.title']()}
description={t['com.affine.nameWorkspace.description']()}
cancelText={t['com.affine.nameWorkspace.button.cancel']()}
confirmText={t['com.affine.nameWorkspace.button.create']()}
confirmButtonOptions={{
variant: 'primary',
loading,
disabled: !workspaceName,
['data-testid' as string]: 'create-workspace-create-button',
}}
closeButtonOptions={{
['data-testid' as string]: 'create-workspace-close-button',
}}
onConfirm={handleCreateWorkspace}
{...props}
>
<div className={styles.avatarWrapper}>
<Avatar size={56} name={workspaceName} colorfulFallback />
</div>
<div className={styles.workspaceNameWrapper}>
<div className={styles.subTitle}>
{t['com.affine.nameWorkspace.subtitle.workspace-name']()}
</div>
<Input
autoFocus
data-testid="create-workspace-input"
onKeyDown={handleKeyDown}
placeholder={t['com.affine.nameWorkspace.placeholder']()}
maxLength={64}
minLength={0}
onChange={setWorkspaceName}
size="large"
/>
</div>
<div className={styles.affineCloudWrapper}>
<div className={styles.subTitle}>{t['AFFiNE Cloud']()}</div>
<div className={styles.card}>
<div className={styles.cardText}>
<div className={styles.cardTitle}>
<span>{t['com.affine.nameWorkspace.affine-cloud.title']()}</span>
<Switch
checked={enable}
onChange={onSwitchChange}
disabled={shouldEnableCloud}
/>
</div>
<div className={styles.cardDescription}>
{t['com.affine.nameWorkspace.affine-cloud.description']()}
</div>
</div>
<div className={styles.cloudSvgContainer}>
<CloudSvg />
</div>
</div>
{shouldEnableCloud ? (
<a
className={styles.cloudTips}
href={runtimeConfig.downloadUrl}
target="_blank"
rel="noreferrer"
>
{t['com.affine.nameWorkspace.affine-cloud.web-tips']()}
</a>
) : null}
</div>
</ConfirmModal>
);
};
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 (
<NameWorkspaceContent
loading={loading}
open
onOpenChange={onOpenChange}
onConfirmName={onConfirmName}
/>
);
} else {
return null;
}
};
export const CreateWorkspaceDialogProvider = () => {
const createWorkspaceDialogService = useService(CreateWorkspaceDialogService);
const isOpen = useLiveData(createWorkspaceDialogService.dialog.isOpen$);
return isOpen ? <CreateWorkspaceDialog /> : null;
};

View File

@ -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);
}
}

View File

@ -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<boolean>(false);
readonly data$ = new LiveData<Uint8Array | null>(null);
readonly error$ = new LiveData<any | null>(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))
);
}
)
);
}

View File

@ -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]);
}

View File

@ -0,0 +1,7 @@
import { Service } from '@toeverything/infra';
import { ImportTemplateDialog } from '../entities/dialog';
export class ImportTemplateDialogService extends Service {
dialog = this.framework.createEntity(ImportTemplateDialog);
}

View File

@ -0,0 +1,7 @@
import { Service } from '@toeverything/infra';
import { TemplateDownloader } from '../entities/downloader';
export class TemplateDownloaderService extends Service {
downloader = this.framework.createEntity(TemplateDownloader);
}

View File

@ -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 };
}
}

View File

@ -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) };
}
}

View File

@ -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',
});

View File

@ -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<any>(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<WorkspaceMetadata | null>(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 (
<>
<div className={styles.dialogContainer}>
<AllDocsIcon className={styles.mainIcon} />
<h6 className={styles.mainTitle}>
{t['com.affine.import-template.dialog.createDocWithTemplate']({
templateName,
})}
</h6>
{noWorkspace ? (
<p className={styles.desc}>A new workspace will be created.</p>
) : (
<>
<p className={styles.desc}>Choose a workspace.</p>
<WorkspaceSelector
workspaceMetadata={selectedWorkspace}
onSelectWorkspace={handleSelectedWorkspace}
onCreatedWorkspace={handleCreatedWorkspace}
className={styles.workspaceSelector}
showArrowDownIcon
disable={disabled}
/>
</>
)}
</div>
{importingError && (
<span style={{ color: cssVar('warningColor') }}>
{t['com.affine.import-template.dialog.errorImport']()}
</span>
)}
{downloadError ? (
<span style={{ color: cssVar('warningColor') }}>
{t['com.affine.import-template.dialog.errorLoad']()}
</span>
) : selectedWorkspace ? (
<Button
className={styles.mainButton}
variant={disabled ? 'secondary' : 'primary'}
loading={disabled}
disabled={disabled}
onClick={handleImportToSelectedWorkspace}
>
{selectedWorkspaceName &&
t['com.affine.import-template.dialog.createDocToWorkspace']({
workspace: selectedWorkspaceName,
})}
</Button>
) : (
<Button
className={styles.mainButton}
variant="primary"
loading={disabled}
disabled={disabled}
onClick={handleImportToNewWorkspace}
>
{t['com.affine.import-template.dialog.createDocToNewWorkspace']()}
</Button>
)}
</>
);
};
export const ImportTemplateDialogProvider = () => {
const importTemplateDialogService = useService(ImportTemplateDialogService);
const isOpen = useLiveData(importTemplateDialogService.dialog.isOpen$);
const template = useLiveData(importTemplateDialogService.dialog.template$);
return (
<Modal
open={isOpen}
modal={true}
persistent
withoutCloseButton
contentOptions={{
className: styles.modal,
}}
onOpenChange={() => importTemplateDialogService.dialog.close()}
>
{template && (
<Dialog
docId={template.docId}
templateName={template.templateName}
workspaceId={template.workspaceId}
onClose={() => importTemplateDialogService.dialog.close()}
/>
)}
</Modal>
);
};

View File

@ -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);
}

View File

@ -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);

View File

@ -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) => {
<AffineErrorBoundary>
<Suspense>
<route.Component />
<Outlet />
</Suspense>
</AffineErrorBoundary>
<ViewBodyTarget viewId={view.id} className={styles.viewBodyContainer} />

View File

@ -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 <RouteContainer route={route} />;
},
};
});
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: <RouteContainer />,
children: viewRoutes,
},
] satisfies RouteObject[];
}, []);
return (
<div className={styles.workbenchViewContainer} ref={containerRef}>
<ViewRoot routes={warpedRoutes} key={view.id} view={view} />
<ViewRoot routes={routes} key={view.id} view={view} />
</div>
);
};

View File

@ -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;
};

View File

@ -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 (
<>
<div
style={{
position: 'fixed',
left: '50%',
top: '50%',
<div
style={{
position: 'fixed',
left: 'calc(50% - 150px)',
top: '50%',
}}
>
<WorkspaceNavigator
open={true}
menuContentOptions={{
forceMount: true,
}}
>
<Menu
rootOptions={{
open: true,
}}
items={<UserWithWorkspaceList />}
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',
},
}}
>
<div></div>
</Menu>
</div>
<AllWorkspaceModals />
</>
/>
</div>
);
};

View File

@ -0,0 +1,12 @@
import { Outlet } from 'react-router-dom';
import { AllWorkspaceModals } from '../providers/modal-provider';
export const RootWrapper = () => {
return (
<>
<AllWorkspaceModals />
<Outlet />
</>
);
};

View File

@ -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 (
<FrameworkScope scope={workspace.scope}>
<AppFallback key="workspaceLoading" />
<AllWorkspaceModals />
</FrameworkScope>
);
}

View File

@ -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 (
<SignOutModal open={open} onOpenChange={setOpen} onConfirm={onConfirm} />
@ -215,35 +224,11 @@ export const SignOutConfirmModal = () => {
};
export const AllWorkspaceModals = (): ReactElement => {
const [isOpenCreateWorkspaceModal, setOpenCreateWorkspaceModal] = useAtom(
openCreateWorkspaceModalAtom
);
const { jumpToSubPath, jumpToPage } = useNavigateHelper();
return (
<>
<CreateWorkspaceModal
mode={isOpenCreateWorkspaceModal}
onClose={useCallback(() => {
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]
)}
/>
<NotificationCenter />
<ImportTemplateDialogProvider />
<CreateWorkspaceDialogProvider />
<AuthModal />
<SignOutConfirmModal />
</>

View File

@ -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<NavigateFunction | null>(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 && (
<NavigateContext.Provider value={navigate}>
<Outlet />
<RootWrapper />
</NavigateContext.Provider>
)
);
@ -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'),

View File

@ -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() {
<CustomThemeModifier />
<DebugProvider>
<GlobalLoading />
<NotificationCenter />
<RouterProvider
fallbackElement={<AppFallback key="RouterFallback" />}
router={router}

View File

@ -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"
}

View File

@ -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() {
<FrameworkRoot framework={frameworkProvider}>
<AffineContext store={getCurrentStore()}>
<Telemetry />
<NotificationCenter />
<RouterProvider
fallbackElement={<AppFallback key="RouterFallback" />}
router={router}

View File

@ -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 (
<FrameworkScope scope={workspace.scope}>
<AppFallback key="workspaceLoading" />
<AllWorkspaceModals />
</FrameworkScope>
);
}
@ -82,7 +78,6 @@ export const WorkspaceLayout = ({
<FrameworkScope scope={workspace.scope}>
<AffineErrorBoundary height="100vh">
<SWRConfigProvider>
<AllWorkspaceModals />
<CurrentWorkspaceModals />
<WorkspaceLayoutProviders>{children}</WorkspaceLayoutProviders>
</SWRConfigProvider>

View File

@ -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() {
<CustomThemeModifier />
<DebugProvider>
<GlobalLoading />
<NotificationCenter />
<RouterProvider
fallbackElement={<AppFallback key="RouterFallback" />}
router={router}

View File

@ -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 });

View File

@ -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();

View File

@ -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 (

View File

@ -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) {