refactor(core): new workspace selector and create dialog (#10323)

This commit is contained in:
Cats Juice
2025-02-21 17:16:38 +08:00
committed by GitHub
parent 22e4bd8c20
commit 09cc5c392b
28 changed files with 760 additions and 563 deletions

View File

@@ -1,77 +0,0 @@
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,44 @@
import { cssVar } from '@toeverything/theme';
import { cssVarV2 } from '@toeverything/theme/v2';
import { style } from '@vanilla-extract/css';
export const content = style({
// to avoid content clipped
width: `calc(100% + 20px)`,
padding: '10px 10px 20px 10px',
marginLeft: '-10px',
});
export const section = style({
display: 'flex',
flexDirection: 'column',
gap: 8,
padding: '12px 0px',
});
export const label = style({
fontSize: cssVar('fontSm'),
fontWeight: 600,
lineHeight: '22px',
color: cssVarV2.text.primary,
});
const baseFormInput = style({
fontSize: 15,
fontWeight: 500,
lineHeight: '24px',
color: cssVarV2.text.primary,
border: `1px solid ${cssVarV2.layer.insideBorder.blackBorder}`,
});
export const input = style([
baseFormInput,
{
borderRadius: 4,
padding: '8px 10px',
},
]);
export const select = style([
baseFormInput,
{
borderRadius: 8,
padding: '10px',
},
]);

View File

@@ -1,226 +1,184 @@
import { Avatar, ConfirmModal, Input, notify, Switch } from '@affine/component';
import type { ConfirmModalProps } from '@affine/component/ui/modal';
import { Button, ConfirmModal, notify, RowInput } from '@affine/component';
import { useAsyncCallback } from '@affine/core/components/hooks/affine-async-hooks';
import { AuthService, ServersService } from '@affine/core/modules/cloud';
import {
AuthService,
type Server,
ServersService,
} from '@affine/core/modules/cloud';
import {
type DialogComponentProps,
type GLOBAL_DIALOG_SCHEMA,
GlobalDialogService,
} from '@affine/core/modules/dialogs';
import { FeatureFlagService } from '@affine/core/modules/feature-flag';
import { CloudSvg } from '@affine/core/modules/share-menu';
import { WorkspacesService } from '@affine/core/modules/workspace';
import { buildShowcaseWorkspace } from '@affine/core/utils/first-app-data';
import { useI18n } from '@affine/i18n';
import { track } from '@affine/track';
import track from '@affine/track';
import { FrameworkScope, useLiveData, useService } from '@toeverything/infra';
import { useCallback, useState } from 'react';
import { buildShowcaseWorkspace } from '../../../utils/first-app-data';
import * as styles from './dialog.css';
import * as styles from './index.css';
import { ServerSelector } from './server-selector';
interface NameWorkspaceContentProps extends ConfirmModalProps {
loading: boolean;
forcedCloud?: boolean;
serverId?: string;
onConfirmName: (
name: string,
workspaceFlavour: string,
avatar?: File
) => void;
}
const NameWorkspaceContent = ({
loading,
onConfirmName,
forcedCloud,
serverId,
...props
}: NameWorkspaceContentProps) => {
const t = useI18n();
const [workspaceName, setWorkspaceName] = useState('');
const [enable, setEnable] = useState(!!forcedCloud);
const session = useService(AuthService).session;
const loginStatus = useLiveData(session.status$);
const globalDialogService = useService(GlobalDialogService);
const openSignInModal = useCallback(() => {
globalDialogService.open('sign-in', {});
}, [globalDialogService]);
const onSwitchChange = useCallback(
(checked: boolean) => {
if (loginStatus !== 'authenticated') {
return openSignInModal();
}
return setEnable(checked);
},
[loginStatus, openSignInModal]
);
const handleCreateWorkspace = useCallback(() => {
if (loginStatus !== 'authenticated' && enable) {
return openSignInModal();
}
onConfirmName(workspaceName, enable ? serverId || 'affine-cloud' : 'local');
}, [
enable,
loginStatus,
onConfirmName,
openSignInModal,
serverId,
workspaceName,
]);
const onEnter = useCallback(() => {
if (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
const FormSection = ({
label,
input,
}: {
label: string;
input: React.ReactNode;
}) => {
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': '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"
onEnter={onEnter}
placeholder={t['com.affine.nameWorkspace.placeholder']()}
maxLength={64}
minLength={0}
onChange={setWorkspaceName}
size="large"
/>
</div>
{!serverId || serverId === 'affine-cloud' ? (
<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={forcedCloud}
/>
</div>
<div className={styles.cardDescription}>
{t['com.affine.nameWorkspace.affine-cloud.description']()}
</div>
</div>
<div className={styles.cloudSvgContainer}>
<CloudSvg />
</div>
</div>
{forcedCloud && BUILD_CONFIG.isWeb ? (
<a
className={styles.cloudTips}
href={BUILD_CONFIG.downloadUrl}
target="_blank"
rel="noreferrer"
>
{t['com.affine.nameWorkspace.affine-cloud.web-tips']()}
</a>
) : null}
</div>
) : null}
</ConfirmModal>
<section className={styles.section}>
<label className={styles.label}>{label}</label>
{input}
</section>
);
};
export const CreateWorkspaceDialog = ({
forcedCloud,
serverId,
close,
...props
}: DialogComponentProps<GLOBAL_DIALOG_SCHEMA['create-workspace']>) => {
const workspacesService = useService(WorkspacesService);
const t = useI18n();
const [workspaceName, setWorkspaceName] = useState('');
const [inputServerId, setInputServerId] = useState(
serverId ?? 'affine-cloud'
);
const serversService = useService(ServersService);
const featureFlagService = useService(FeatureFlagService);
const enableLocalWorkspace = useLiveData(
featureFlagService.flags.enable_local_workspace.$
);
const server = useLiveData(
serverId ? serversService.server$(serverId) : null
);
const [loading, setLoading] = useState(false);
const onConfirmName = useAsyncCallback(
async (name: string, workspaceFlavour: string) => {
track.$.$.$.createWorkspace({ flavour: workspaceFlavour });
if (loading) return;
setLoading(true);
// this will be the last step for web for now
// fix me later
try {
const { meta, defaultDocId } = await buildShowcaseWorkspace(
workspacesService,
workspaceFlavour,
name
);
close({ metadata: meta, defaultDocId });
} catch (e) {
console.error(e);
notify.error({
title: 'Failed to create workspace',
message: 'please try again later.',
});
} finally {
setLoading(false);
}
},
[loading, workspacesService, close]
inputServerId ? serversService.server$(inputServerId) : null
);
const onOpenChange = useCallback(
(open: boolean) => {
if (!open) {
close();
}
if (!open) close();
},
[close]
);
return (
<FrameworkScope scope={server?.scope}>
<NameWorkspaceContent
loading={loading}
serverId={serverId}
open
forcedCloud={forcedCloud || !enableLocalWorkspace}
onOpenChange={onOpenChange}
onConfirmName={onConfirmName}
<ConfirmModal
open
onOpenChange={onOpenChange}
title={t['com.affine.nameWorkspace.title']()}
description={t['com.affine.nameWorkspace.description']()}
cancelText={t['com.affine.nameWorkspace.button.cancel']()}
closeButtonOptions={{
['data-testid' as string]: 'create-workspace-close-button',
}}
contentOptions={{}}
childrenContentClassName={styles.content}
customConfirmButton={() => {
return (
<FrameworkScope scope={server?.scope}>
<CustomConfirmButton
workspaceName={workspaceName}
server={server}
onCreated={res =>
close({ metadata: res.meta, defaultDocId: res.defaultDocId })
}
/>
</FrameworkScope>
);
}}
{...props}
>
<FormSection
label={t['com.affine.nameWorkspace.subtitle.workspace-name']()}
input={
<RowInput
autoFocus
className={styles.input}
data-testid="create-workspace-input"
placeholder={t['com.affine.nameWorkspace.placeholder']()}
maxLength={64}
minLength={0}
onChange={setWorkspaceName}
/>
}
/>
</FrameworkScope>
<FormSection
label={t['com.affine.nameWorkspace.subtitle.workspace-type']()}
input={
<ServerSelector
className={styles.select}
selectedId={inputServerId}
onChange={setInputServerId}
/>
}
/>
</ConfirmModal>
);
};
const CustomConfirmButton = ({
workspaceName,
server,
onCreated,
}: {
workspaceName: string;
server?: Server | null;
onCreated: (res: Awaited<ReturnType<typeof buildShowcaseWorkspace>>) => void;
}) => {
const t = useI18n();
const [loading, setLoading] = useState(false);
const session = useService(AuthService).session;
const loginStatus = useLiveData(session.status$);
const globalDialogService = useService(GlobalDialogService);
const workspacesService = useService(WorkspacesService);
const openSignInModal = useCallback(() => {
globalDialogService.open('sign-in', { server: server?.baseUrl });
}, [globalDialogService, server?.baseUrl]);
const handleConfirm = useAsyncCallback(async () => {
if (loading) return;
setLoading(true);
track.$.$.$.createWorkspace({
flavour: !server ? 'local' : 'affine-cloud',
});
// this will be the last step for web for now
// fix me later
try {
const res = await buildShowcaseWorkspace(
workspacesService,
server?.id ?? 'local',
workspaceName
);
onCreated(res);
} catch (e) {
console.error(e);
notify.error({
title: 'Failed to create workspace',
message: 'please try again later.',
});
} finally {
setLoading(false);
}
}, [loading, onCreated, server, workspaceName, workspacesService]);
const handleCheckSessionAndConfirm = useCallback(() => {
if (server && loginStatus !== 'authenticated') {
return openSignInModal();
}
handleConfirm();
}, [handleConfirm, loginStatus, openSignInModal, server]);
return (
<Button
disabled={!workspaceName}
data-testid="create-workspace-create-button"
variant="primary"
onClick={handleCheckSessionAndConfirm}
loading={loading}
>
{t['com.affine.nameWorkspace.button.create']()}
</Button>
);
};

View File

@@ -0,0 +1,36 @@
import { cssVarV2 } from '@toeverything/theme/v2';
import { style } from '@vanilla-extract/css';
export const trigger = style({
display: 'flex',
alignItems: 'center',
justifyContent: 'space-between',
});
export const arrow = style({
transition: 'transform 0.2s ease',
transform: 'rotate(0deg)',
fontSize: 16,
color: cssVarV2.icon.primary,
selectors: {
'&.open': {
transform: 'rotate(180deg)',
},
},
});
export const list = style({
display: 'flex',
flexDirection: 'column',
gap: 4,
});
export const item = style({
padding: 4,
gap: 8,
});
export const done = style({
color: cssVarV2.icon.primary,
fontSize: 20,
});

View File

@@ -0,0 +1,147 @@
import { Menu, MenuItem } from '@affine/component';
import { type Server, ServersService } from '@affine/core/modules/cloud';
import { useI18n } from '@affine/i18n';
import {
ArrowDownSmallIcon,
CloudWorkspaceIcon,
DoneIcon,
LocalWorkspaceIcon,
SelfhostIcon,
} from '@blocksuite/icons/rc';
import { useLiveData, useService } from '@toeverything/infra';
import clsx from 'clsx';
import {
type HTMLAttributes,
type ReactNode,
useCallback,
useMemo,
useState,
} from 'react';
import * as styles from './server-selector.css';
export interface ServerSelectorProps
extends Omit<HTMLAttributes<HTMLDivElement>, 'onChange'> {
selectedId: Server['id'];
onChange: (id: Server['id']) => void;
placeholder?: ReactNode;
}
export const ServerSelector = ({
selectedId,
onChange,
placeholder,
className,
...props
}: ServerSelectorProps) => {
const t = useI18n();
const [open, setOpen] = useState(false);
const serversService = useService(ServersService);
const servers = useLiveData(serversService.servers$);
const selectedServer = useMemo(() => {
return servers.find(s => s.id === selectedId);
}, [selectedId, servers]);
const serverName = useLiveData(
selectedServer?.config$.selector(c => c.serverName)
);
const selectedServerName =
selectedId === 'local'
? t['com.affine.workspaceList.workspaceListType.local']()
: serverName;
return (
<Menu
rootOptions={{
open,
onOpenChange: setOpen,
}}
contentOptions={{
style: {
maxWidth: 432,
width: 'calc(100dvw - 68px)',
},
}}
items={
<ul className={styles.list} data-testid="server-selector-list">
<LocalSelectorItem
onSelect={onChange}
active={selectedId === 'local'}
/>
{servers.map(server => (
<ServerSelectorItem
key={server.id}
server={server}
onSelect={onChange}
active={selectedId === server.id}
/>
))}
</ul>
}
>
<div
data-testid="server-selector-trigger"
className={clsx(styles.trigger, className)}
{...props}
>
{selectedServerName ?? placeholder}
<ArrowDownSmallIcon className={clsx(styles.arrow, { open })} />
</div>
</Menu>
);
};
const LocalSelectorItem = ({
onSelect,
active,
}: {
onSelect?: (id: string) => void;
active?: boolean;
}) => {
const t = useI18n();
const handleSelect = useCallback(() => {
onSelect?.('local');
}, [onSelect]);
return (
<MenuItem
data-testid="local"
className={styles.item}
prefixIcon={<LocalWorkspaceIcon />}
onClick={handleSelect}
suffixIcon={active ? <DoneIcon className={styles.done} /> : null}
>
{t['com.affine.workspaceList.workspaceListType.local']()}
</MenuItem>
);
};
const ServerSelectorItem = ({
server,
onSelect,
active,
}: {
server: Server;
onSelect?: (id: string) => void;
active?: boolean;
}) => {
const name = useLiveData(server.config$.selector(c => c.serverName));
const Icon = server.id === 'affine-cloud' ? CloudWorkspaceIcon : SelfhostIcon;
const handleSelect = useCallback(() => {
onSelect?.(server.id);
}, [onSelect, server.id]);
return (
<MenuItem
data-testid={server.id}
className={styles.item}
prefixIcon={<Icon />}
onClick={handleSelect}
suffixIcon={active ? <DoneIcon className={styles.done} /> : null}
>
{name}
</MenuItem>
);
};

View File

@@ -186,6 +186,14 @@ const Dialog = ({
className={styles.workspaceSelector}
showArrowDownIcon
disable={disabled}
menuContentOptions={{
side: 'top',
style: {
maxHeight: 'min(600px, calc(50vh + 50px))',
width: 352,
maxWidth: 'calc(100vw - 20px)',
},
}}
/>
</>
)}