refactor(core): desktop project struct (#8334)

This commit is contained in:
EYHN
2024-11-05 11:00:33 +08:00
committed by GitHub
parent 89d09fd5e9
commit 902635e60f
343 changed files with 3846 additions and 3508 deletions
@@ -0,0 +1,17 @@
import { style } from '@vanilla-extract/css';
export const islandContainer = style({
position: 'absolute',
right: 16,
bottom: 16,
zIndex: 1,
display: 'flex',
flexDirection: 'column',
alignItems: 'center',
gap: '12px',
selectors: {
'&.trash': {
bottom: '78px',
},
},
});
@@ -0,0 +1,26 @@
import {
DocsService,
GlobalContextService,
useLiveData,
useService,
} from '@toeverything/infra';
import clsx from 'clsx';
import type { PropsWithChildren, ReactElement } from 'react';
import { islandContainer } from './container.css';
export const IslandContainer = (
props: PropsWithChildren<{ className?: string }>
): ReactElement => {
const docId = useLiveData(
useService(GlobalContextService).globalContext.docId.$
);
const docRecordList = useService(DocsService).list;
const doc = useLiveData(docId ? docRecordList.doc$(docId) : undefined);
const inTrash = useLiveData(doc?.meta$)?.trash;
return (
<div className={clsx(islandContainer, { trash: inTrash }, props.className)}>
{props.children}
</div>
);
};
@@ -0,0 +1,27 @@
export const AIIcon = () => {
return (
<svg
width="24"
height="24"
viewBox="0 0 24 24"
fill="none"
xmlns="http://www.w3.org/2000/svg"
>
<g clipPath="url(#clip0_5864_85390)">
<path
d="M11.2812 5.49104C11.2403 5.13024 10.9353 4.85751 10.5722 4.85714C10.2091 4.85677 9.90345 5.12887 9.86185 5.48959C9.59131 7.83515 8.89003 9.48448 7.75868 10.6158C6.62734 11.7472 4.97801 12.4485 2.63244 12.719C2.27173 12.7606 1.99963 13.0662 2 13.4293C2.00037 13.7924 2.2731 14.0975 2.63389 14.1383C4.94069 14.3996 6.62508 15.1006 7.78328 16.2379C8.93713 17.3709 9.65305 19.0198 9.85994 21.3489C9.89271 21.7178 10.2019 22.0004 10.5722 22C10.9425 21.9996 11.2511 21.7162 11.2831 21.3473C11.4813 19.0565 12.1966 17.3729 13.3562 16.2133C14.5157 15.0537 16.1994 14.3385 18.4902 14.1402C18.8591 14.1083 19.1424 13.7997 19.1429 13.4294C19.1433 13.0591 18.8606 12.7499 18.4918 12.7171C16.1627 12.5102 14.5137 11.7943 13.3807 10.6404C12.2435 9.48222 11.5425 7.79783 11.2812 5.49104Z"
fill="#77757D"
/>
<path
d="M18.9427 2.24651C18.9268 2.1062 18.8082 2.00014 18.667 2C18.5257 1.99986 18.4069 2.10567 18.3907 2.24595C18.2855 3.15811 18.0128 3.79952 17.5728 4.23949C17.1329 4.67946 16.4914 4.95218 15.5793 5.05739C15.439 5.07356 15.3332 5.19241 15.3333 5.33362C15.3335 5.47482 15.4395 5.59345 15.5798 5.60935C16.4769 5.71096 17.132 5.98357 17.5824 6.42584C18.0311 6.86644 18.3095 7.50771 18.39 8.41347C18.4027 8.55691 18.523 8.66683 18.667 8.66667C18.811 8.6665 18.931 8.55632 18.9434 8.41284C19.0205 7.52199 19.2987 6.86723 19.7496 6.41629C20.2006 5.96534 20.8553 5.68719 21.7462 5.61008C21.8896 5.59766 21.9998 5.47765 22 5.33365C22.0002 5.18964 21.8902 5.06939 21.7468 5.05664C20.841 4.97619 20.1998 4.69777 19.7592 4.24905C19.3169 3.79864 19.0443 3.1436 18.9427 2.24651Z"
fill="#77757D"
/>
</g>
<defs>
<clipPath id="clip0_5864_85390">
<rect width="24" height="24" fill="white" />
</clipPath>
</defs>
</svg>
);
};
@@ -0,0 +1,43 @@
import { WorkbenchService } from '@affine/core/modules/workbench';
import { useLiveData, useService } from '@toeverything/infra';
import clsx from 'clsx';
import { useEffect, useState } from 'react';
import { IslandContainer } from './container';
import { AIIcon } from './icons';
import { aiIslandBtn, aiIslandWrapper, toolStyle } from './styles.css';
export const AIIsland = () => {
// to make sure ai island is hidden first and animate in
const [hide, setHide] = useState(true);
const workbench = useService(WorkbenchService).workbench;
const activeView = useLiveData(workbench.activeView$);
const haveChatTab = useLiveData(
activeView.sidebarTabs$.map(tabs => tabs.some(t => t.id === 'chat'))
);
const activeTab = useLiveData(activeView.activeSidebarTab$);
const sidebarOpen = useLiveData(workbench.sidebarOpen$);
useEffect(() => {
setHide((sidebarOpen && activeTab?.id === 'chat') || !haveChatTab);
}, [activeTab, haveChatTab, sidebarOpen]);
return (
<IslandContainer className={clsx(toolStyle, { hide })}>
<div className={aiIslandWrapper} data-hide={hide}>
<button
className={aiIslandBtn}
data-testid="ai-island"
onClick={() => {
if (hide) return;
workbench.openSidebar();
activeView.activeSidebarTab('chat');
}}
>
<AIIcon />
</button>
</div>
</IslandContainer>
);
};
@@ -0,0 +1,51 @@
import { cssVar } from '@toeverything/theme';
import { style } from '@vanilla-extract/css';
export const toolStyle = style({
selectors: {
'&.hide': {
pointerEvents: 'none',
},
},
});
export const aiIslandWrapper = style({
width: 44,
height: 44,
position: 'relative',
transform: 'translateY(0)',
transition: 'transform 0.2s ease',
selectors: {
'&[data-hide="true"]': {
transform: 'translateY(120px)',
transitionDelay: '0.2s',
},
},
});
export const aiIslandBtn = style({
width: 'inherit',
height: 'inherit',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
borderRadius: '50%',
color: cssVar('iconColor'),
border: `0.5px solid ${cssVar('borderColor')}`,
boxShadow: '0px 2px 2px rgba(0,0,0,0.05)',
background: cssVar('backgroundOverlayPanelColor'),
position: 'relative',
selectors: {
[`${aiIslandWrapper}[data-animation="true"] &`]: {
borderColor: 'transparent',
},
'&:hover::after': {
content: '""',
position: 'absolute',
inset: 0,
borderRadius: '50%',
background: cssVar('hoverColor'),
},
},
});
@@ -0,0 +1,133 @@
import { useAppSettingHelper } from '@affine/core/components/hooks/affine/use-app-setting-helper';
import { RootAppSidebar } from '@affine/core/components/root-app-sidebar';
import { AppSidebarService } from '@affine/core/modules/app-sidebar';
import {
AppSidebarFallback,
OpenInAppCard,
SidebarSwitch,
} from '@affine/core/modules/app-sidebar/views';
import { AppTabsHeader } from '@affine/core/modules/app-tabs-header';
import { NavigationButtons } from '@affine/core/modules/navigation';
import {
useLiveData,
useService,
useServiceOptional,
WorkspaceService,
} from '@toeverything/infra';
import clsx from 'clsx';
import {
forwardRef,
type HTMLAttributes,
type PropsWithChildren,
type ReactElement,
} from 'react';
import * as styles from './styles.css';
export const AppContainer = ({
children,
className,
fallback = false,
...rest
}: PropsWithChildren<{
className?: string;
fallback?: boolean;
}>) => {
const { appSettings } = useAppSettingHelper();
const noisyBackground =
BUILD_CONFIG.isElectron && appSettings.enableNoisyBackground;
const blurBackground =
BUILD_CONFIG.isElectron &&
environment.isMacOs &&
appSettings.enableBlurBackground;
return (
<div
{...rest}
className={clsx(styles.appStyle, className, {
'noisy-background': noisyBackground,
'blur-background': blurBackground,
})}
data-noise-background={noisyBackground}
data-blur-background={blurBackground}
>
<LayoutComponent fallback={fallback}>{children}</LayoutComponent>
</div>
);
};
const DesktopLayout = ({
children,
fallback = false,
}: PropsWithChildren<{ fallback?: boolean }>) => {
const workspaceService = useServiceOptional(WorkspaceService);
const isInWorkspace = !!workspaceService;
return (
<div className={styles.desktopAppViewContainer}>
<div className={styles.desktopTabsHeader}>
<AppTabsHeader
left={
<>
{isInWorkspace && <SidebarSwitch show />}
{isInWorkspace && <NavigationButtons />}
</>
}
/>
</div>
<div className={styles.desktopAppViewMain}>
{fallback ? (
<AppSidebarFallback />
) : (
isInWorkspace && <RootAppSidebar />
)}
<MainContainer>{children}</MainContainer>
</div>
</div>
);
};
const BrowserLayout = ({
children,
fallback = false,
}: PropsWithChildren<{ fallback?: boolean }>) => {
const workspaceService = useServiceOptional(WorkspaceService);
const isInWorkspace = !!workspaceService;
return (
<div className={styles.browserAppViewContainer}>
<OpenInAppCard />
{fallback ? <AppSidebarFallback /> : isInWorkspace && <RootAppSidebar />}
<MainContainer>{children}</MainContainer>
</div>
);
};
const LayoutComponent = BUILD_CONFIG.isElectron ? DesktopLayout : BrowserLayout;
const MainContainer = forwardRef<
HTMLDivElement,
PropsWithChildren<HTMLAttributes<HTMLDivElement>>
>(function MainContainer({ className, children, ...props }, ref): ReactElement {
const workspaceService = useServiceOptional(WorkspaceService);
const isInWorkspace = !!workspaceService;
const { appSettings } = useAppSettingHelper();
const appSidebarService = useService(AppSidebarService).sidebar;
const open = useLiveData(appSidebarService.open$);
return (
<div
{...props}
className={clsx(styles.mainContainerStyle, className)}
data-is-desktop={BUILD_CONFIG.isElectron}
data-transparent={false}
data-client-border={appSettings.clientBorder}
data-side-bar-open={open && isInWorkspace}
data-testid="main-container"
ref={ref}
>
{children}
</div>
);
});
MainContainer.displayName = 'MainContainer';
@@ -0,0 +1,115 @@
import { cssVar, lightCssVariables } from '@toeverything/theme';
import { globalStyle, style } from '@vanilla-extract/css';
export const appStyle = style({
width: '100%',
position: 'relative',
height: '100dvh',
flexGrow: '1',
display: 'flex',
backgroundColor: cssVar('backgroundPrimaryColor'),
selectors: {
'&.blur-background': {
backgroundColor: 'transparent',
},
'&.noisy-background::before': {
content: '""',
position: 'absolute',
inset: 0,
opacity: `var(--affine-noise-opacity, 0)`,
backgroundRepeat: 'repeat',
backgroundSize: '50px',
// TODO(@Peng): figure out how to use vanilla-extract webpack plugin to inject img url
backgroundImage: `var(--noise-background)`,
},
},
});
globalStyle(`html[data-theme="light"] ${appStyle}`, {
vars: {
'--affine-noise-opacity': '0.2',
},
});
globalStyle(`html[data-theme="dark"] ${appStyle}`, {
vars: {
'--affine-noise-opacity': '1',
},
'@media': {
print: {
vars: lightCssVariables,
},
},
});
export const browserAppViewContainer = style({
display: 'flex',
flexFlow: 'row',
height: '100%',
width: '100%',
position: 'relative',
});
export const desktopAppViewContainer = style({
display: 'flex',
flexFlow: 'column',
height: '100%',
width: '100%',
});
export const desktopAppViewMain = style({
display: 'flex',
flexFlow: 'row',
width: '100%',
height: 'calc(100% - 52px)',
position: 'relative',
});
export const desktopTabsHeader = style({
display: 'flex',
flexFlow: 'row',
height: '52px',
zIndex: 1,
width: '100%',
overflow: 'hidden',
});
export const mainContainerStyle = style({
position: 'relative',
zIndex: 0,
width: '100%',
display: 'flex',
flex: 1,
overflow: 'clip',
maxWidth: '100%',
selectors: {
'&[data-client-border="true"]': {
borderRadius: 6,
margin: '8px',
overflow: 'clip',
'@media': {
print: {
overflow: 'visible',
margin: '0px',
borderRadius: '0px',
},
},
},
'&[data-client-border="true"][data-side-bar-open="true"]': {
marginLeft: 0,
},
'&[data-client-border="true"][data-is-desktop="true"]': {
marginTop: 0,
},
'&[data-client-border="false"][data-is-desktop="true"][data-side-bar-open="true"]':
{
borderTopLeftRadius: 6,
},
'&[data-client-border="false"][data-is-desktop="true"]': {
borderTop: `0.5px solid ${cssVar('borderColor')}`,
borderLeft: `0.5px solid ${cssVar('borderColor')}`,
},
'&[data-transparent=true]': {
backgroundColor: 'transparent',
},
},
});
@@ -0,0 +1,144 @@
import { cssVar } from '@toeverything/theme';
import { style } from '@vanilla-extract/css';
export const ellipsis = style({
overflow: 'hidden',
textOverflow: 'ellipsis',
whiteSpace: 'nowrap',
});
export const bottomLeft = style({
display: 'flex',
gap: 8,
alignItems: 'center',
});
export const rulesBottom = style({
display: 'flex',
justifyContent: 'space-between',
padding: '20px 24px',
borderTop: `1px solid ${cssVar('borderColor')}`,
flexWrap: 'wrap',
gap: '12px',
});
export const includeListTitle = style({
fontSize: 14,
fontWeight: 400,
lineHeight: '22px',
color: cssVar('textSecondaryColor'),
padding: '4px 16px',
borderTop: `1px solid ${cssVar('borderColor')}`,
});
export const rulesContainerRight = style({
flex: 2,
flexDirection: 'column',
borderLeft: `1px solid ${cssVar('borderColor')}`,
overflowX: 'hidden',
overflowY: 'auto',
});
export const includeItemTitle = style({
overflow: 'hidden',
fontWeight: 600,
});
export const includeItemContentIs = style({
padding: '0 8px',
color: cssVar('textSecondaryColor'),
});
export const includeItemContent = style({
display: 'flex',
alignItems: 'center',
gap: 4,
fontSize: 12,
lineHeight: '20px',
overflow: 'hidden',
});
export const includeItem = style({
display: 'flex',
alignItems: 'center',
width: 'max-content',
backgroundColor: cssVar('backgroundPrimaryColor'),
overflow: 'hidden',
gap: 16,
whiteSpace: 'nowrap',
border: `1px solid ${cssVar('borderColor')}`,
borderRadius: 8,
padding: '4px 8px 4px',
});
export const includeTitle = style({
display: 'flex',
alignItems: 'center',
gap: 10,
fontSize: 14,
lineHeight: '22px',
});
export const rulesContainerLeftContentInclude = style({
overflow: 'hidden',
display: 'flex',
flexDirection: 'column',
gap: 8,
flexShrink: 0,
});
export const rulesContainerLeftContent = style({
padding: '12px 16px 16px',
display: 'flex',
flexDirection: 'column',
flex: 1,
overflow: 'hidden',
});
export const rulesContainerLeftTab = style({
display: 'flex',
justifyContent: 'space-between',
gap: 8,
alignItems: 'center',
padding: '16px 16px 8px 16px',
});
export const rulesContainerLeft = style({
flex: 1,
display: 'flex',
flexDirection: 'column',
overflow: 'hidden',
});
export const rulesContainer = style({
display: 'flex',
overflow: 'hidden',
flex: 1,
});
export const collectionEditContainer = style({
display: 'flex',
flexDirection: 'column',
height: '100%',
});
export const confirmButton = style({
marginLeft: 20,
});
export const resultPages = style({
width: '100%',
});
export const previewCountTipsHighlight = style({
color: cssVar('primaryColor'),
});
export const previewCountTips = style({
fontSize: 12,
lineHeight: '20px',
color: cssVar('textSecondaryColor'),
});
export const rulesTitleHighlight = style({
color: cssVar('primaryColor'),
fontStyle: 'italic',
fontWeight: 800,
});
export const bottomButton = style({
padding: '4px 12px',
borderRadius: 8,
});
export const actionButton = style({
minWidth: 80,
});
export const previewActive = style({
backgroundColor: cssVar('hoverColorFilled'),
});
export const rulesTitle = style({
padding: '20px 24px',
userSelect: 'none',
fontSize: 20,
lineHeight: '24px',
color: cssVar('textSecondaryColor'),
borderBottom: `1px solid ${cssVar('borderColor')}`,
});
@@ -0,0 +1,124 @@
import { Button, RadioGroup } from '@affine/component';
import { useAllPageListConfig } from '@affine/core/components/hooks/affine/use-all-page-list-config';
import { SelectPage } from '@affine/core/components/page-list/docs/select-page';
import type { Collection } from '@affine/env/filter';
import { useI18n } from '@affine/i18n';
import { useCallback, useMemo, useState } from 'react';
import * as styles from './edit-collection.css';
import { RulesMode } from './rules-mode';
export type EditCollectionMode = 'page' | 'rule';
export interface EditCollectionProps {
onConfirmText?: string;
init: Collection;
mode?: EditCollectionMode;
onCancel: () => void;
onConfirm: (collection: Collection) => void;
}
export const EditCollection = ({
init,
onConfirm,
onCancel,
onConfirmText,
mode: initMode,
}: EditCollectionProps) => {
const t = useI18n();
const config = useAllPageListConfig();
const [value, onChange] = useState<Collection>(init);
const [mode, setMode] = useState<'page' | 'rule'>(
initMode ?? (init.filterList.length === 0 ? 'page' : 'rule')
);
const isNameEmpty = useMemo(() => value.name.trim().length === 0, [value]);
const onSaveCollection = useCallback(() => {
if (!isNameEmpty) {
onConfirm(value);
}
}, [value, isNameEmpty, onConfirm]);
const reset = useCallback(() => {
onChange({
...value,
filterList: init.filterList,
allowList: init.allowList,
});
}, [init.allowList, init.filterList, value]);
const onIdsChange = useCallback(
(ids: string[]) => {
onChange({ ...value, allowList: ids });
},
[value]
);
const buttons = useMemo(
() => (
<>
<Button onClick={onCancel} className={styles.actionButton}>
{t['com.affine.editCollection.button.cancel']()}
</Button>
<Button
className={styles.actionButton}
data-testid="save-collection"
variant="primary"
disabled={isNameEmpty}
onClick={onSaveCollection}
>
{onConfirmText ?? t['com.affine.editCollection.button.create']()}
</Button>
</>
),
[onCancel, t, isNameEmpty, onSaveCollection, onConfirmText]
);
const switchMode = useMemo(
() => (
<RadioGroup
key="mode-switcher"
style={{ minWidth: 158 }}
value={mode}
onChange={setMode}
items={[
{
value: 'page',
label: t['com.affine.editCollection.pages'](),
testId: 'edit-collection-pages-button',
},
{
value: 'rule',
label: t['com.affine.editCollection.rules'](),
testId: 'edit-collection-rules-button',
},
]}
/>
),
[mode, t]
);
return (
<div
onKeyDown={e => {
if (e.key === 'Escape') {
return;
}
e.stopPropagation();
}}
className={styles.collectionEditContainer}
>
{mode === 'page' ? (
<SelectPage
init={value.allowList}
onChange={onIdsChange}
header={switchMode}
buttons={buttons}
/>
) : (
<RulesMode
allPageListConfig={config}
collection={value}
switchMode={switchMode}
reset={reset}
updateCollection={onChange}
buttons={buttons}
/>
)}
</div>
);
};
@@ -0,0 +1,60 @@
import { Modal } from '@affine/component';
import { CollectionService } from '@affine/core/modules/collection';
import type { DialogComponentProps } from '@affine/core/modules/dialogs';
import type { WORKSPACE_DIALOG_SCHEMA } from '@affine/core/modules/dialogs/constant';
import type { Collection } from '@affine/env/filter';
import { useI18n } from '@affine/i18n';
import { useLiveData, useService } from '@toeverything/infra';
import { useCallback } from 'react';
import { EditCollection } from './edit-collection';
export const CollectionEditorDialog = ({
close,
collectionId,
mode,
}: DialogComponentProps<WORKSPACE_DIALOG_SCHEMA['collection-editor']>) => {
const t = useI18n();
const collectionService = useService(CollectionService);
const collection = useLiveData(collectionService.collection$(collectionId));
const onConfirmOnCollection = useCallback(
(collection: Collection) => {
collectionService.updateCollection(collection.id, () => collection);
close();
},
[close, collectionService]
);
const onCancel = useCallback(() => {
close();
}, [close]);
if (!collection) {
return null;
}
return (
<Modal
open
onOpenChange={onCancel}
withoutCloseButton
width="calc(100% - 64px)"
height="80%"
contentOptions={{
style: {
padding: 0,
maxWidth: 944,
backgroundColor: 'var(--affine-background-primary-color)',
},
}}
persistent
>
<EditCollection
onConfirmText={t['com.affine.editCollection.save']()}
init={collection}
mode={mode}
onCancel={onCancel}
onConfirm={onConfirmOnCollection}
/>
</Modal>
);
};
@@ -0,0 +1,322 @@
import { Button, IconButton, Tooltip } from '@affine/component';
import type { AllPageListConfig } from '@affine/core/components/hooks/affine/use-all-page-list-config';
import {
AffineShapeIcon,
FilterList,
filterPageByRules,
List,
type ListItem,
ListScrollContainer,
} from '@affine/core/components/page-list';
import { CompatibleFavoriteItemsAdapter } from '@affine/core/modules/favorite';
import type { Collection } from '@affine/env/filter';
import { Trans, useI18n } from '@affine/i18n';
import type { DocMeta } from '@blocksuite/affine/store';
import {
CloseIcon,
EdgelessIcon,
PageIcon,
ToggleCollapseIcon,
} from '@blocksuite/icons/rc';
import { DocsService, useLiveData, useService } from '@toeverything/infra';
import { cssVar } from '@toeverything/theme';
import clsx from 'clsx';
import type { ReactNode } from 'react';
import { useCallback, useMemo, useState } from 'react';
import * as styles from './edit-collection.css';
export const RulesMode = ({
collection,
updateCollection,
reset,
buttons,
switchMode,
allPageListConfig,
}: {
collection: Collection;
updateCollection: (collection: Collection) => void;
reset: () => void;
buttons: ReactNode;
switchMode: ReactNode;
allPageListConfig: AllPageListConfig;
}) => {
const t = useI18n();
const [showPreview, setShowPreview] = useState(true);
const allowListPages: DocMeta[] = [];
const rulesPages: DocMeta[] = [];
const docsService = useService(DocsService);
const favAdapter = useService(CompatibleFavoriteItemsAdapter);
const favorites = useLiveData(favAdapter.favorites$);
allPageListConfig.allPages.forEach(meta => {
if (meta.trash) {
return;
}
const pageData = {
meta,
publicMode: allPageListConfig.getPublicMode(meta.id),
favorite: favorites.some(f => f.id === meta.id),
};
if (
collection.filterList.length &&
filterPageByRules(collection.filterList, [], pageData)
) {
rulesPages.push(meta);
}
if (collection.allowList.includes(meta.id)) {
allowListPages.push(meta);
}
});
const [expandInclude, setExpandInclude] = useState(
collection.allowList.length > 0
);
const operationsRenderer = useCallback(
(item: ListItem) => {
const page = item as DocMeta;
return allPageListConfig.favoriteRender(page);
},
[allPageListConfig]
);
const tips = useMemo(
() => (
<Trans
i18nKey="com.affine.editCollection.rules.tips"
values={{
highlight: t['com.affine.editCollection.rules.tips.highlight'](),
}}
components={{
2: <span className={styles.rulesTitleHighlight} />,
}}
/>
),
[t]
);
return (
<>
{/*prevents modal autofocus to the first input*/}
<input
type="text"
style={{ width: 0, height: 0 }}
onFocus={e => requestAnimationFrame(() => e.target.blur())}
/>
<Tooltip content={tips}>
<div className={clsx(styles.rulesTitle, styles.ellipsis)}>{tips}</div>
</Tooltip>
<div className={styles.rulesContainer}>
<div className={styles.rulesContainerLeft}>
<div className={styles.rulesContainerLeftTab}>{switchMode}</div>
<div className={styles.rulesContainerLeftContent}>
<div
style={{
flex: 1,
display: 'flex',
flexDirection: 'column',
gap: 8,
overflowY: 'auto',
}}
>
<FilterList
propertiesMeta={allPageListConfig.docCollection.meta.properties}
value={collection.filterList}
onChange={useCallback(
filterList => updateCollection({ ...collection, filterList }),
[collection, updateCollection]
)}
/>
<div className={styles.rulesContainerLeftContentInclude}>
{collection.allowList.length > 0 ? (
<div className={styles.includeTitle}>
<IconButton
onClick={() => setExpandInclude(!expandInclude)}
iconStyle={{
transform: expandInclude ? 'rotate(90deg)' : undefined,
}}
icon={<ToggleCollapseIcon />}
/>
<div style={{ color: cssVar('textSecondaryColor') }}>
{t['com.affine.editCollection.rules.include.title']()}
</div>
</div>
) : null}
<div
style={{
display: expandInclude ? 'flex' : 'none',
flexWrap: 'wrap',
gap: '8px 16px',
}}
>
{collection.allowList.map(id => {
const page = allPageListConfig.allPages.find(
v => v.id === id
);
return (
<div className={styles.includeItem} key={id}>
<div className={styles.includeItemContent}>
<div
style={{
display: 'flex',
gap: 6,
alignItems: 'center',
}}
>
{docsService.list.getPrimaryMode(id) ===
'edgeless' ? (
<EdgelessIcon style={{ width: 16, height: 16 }} />
) : (
<PageIcon style={{ width: 16, height: 16 }} />
)}
{t[
'com.affine.editCollection.rules.include.page'
]()}
</div>
<div className={styles.includeItemContentIs}>
{t['com.affine.editCollection.rules.include.is']()}
</div>
<div
className={clsx(
styles.includeItemTitle,
styles.ellipsis
)}
>
{page?.title || t['Untitled']()}
</div>
</div>
<IconButton
size="14"
icon={<CloseIcon />}
onClick={() => {
updateCollection({
...collection,
allowList: collection.allowList.filter(
v => v !== id
),
});
}}
/>
</div>
);
})}
</div>
</div>
</div>
</div>
</div>
<ListScrollContainer
className={styles.rulesContainerRight}
style={{
display: showPreview ? 'flex' : 'none',
}}
>
{rulesPages.length > 0 ? (
<List
hideHeader
className={styles.resultPages}
items={rulesPages}
docCollection={allPageListConfig.docCollection}
operationsRenderer={operationsRenderer}
></List>
) : (
<RulesEmpty
noRules={collection.filterList.length === 0}
fullHeight={allowListPages.length === 0}
/>
)}
{allowListPages.length > 0 ? (
<div>
<div className={styles.includeListTitle}>
{t['com.affine.editCollection.rules.include.title']()}
</div>
<List
hideHeader
className={styles.resultPages}
items={allowListPages}
docCollection={allPageListConfig.docCollection}
operationsRenderer={operationsRenderer}
></List>
</div>
) : null}
</ListScrollContainer>
</div>
<div className={styles.rulesBottom}>
<div className={styles.bottomLeft}>
<Button
onClick={() => {
setShowPreview(!showPreview);
}}
>
{t['com.affine.editCollection.rules.preview']()}
</Button>
<Button variant="plain" onClick={reset}>
{t['com.affine.editCollection.rules.reset']()}
</Button>
<div className={styles.previewCountTips}>
<Trans
i18nKey="com.affine.editCollection.rules.countTips"
values={{
selectedCount: allowListPages.length,
filteredCount: rulesPages.length,
}}
>
Selected
<span className={styles.previewCountTipsHighlight}>count</span>,
filtered
<span className={styles.previewCountTipsHighlight}>count</span>
</Trans>
</div>
</div>
<div style={{ display: 'flex', alignItems: 'center', gap: 20 }}>
{buttons}
</div>
</div>
</>
);
};
const RulesEmpty = ({
noRules,
fullHeight,
}: {
noRules: boolean;
fullHeight: boolean;
}) => {
const t = useI18n();
return (
<div
style={{
height: fullHeight ? '100%' : '70%',
display: 'flex',
flexDirection: 'column',
alignItems: 'center',
justifyContent: 'center',
gap: 18,
padding: '48px 0',
}}
>
<AffineShapeIcon />
<strong style={{ fontSize: 20, lineHeight: '28px' }}>
{noRules
? t['com.affine.editCollection.rules.empty.noRules']()
: t['com.affine.editCollection.rules.empty.noResults']()}
</strong>
<div
style={{
width: '389px',
textAlign: 'center',
fontSize: 15,
lineHeight: '24px',
}}
>
{noRules ? (
<Trans i18nKey="com.affine.editCollection.rules.empty.noRules.tips">
Please <strong>add rules</strong> to save this collection or switch
to <strong>Pages</strong>, use manual selection mode
</Trans>
) : (
t['com.affine.editCollection.rules.empty.noResults.tips']()
)}
</div>
</div>
);
};
@@ -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',
});
@@ -0,0 +1,200 @@
import { Avatar, ConfirmModal, Input, Switch } from '@affine/component';
import type { ConfirmModalProps } from '@affine/component/ui/modal';
import { CloudSvg } from '@affine/core/components/affine/share-page-modal/cloud-svg';
import { authAtom } from '@affine/core/components/atoms';
import { useAsyncCallback } from '@affine/core/components/hooks/affine-async-hooks';
import { AuthService } from '@affine/core/modules/cloud';
import {
type DialogComponentProps,
type GLOBAL_DIALOG_SCHEMA,
} from '@affine/core/modules/dialogs';
import { WorkspaceFlavour } from '@affine/env/workspace';
import { useI18n } from '@affine/i18n';
import { track } from '@affine/track';
import {
FeatureFlagService,
useLiveData,
useService,
WorkspacesService,
} from '@toeverything/infra';
import { useSetAtom } from 'jotai';
import { useCallback, useState } from 'react';
import { buildShowcaseWorkspace } from '../../../utils/first-app-data';
import * as styles from './dialog.css';
interface NameWorkspaceContentProps extends ConfirmModalProps {
loading: boolean;
onConfirmName: (
name: string,
workspaceFlavour: WorkspaceFlavour,
avatar?: File
) => void;
}
const NameWorkspaceContent = ({
loading,
onConfirmName,
...props
}: NameWorkspaceContentProps) => {
const t = useI18n();
const [workspaceName, setWorkspaceName] = useState('');
const featureFlagService = useService(FeatureFlagService);
const enableLocalWorkspace = useLiveData(
featureFlagService.flags.enable_local_workspace.$
);
const [enable, setEnable] = useState(!enableLocalWorkspace);
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 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
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>
<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={!enableLocalWorkspace}
/>
</div>
<div className={styles.cardDescription}>
{t['com.affine.nameWorkspace.affine-cloud.description']()}
</div>
</div>
<div className={styles.cloudSvgContainer}>
<CloudSvg />
</div>
</div>
{!enableLocalWorkspace ? (
<a
className={styles.cloudTips}
href={BUILD_CONFIG.downloadUrl}
target="_blank"
rel="noreferrer"
>
{t['com.affine.nameWorkspace.affine-cloud.web-tips']()}
</a>
) : null}
</div>
</ConfirmModal>
);
};
export const CreateWorkspaceDialog = ({
close,
}: DialogComponentProps<GLOBAL_DIALOG_SCHEMA['create-workspace']>) => {
const workspacesService = useService(WorkspacesService);
const [loading, setLoading] = useState(false);
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
const { meta, defaultDocId } = await buildShowcaseWorkspace(
workspacesService,
workspaceFlavour,
name
);
close({ metadata: meta, defaultDocId });
setLoading(false);
},
[loading, workspacesService, close]
);
const onOpenChange = useCallback(
(open: boolean) => {
if (!open) {
close();
}
},
[close]
);
return (
<NameWorkspaceContent
loading={loading}
open
onOpenChange={onOpenChange}
onConfirmName={onConfirmName}
/>
);
};
@@ -0,0 +1,62 @@
import { Modal, Scrollable } from '@affine/component';
import { BlocksuiteHeaderTitle } from '@affine/core/components/blocksuite/block-suite-header/title';
import type { DialogComponentProps } from '@affine/core/modules/dialogs';
import type { WORKSPACE_DIALOG_SCHEMA } from '@affine/core/modules/dialogs/constant';
import type { Doc } from '@toeverything/infra';
import { DocsService, FrameworkScope, useService } from '@toeverything/infra';
import { useEffect, useState } from 'react';
import { InfoTable } from './info-modal';
import * as styles from './styles.css';
export const DocInfoDialog = ({
close,
docId,
}: DialogComponentProps<WORKSPACE_DIALOG_SCHEMA['doc-info']>) => {
const docsService = useService(DocsService);
const [doc, setDoc] = useState<Doc | null>(null);
useEffect(() => {
if (!docId) return;
const docRef = docsService.open(docId);
setDoc(docRef.doc);
return () => {
docRef.release();
setDoc(null);
};
}, [docId, docsService]);
if (!doc || !docId) return null;
return (
<FrameworkScope scope={doc.scope}>
<Modal
contentOptions={{
className: styles.container,
}}
open
onOpenChange={() => close()}
withoutCloseButton
>
<Scrollable.Root>
<Scrollable.Viewport
className={styles.viewport}
data-testid="info-modal"
>
<div
className={styles.titleContainer}
data-testid="info-modal-title"
>
<BlocksuiteHeaderTitle
docId={docId}
className={styles.titleStyle}
/>
</div>
<InfoTable docId={docId} onClose={() => close()} />
</Scrollable.Viewport>
<Scrollable.Scrollbar className={styles.scrollBar} />
</Scrollable.Root>
</Modal>
</FrameworkScope>
);
};
@@ -0,0 +1,88 @@
import { cssVar } from '@toeverything/theme';
import { cssVarV2 } from '@toeverything/theme/v2';
import { globalStyle, style } from '@vanilla-extract/css';
export const container = style({
maxWidth: 480,
minWidth: 360,
padding: '20px 0',
alignSelf: 'start',
marginTop: '120px',
});
export const titleContainer = style({
display: 'flex',
width: '100%',
flexDirection: 'column',
marginBottom: 20,
padding: 2,
});
export const titleStyle = style({
fontSize: cssVar('fontH2'),
fontWeight: '600',
minHeight: 42,
padding: 0,
});
export const rowNameContainer = style({
display: 'flex',
flexDirection: 'row',
gap: 6,
padding: 6,
width: '160px',
});
export const viewport = style({
maxHeight: 'calc(100vh - 220px)',
padding: '0 24px',
});
export const scrollBar = style({
width: 6,
transform: 'translateX(-4px)',
});
export const hiddenInput = style({
width: '0',
height: '0',
position: 'absolute',
});
export const timeRow = style({
marginTop: 20,
borderBottom: 4,
});
export const tableBodyRoot = style({
display: 'flex',
flexDirection: 'column',
position: 'relative',
});
export const addPropertyButton = style({
alignSelf: 'flex-start',
fontSize: cssVar('fontSm'),
color: `${cssVarV2('text/secondary')}`,
padding: '0 4px',
height: 36,
fontWeight: 400,
gap: 6,
'@media': {
print: {
display: 'none',
},
},
selectors: {
[`[data-property-collapsed="true"] &`]: {
display: 'none',
},
},
});
globalStyle(`${addPropertyButton} svg`, {
fontSize: 16,
color: cssVarV2('icon/secondary'),
});
globalStyle(`${addPropertyButton}:hover svg`, {
color: cssVarV2('icon/primary'),
});
@@ -0,0 +1,126 @@
import {
Button,
Divider,
Menu,
PropertyCollapsibleContent,
PropertyCollapsibleSection,
} from '@affine/component';
import { CreatePropertyMenuItems } from '@affine/core/components/doc-properties/menu/create-doc-property';
import { DocPropertyRow } from '@affine/core/components/doc-properties/table';
import { DocDatabaseBacklinkInfo } from '@affine/core/modules/doc-info';
import { DocsSearchService } from '@affine/core/modules/docs-search';
import { useI18n } from '@affine/i18n';
import { PlusIcon } from '@blocksuite/icons/rc';
import {
DocsService,
LiveData,
useLiveData,
useServices,
} from '@toeverything/infra';
import { useMemo, useState } from 'react';
import * as styles from './info-modal.css';
import { LinksRow } from './links-row';
export const InfoTable = ({
onClose,
docId,
}: {
docId: string;
onClose: () => void;
}) => {
const t = useI18n();
const { docsSearchService, docsService } = useServices({
DocsSearchService,
DocsService,
});
const [newPropertyId, setNewPropertyId] = useState<string | null>(null);
const properties = useLiveData(docsService.propertyList.sortedProperties$);
const links = useLiveData(
useMemo(
() => LiveData.from(docsSearchService.watchRefsFrom(docId), null),
[docId, docsSearchService]
)
);
const backlinks = useLiveData(
useMemo(
() => LiveData.from(docsSearchService.watchRefsTo(docId), null),
[docId, docsSearchService]
)
);
return (
<>
{backlinks && backlinks.length > 0 ? (
<>
<LinksRow
references={backlinks}
onClick={onClose}
label={t['com.affine.page-properties.backlinks']()}
/>
<Divider size="thinner" />
</>
) : null}
{links && links.length > 0 ? (
<>
<LinksRow
references={links}
onClick={onClose}
label={t['com.affine.page-properties.outgoing-links']()}
/>
<Divider size="thinner" />
</>
) : null}
<PropertyCollapsibleSection
title={t.t('com.affine.workspace.properties')}
>
<PropertyCollapsibleContent
className={styles.tableBodyRoot}
collapseButtonText={({ hide, isCollapsed }) =>
isCollapsed
? hide === 1
? t['com.affine.page-properties.more-property.one']({
count: hide.toString(),
})
: t['com.affine.page-properties.more-property.more']({
count: hide.toString(),
})
: hide === 1
? t['com.affine.page-properties.hide-property.one']({
count: hide.toString(),
})
: t['com.affine.page-properties.hide-property.more']({
count: hide.toString(),
})
}
>
{properties.map(property => (
<DocPropertyRow
key={property.id}
propertyInfo={property}
defaultOpenEditMenu={newPropertyId === property.id}
/>
))}
<Menu
items={<CreatePropertyMenuItems onCreated={setNewPropertyId} />}
contentOptions={{
onClick(e) {
e.stopPropagation();
},
}}
>
<Button
variant="plain"
prefix={<PlusIcon />}
className={styles.addPropertyButton}
>
{t['com.affine.page-properties.add-property']()}
</Button>
</Menu>
</PropertyCollapsibleContent>
</PropertyCollapsibleSection>
<Divider size="thinner" />
<DocDatabaseBacklinkInfo />
</>
);
};
@@ -0,0 +1,30 @@
import { cssVar } from '@toeverything/theme';
import { cssVarV2 } from '@toeverything/theme/v2';
import { globalStyle, style } from '@vanilla-extract/css';
export const wrapper = style({
width: '100%',
borderRadius: 4,
color: cssVar('textPrimaryColor'),
display: 'flex',
flexDirection: 'row',
alignItems: 'center',
padding: 4,
':hover': {
background: cssVarV2('layer/background/hoverOverlay'),
},
});
globalStyle(`${wrapper} svg`, {
color: cssVar('iconSecondary'),
fontSize: 16,
transform: 'none',
});
globalStyle(`${wrapper} span`, {
fontSize: cssVar('fontSm'),
whiteSpace: 'nowrap',
overflow: 'hidden',
textOverflow: 'ellipsis',
// don't modify border width to avoid layout shift
borderBottomColor: 'transparent',
});
@@ -0,0 +1,35 @@
import { PropertyCollapsibleSection } from '@affine/component';
import { AffinePageReference } from '@affine/core/components/affine/reference-link';
import type { Backlink, Link } from '@affine/core/modules/doc-link';
import type { MouseEvent } from 'react';
import * as styles from './links-row.css';
export const LinksRow = ({
references,
label,
className,
onClick,
}: {
references: Backlink[] | Link[];
label: string;
className?: string;
onClick?: (e: MouseEvent) => void;
}) => {
return (
<PropertyCollapsibleSection
title={`${label} · ${references.length}`}
className={className}
>
{references.map((link, index) => (
<AffinePageReference
key={index}
pageId={link.docId}
params={'params' in link ? link.params : undefined}
className={styles.wrapper}
onClick={onClick}
/>
))}
</PropertyCollapsibleSection>
);
};
@@ -0,0 +1,84 @@
import { cssVar } from '@toeverything/theme';
import { cssVarV2 } from '@toeverything/theme/v2';
import { globalStyle, style } from '@vanilla-extract/css';
export const container = style({
maxWidth: 480,
minWidth: 360,
padding: '20px 0',
alignSelf: 'start',
marginTop: '120px',
});
export const titleContainer = style({
display: 'flex',
width: '100%',
flexDirection: 'column',
});
export const titleStyle = style({
fontSize: cssVar('fontH6'),
fontWeight: '600',
});
export const rowNameContainer = style({
display: 'flex',
flexDirection: 'row',
gap: 6,
padding: 6,
width: '160px',
});
export const viewport = style({
maxHeight: 'calc(100vh - 220px)',
padding: '0 24px',
});
export const scrollBar = style({
width: 6,
transform: 'translateX(-4px)',
});
export const hiddenInput = style({
width: '0',
height: '0',
position: 'absolute',
});
export const timeRow = style({
marginTop: 20,
borderBottom: 4,
});
export const tableBodyRoot = style({
display: 'flex',
flexDirection: 'column',
position: 'relative',
});
export const addPropertyButton = style({
alignSelf: 'flex-start',
fontSize: cssVar('fontSm'),
color: `${cssVarV2('text/secondary')}`,
padding: '0 4px',
height: 36,
fontWeight: 400,
gap: 6,
'@media': {
print: {
display: 'none',
},
},
selectors: {
[`[data-property-collapsed="true"] &`]: {
display: 'none',
},
},
});
globalStyle(`${addPropertyButton} svg`, {
fontSize: 16,
color: cssVarV2('icon/secondary'),
});
globalStyle(`${addPropertyButton}:hover svg`, {
color: cssVarV2('icon/primary'),
});
@@ -0,0 +1,6 @@
import { style } from '@vanilla-extract/css';
export const container = style({
display: 'flex',
flexDirection: 'column',
});
@@ -0,0 +1,87 @@
import { PropertyName, PropertyRoot, PropertyValue } from '@affine/component';
import { i18nTime, useI18n } from '@affine/i18n';
import { DateTimeIcon, HistoryIcon } from '@blocksuite/icons/rc';
import {
DocsService,
useLiveData,
useService,
WorkspaceService,
} from '@toeverything/infra';
import clsx from 'clsx';
import type { ConfigType } from 'dayjs';
import { useDebouncedValue } from 'foxact/use-debounced-value';
import { useMemo } from 'react';
import * as styles from './time-row.css';
export const TimeRow = ({
docId,
className,
}: {
docId: string;
className?: string;
}) => {
const t = useI18n();
const workspaceService = useService(WorkspaceService);
const docsService = useService(DocsService);
const { syncing, retrying, serverClock } = useLiveData(
workspaceService.workspace.engine.doc.docState$(docId)
);
const docRecord = useLiveData(docsService.list.doc$(docId));
const docMeta = useLiveData(docRecord?.meta$);
const timestampElement = useMemo(() => {
const formatI18nTime = (time: ConfigType) =>
i18nTime(time, {
relative: {
max: [1, 'day'],
accuracy: 'minute',
},
absolute: {
accuracy: 'day',
},
});
const localizedCreateTime = docMeta
? formatI18nTime(docMeta.createDate)
: null;
return (
<>
<PropertyRoot>
<PropertyName name={t['Created']()} icon={<DateTimeIcon />} />
<PropertyValue>
{docMeta ? formatI18nTime(docMeta.createDate) : localizedCreateTime}
</PropertyValue>
</PropertyRoot>
{serverClock ? (
<PropertyRoot>
<PropertyName
name={t[
!syncing && !retrying ? 'Updated' : 'com.affine.syncing'
]()}
icon={<HistoryIcon />}
/>
<PropertyValue>
{!syncing && !retrying
? formatI18nTime(serverClock)
: docMeta?.updatedDate
? formatI18nTime(docMeta.updatedDate)
: null}
</PropertyValue>
</PropertyRoot>
) : docMeta?.updatedDate ? (
<PropertyRoot>
<PropertyName name={t['Updated']()} icon={<HistoryIcon />} />
<PropertyValue>{formatI18nTime(docMeta.updatedDate)}</PropertyValue>
</PropertyRoot>
) : null}
</>
);
}, [docMeta, retrying, serverClock, syncing, t]);
const dTimestampElement = useDebouncedValue(timestampElement, 500);
return (
<div className={clsx(styles.container, className)}>{dTimestampElement}</div>
);
};
@@ -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',
});
@@ -0,0 +1,256 @@
import { Button, Modal } from '@affine/component';
import { useAsyncCallback } from '@affine/core/components/hooks/affine-async-hooks';
import { useNavigateHelper } from '@affine/core/components/hooks/use-navigate-helper';
import { useWorkspaceName } from '@affine/core/components/hooks/use-workspace-info';
import { WorkspaceSelector } from '@affine/core/components/workspace-selector';
import { AuthService } from '@affine/core/modules/cloud';
import {
type DialogComponentProps,
type GLOBAL_DIALOG_SCHEMA,
} from '@affine/core/modules/dialogs';
import {
ImportTemplateService,
TemplateDownloaderService,
} from '@affine/core/modules/import-template';
import { WorkspaceFlavour } from '@affine/env/workspace';
import { useI18n } from '@affine/i18n';
import type { DocMode } from '@blocksuite/affine/blocks';
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 * as styles from './dialog.css';
const Dialog = ({
templateName,
templateMode,
snapshotUrl,
onClose,
}: {
templateName: string;
templateMode: DocMode;
snapshotUrl: 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?' +
'&name=' +
templateName +
'&mode=' +
templateMode +
'&snapshotUrl=' +
snapshotUrl
);
onClose?.();
}
}, [
isSessionRevalidating,
jumpToSignIn,
notLogin,
onClose,
snapshotUrl,
templateName,
templateMode,
]);
useEffect(() => {
templateDownloader.download({ snapshotUrl });
}, [snapshotUrl, templateDownloader]);
const handleSelectedWorkspace = useCallback(
(workspaceMetadata: WorkspaceMetadata) => {
return setSelectedWorkspace(workspaceMetadata);
},
[]
);
const handleCreatedWorkspace = useCallback(
(payload: { metadata: WorkspaceMetadata; defaultDocId?: string }) => {
return setSelectedWorkspace(payload.metadata);
},
[]
);
const handleImportToSelectedWorkspace = useAsyncCallback(async () => {
if (templateDownloader.data$.value && selectedWorkspace) {
setImporting(true);
try {
const docId = await importTemplateService.importToWorkspace(
selectedWorkspace,
templateDownloader.data$.value,
templateMode
);
openPage(selectedWorkspace.id, docId);
onClose?.();
} catch (err) {
setImportingError(err);
} finally {
setImporting(false);
}
}
}, [
importTemplateService,
onClose,
openPage,
selectedWorkspace,
templateDownloader.data$.value,
templateMode,
]);
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 ImportTemplateDialog = ({
close,
snapshotUrl,
templateName,
templateMode,
}: DialogComponentProps<GLOBAL_DIALOG_SCHEMA['import-template']>) => {
return (
<Modal
open
modal={true}
persistent
withoutCloseButton
contentOptions={{
className: styles.modal,
}}
onOpenChange={() => close()}
>
<Dialog
templateName={templateName}
templateMode={templateMode}
snapshotUrl={snapshotUrl}
onClose={() => close()}
/>
</Modal>
);
};
@@ -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',
});
@@ -0,0 +1,59 @@
import { toast } from '@affine/component';
import {
type DialogComponentProps,
type GLOBAL_DIALOG_SCHEMA,
} from '@affine/core/modules/dialogs';
import { _addLocalWorkspace } from '@affine/core/modules/workspace-engine';
import { DebugLogger } from '@affine/debug';
import { apis } from '@affine/electron-api';
import { WorkspaceFlavour } from '@affine/env/workspace';
import { useI18n } from '@affine/i18n';
import { useService, WorkspacesService } from '@toeverything/infra';
import { useLayoutEffect } from 'react';
const logger = new DebugLogger('ImportWorkspaceDialog');
export const ImportWorkspaceDialog = ({
close,
}: DialogComponentProps<GLOBAL_DIALOG_SCHEMA['import-workspace']>) => {
const t = useI18n();
const workspacesService = useService(WorkspacesService);
// TODO(@Peng): maybe refactor using xstate?
useLayoutEffect(() => {
let canceled = false;
// 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();
close({
workspace: {
flavour: WorkspaceFlavour.LOCAL,
id: result.workspaceId,
},
});
} else if (result.error || result.canceled) {
if (result.error) {
toast(t[result.error]());
}
close();
}
})().catch(err => {
console.error(err);
});
return () => {
canceled = true;
};
}, [close, t, workspacesService]);
return null;
};
@@ -0,0 +1,365 @@
import { Button, IconButton, Modal } from '@affine/component';
import { useAsyncCallback } from '@affine/core/components/hooks/affine-async-hooks';
import type {
DialogComponentProps,
GLOBAL_DIALOG_SCHEMA,
} from '@affine/core/modules/dialogs';
import { UrlService } from '@affine/core/modules/url';
import { WorkbenchService } from '@affine/core/modules/workbench';
import { useI18n } from '@affine/i18n';
import {
MarkdownTransformer,
NotionHtmlTransformer,
openFileOrFiles,
} from '@blocksuite/affine/blocks';
import type { DocCollection } from '@blocksuite/affine/store';
import {
ExportToMarkdownIcon,
HelpIcon,
NotionIcon,
} from '@blocksuite/icons/rc';
import { useService, WorkspaceService } from '@toeverything/infra';
import { cssVar } from '@toeverything/theme';
import { cssVarV2 } from '@toeverything/theme/v2';
import { type ReactElement, useCallback, useState } from 'react';
import * as style from './styles.css';
type ImportType = 'markdown' | 'markdownZip' | 'notion';
type AcceptType = 'Markdown' | 'Zip';
type Status = 'idle' | 'importing' | 'success' | 'error';
type ImportConfig = {
fileOptions: { acceptType: AcceptType; multiple: boolean };
importFunction: (
docCollection: DocCollection,
file: File | File[]
) => Promise<string[]>;
};
const DISCORD_URL = 'https://discord.gg/whd5mjYqVw';
const importOptions = [
{
label: 'com.affine.import.markdown-files',
prefixIcon: (
<ExportToMarkdownIcon
color={cssVarV2('icon/primary')}
width={20}
height={20}
/>
),
testId: 'editor-option-menu-import-markdown-files',
type: 'markdown' as ImportType,
},
{
label: 'com.affine.import.markdown-with-media-files',
prefixIcon: (
<ExportToMarkdownIcon
color={cssVarV2('icon/primary')}
width={20}
height={20}
/>
),
testId: 'editor-option-menu-import-markdown-with-media',
type: 'markdownZip' as ImportType,
},
{
label: 'com.affine.import.notion',
prefixIcon: <NotionIcon color={cssVar('black')} width={20} height={20} />,
suffixIcon: (
<HelpIcon color={cssVarV2('icon/primary')} width={20} height={20} />
),
suffixTooltip: 'com.affine.import.notion.tooltip',
testId: 'editor-option-menu-import-notion',
type: 'notion' as ImportType,
},
];
const importConfigs: Record<ImportType, ImportConfig> = {
markdown: {
fileOptions: { acceptType: 'Markdown', multiple: true },
importFunction: async (docCollection, files) => {
if (!Array.isArray(files)) {
throw new Error('Expected an array of files for markdown files import');
}
const pageIds: string[] = [];
for (const file of files) {
const text = await file.text();
const fileName = file.name.split('.').slice(0, -1).join('.');
const pageId = await MarkdownTransformer.importMarkdownToDoc({
collection: docCollection,
markdown: text,
fileName,
});
if (pageId) pageIds.push(pageId);
}
return pageIds;
},
},
markdownZip: {
fileOptions: { acceptType: 'Zip', multiple: false },
importFunction: async (docCollection, file) => {
if (Array.isArray(file)) {
throw new Error('Expected a single zip file for markdownZip import');
}
return MarkdownTransformer.importMarkdownZip({
collection: docCollection,
imported: file,
});
},
},
notion: {
fileOptions: { acceptType: 'Zip', multiple: false },
importFunction: async (docCollection, file) => {
if (Array.isArray(file)) {
throw new Error('Expected a single zip file for notion import');
}
const { pageIds } = await NotionHtmlTransformer.importNotionZip({
collection: docCollection,
imported: file,
});
return pageIds;
},
},
};
const ImportOptionItem = ({
label,
prefixIcon,
suffixIcon,
suffixTooltip,
type,
onImport,
}: {
label: string;
prefixIcon: ReactElement;
suffixIcon?: ReactElement;
suffixTooltip?: string;
type: ImportType;
onImport: (type: ImportType) => void;
}) => {
const t = useI18n();
return (
<div className={style.importItem} onClick={() => onImport(type)}>
{prefixIcon}
<div className={style.importItemLabel}>{t[label]()}</div>
{suffixIcon && (
<IconButton
className={style.importItemSuffix}
icon={suffixIcon}
tooltip={suffixTooltip ? t[suffixTooltip]() : undefined}
/>
)}
</div>
);
};
const ImportOptions = ({
onImport,
}: {
onImport: (type: ImportType) => void;
}) => {
const t = useI18n();
return (
<>
<div className={style.importModalTitle}>{t['Import']()}</div>
<div className={style.importModalContent}>
{importOptions.map(
({ label, prefixIcon, suffixIcon, suffixTooltip, testId, type }) => (
<ImportOptionItem
key={testId}
prefixIcon={prefixIcon}
suffixIcon={suffixIcon}
suffixTooltip={suffixTooltip}
label={label}
data-testid={testId}
type={type}
onImport={onImport}
/>
)
)}
</div>
<div className={style.importModalTip}>
{t['com.affine.import.modal.tip']()}{' '}
<a
className={style.link}
href="https://discord.gg/whd5mjYqVw"
target="_blank"
rel="noreferrer"
>
Discord
</a>{' '}
.
</div>
</>
);
};
const ImportingStatus = () => {
const t = useI18n();
return (
<>
<div className={style.importModalTitle}>
{t['com.affine.import.status.importing.title']()}
</div>
<p className={style.importStatusContent}>
{t['com.affine.import.status.importing.message']()}
</p>
</>
);
};
const SuccessStatus = ({ onComplete }: { onComplete: () => void }) => {
const t = useI18n();
return (
<>
<div className={style.importModalTitle}>
{t['com.affine.import.status.success.title']()}
</div>
<p className={style.importStatusContent}>
{t['com.affine.import.status.success.message']()}{' '}
<a
className={style.link}
href={DISCORD_URL}
target="_blank"
rel="noreferrer"
>
Discord
</a>
.
</p>
<div className={style.importModalButtonContainer}>
<Button onClick={onComplete} variant="primary">
{t['Complete']()}
</Button>
</div>
</>
);
};
const ErrorStatus = ({
error,
onRetry,
}: {
error: string | null;
onRetry: () => void;
}) => {
const t = useI18n();
const urlService = useService(UrlService);
return (
<>
<div className={style.importModalTitle}>
{t['com.affine.import.status.failed.title']()}
</div>
<p className={style.importStatusContent}>
{error || 'Unknown error occurred'}
</p>
<div className={style.importModalButtonContainer}>
<Button
onClick={() => {
urlService.openPopupWindow(DISCORD_URL);
}}
variant="secondary"
>
{t['Feedback']()}
</Button>
<Button onClick={onRetry} variant="primary">
{t['Retry']()}
</Button>
</div>
</>
);
};
export const ImportDialog = ({
close,
}: DialogComponentProps<GLOBAL_DIALOG_SCHEMA['import']>) => {
const t = useI18n();
const [status, setStatus] = useState<Status>('idle');
const [importError, setImportError] = useState<string | null>(null);
const [pageIds, setPageIds] = useState<string[]>([]);
const workspace = useService(WorkspaceService).workspace;
const workbench = useService(WorkbenchService).workbench;
const docCollection = workspace.docCollection;
const handleImport = useAsyncCallback(
async (type: ImportType) => {
setImportError(null);
try {
const importConfig = importConfigs[type];
const file = await openFileOrFiles(importConfig.fileOptions);
if (!file || (Array.isArray(file) && file.length === 0)) {
throw new Error(
t['com.affine.import.status.failed.message.no-file-selected']()
);
}
setStatus('importing');
const pageIds = await importConfig.importFunction(docCollection, file);
setPageIds(pageIds);
setStatus('success');
} catch (error) {
setImportError(
error instanceof Error ? error.message : 'Unknown error occurred'
);
setStatus('error');
}
},
[docCollection, t]
);
const handleComplete = useCallback(() => {
if (pageIds.length > 1) {
workbench.openAll();
} else if (pageIds.length === 1) {
workbench.openDoc(pageIds[0]);
}
close();
}, [pageIds, close, workbench]);
const handleRetry = () => {
setStatus('idle');
};
const statusComponents = {
idle: <ImportOptions onImport={handleImport} />,
importing: <ImportingStatus />,
success: <SuccessStatus onComplete={handleComplete} />,
error: <ErrorStatus error={importError} onRetry={handleRetry} />,
};
return (
<Modal
open
onOpenChange={() => {
close();
}}
width={480}
contentOptions={{
['data-testid' as string]: 'import-modal',
style: {
maxHeight: '85vh',
maxWidth: '70vw',
minHeight: '126px',
padding: 0,
overflow: 'hidden',
display: 'flex',
background: cssVarV2('layer/background/primary'),
},
}}
closeButtonOptions={{
className: style.closeButton,
}}
withoutCloseButton={status === 'importing'}
persistent={status === 'importing'}
>
<div className={style.importModalContainer}>
{statusComponents[status]}
</div>
</Modal>
);
};
@@ -0,0 +1,110 @@
import { cssVar } from '@toeverything/theme';
import { cssVarV2 } from '@toeverything/theme/v2';
import { style } from '@vanilla-extract/css';
export const importModalContainer = style({
width: '100%',
height: '100%',
display: 'flex',
boxSizing: 'border-box',
flexDirection: 'column',
alignItems: 'center',
justifyContent: 'center',
padding: '20px 24px',
gap: '12px',
});
export const importModalTitle = style({
width: '100%',
height: 'auto',
fontSize: cssVar('fontH6'),
fontWeight: '600',
lineHeight: cssVar('lineHeight'),
});
export const importModalContent = style({
width: '100%',
flex: 1,
display: 'flex',
flexDirection: 'column',
gap: '12px',
});
export const closeButton = style({
top: '24px',
right: '24px',
});
export const importModalTip = style({
width: '100%',
height: 'auto',
fontSize: cssVar('fontSm'),
lineHeight: cssVar('lineHeight'),
fontWeight: '400',
color: cssVar('textSecondaryColor'),
});
export const link = style({
color: cssVar('linkColor'),
cursor: 'pointer',
});
export const importStatusContent = style({
width: '100%',
fontSize: cssVar('fontBase'),
lineHeight: cssVar('lineHeight'),
fontWeight: '400',
color: cssVar('textPrimaryColor'),
});
export const importModalButtonContainer = style({
width: '100%',
display: 'flex',
flexDirection: 'row',
gap: '20px',
justifyContent: 'end',
marginTop: '20px',
});
export const importItem = style({
display: 'flex',
flexDirection: 'row',
justifyContent: 'space-between',
alignItems: 'center',
width: '100%',
height: 'auto',
gap: '4px',
padding: '8px 12px',
borderRadius: '8px',
border: `1px solid ${cssVarV2('layer/insideBorder/border')}`,
background: cssVarV2('button/secondary'),
selectors: {
'&:hover': {
background: cssVarV2('layer/background/hoverOverlay'),
cursor: 'pointer',
transition: 'background .30s',
},
},
});
export const importItemLabel = style({
display: 'flex',
alignItems: 'center',
padding: '0 4px',
textAlign: 'left',
flex: 1,
color: cssVar('textPrimaryColor'),
fontSize: cssVar('fontBase'),
lineHeight: cssVar('lineHeight'),
fontWeight: '500',
whiteSpace: 'nowrap',
overflow: 'hidden',
});
export const importItemPrefix = style({
marginRight: 'auto',
});
export const importItemSuffix = style({
marginLeft: 'auto',
});
@@ -0,0 +1,96 @@
import { AuthModal } from '@affine/core/components/affine/auth';
import {
type DialogComponentProps,
type GLOBAL_DIALOG_SCHEMA,
GlobalDialogService,
WorkspaceDialogService,
} from '@affine/core/modules/dialogs';
import type { WORKSPACE_DIALOG_SCHEMA } from '@affine/core/modules/dialogs/constant';
import { useLiveData, useService } from '@toeverything/infra';
import { CollectionEditorDialog } from './collection-editor';
import { CreateWorkspaceDialog } from './create-workspace';
import { DocInfoDialog } from './doc-info';
import { ImportDialog } from './import';
import { ImportTemplateDialog } from './import-template';
import { ImportWorkspaceDialog } from './import-workspace';
import { CollectionSelectorDialog } from './selectors/collection';
import { DocSelectorDialog } from './selectors/doc';
import { TagSelectorDialog } from './selectors/tag';
import { SettingDialog } from './setting';
const GLOBAL_DIALOGS = {
'create-workspace': CreateWorkspaceDialog,
'import-workspace': ImportWorkspaceDialog,
'import-template': ImportTemplateDialog,
setting: SettingDialog,
import: ImportDialog,
} satisfies {
[key in keyof GLOBAL_DIALOG_SCHEMA]?: React.FC<
DialogComponentProps<GLOBAL_DIALOG_SCHEMA[key]>
>;
};
const WORKSPACE_DIALOGS = {
'doc-info': DocInfoDialog,
'collection-editor': CollectionEditorDialog,
'tag-selector': TagSelectorDialog,
'doc-selector': DocSelectorDialog,
'collection-selector': CollectionSelectorDialog,
} satisfies {
[key in keyof WORKSPACE_DIALOG_SCHEMA]?: React.FC<
DialogComponentProps<WORKSPACE_DIALOG_SCHEMA[key]>
>;
};
export const GlobalDialogs = () => {
const globalDialogService = useService(GlobalDialogService);
const dialogs = useLiveData(globalDialogService.dialogs$);
return (
<>
{dialogs.map(dialog => {
const DialogComponent =
GLOBAL_DIALOGS[dialog.type as keyof typeof GLOBAL_DIALOGS];
if (!DialogComponent) {
return null;
}
return (
<DialogComponent
key={dialog.id}
{...(dialog.props as any)}
close={(result?: unknown) => {
globalDialogService.close(dialog.id, result);
}}
/>
);
})}
<AuthModal />
</>
);
};
export const WorkspaceDialogs = () => {
const workspaceDialogService = useService(WorkspaceDialogService);
const dialogs = useLiveData(workspaceDialogService.dialogs$);
return (
<>
{dialogs.map(dialog => {
const DialogComponent =
WORKSPACE_DIALOGS[dialog.type as keyof typeof WORKSPACE_DIALOGS];
if (!DialogComponent) {
return null;
}
return (
<DialogComponent
key={dialog.id}
{...(dialog.props as any)}
close={(result?: unknown) => {
workspaceDialogService.close(dialog.id, result);
}}
/>
);
})}
</>
);
};
@@ -0,0 +1,125 @@
import { Modal, toast } from '@affine/component';
import {
collectionHeaderColsDef,
CollectionListItemRenderer,
type CollectionMeta,
FavoriteTag,
type ListItem,
ListTableHeader,
VirtualizedList,
} from '@affine/core/components/page-list';
import { SelectorLayout } from '@affine/core/components/page-list/selector/selector-layout';
import { CollectionService } from '@affine/core/modules/collection';
import type { DialogComponentProps } from '@affine/core/modules/dialogs';
import type { WORKSPACE_DIALOG_SCHEMA } from '@affine/core/modules/dialogs/constant';
import { CompatibleFavoriteItemsAdapter } from '@affine/core/modules/favorite';
import { useI18n } from '@affine/i18n';
import { useLiveData, useService, WorkspaceService } from '@toeverything/infra';
import { cssVar } from '@toeverything/theme';
import { useCallback, useMemo, useState } from 'react';
const FavoriteOperation = ({ collection }: { collection: ListItem }) => {
const t = useI18n();
const favAdapter = useService(CompatibleFavoriteItemsAdapter);
const isFavorite = useLiveData(
favAdapter.isFavorite$(collection.id, 'collection')
);
const onToggleFavoriteCollection = useCallback(() => {
favAdapter.toggle(collection.id, 'collection');
toast(
isFavorite
? t['com.affine.toastMessage.removedFavorites']()
: t['com.affine.toastMessage.addedFavorites']()
);
}, [collection.id, favAdapter, isFavorite, t]);
return (
<FavoriteTag
style={{ marginRight: 8 }}
onClick={onToggleFavoriteCollection}
active={isFavorite}
/>
);
};
export const CollectionSelectorDialog = ({
close,
init: selectedCollectionIds,
}: DialogComponentProps<WORKSPACE_DIALOG_SCHEMA['collection-selector']>) => {
const t = useI18n();
const collectionService = useService(CollectionService);
const workspace = useService(WorkspaceService).workspace;
const collections = useLiveData(collectionService.collections$);
const [selection, setSelection] = useState(selectedCollectionIds);
const [keyword, setKeyword] = useState('');
const collectionMetas = useMemo(() => {
const collectionsList: CollectionMeta[] = collections
.map(collection => {
return {
...collection,
title: collection.name,
};
})
.filter(meta => {
const reg = new RegExp(keyword, 'i');
return reg.test(meta.title);
});
return collectionsList;
}, [collections, keyword]);
const collectionItemRenderer = useCallback((item: ListItem) => {
return <CollectionListItemRenderer {...item} />;
}, []);
const collectionHeaderRenderer = useCallback(() => {
return <ListTableHeader headerCols={collectionHeaderColsDef} />;
}, []);
const collectionOperationRenderer = useCallback((item: ListItem) => {
return <FavoriteOperation collection={item} />;
}, []);
return (
<Modal
open
onOpenChange={() => close()}
withoutCloseButton
width="calc(100% - 32px)"
height="80%"
contentOptions={{
style: {
padding: 0,
maxWidth: 976,
background: cssVar('backgroundPrimaryColor'),
},
}}
>
<SelectorLayout
searchPlaceholder={t[
'com.affine.selector-collection.search.placeholder'
]()}
selectedCount={selection.length}
onSearch={setKeyword}
onClear={() => setSelection([])}
onCancel={() => close()}
onConfirm={() => close(selection)}
>
<VirtualizedList
selectable={true}
draggable={false}
selectedIds={selection}
onSelectedIdsChange={setSelection}
items={collectionMetas}
itemRenderer={collectionItemRenderer}
rowAsLink
docCollection={workspace.docCollection}
operationsRenderer={collectionOperationRenderer}
headerRenderer={collectionHeaderRenderer}
/>
</SelectorLayout>
</Modal>
);
};
@@ -0,0 +1,35 @@
import { Modal } from '@affine/component';
import { SelectPage } from '@affine/core/components/page-list/docs/select-page';
import type {
DialogComponentProps,
WORKSPACE_DIALOG_SCHEMA,
} from '@affine/core/modules/dialogs';
import { cssVar } from '@toeverything/theme';
export const DocSelectorDialog = ({
close,
init: selectedDocIds,
}: DialogComponentProps<WORKSPACE_DIALOG_SCHEMA['doc-selector']>) => {
return (
<Modal
open
onOpenChange={() => close()}
withoutCloseButton
width="calc(100% - 32px)"
height="80%"
contentOptions={{
style: {
padding: 0,
maxWidth: 976,
background: cssVar('backgroundPrimaryColor'),
},
}}
>
<SelectPage
init={selectedDocIds}
onCancel={() => close()}
onConfirm={value => close(value)}
/>
</Modal>
);
};
@@ -0,0 +1,117 @@
import { Modal, toast } from '@affine/component';
import {
FavoriteTag,
type ListItem,
ListTableHeader,
tagHeaderColsDef,
TagListItemRenderer,
type TagMeta,
VirtualizedList,
} from '@affine/core/components/page-list';
import { SelectorLayout } from '@affine/core/components/page-list/selector/selector-layout';
import type {
DialogComponentProps,
WORKSPACE_DIALOG_SCHEMA,
} from '@affine/core/modules/dialogs';
import { FavoriteService } from '@affine/core/modules/favorite';
import { TagService } from '@affine/core/modules/tag';
import { useI18n } from '@affine/i18n';
import { useLiveData, useService, WorkspaceService } from '@toeverything/infra';
import { cssVar } from '@toeverything/theme';
import { useCallback, useMemo, useState } from 'react';
const FavoriteOperation = ({ tag }: { tag: ListItem }) => {
const t = useI18n();
const favoriteService = useService(FavoriteService);
const isFavorite = useLiveData(
favoriteService.favoriteList.isFavorite$('tag', tag.id)
);
const onToggleFavoriteCollection = useCallback(() => {
favoriteService.favoriteList.toggle('tag', tag.id);
toast(
isFavorite
? t['com.affine.toastMessage.removedFavorites']()
: t['com.affine.toastMessage.addedFavorites']()
);
}, [favoriteService.favoriteList, tag.id, isFavorite, t]);
return (
<FavoriteTag
style={{ marginRight: 8 }}
onClick={onToggleFavoriteCollection}
active={isFavorite}
/>
);
};
export const TagSelectorDialog = ({
close,
init: selectedTagIds,
}: DialogComponentProps<WORKSPACE_DIALOG_SCHEMA['tag-selector']>) => {
const t = useI18n();
const workspace = useService(WorkspaceService).workspace;
const tagList = useService(TagService).tagList;
const [selection, setSelection] = useState(selectedTagIds);
const [keyword, setKeyword] = useState('');
const tagMetas: TagMeta[] = useLiveData(tagList.tagMetas$);
const filteredTagMetas = useMemo(() => {
return tagMetas.filter(tag => {
const reg = new RegExp(keyword, 'i');
return reg.test(tag.title);
});
}, [keyword, tagMetas]);
const tagItemRenderer = useCallback((item: ListItem) => {
return <TagListItemRenderer {...item} />;
}, []);
const tagOperationRenderer = useCallback((item: ListItem) => {
return <FavoriteOperation tag={item} />;
}, []);
const tagHeaderRenderer = useCallback(() => {
return <ListTableHeader headerCols={tagHeaderColsDef} />;
}, []);
return (
<Modal
open
onOpenChange={() => close()}
withoutCloseButton
width="calc(100% - 32px)"
height="80%"
contentOptions={{
style: {
padding: 0,
maxWidth: 976,
background: cssVar('backgroundPrimaryColor'),
},
}}
>
<SelectorLayout
searchPlaceholder={t['com.affine.selector-tag.search.placeholder']()}
selectedCount={selection.length}
onSearch={setKeyword}
onConfirm={() => close(selection)}
onCancel={close}
onClear={() => setSelection([])}
>
<VirtualizedList
selectable={true}
draggable={false}
selectedIds={selection}
onSelectedIdsChange={setSelection}
items={filteredTagMetas}
docCollection={workspace.docCollection}
itemRenderer={tagItemRenderer}
operationsRenderer={tagOperationRenderer}
headerRenderer={tagHeaderRenderer}
/>
</SelectorLayout>
</Modal>
);
};
@@ -0,0 +1,140 @@
import { Button, ErrorMessage, Skeleton } from '@affine/component';
import { SettingRow } from '@affine/component/setting-components';
import {
ServerConfigService,
SubscriptionService,
UserCopilotQuotaService,
} from '@affine/core/modules/cloud';
import { SubscriptionPlan } from '@affine/graphql';
import { useI18n } from '@affine/i18n';
import { track } from '@affine/track';
import { useLiveData, useService } from '@toeverything/infra';
import { cssVar } from '@toeverything/theme';
import { useCallback, useEffect } from 'react';
import { AIResume, AISubscribe } from '../general-setting/plans/ai/actions';
import type { SettingState } from '../types';
import * as styles from './storage-progress.css';
export const AIUsagePanel = ({
onChangeSettingState,
}: {
onChangeSettingState?: (settingState: SettingState) => void;
}) => {
const t = useI18n();
const serverConfigService = useService(ServerConfigService);
const hasPaymentFeature = useLiveData(
serverConfigService.serverConfig.features$.map(f => f?.payment)
);
const subscriptionService = useService(SubscriptionService);
const aiSubscription = useLiveData(subscriptionService.subscription.ai$);
useEffect(() => {
// revalidate latest subscription status
subscriptionService.subscription.revalidate();
}, [subscriptionService]);
const copilotQuotaService = useService(UserCopilotQuotaService);
useEffect(() => {
copilotQuotaService.copilotQuota.revalidate();
}, [copilotQuotaService]);
const copilotActionLimit = useLiveData(
copilotQuotaService.copilotQuota.copilotActionLimit$
);
const copilotActionUsed = useLiveData(
copilotQuotaService.copilotQuota.copilotActionUsed$
);
const loading = copilotActionLimit === null || copilotActionUsed === null;
const loadError = useLiveData(copilotQuotaService.copilotQuota.error$);
const openBilling = useCallback(() => {
onChangeSettingState?.({
activeTab: 'billing',
});
track.$.settingsPanel.accountUsage.viewPlans({ plan: SubscriptionPlan.AI });
}, [onChangeSettingState]);
if (loading) {
if (loadError) {
return (
<SettingRow
name={t['com.affine.payment.ai.usage-title']()}
desc={''}
spreadCol={false}
>
{/* TODO(@catsjuice): i18n */}
<ErrorMessage>Load error</ErrorMessage>
</SettingRow>
);
}
return (
<SettingRow
name={t['com.affine.payment.ai.usage-title']()}
desc={''}
spreadCol={false}
>
<Skeleton height={42} />
</SettingRow>
);
}
const percent =
copilotActionLimit === 'unlimited'
? 0
: Math.min(
100,
Math.max(
0.5,
Number(((copilotActionUsed / copilotActionLimit) * 100).toFixed(4))
)
);
const color = percent > 80 ? cssVar('errorColor') : cssVar('processingColor');
return (
<SettingRow
spreadCol={aiSubscription ? true : false}
desc={
aiSubscription
? t['com.affine.payment.ai.usage-description-purchased']()
: ''
}
name={t['com.affine.payment.ai.usage-title']()}
>
{copilotActionLimit === 'unlimited' ? (
hasPaymentFeature && aiSubscription?.canceledAt ? (
<AIResume />
) : (
<Button onClick={openBilling}>
{t['com.affine.payment.ai.usage.change-button-label']()}
</Button>
)
) : (
<div className={styles.storageProgressContainer}>
<div className={styles.storageProgressWrapper}>
<div className="storage-progress-desc">
<span>{t['com.affine.payment.ai.usage.used-caption']()}</span>
<span>
{t['com.affine.payment.ai.usage.used-detail']({
used: copilotActionUsed.toString(),
limit: copilotActionLimit.toString(),
})}
</span>
</div>
<div className="storage-progress-bar-wrapper">
<div
className={styles.storageProgressBar}
style={{ width: `${percent}%`, backgroundColor: color }}
></div>
</div>
</div>
{hasPaymentFeature && (
<AISubscribe variant="primary">
{t['com.affine.payment.ai.usage.purchase-button-label']()}
</AISubscribe>
)}
</div>
)}
</SettingRow>
);
};
@@ -0,0 +1,272 @@
import { FlexWrapper, Input, notify } from '@affine/component';
import {
SettingHeader,
SettingRow,
} from '@affine/component/setting-components';
import { Avatar } from '@affine/component/ui/avatar';
import { Button } from '@affine/component/ui/button';
import { authAtom } from '@affine/core/components/atoms';
import { useSignOut } from '@affine/core/components/hooks/affine/use-sign-out';
import { useAsyncCallback } from '@affine/core/components/hooks/affine-async-hooks';
import { useCatchEventCallback } from '@affine/core/components/hooks/use-catch-event-hook';
import { Upload } from '@affine/core/components/pure/file-upload';
import { SubscriptionPlan } from '@affine/graphql';
import { useI18n } from '@affine/i18n';
import { track } from '@affine/track';
import { ArrowRightSmallIcon, CameraIcon } from '@blocksuite/icons/rc';
import {
useEnsureLiveData,
useLiveData,
useService,
useServices,
} from '@toeverything/infra';
import { useSetAtom } from 'jotai';
import { useCallback, useEffect, useState } from 'react';
import { AuthService, ServerConfigService } from '../../../../modules/cloud';
import type { SettingState } from '../types';
import { AIUsagePanel } from './ai-usage-panel';
import { StorageProgress } from './storage-progress';
import * as styles from './style.css';
export const UserAvatar = () => {
const t = useI18n();
const session = useService(AuthService).session;
const account = useEnsureLiveData(session.account$);
const handleUpdateUserAvatar = useAsyncCallback(
async (file: File) => {
try {
track.$.settingsPanel.accountSettings.uploadAvatar();
await session.uploadAvatar(file);
notify.success({ title: 'Update user avatar success' });
} catch (e) {
// TODO(@catsjuice): i18n
notify.error({
title: 'Update user avatar failed',
message: String(e),
});
}
},
[session]
);
const handleRemoveUserAvatar = useCatchEventCallback(async () => {
track.$.settingsPanel.accountSettings.removeAvatar();
await session.removeAvatar();
}, [session]);
return (
<Upload
accept="image/gif,image/jpeg,image/jpg,image/png,image/svg"
fileChange={handleUpdateUserAvatar}
data-testid="upload-user-avatar"
>
<Avatar
size={56}
name={account.label}
url={account.avatar}
hoverIcon={<CameraIcon />}
onRemove={account.avatar ? handleRemoveUserAvatar : undefined}
avatarTooltipOptions={{ content: t['Click to replace photo']() }}
removeTooltipOptions={{ content: t['Remove photo']() }}
data-testid="user-setting-avatar"
removeButtonProps={{
['data-testid' as string]: 'user-setting-remove-avatar-button',
}}
/>
</Upload>
);
};
export const AvatarAndName = () => {
const t = useI18n();
const session = useService(AuthService).session;
const account = useEnsureLiveData(session.account$);
const [input, setInput] = useState<string>(account.label);
const allowUpdate = !!input && input !== account.label;
const handleUpdateUserName = useAsyncCallback(async () => {
if (account === null) {
return;
}
if (!allowUpdate) {
return;
}
try {
track.$.settingsPanel.accountSettings.updateUserName();
await session.updateLabel(input);
} catch (e) {
notify.error({
title: 'Failed to update user name.',
message: String(e),
});
}
}, [account, allowUpdate, session, input]);
return (
<SettingRow
name={t['com.affine.settings.profile']()}
desc={t['com.affine.settings.profile.message']()}
spreadCol={false}
>
<FlexWrapper style={{ margin: '12px 0 24px 0' }} alignItems="center">
<UserAvatar />
<div className={styles.profileInputWrapper}>
<label>{t['com.affine.settings.profile.name']()}</label>
<FlexWrapper alignItems="center">
<Input
defaultValue={input}
data-testid="user-name-input"
placeholder={t['com.affine.settings.profile.placeholder']()}
maxLength={64}
minLength={0}
style={{ width: 280, height: 32 }}
onChange={setInput}
onEnter={handleUpdateUserName}
/>
{allowUpdate ? (
<Button
data-testid="save-user-name"
onClick={handleUpdateUserName}
style={{
marginLeft: '12px',
}}
>
{t['com.affine.editCollection.save']()}
</Button>
) : null}
</FlexWrapper>
</div>
</FlexWrapper>
</SettingRow>
);
};
const StoragePanel = ({
onChangeSettingState,
}: {
onChangeSettingState?: (settingState: SettingState) => void;
}) => {
const t = useI18n();
const onUpgrade = useCallback(() => {
track.$.settingsPanel.accountUsage.viewPlans({
plan: SubscriptionPlan.Pro,
});
onChangeSettingState?.({
activeTab: 'plans',
scrollAnchor: 'cloudPricingPlan',
});
}, [onChangeSettingState]);
return (
<SettingRow
name={t['com.affine.storage.title']()}
desc=""
spreadCol={false}
>
<StorageProgress onUpgrade={onUpgrade} />
</SettingRow>
);
};
export const AccountSetting = ({
onChangeSettingState,
}: {
onChangeSettingState?: (settingState: SettingState) => void;
}) => {
const { authService, serverConfigService } = useServices({
AuthService,
ServerConfigService,
});
const serverFeatures = useLiveData(
serverConfigService.serverConfig.features$
);
const t = useI18n();
const session = authService.session;
useEffect(() => {
session.revalidate();
}, [session]);
const account = useEnsureLiveData(session.account$);
const setAuthModal = useSetAtom(authAtom);
const openSignOutModal = useSignOut();
const onChangeEmail = useCallback(() => {
setAuthModal({
openModal: true,
state: 'sendEmail',
// @ts-expect-error accont email is always defined
email: account.email,
emailType: account.info?.emailVerified ? 'changeEmail' : 'verifyEmail',
});
}, [account.email, account.info?.emailVerified, setAuthModal]);
const onPasswordButtonClick = useCallback(() => {
setAuthModal({
openModal: true,
state: 'sendEmail',
// @ts-expect-error accont email is always defined
email: account.email,
emailType: account.info?.hasPassword ? 'changePassword' : 'setPassword',
});
}, [account.email, account.info?.hasPassword, setAuthModal]);
return (
<>
<SettingHeader
title={t['com.affine.setting.account']()}
subtitle={t['com.affine.setting.account.message']()}
data-testid="account-title"
/>
<AvatarAndName />
<SettingRow name={t['com.affine.settings.email']()} desc={account.email}>
<Button onClick={onChangeEmail}>
{account.info?.emailVerified
? t['com.affine.settings.email.action.change']()
: t['com.affine.settings.email.action.verify']()}
</Button>
</SettingRow>
<SettingRow
name={t['com.affine.settings.password']()}
desc={t['com.affine.settings.password.message']()}
>
<Button onClick={onPasswordButtonClick}>
{account.info?.hasPassword
? t['com.affine.settings.password.action.change']()
: t['com.affine.settings.password.action.set']()}
</Button>
</SettingRow>
<StoragePanel onChangeSettingState={onChangeSettingState} />
{serverFeatures?.copilot && (
<AIUsagePanel onChangeSettingState={onChangeSettingState} />
)}
<SettingRow
name={t[`Sign out`]()}
desc={t['com.affine.setting.sign.out.message']()}
style={{ cursor: 'pointer' }}
data-testid="sign-out-button"
onClick={openSignOutModal}
>
<ArrowRightSmallIcon />
</SettingRow>
{/*<SettingRow*/}
{/* name={*/}
{/* <span style={{ color: 'var(--affine-warning-color)' }}>*/}
{/* {t['com.affine.setting.account.delete']()}*/}
{/* </span>*/}
{/* }*/}
{/* desc={t['com.affine.setting.account.delete.message']()}*/}
{/* style={{ cursor: 'pointer' }}*/}
{/* onClick={useCallback(() => {*/}
{/* toast('Function coming soon');*/}
{/* }, [])}*/}
{/* testId="delete-account-button"*/}
{/*>*/}
{/* <ArrowRightSmallIcon />*/}
{/*</SettingRow>*/}
</>
);
};
@@ -0,0 +1,30 @@
import { cssVar } from '@toeverything/theme';
import { globalStyle, style } from '@vanilla-extract/css';
export const storageProgressContainer = style({
display: 'flex',
justifyContent: 'space-between',
alignItems: 'center',
});
export const storageProgressWrapper = style({
flexGrow: 1,
marginRight: '20px',
});
globalStyle(`${storageProgressWrapper} .storage-progress-desc`, {
fontSize: cssVar('fontXs'),
color: cssVar('textSecondaryColor'),
height: '20px',
display: 'flex',
justifyContent: 'space-between',
alignItems: 'center',
marginBottom: 2,
});
globalStyle(`${storageProgressWrapper} .storage-progress-bar-wrapper`, {
height: '8px',
borderRadius: '4px',
backgroundColor: cssVar('black10'),
overflow: 'hidden',
});
export const storageProgressBar = style({
height: '100%',
});
@@ -0,0 +1,114 @@
import { Button, ErrorMessage, Skeleton, Tooltip } from '@affine/component';
import { useI18n } from '@affine/i18n';
import { useLiveData, useService } from '@toeverything/infra';
import { cssVar } from '@toeverything/theme';
import { useEffect, useMemo } from 'react';
import {
ServerConfigService,
SubscriptionService,
UserQuotaService,
} from '../../../../modules/cloud';
import * as styles from './storage-progress.css';
export interface StorageProgressProgress {
upgradable?: boolean;
onUpgrade: () => void;
}
enum ButtonType {
Primary = 'primary',
Default = 'secondary',
}
export const StorageProgress = ({ onUpgrade }: StorageProgressProgress) => {
const t = useI18n();
const quota = useService(UserQuotaService).quota;
useEffect(() => {
// revalidate quota to get the latest status
quota.revalidate();
}, [quota]);
const color = useLiveData(quota.color$);
const usedFormatted = useLiveData(quota.usedFormatted$);
const maxFormatted = useLiveData(quota.maxFormatted$);
const percent = useLiveData(quota.percent$);
const serverConfigService = useService(ServerConfigService);
const hasPaymentFeature = useLiveData(
serverConfigService.serverConfig.features$.map(f => f?.payment)
);
const subscription = useService(SubscriptionService).subscription;
useEffect(() => {
// revalidate subscription to get the latest status
subscription.revalidate();
}, [subscription]);
const proSubscription = useLiveData(subscription.pro$);
const isFreeUser = !proSubscription;
const quotaName = useLiveData(
quota.quota$.map(q => (q !== null ? q?.humanReadable.name : null))
);
const loading =
proSubscription === null || percent === null || quotaName === null;
const loadError = useLiveData(quota.error$);
const buttonType = useMemo(() => {
if (isFreeUser) {
return ButtonType.Primary;
}
return ButtonType.Default;
}, [isFreeUser]);
if (loading) {
if (loadError) {
// TODO(@catsjuice): i18n
return <ErrorMessage>Load error</ErrorMessage>;
}
return <Skeleton height={42} />;
}
return (
<div className={styles.storageProgressContainer}>
<div className={styles.storageProgressWrapper}>
<div className="storage-progress-desc">
<span>{t['com.affine.storage.used.hint']()}</span>
<span>
{usedFormatted}/{maxFormatted}
{` (${quotaName} ${t['com.affine.storage.plan']()})`}
</span>
</div>
<div className="storage-progress-bar-wrapper">
<div
className={styles.storageProgressBar}
style={{
width: `${percent}%`,
backgroundColor: color ?? cssVar('processingColor'),
}}
></div>
</div>
</div>
{hasPaymentFeature ? (
<Tooltip
options={{ hidden: percent < 100 }}
content={
isFreeUser
? t['com.affine.storage.maximum-tips']()
: t['com.affine.storage.maximum-tips.pro']()
}
>
<span tabIndex={0}>
<Button variant={buttonType} onClick={onUpgrade}>
{isFreeUser
? t['com.affine.storage.upgrade']()
: t['com.affine.storage.change-plan']()}
</Button>
</span>
</Tooltip>
) : null}
</div>
);
};
@@ -0,0 +1,41 @@
import { cssVar } from '@toeverything/theme';
import { globalStyle, style } from '@vanilla-extract/css';
export const profileInputWrapper = style({
marginLeft: '20px',
});
globalStyle(`${profileInputWrapper} label`, {
display: 'block',
fontSize: cssVar('fontXs'),
color: cssVar('textSecondaryColor'),
marginBottom: '4px',
});
export const avatarWrapper = style({
width: '56px',
height: '56px',
borderRadius: '50%',
position: 'relative',
cursor: 'pointer',
flexShrink: '0',
selectors: {
'&.disable': {
cursor: 'default',
pointerEvents: 'none',
},
},
});
globalStyle(`${avatarWrapper}:hover .camera-icon-wrapper`, {
display: 'flex',
});
globalStyle(`${avatarWrapper} .camera-icon-wrapper`, {
width: '56px',
height: '56px',
borderRadius: '50%',
position: 'absolute',
display: 'none',
justifyContent: 'center',
alignItems: 'center',
backgroundColor: 'rgba(60, 61, 63, 0.5)',
zIndex: '1',
color: cssVar('white'),
fontSize: cssVar('fontH4'),
});
@@ -0,0 +1,41 @@
import {
DiscordIcon,
GithubIcon,
RedditIcon,
TelegramIcon,
TwitterIcon,
YouTubeIcon,
} from './icons';
export const relatedLinks = [
{
icon: <GithubIcon />,
title: 'GitHub',
link: 'https://github.com/toeverything/AFFiNE',
},
{
icon: <TwitterIcon />,
title: 'X',
link: 'https://twitter.com/AffineOfficial',
},
{
icon: <DiscordIcon />,
title: 'Discord',
link: 'https://discord.gg/whd5mjYqVw',
},
{
icon: <YouTubeIcon />,
title: 'YouTube',
link: 'https://www.youtube.com/@affinepro',
},
{
icon: <TelegramIcon />,
title: 'Telegram',
link: 'https://t.me/affineworkos',
},
{
icon: <RedditIcon />,
title: 'Reddit',
link: 'https://www.reddit.com/r/Affine/',
},
];
@@ -0,0 +1,175 @@
// The icons here have been specially adjusted, theyre different from the ones in the @blocksuite/icons/rc.
export { TwitterIcon } from '@blocksuite/icons/rc';
export const LogoIcon = () => {
return (
<svg
width="50"
height="50"
viewBox="0 0 50 50"
fill="currentColor"
xmlns="http://www.w3.org/2000/svg"
>
<path
fillRule="evenodd"
clipRule="evenodd"
d="M21.1996 0L4 50H14.0741L25.0146 15.4186L35.96 50H46L28.7978 0H21.1996Z"
/>
</svg>
);
};
export const DocIcon = () => {
return (
<svg
width="50"
height="50"
viewBox="0 0 50 50"
fill="currentColor"
xmlns="http://www.w3.org/2000/svg"
>
<path
fillRule="evenodd"
clipRule="evenodd"
d="M2 40.5353V9.46462C2 6.95444 2.99716 4.54708 4.77212 2.77212C6.54708 0.997163 8.95444 0 11.4646 0H37.7552C39.0224 0 40.0497 1.02726 40.0497 2.29445V33.3652C40.0497 33.4357 40.0465 33.5055 40.0403 33.5744C39.9882 34.1502 39.7234 34.6646 39.3251 35.0385C38.9147 35.4237 38.3625 35.6597 37.7552 35.6597H11.4646C11.0129 35.6597 10.5676 35.7224 10.1404 35.8429C8.60419 36.2781 7.37011 37.4505 6.85245 38.9541C6.67955 39.4584 6.58891 39.9922 6.58891 40.5354C6.58891 41.8285 7.1026 43.0687 8.01697 43.983C8.93134 44.8974 10.1715 45.4111 11.4646 45.4111H42.6309V4.68456C42.6309 3.41736 43.6582 2.3901 44.9254 2.3901C46.1926 2.3901 47.2198 3.41736 47.2198 4.68456V47.7055C47.2198 48.9727 46.1926 50 44.9254 50H11.4646C8.95445 50 6.54708 49.0028 4.77212 47.2279C2.99716 45.4529 2 43.0456 2 40.5353ZM12.6596 38.2409C11.3925 38.2409 10.3652 39.2682 10.3652 40.5354C10.3652 41.8026 11.3925 42.8298 12.6596 42.8298H36.5602C37.8274 42.8298 38.8546 41.8026 38.8546 40.5354C38.8546 39.2682 37.8274 38.2409 36.5602 38.2409H12.6596Z"
/>
</svg>
);
};
export const GithubIcon = () => {
return (
<svg
width="25"
height="24"
viewBox="0 0 25 24"
fill="currentColor"
xmlns="http://www.w3.org/2000/svg"
>
<g clipPath="url(#clip0_3073_4801)">
<path
fillRule="evenodd"
clipRule="evenodd"
d="M12.667 2C7.14199 2 2.66699 6.58819 2.66699 12.2529C2.66699 16.7899 5.52949 20.6219 9.50449 21.9804C10.0045 22.0701 10.192 21.7625 10.192 21.4934C10.192 21.2499 10.1795 20.4425 10.1795 19.5838C7.66699 20.058 7.01699 18.9558 6.81699 18.3791C6.70449 18.0843 6.21699 17.1743 5.79199 16.9308C5.44199 16.7386 4.94199 16.2644 5.77949 16.2516C6.56699 16.2388 7.12949 16.9949 7.31699 17.3025C8.21699 18.8533 9.65449 18.4175 10.2295 18.1484C10.317 17.4819 10.5795 17.0334 10.867 16.777C8.64199 16.5207 6.31699 15.6364 6.31699 11.7147C6.31699 10.5997 6.70449 9.67689 7.34199 8.95918C7.24199 8.70286 6.89199 7.65193 7.44199 6.24215C7.44199 6.24215 8.27949 5.97301 10.192 7.29308C10.992 7.06239 11.842 6.94704 12.692 6.94704C13.542 6.94704 14.392 7.06239 15.192 7.29308C17.1045 5.9602 17.942 6.24215 17.942 6.24215C18.492 7.65193 18.142 8.70286 18.042 8.95918C18.6795 9.67689 19.067 10.5868 19.067 11.7147C19.067 15.6492 16.7295 16.5207 14.5045 16.777C14.867 17.0975 15.1795 17.7126 15.1795 18.6738C15.1795 20.0452 15.167 21.1474 15.167 21.4934C15.167 21.7625 15.3545 22.0829 15.8545 21.9804C17.8396 21.2932 19.5646 19.9851 20.7867 18.2401C22.0088 16.4951 22.6664 14.4012 22.667 12.2529C22.667 6.58819 18.192 2 12.667 2Z"
/>
</g>
<defs>
<clipPath id="clip0_3073_4801">
<rect width="25" height="24" fill="white" />
</clipPath>
</defs>
</svg>
);
};
export const DiscordIcon = () => {
return (
<svg
width="25"
height="24"
viewBox="0 0 25 24"
fill="none"
xmlns="http://www.w3.org/2000/svg"
>
<g clipPath="url(#clip0_3073_4801)">
<path
d="M19.2565 5.64663C17.9898 5.05614 16.6183 4.62755 15.1897 4.37993C15.1772 4.37953 15.1647 4.38188 15.1532 4.38681C15.1417 4.39175 15.1314 4.39915 15.1231 4.4085C14.9516 4.72279 14.7516 5.13233 14.6183 5.44662C13.103 5.21804 11.562 5.21804 10.0467 5.44662C9.9134 5.1228 9.71339 4.72279 9.53243 4.4085C9.52291 4.38945 9.49434 4.37993 9.46576 4.37993C8.03715 4.62755 6.67521 5.05614 5.39899 5.64663C5.38946 5.64663 5.37994 5.65615 5.37041 5.66568C2.77987 9.54197 2.06556 13.3135 2.41795 17.0469C2.41795 17.066 2.42748 17.085 2.44652 17.0946C4.16086 18.3517 5.80852 19.1137 7.43714 19.6184C7.46571 19.628 7.49428 19.6184 7.50381 19.5994C7.88477 19.0756 8.22764 18.5232 8.52288 17.9422C8.54193 17.9041 8.52288 17.866 8.48479 17.8565C7.94191 17.647 7.42761 17.3993 6.92284 17.1136C6.88474 17.0946 6.88474 17.0374 6.91331 17.0088C7.01808 16.9327 7.12284 16.8469 7.22761 16.7707C7.24666 16.7517 7.27523 16.7517 7.29428 16.7612C10.5706 18.2565 14.104 18.2565 17.3422 16.7612C17.3612 16.7517 17.3898 16.7517 17.4088 16.7707C17.5136 16.8565 17.6184 16.9327 17.7231 17.0184C17.7612 17.0469 17.7612 17.1041 17.7136 17.1231C17.2184 17.4184 16.6945 17.6565 16.1517 17.866C16.1136 17.8755 16.104 17.9232 16.1136 17.9517C16.4183 18.5327 16.7612 19.0851 17.1326 19.6089C17.1612 19.6184 17.1898 19.628 17.2184 19.6184C18.8565 19.1137 20.5042 18.3517 22.2185 17.0946C22.2375 17.085 22.2471 17.066 22.2471 17.0469C22.6661 12.7325 21.5518 8.98958 19.2946 5.66568C19.2851 5.65615 19.2756 5.64663 19.2565 5.64663ZM9.01813 14.7707C8.03715 14.7707 7.21808 13.8659 7.21808 12.7516C7.21808 11.6373 8.01811 10.7325 9.01813 10.7325C10.0277 10.7325 10.8277 11.6468 10.8182 12.7516C10.8182 13.8659 10.0182 14.7707 9.01813 14.7707ZM15.6564 14.7707C14.6754 14.7707 13.8564 13.8659 13.8564 12.7516C13.8564 11.6373 14.6564 10.7325 15.6564 10.7325C16.666 10.7325 17.466 11.6468 17.4565 12.7516C17.4565 13.8659 16.666 14.7707 15.6564 14.7707Z"
fill="#5865F2"
/>
</g>
<defs>
<clipPath id="clip0_3073_4801">
<rect width="25" height="24" fill="white" />
</clipPath>
</defs>
</svg>
);
};
export const TelegramIcon = () => {
return (
<svg
width="24"
height="24"
viewBox="0 0 24 24"
fill="none"
xmlns="http://www.w3.org/2000/svg"
>
<path
d="M12 2C9.34844 2 6.80312 3.05422 4.92969 4.92891C3.05432 6.80434 2.00052 9.34778 2 12C2 14.6511 3.05469 17.1964 4.92969 19.0711C6.80312 20.9458 9.34844 22 12 22C14.6516 22 17.1969 20.9458 19.0703 19.0711C20.9453 17.1964 22 14.6511 22 12C22 9.34891 20.9453 6.80359 19.0703 4.92891C17.1969 3.05422 14.6516 2 12 2Z"
fill="url(#paint0_linear_8233_169329)"
/>
<path
d="M6.5267 11.8943C9.44232 10.6243 11.3861 9.78694 12.3579 9.38241C15.1361 8.22726 15.7126 8.02663 16.0892 8.01983C16.172 8.01851 16.3564 8.03898 16.4767 8.13624C16.5767 8.21827 16.6048 8.32921 16.6189 8.4071C16.6314 8.48491 16.6486 8.66226 16.6345 8.80069C16.4845 10.3819 15.8329 14.2191 15.5017 15.9902C15.3626 16.7396 15.0861 16.9908 14.8189 17.0154C14.2376 17.0688 13.797 16.6316 13.2345 16.263C12.3548 15.686 11.8579 15.3269 11.0033 14.764C10.0158 14.1134 10.6564 13.7557 11.2189 13.1713C11.3658 13.0184 13.9251 10.691 13.9736 10.4799C13.9798 10.4535 13.9861 10.3551 13.9267 10.3032C13.8689 10.2512 13.7829 10.269 13.7204 10.283C13.6314 10.303 12.2267 11.2324 9.5017 13.071C9.10326 13.3451 8.74232 13.4787 8.41732 13.4716C8.06107 13.464 7.37357 13.2698 6.86264 13.1038C6.23764 12.9002 5.7392 12.7926 5.78295 12.4468C5.80482 12.2668 6.05326 12.0826 6.5267 11.8943Z"
fill="white"
/>
<defs>
<linearGradient
id="paint0_linear_8233_169329"
x1="1002"
y1="2"
x2="1002"
y2="2002"
gradientUnits="userSpaceOnUse"
>
<stop stopColor="#2AABEE" />
<stop offset="1" stopColor="#229ED9" />
</linearGradient>
</defs>
</svg>
);
};
export const RedditIcon = () => {
return (
<svg
width="25"
height="24"
viewBox="0 0 25 24"
fill="none"
xmlns="http://www.w3.org/2000/svg"
>
<path
d="M12.334 22C17.8568 22 22.334 17.5228 22.334 12C22.334 6.47715 17.8568 2 12.334 2C6.81114 2 2.33398 6.47715 2.33398 12C2.33398 17.5228 6.81114 22 12.334 22Z"
fill="#FF4500"
/>
<path
d="M18.9863 12.0954C18.9863 11.2848 18.3308 10.641 17.5319 10.641C17.1545 10.6404 16.7915 10.7857 16.5186 11.0463C15.5172 10.331 14.1461 9.86611 12.6202 9.8065L13.2877 6.68299L15.4574 7.14783C15.4814 7.69627 15.9343 8.13744 16.4948 8.13744C17.067 8.13744 17.5319 7.6726 17.5319 7.1001C17.5319 6.52791 17.067 6.06299 16.4948 6.06299C16.0895 6.06299 15.7316 6.30143 15.5648 6.64721L13.1448 6.13455C13.0732 6.12252 13.0016 6.13455 12.9539 6.17033C12.8943 6.20611 12.8586 6.26564 12.8468 6.33721L12.1074 9.8183C10.5577 9.86611 9.16273 10.331 8.14945 11.0583C7.87653 10.7976 7.51349 10.6524 7.13609 10.653C6.32539 10.653 5.68164 11.3085 5.68164 12.1074C5.68164 12.7035 6.03922 13.2041 6.54008 13.4308C6.51576 13.5766 6.50379 13.7241 6.5043 13.8719C6.5043 16.113 9.11524 17.9372 12.3341 17.9372C15.553 17.9372 18.1639 16.125 18.1639 13.8719C18.1638 13.7241 18.1519 13.5766 18.1281 13.4308C18.6288 13.2041 18.9863 12.6914 18.9863 12.0954ZM8.99586 13.1325C8.99586 12.5603 9.4607 12.0954 10.0332 12.0954C10.6054 12.0954 11.0703 12.5603 11.0703 13.1325C11.0703 13.7048 10.6055 14.1699 10.0332 14.1699C9.46078 14.1816 8.99586 13.7048 8.99586 13.1325ZM14.8019 15.8865C14.0866 16.6019 12.7274 16.6496 12.3341 16.6496C11.9288 16.6496 10.5697 16.5899 9.86609 15.8865C9.75898 15.7792 9.75898 15.6123 9.86609 15.505C9.97344 15.3979 10.1403 15.3979 10.2477 15.505C10.7008 15.9581 11.6545 16.113 12.3341 16.113C13.0137 16.113 13.9792 15.9581 14.4203 15.505C14.5277 15.3979 14.6945 15.3979 14.8019 15.505C14.8972 15.6123 14.8972 15.7792 14.8019 15.8865ZM14.611 14.1817C14.0387 14.1817 13.5739 13.7168 13.5739 13.1446C13.5739 12.5723 14.0387 12.1074 14.611 12.1074C15.1834 12.1074 15.6483 12.5723 15.6483 13.1446C15.6483 13.7047 15.1834 14.1817 14.611 14.1817Z"
fill="white"
/>
</svg>
);
};
export const LinkIcon = () => {
return (
<svg
width="16"
height="16"
viewBox="0 0 16 16"
fill="currentColor"
xmlns="http://www.w3.org/2000/svg"
>
<path
fillRule="evenodd"
clipRule="evenodd"
d="M10.2917 1.33334C10.2917 0.988166 10.5715 0.708344 10.9167 0.708344H14.6667C15.0118 0.708344 15.2917 0.988166 15.2917 1.33334V5.08334C15.2917 5.42852 15.0118 5.70834 14.6667 5.70834C14.3215 5.70834 14.0417 5.42852 14.0417 5.08334V2.84223L8.44194 8.44195C8.19787 8.68603 7.80214 8.68603 7.55806 8.44195C7.31398 8.19787 7.31398 7.80215 7.55806 7.55807L13.1578 1.95834H10.9167C10.5715 1.95834 10.2917 1.67852 10.2917 1.33334ZM3.97464 1.54168L7.58334 1.54168C7.92851 1.54168 8.20834 1.8215 8.20834 2.16668C8.20834 2.51185 7.92851 2.79168 7.58334 2.79168H4C3.52298 2.79168 3.2028 2.79216 2.95623 2.81231C2.71697 2.83186 2.60256 2.86676 2.5271 2.90521C2.33109 3.00508 2.17174 3.16443 2.07187 3.36044C2.03342 3.4359 1.99852 3.55031 1.97897 3.78957C1.95882 4.03614 1.95834 4.35632 1.95834 4.83334V12C1.95834 12.477 1.95882 12.7972 1.97897 13.0438C1.99852 13.283 2.03342 13.3974 2.07187 13.4729C2.17174 13.6689 2.33109 13.8283 2.5271 13.9281C2.60256 13.9666 2.71697 14.0015 2.95623 14.021C3.2028 14.0412 3.52298 14.0417 4 14.0417H11.1667C11.6437 14.0417 11.9639 14.0412 12.2104 14.021C12.4497 14.0015 12.5641 13.9666 12.6396 13.9281C12.8356 13.8283 12.9949 13.6689 13.0948 13.4729C13.1333 13.3974 13.1682 13.283 13.1877 13.0438C13.2079 12.7972 13.2083 12.477 13.2083 12V8.41668C13.2083 8.0715 13.4882 7.79168 13.8333 7.79168C14.1785 7.79168 14.4583 8.0715 14.4583 8.41668V12.0254C14.4583 12.4705 14.4584 12.842 14.4336 13.1456C14.4077 13.4621 14.3518 13.7594 14.2086 14.0404C13.9888 14.4716 13.6383 14.8222 13.2071 15.0419C12.926 15.1851 12.6288 15.241 12.3122 15.2669C12.0087 15.2917 11.6372 15.2917 11.192 15.2917H3.97463C3.5295 15.2917 3.15797 15.2917 2.85444 15.2669C2.53787 15.241 2.24066 15.1851 1.95961 15.0419C1.5284 14.8222 1.17782 14.4716 0.958113 14.0404C0.81491 13.7594 0.758984 13.4621 0.733119 13.1456C0.70832 12.842 0.708327 12.4705 0.708336 12.0254V4.80798C0.708327 4.36285 0.70832 3.99131 0.733119 3.68779C0.758984 3.37121 0.81491 3.074 0.958113 2.79295C1.17782 2.36174 1.5284 2.01116 1.95961 1.79145C2.24066 1.64825 2.53787 1.59232 2.85444 1.56646C3.15797 1.54166 3.52951 1.54167 3.97464 1.54168Z"
/>
</svg>
);
};
export const YouTubeIcon = () => {
return (
<svg
width="25"
height="24"
viewBox="0 0 25 24"
fill="none"
xmlns="http://www.w3.org/2000/svg"
>
<path
d="M21.7477 7.19232C21.6387 6.76858 21.4261 6.38227 21.1311 6.07186C20.8361 5.76145 20.4689 5.53776 20.0662 5.42308C18.5917 5 12.6575 5 12.6575 5C12.6575 5 6.72304 5.01281 5.24858 5.43589C4.84583 5.55057 4.47865 5.77427 4.18363 6.0847C3.88861 6.39512 3.67602 6.78145 3.56705 7.2052C3.12106 9.96155 2.94806 14.1616 3.5793 16.8077C3.68828 17.2314 3.90087 17.6177 4.19589 17.9281C4.49092 18.2386 4.85808 18.4622 5.26083 18.5769C6.73528 19 12.6696 19 12.6696 19C12.6696 19 18.6039 19 20.0783 18.5769C20.481 18.4623 20.8482 18.2386 21.1432 17.9282C21.4383 17.6177 21.6509 17.2314 21.7599 16.8077C22.2303 14.0474 22.3752 9.85004 21.7477 7.1924V7.19232Z"
fill="#FF0000"
/>
<path d="M10.667 15L15.667 12L10.667 9V15Z" fill="white" />
</svg>
);
};
@@ -0,0 +1,209 @@
import { Switch } from '@affine/component';
import {
SettingHeader,
SettingRow,
SettingWrapper,
} from '@affine/component/setting-components';
import { useAppUpdater } from '@affine/core/components/hooks/use-app-updater';
import { UrlService } from '@affine/core/modules/url';
import { appIconMap, appNames } from '@affine/core/utils/channel';
import { useI18n } from '@affine/i18n';
import { mixpanel } from '@affine/track';
import { ArrowRightSmallIcon, OpenInNewIcon } from '@blocksuite/icons/rc';
import {
FeatureFlagService,
useLiveData,
useServices,
} from '@toeverything/infra';
import { useCallback } from 'react';
import { useAppSettingHelper } from '../../../../../components/hooks/affine/use-app-setting-helper';
import { relatedLinks } from './config';
import * as styles from './style.css';
import { UpdateCheckSection } from './update-check-section';
export const AboutAffine = () => {
const t = useI18n();
const { appSettings, updateSettings } = useAppSettingHelper();
const { toggleAutoCheck, toggleAutoDownload } = useAppUpdater();
const channel = BUILD_CONFIG.appBuildType;
const appIcon = appIconMap[channel];
const appName = appNames[channel];
const { urlService, featureFlagService } = useServices({
UrlService,
FeatureFlagService,
});
const enableSnapshotImportExport = useLiveData(
featureFlagService.flags.enable_snapshot_import_export.$
);
const onSwitchAutoCheck = useCallback(
(checked: boolean) => {
toggleAutoCheck(checked);
updateSettings('autoCheckUpdate', checked);
},
[toggleAutoCheck, updateSettings]
);
const onSwitchAutoDownload = useCallback(
(checked: boolean) => {
toggleAutoDownload(checked);
updateSettings('autoDownloadUpdate', checked);
},
[toggleAutoDownload, updateSettings]
);
const onSwitchTelemetry = useCallback(
(checked: boolean) => {
if (!checked) {
mixpanel.opt_out_tracking();
} else {
mixpanel.opt_in_tracking();
}
updateSettings('enableTelemetry', checked);
},
[updateSettings]
);
const onSwitchSnapshotImportExport = useCallback(
(checked: boolean) => {
featureFlagService.flags.enable_snapshot_import_export.set(checked);
},
[featureFlagService]
);
return (
<>
<SettingHeader
title={t['com.affine.aboutAFFiNE.title']()}
subtitle={t['com.affine.aboutAFFiNE.subtitle']()}
data-testid="about-title"
/>
<SettingWrapper title={t['com.affine.aboutAFFiNE.version.title']()}>
<SettingRow
name={appName}
desc={BUILD_CONFIG.appVersion}
className={styles.appImageRow}
>
<img src={appIcon} alt={appName} width={56} height={56} />
</SettingRow>
<SettingRow
name={t['com.affine.aboutAFFiNE.version.editor.title']()}
desc={BUILD_CONFIG.editorVersion}
/>
{BUILD_CONFIG.isElectron ? (
<>
<UpdateCheckSection />
<SettingRow
name={t['com.affine.aboutAFFiNE.autoCheckUpdate.title']()}
desc={t['com.affine.aboutAFFiNE.autoCheckUpdate.description']()}
>
<Switch
checked={appSettings.autoCheckUpdate}
onChange={onSwitchAutoCheck}
/>
</SettingRow>
<SettingRow
name={t['com.affine.aboutAFFiNE.autoDownloadUpdate.title']()}
desc={t[
'com.affine.aboutAFFiNE.autoDownloadUpdate.description'
]()}
>
<Switch
checked={appSettings.autoDownloadUpdate}
onChange={onSwitchAutoDownload}
/>
</SettingRow>
<SettingRow
name={t['com.affine.aboutAFFiNE.changelog.title']()}
desc={t['com.affine.aboutAFFiNE.changelog.description']()}
style={{ cursor: 'pointer' }}
onClick={() => {
urlService.openPopupWindow(BUILD_CONFIG.changelogUrl);
}}
>
<ArrowRightSmallIcon />
</SettingRow>
</>
) : null}
<SettingRow
name={t['com.affine.telemetry.enable']()}
desc={t['com.affine.telemetry.enable.desc']()}
>
<Switch
checked={appSettings.enableTelemetry !== false}
onChange={onSwitchTelemetry}
/>
</SettingRow>
</SettingWrapper>
<SettingWrapper title={t['com.affine.aboutAFFiNE.contact.title']()}>
<a
className={styles.link}
rel="noreferrer"
href="https://affine.pro"
target="_blank"
>
{t['com.affine.aboutAFFiNE.contact.website']()}
<OpenInNewIcon className="icon" />
</a>
<a
className={styles.link}
rel="noreferrer"
href="https://community.affine.pro"
target="_blank"
>
{t['com.affine.aboutAFFiNE.contact.community']()}
<OpenInNewIcon className="icon" />
</a>
<SettingRow
name={t['com.affine.snapshot.import-export.enable']()}
desc={t['com.affine.snapshot.import-export.enable.desc']()}
className={styles.snapshotImportExportRow}
>
<Switch
checked={enableSnapshotImportExport}
onChange={onSwitchSnapshotImportExport}
/>
</SettingRow>
</SettingWrapper>
<SettingWrapper title={t['com.affine.aboutAFFiNE.community.title']()}>
<div className={styles.communityWrapper}>
{relatedLinks.map(({ icon, title, link }) => {
return (
<div
className={styles.communityItem}
onClick={() => {
urlService.openPopupWindow(link);
}}
key={title}
>
{icon}
<p>{title}</p>
</div>
);
})}
</div>
</SettingWrapper>
<SettingWrapper title={t['com.affine.aboutAFFiNE.legal.title']()}>
<a
className={styles.link}
rel="noreferrer"
href="https://affine.pro/privacy"
target="_blank"
>
{t['com.affine.aboutAFFiNE.legal.privacy']()}
<OpenInNewIcon className="icon" />
</a>
<a
className={styles.link}
rel="noreferrer"
href="https://affine.pro/terms"
target="_blank"
>
{t['com.affine.aboutAFFiNE.legal.tos']()}
<OpenInNewIcon className="icon" />
</a>
</SettingWrapper>
</>
);
};
@@ -0,0 +1,76 @@
import { cssVar } from '@toeverything/theme';
import { globalStyle, style } from '@vanilla-extract/css';
export const link = style({
height: '18px',
display: 'flex',
alignItems: 'center',
color: cssVar('textPrimaryColor'),
fontSize: cssVar('fontSm'),
fontWeight: 600,
marginBottom: '12px',
selectors: {
'&:last-of-type': {
marginBottom: '0',
},
},
});
globalStyle(`${link} .icon`, {
color: cssVar('iconColor'),
fontSize: cssVar('fontBase'),
marginLeft: '5px',
});
export const communityWrapper = style({
display: 'grid',
gridTemplateColumns: '15% 15% 15% 15% 15% 15%',
gap: '2%',
});
export const communityItem = style({
borderRadius: '8px',
border: `1px solid ${cssVar('borderColor')}`,
color: cssVar('textPrimaryColor'),
cursor: 'pointer',
padding: '6px 8px',
});
globalStyle(`${communityItem} svg`, {
width: '24px',
height: '24px',
display: 'block',
margin: '0 auto 2px',
});
globalStyle(`${communityItem} p`, {
fontSize: cssVar('fontXs'),
textAlign: 'center',
});
export const checkUpdateDesc = style({
color: cssVar('textSecondaryColor'),
fontSize: cssVar('fontXs'),
display: 'inline-flex',
alignItems: 'center',
justifyContent: 'flex-start',
selectors: {
'&.active': {
color: cssVar('textEmphasisColor'),
},
'&.error': {
color: cssVar('errorColor'),
},
},
});
globalStyle(`${checkUpdateDesc} svg`, {
marginRight: '4px',
});
export const appImageRow = style({
flexDirection: 'row-reverse',
selectors: {
'&.two-col': {
justifyContent: 'flex-end',
},
},
});
globalStyle(`${appImageRow} .right-col`, {
paddingLeft: '0',
paddingRight: '20px',
});
export const snapshotImportExportRow = style({
marginTop: '12px',
});
@@ -0,0 +1,160 @@
import { Loading } from '@affine/component';
import { SettingRow } from '@affine/component/setting-components';
import { Button } from '@affine/component/ui/button';
import { useAsyncCallback } from '@affine/core/components/hooks/affine-async-hooks';
import { useAppUpdater } from '@affine/core/components/hooks/use-app-updater';
import { useI18n } from '@affine/i18n';
import clsx from 'clsx';
import { useCallback, useMemo, useState } from 'react';
import * as styles from './style.css';
enum CheckUpdateStatus {
UNCHECK = 'uncheck',
LATEST = 'latest',
UPDATE_AVAILABLE = 'update-available',
ERROR = 'error',
}
const useUpdateStatusLabels = (checkUpdateStatus: CheckUpdateStatus) => {
const t = useI18n();
const { updateAvailable, downloadProgress, updateReady, checkingForUpdates } =
useAppUpdater();
const buttonLabel = useMemo(() => {
if (updateReady) {
return t['com.affine.aboutAFFiNE.checkUpdate.button.restart']();
}
if (updateAvailable && downloadProgress === null) {
return t['com.affine.aboutAFFiNE.checkUpdate.button.download']();
}
if (
checkUpdateStatus === CheckUpdateStatus.LATEST ||
checkUpdateStatus === CheckUpdateStatus.ERROR
) {
return t['com.affine.aboutAFFiNE.checkUpdate.button.retry']();
}
return t['com.affine.aboutAFFiNE.checkUpdate.button.check']();
}, [checkUpdateStatus, downloadProgress, t, updateAvailable, updateReady]);
const subtitleLabel = useMemo(() => {
if (updateReady) {
return t['com.affine.aboutAFFiNE.checkUpdate.subtitle.restart']();
} else if (updateAvailable && downloadProgress === null) {
return t['com.affine.aboutAFFiNE.checkUpdate.subtitle.update-available']({
version: updateAvailable.version,
});
} else if (checkingForUpdates) {
return t['com.affine.aboutAFFiNE.checkUpdate.subtitle.checking']();
} else if (updateAvailable && downloadProgress !== null) {
return t['com.affine.aboutAFFiNE.checkUpdate.subtitle.downloading']();
} else if (checkUpdateStatus === CheckUpdateStatus.ERROR) {
return t['com.affine.aboutAFFiNE.checkUpdate.subtitle.error']();
} else if (checkUpdateStatus === CheckUpdateStatus.LATEST) {
return t['com.affine.aboutAFFiNE.checkUpdate.subtitle.latest']();
}
return t['com.affine.aboutAFFiNE.checkUpdate.subtitle.check']();
}, [
checkUpdateStatus,
downloadProgress,
checkingForUpdates,
t,
updateAvailable,
updateReady,
]);
const subtitle = useMemo(() => {
return (
<span
className={clsx(styles.checkUpdateDesc, {
active:
updateReady ||
(updateAvailable && downloadProgress === null) ||
checkUpdateStatus === CheckUpdateStatus.LATEST,
error: checkUpdateStatus === CheckUpdateStatus.ERROR,
})}
>
{checkingForUpdates ? <Loading size={14} /> : null}
{subtitleLabel}
</span>
);
}, [
checkUpdateStatus,
downloadProgress,
checkingForUpdates,
subtitleLabel,
updateAvailable,
updateReady,
]);
return { subtitle, buttonLabel };
};
export const UpdateCheckSection = () => {
const t = useI18n();
const {
checkForUpdates,
downloadUpdate,
quitAndInstall,
updateAvailable,
downloadProgress,
updateReady,
} = useAppUpdater();
const [checkUpdateStatus, setCheckUpdateStatus] = useState<CheckUpdateStatus>(
CheckUpdateStatus.UNCHECK
);
const { buttonLabel, subtitle } = useUpdateStatusLabels(checkUpdateStatus);
const asyncCheckForUpdates = useAsyncCallback(async () => {
let statusCheck = CheckUpdateStatus.UNCHECK;
try {
const status = await checkForUpdates();
if (status === null) {
statusCheck = CheckUpdateStatus.ERROR;
} else if (status === false) {
statusCheck = CheckUpdateStatus.LATEST;
} else if (typeof status === 'string') {
statusCheck = CheckUpdateStatus.UPDATE_AVAILABLE;
}
} catch (e) {
console.error(e);
statusCheck = CheckUpdateStatus.ERROR;
} finally {
setCheckUpdateStatus(statusCheck);
}
}, [checkForUpdates]);
const handleClick = useCallback(() => {
if (updateAvailable && downloadProgress === null) {
return downloadUpdate();
}
if (updateReady) {
return quitAndInstall();
}
asyncCheckForUpdates();
}, [
asyncCheckForUpdates,
downloadProgress,
downloadUpdate,
quitAndInstall,
updateAvailable,
updateReady,
]);
return (
<SettingRow
name={t['com.affine.aboutAFFiNE.checkUpdate.title']()}
desc={subtitle}
>
<Button
data-testid="check-update-button"
onClick={handleClick}
disabled={downloadProgress !== null && !updateReady}
>
{buttonLabel}
</Button>
</SettingRow>
);
};
@@ -0,0 +1,158 @@
import type { RadioItem } from '@affine/component';
import { RadioGroup, Switch } from '@affine/component';
import {
SettingHeader,
SettingRow,
SettingWrapper,
} from '@affine/component/setting-components';
import { LanguageMenu } from '@affine/core/components/affine/language-menu';
import { useI18n } from '@affine/i18n';
import {
FeatureFlagService,
useLiveData,
useService,
} from '@toeverything/infra';
import { useTheme } from 'next-themes';
import { useCallback, useMemo } from 'react';
import { useAppSettingHelper } from '../../../../../components/hooks/affine/use-app-setting-helper';
import { OpenInAppLinksMenu } from './links';
import { settingWrapper } from './style.css';
import { ThemeEditorSetting } from './theme-editor-setting';
export const getThemeOptions = (t: ReturnType<typeof useI18n>) =>
[
{
value: 'system',
label: t['com.affine.themeSettings.system'](),
testId: 'system-theme-trigger',
},
{
value: 'light',
label: t['com.affine.themeSettings.light'](),
testId: 'light-theme-trigger',
},
{
value: 'dark',
label: t['com.affine.themeSettings.dark'](),
testId: 'dark-theme-trigger',
},
] satisfies RadioItem[];
export const ThemeSettings = () => {
const t = useI18n();
const { setTheme, theme } = useTheme();
const radioItems = useMemo<RadioItem[]>(() => getThemeOptions(t), [t]);
return (
<RadioGroup
items={radioItems}
value={theme}
width={250}
className={settingWrapper}
onChange={useCallback(
(value: string) => {
setTheme(value);
},
[setTheme]
)}
/>
);
};
export const AppearanceSettings = () => {
const t = useI18n();
const featureFlagService = useService(FeatureFlagService);
const enableThemeEditor = useLiveData(
featureFlagService.flags.enable_theme_editor.$
);
const { appSettings, updateSettings } = useAppSettingHelper();
return (
<>
<SettingHeader
title={t['com.affine.appearanceSettings.title']()}
subtitle={t['com.affine.appearanceSettings.subtitle']()}
/>
<SettingWrapper title={t['com.affine.appearanceSettings.theme.title']()}>
<SettingRow
name={t['com.affine.appearanceSettings.color.title']()}
desc={t['com.affine.appearanceSettings.color.description']()}
>
<ThemeSettings />
</SettingRow>
<SettingRow
name={t['com.affine.appearanceSettings.language.title']()}
desc={t['com.affine.appearanceSettings.language.description']()}
>
<div className={settingWrapper}>
<LanguageMenu />
</div>
</SettingRow>
{BUILD_CONFIG.isElectron ? (
<SettingRow
name={t['com.affine.appearanceSettings.clientBorder.title']()}
desc={t['com.affine.appearanceSettings.clientBorder.description']()}
data-testid="client-border-style-trigger"
>
<Switch
checked={appSettings.clientBorder}
onChange={checked => updateSettings('clientBorder', checked)}
/>
</SettingRow>
) : null}
{enableThemeEditor ? <ThemeEditorSetting /> : null}
</SettingWrapper>
{BUILD_CONFIG.isWeb && !environment.isMobile ? (
<SettingWrapper title={t['com.affine.setting.appearance.links']()}>
<SettingRow
name={t['com.affine.setting.appearance.open-in-app']()}
desc={t['com.affine.setting.appearance.open-in-app.hint']()}
data-testid="open-in-app-links-trigger"
>
<OpenInAppLinksMenu />
</SettingRow>
</SettingWrapper>
) : null}
{BUILD_CONFIG.isElectron ? (
<SettingWrapper
title={t['com.affine.appearanceSettings.sidebar.title']()}
>
<SettingRow
name={t['com.affine.appearanceSettings.noisyBackground.title']()}
desc={t[
'com.affine.appearanceSettings.noisyBackground.description'
]()}
>
<Switch
checked={appSettings.enableNoisyBackground}
onChange={checked =>
updateSettings('enableNoisyBackground', checked)
}
/>
</SettingRow>
{environment.isMacOs && (
<SettingRow
name={t['com.affine.appearanceSettings.translucentUI.title']()}
desc={t[
'com.affine.appearanceSettings.translucentUI.description'
]()}
>
<Switch
checked={appSettings.enableBlurBackground}
onChange={checked =>
updateSettings('enableBlurBackground', checked)
}
/>
</SettingRow>
)}
</SettingWrapper>
) : null}
</>
);
};
@@ -0,0 +1,18 @@
import { cssVar } from '@toeverything/theme';
import { style } from '@vanilla-extract/css';
export const menu = style({
background: cssVar('white'),
width: '250px',
maxHeight: '30vh',
overflowY: 'auto',
});
export const menuItem = style({
color: cssVar('textPrimaryColor'),
selectors: {
'&[data-selected=true]': {
color: cssVar('primaryColor'),
},
},
});
@@ -0,0 +1,52 @@
import { Menu, MenuItem, MenuTrigger } from '@affine/component';
import {
OpenInAppService,
OpenLinkMode,
} from '@affine/core/modules/open-in-app';
import { useI18n } from '@affine/i18n';
import { useLiveData, useService } from '@toeverything/infra';
import { useMemo } from 'react';
import * as styles from './links.css';
export const OpenInAppLinksMenu = () => {
const t = useI18n();
const openInAppService = useService(OpenInAppService);
const currentOpenInAppMode = useLiveData(openInAppService.openLinkMode$);
const options = useMemo(
() =>
Object.values(OpenLinkMode).map(mode => ({
label:
t.t(`com.affine.setting.appearance.open-in-app.${mode}`) ||
`com.affine.setting.appearance.open-in-app.${mode}`,
value: mode,
})),
[t]
);
return (
<Menu
items={options.map(option => {
return (
<MenuItem
key={option.value}
title={option.label}
onSelect={() => openInAppService.setOpenLinkMode(option.value)}
data-selected={currentOpenInAppMode === option.value}
>
{option.label}
</MenuItem>
);
})}
contentOptions={{
className: styles.menu,
align: 'end',
}}
>
<MenuTrigger style={{ fontWeight: 600, width: '250px' }} block={true}>
{options.find(option => option.value === currentOpenInAppMode)?.label}
</MenuTrigger>
</Menu>
);
};
@@ -0,0 +1,8 @@
import { style } from '@vanilla-extract/css';
export const settingWrapper = style({
flexGrow: 1,
display: 'flex',
justifyContent: 'flex-end',
minWidth: '150px',
maxWidth: '250px',
});
@@ -0,0 +1,55 @@
import { Button } from '@affine/component';
import { SettingRow } from '@affine/component/setting-components';
import { DesktopApiService } from '@affine/core/modules/desktop-api';
import { ThemeEditorService } from '@affine/core/modules/theme-editor';
import { UrlService } from '@affine/core/modules/url';
import { DeleteIcon } from '@blocksuite/icons/rc';
import {
useLiveData,
useService,
useServiceOptional,
} from '@toeverything/infra';
import { cssVar } from '@toeverything/theme';
import { useCallback } from 'react';
export const ThemeEditorSetting = () => {
const themeEditor = useService(ThemeEditorService);
const modified = useLiveData(themeEditor.modified$);
const urlService = useService(UrlService);
const desktopApi = useServiceOptional(DesktopApiService);
const open = useCallback(() => {
if (desktopApi) {
desktopApi?.handler.ui.openThemeEditor().catch(console.error);
} else if (BUILD_CONFIG.isMobileWeb || BUILD_CONFIG.isWeb) {
urlService.openPopupWindow(location.origin + '/theme-editor');
}
}, [desktopApi, urlService]);
return (
<SettingRow
name="Customize Theme"
desc="Edit all AFFiNE theme variables here"
>
<div style={{ display: 'flex', gap: 16 }}>
{modified ? (
<Button
style={{
color: cssVar('errorColor'),
borderColor: cssVar('errorColor'),
}}
prefixStyle={{
color: cssVar('errorColor'),
}}
onClick={() => themeEditor.reset()}
variant="secondary"
prefix={<DeleteIcon />}
>
Reset all
</Button>
) : null}
<Button onClick={open}>Open Theme Editor</Button>
</div>
</SettingRow>
);
};
@@ -0,0 +1,638 @@
import { Skeleton } from '@affine/component';
import { Pagination } from '@affine/component/member-components';
import {
SettingHeader,
SettingRow,
SettingWrapper,
} from '@affine/component/setting-components';
import { Button, IconButton } from '@affine/component/ui/button';
import { Loading } from '@affine/component/ui/loading';
import { getUpgradeQuestionnaireLink } from '@affine/core/components/hooks/affine/use-subscription-notify';
import { useAsyncCallback } from '@affine/core/components/hooks/affine-async-hooks';
import {
AuthService,
InvoicesService,
SubscriptionService,
} from '@affine/core/modules/cloud';
import { UrlService } from '@affine/core/modules/url';
import type { InvoicesQuery } from '@affine/graphql';
import {
createCustomerPortalMutation,
InvoiceStatus,
SubscriptionPlan,
SubscriptionRecurring,
SubscriptionStatus,
UserFriendlyError,
} from '@affine/graphql';
import { type I18nString, i18nTime, Trans, useI18n } from '@affine/i18n';
import { track } from '@affine/track';
import { ArrowRightSmallIcon } from '@blocksuite/icons/rc';
import { useLiveData, useService } from '@toeverything/infra';
import { cssVar } from '@toeverything/theme';
import { useCallback, useEffect, useState } from 'react';
import { useMutation } from '../../../../../components/hooks/use-mutation';
import type { SettingState } from '../../types';
import { CancelAction, ResumeAction } from '../plans/actions';
import { AICancel, AIResume, AISubscribe } from '../plans/ai/actions';
import { AIRedeemCodeButton } from '../plans/ai/actions/redeem';
import { BelieverCard } from '../plans/lifetime/believer-card';
import { BelieverBenefits } from '../plans/lifetime/benefits';
import { RedeemCode } from '../plans/plan-card';
import * as styles from './style.css';
const DescriptionI18NKey = {
Basic: 'com.affine.payment.billing-setting.current-plan.description',
Monthly:
'com.affine.payment.billing-setting.current-plan.description.monthly',
Yearly: 'com.affine.payment.billing-setting.current-plan.description.yearly',
Lifetime:
'com.affine.payment.billing-setting.current-plan.description.lifetime',
} as const satisfies { [key: string]: I18nString };
const getMessageKey = (
plan: SubscriptionPlan,
recurring: SubscriptionRecurring
) => {
if (plan !== SubscriptionPlan.Pro) {
return DescriptionI18NKey.Basic;
}
return DescriptionI18NKey[recurring];
};
export const BillingSettings = ({
onChangeSettingState,
}: {
onChangeSettingState: (state: SettingState) => void;
}) => {
const t = useI18n();
return (
<>
<SettingHeader
title={t['com.affine.payment.billing-setting.title']()}
subtitle={t['com.affine.payment.billing-setting.subtitle']()}
/>
<SettingWrapper
title={t['com.affine.payment.billing-setting.information']()}
>
<SubscriptionSettings onChangeSettingState={onChangeSettingState} />
</SettingWrapper>
<SettingWrapper title={t['com.affine.payment.billing-setting.history']()}>
<BillingHistory />
</SettingWrapper>
</>
);
};
const SubscriptionSettings = ({
onChangeSettingState,
}: {
onChangeSettingState: (state: SettingState) => void;
}) => {
const t = useI18n();
const subscriptionService = useService(SubscriptionService);
useEffect(() => {
subscriptionService.subscription.revalidate();
subscriptionService.prices.revalidate();
}, [subscriptionService]);
const proSubscription = useLiveData(subscriptionService.subscription.pro$);
const proPrice = useLiveData(subscriptionService.prices.proPrice$);
const isBeliever = useLiveData(subscriptionService.subscription.isBeliever$);
const isOnetime = useLiveData(subscriptionService.subscription.isOnetimeAI$);
const [openCancelModal, setOpenCancelModal] = useState(false);
const currentPlan = proSubscription?.plan ?? SubscriptionPlan.Free;
const currentRecurring =
proSubscription?.recurring ?? SubscriptionRecurring.Monthly;
const openPlans = useCallback(
(scrollAnchor?: string) => {
track.$.settingsPanel.billing.viewPlans();
onChangeSettingState({
activeTab: 'plans',
scrollAnchor: scrollAnchor,
});
},
[onChangeSettingState]
);
const gotoCloudPlansSetting = useCallback(
() => openPlans('cloudPricingPlan'),
[openPlans]
);
const gotoAiPlanSetting = useCallback(
() => openPlans('aiPricingPlan'),
[openPlans]
);
const amount = proSubscription
? proPrice
? proSubscription.recurring === SubscriptionRecurring.Monthly
? String((proPrice.amount ?? 0) / 100)
: String((proPrice.yearlyAmount ?? 0) / 100)
: '?'
: '0';
return (
<div className={styles.subscription}>
<AIPlanCard onClick={gotoAiPlanSetting} />
{/* loaded */}
{proSubscription !== null ? (
isBeliever ? (
<BelieverIdentifier onOpenPlans={gotoCloudPlansSetting} />
) : (
<div className={styles.planCard}>
<div className={styles.currentPlan}>
<SettingRow
spreadCol={false}
name={t['com.affine.payment.billing-setting.current-plan']()}
desc={
<>
<Trans
i18nKey={getMessageKey(currentPlan, currentRecurring)}
values={{
planName: currentPlan,
}}
components={{
1: (
<span
onClick={gotoCloudPlansSetting}
className={styles.currentPlanName}
/>
),
}}
/>
<CloudExpirationInfo />
</>
}
/>
<PlanAction
plan={currentPlan}
gotoPlansSetting={gotoCloudPlansSetting}
/>
</div>
<p className={styles.planPrice}>
${amount}
<span className={styles.billingFrequency}>
/
{currentRecurring === SubscriptionRecurring.Monthly
? t['com.affine.payment.billing-setting.month']()
: t['com.affine.payment.billing-setting.year']()}
</span>
</p>
</div>
)
) : (
<SubscriptionSettingSkeleton />
)}
<TypeFormLink />
{proSubscription !== null ? (
proSubscription?.status === SubscriptionStatus.Active && (
<>
<SettingRow
className={styles.paymentMethod}
name={t['com.affine.payment.billing-setting.payment-method']()}
desc={t[
'com.affine.payment.billing-setting.payment-method.description'
]()}
>
<PaymentMethodUpdater />
</SettingRow>
{isBeliever || isOnetime ? null : proSubscription.end &&
proSubscription.canceledAt ? (
<SettingRow
name={t['com.affine.payment.billing-setting.expiration-date']()}
desc={t[
'com.affine.payment.billing-setting.expiration-date.description'
]({
expirationDate: new Date(
proSubscription.end
).toLocaleDateString(),
})}
>
<ResumeSubscription />
</SettingRow>
) : (
<CancelAction
open={openCancelModal}
onOpenChange={setOpenCancelModal}
>
<SettingRow
style={{ cursor: 'pointer' }}
onClick={() => {
setOpenCancelModal(true);
}}
className="dangerous-setting"
name={t[
'com.affine.payment.billing-setting.cancel-subscription'
]()}
desc={t[
'com.affine.payment.billing-setting.cancel-subscription.description'
]()}
>
<CancelSubscription />
</SettingRow>
</CancelAction>
)}
</>
)
) : (
<SubscriptionSettingSkeleton />
)}
</div>
);
};
const CloudExpirationInfo = () => {
const t = useI18n();
const subscriptionService = useService(SubscriptionService);
const subscription = useLiveData(subscriptionService.subscription.pro$);
let text = '';
if (subscription?.nextBillAt) {
text = t['com.affine.payment.billing-setting.renew-date.description']({
renewDate: i18nTime(subscription.nextBillAt, {
absolute: { accuracy: 'day' },
}),
});
} else if (subscription?.end) {
text = t['com.affine.payment.billing-setting.due-date.description']({
dueDate: i18nTime(subscription.end, {
absolute: { accuracy: 'day' },
}),
});
}
return text ? (
<>
<br />
{text}
</>
) : null;
};
const TypeFormLink = () => {
const t = useI18n();
const subscriptionService = useService(SubscriptionService);
const authService = useService(AuthService);
const pro = useLiveData(subscriptionService.subscription.pro$);
const ai = useLiveData(subscriptionService.subscription.ai$);
const account = useLiveData(authService.session.account$);
if (!account) return null;
if (!pro && !ai) return null;
const plan = [];
if (pro) plan.push(SubscriptionPlan.Pro);
if (ai) plan.push(SubscriptionPlan.AI);
const link = getUpgradeQuestionnaireLink({
name: account.info?.name,
id: account.id,
email: account.email,
recurring: pro?.recurring ?? ai?.recurring ?? SubscriptionRecurring.Yearly,
plan,
});
return (
<SettingRow
className={styles.paymentMethod}
name={t['com.affine.payment.billing-type-form.title']()}
desc={t['com.affine.payment.billing-type-form.description']()}
>
<a target="_blank" href={link} rel="noreferrer">
<Button>{t['com.affine.payment.billing-type-form.go']()}</Button>
</a>
</SettingRow>
);
};
const BelieverIdentifier = ({ onOpenPlans }: { onOpenPlans?: () => void }) => {
const t = useI18n();
const subscriptionService = useService(SubscriptionService);
const readableLifetimePrice = useLiveData(
subscriptionService.prices.readableLifetimePrice$
);
if (!readableLifetimePrice) return null;
return (
<BelieverCard type={2} style={{ borderRadius: 8, padding: 12 }}>
<header className={styles.believerHeader}>
<div>
<div className={styles.believerTitle}>
{t['com.affine.payment.billing-setting.believer.title']()}
</div>
<div className={styles.believerSubtitle}>
<Trans
i18nKey={
'com.affine.payment.billing-setting.believer.description'
}
components={{
a: <a href="#" onClick={onOpenPlans} />,
}}
/>
</div>
</div>
<div className={styles.believerPriceWrapper}>
<div className={styles.believerPrice}>{readableLifetimePrice}</div>
<div className={styles.believerPriceCaption}>
{t['com.affine.payment.billing-setting.believer.price-caption']()}
</div>
</div>
</header>
<BelieverBenefits />
</BelieverCard>
);
};
const AIPlanCard = ({ onClick }: { onClick: () => void }) => {
const t = useI18n();
const subscriptionService = useService(SubscriptionService);
useEffect(() => {
subscriptionService.subscription.revalidate();
subscriptionService.prices.revalidate();
}, [subscriptionService]);
const price = useLiveData(subscriptionService.prices.aiPrice$);
const subscription = useLiveData(subscriptionService.subscription.ai$);
const isOnetime = useLiveData(subscriptionService.subscription.isOnetimeAI$);
const priceReadable = price?.yearlyAmount
? `$${(price.yearlyAmount / 100).toFixed(2)}`
: '?';
const priceFrequency = t['com.affine.payment.billing-setting.year']();
if (subscription === null) {
return <Skeleton height={100} />;
}
const billingTip =
subscription === undefined ? (
<Trans
i18nKey={'com.affine.payment.billing-setting.ai.free-desc'}
components={{
a: (
<a href="#" onClick={onClick} className={styles.currentPlanName} />
),
}}
/>
) : subscription?.nextBillAt ? (
t['com.affine.payment.ai.billing-tip.next-bill-at']({
due: i18nTime(subscription.nextBillAt, {
absolute: { accuracy: 'day' },
}),
})
) : (isOnetime || subscription?.canceledAt) && subscription.end ? (
t['com.affine.payment.ai.billing-tip.end-at']({
end: i18nTime(subscription.end, { absolute: { accuracy: 'day' } }),
})
) : null;
return (
<div className={styles.planCard} style={{ marginBottom: 24 }}>
<div className={styles.currentPlan}>
<SettingRow
spreadCol={false}
name={t['com.affine.payment.billing-setting.ai-plan']()}
desc={billingTip}
/>
{price?.yearlyAmount ? (
subscription ? (
isOnetime ? (
<AIRedeemCodeButton className={styles.planAction} />
) : subscription.canceledAt ? (
<AIResume className={styles.planAction} />
) : (
<AICancel className={styles.planAction} />
)
) : (
<AISubscribe className={styles.planAction}>
{t['com.affine.payment.billing-setting.ai.purchase']()}
</AISubscribe>
)
) : null}
</div>
<p className={styles.planPrice}>
{subscription ? priceReadable : '$0'}
<span className={styles.billingFrequency}>/{priceFrequency}</span>
</p>
</div>
);
};
const PlanAction = ({
plan,
gotoPlansSetting,
}: {
plan: string;
gotoPlansSetting: () => void;
}) => {
const t = useI18n();
const subscription = useService(SubscriptionService).subscription;
const isOnetimePro = useLiveData(subscription.isOnetimePro$);
if (isOnetimePro) {
return <RedeemCode variant="primary" className={styles.planAction} />;
}
return (
<Button
className={styles.planAction}
variant="primary"
onClick={gotoPlansSetting}
>
{plan === SubscriptionPlan.Pro
? t['com.affine.payment.billing-setting.change-plan']()
: t['com.affine.payment.billing-setting.upgrade']()}
</Button>
);
};
const PaymentMethodUpdater = () => {
const { isMutating, trigger } = useMutation({
mutation: createCustomerPortalMutation,
});
const urlService = useService(UrlService);
const t = useI18n();
const update = useAsyncCallback(async () => {
await trigger(null, {
onSuccess: data => {
urlService.openPopupWindow(data.createCustomerPortal);
},
});
}, [trigger, urlService]);
return (
<Button onClick={update} loading={isMutating} disabled={isMutating}>
{t['com.affine.payment.billing-setting.update']()}
</Button>
);
};
const ResumeSubscription = () => {
const t = useI18n();
const [open, setOpen] = useState(false);
const subscription = useService(SubscriptionService).subscription;
const handleClick = useCallback(() => {
setOpen(true);
}, []);
return (
<ResumeAction open={open} onOpenChange={setOpen}>
<Button
onClick={handleClick}
data-event-props="$.settingsPanel.plans.resumeSubscription"
data-event-args-type={subscription.pro$.value?.plan}
data-event-args-category={subscription.pro$.value?.recurring}
>
{t['com.affine.payment.billing-setting.resume-subscription']()}
</Button>
</ResumeAction>
);
};
const CancelSubscription = ({ loading }: { loading?: boolean }) => {
return (
<IconButton
style={{ pointerEvents: 'none' }}
disabled={loading}
loading={loading}
>
<ArrowRightSmallIcon />
</IconButton>
);
};
const BillingHistory = () => {
const t = useI18n();
const invoicesService = useService(InvoicesService);
const pageInvoices = useLiveData(invoicesService.invoices.pageInvoices$);
const invoiceCount = useLiveData(invoicesService.invoices.invoiceCount$);
const isLoading = useLiveData(invoicesService.invoices.isLoading$);
const error = useLiveData(invoicesService.invoices.error$);
const pageNum = useLiveData(invoicesService.invoices.pageNum$);
useEffect(() => {
invoicesService.invoices.revalidate();
}, [invoicesService]);
const handlePageChange = useCallback(
(_: number, pageNum: number) => {
invoicesService.invoices.setPageNum(pageNum);
invoicesService.invoices.revalidate();
},
[invoicesService]
);
if (invoiceCount === undefined) {
if (isLoading) {
return <BillingHistorySkeleton />;
} else {
return (
<span style={{ color: cssVar('errorColor') }}>
{error
? UserFriendlyError.fromAnyError(error).message
: 'Failed to load members'}
</span>
);
}
}
return (
<div className={styles.history}>
<div className={styles.historyContent}>
{invoiceCount === 0 ? (
<p className={styles.noInvoice}>
{t['com.affine.payment.billing-setting.no-invoice']()}
</p>
) : (
pageInvoices?.map(invoice => (
<InvoiceLine key={invoice.id} invoice={invoice} />
))
)}
</div>
{invoiceCount > invoicesService.invoices.PAGE_SIZE && (
<Pagination
totalCount={invoiceCount}
countPerPage={invoicesService.invoices.PAGE_SIZE}
pageNum={pageNum}
onPageChange={handlePageChange}
/>
)}
</div>
);
};
const InvoiceLine = ({
invoice,
}: {
invoice: NonNullable<InvoicesQuery['currentUser']>['invoices'][0];
}) => {
const t = useI18n();
const urlService = useService(UrlService);
const open = useCallback(() => {
if (invoice.link) {
urlService.openPopupWindow(invoice.link);
}
}, [invoice.link, urlService]);
const planText =
invoice.plan === SubscriptionPlan.AI
? 'AFFiNE AI'
: invoice.plan === SubscriptionPlan.Pro
? invoice.recurring === SubscriptionRecurring.Lifetime
? 'AFFiNE Cloud Believer'
: 'AFFiNE Cloud'
: null;
return (
<SettingRow
key={invoice.id}
name={new Date(invoice.createdAt).toLocaleDateString()}
desc={`${
invoice.status === InvoiceStatus.Paid
? t['com.affine.payment.billing-setting.paid']()
: ''
} $${invoice.amount / 100} - ${planText}`}
>
<Button onClick={open}>
{t['com.affine.payment.billing-setting.view-invoice']()}
</Button>
</SettingRow>
);
};
const SubscriptionSettingSkeleton = () => {
const t = useI18n();
return (
<SettingWrapper
title={t['com.affine.payment.billing-setting.information']()}
>
<div className={styles.subscriptionSettingSkeleton}>
<Skeleton variant="rounded" height="104px" />
<Skeleton variant="rounded" height="46px" />
</div>
</SettingWrapper>
);
};
const BillingHistorySkeleton = () => {
const t = useI18n();
return (
<SettingWrapper title={t['com.affine.payment.billing-setting.history']()}>
<div className={styles.billingHistorySkeleton}>
<Loading />
</div>
</SettingWrapper>
);
};
@@ -0,0 +1,101 @@
import { cssVar } from '@toeverything/theme';
import { globalStyle, style } from '@vanilla-extract/css';
export const subscription = style({});
export const history = style({
display: 'flex',
flexDirection: 'column',
alignItems: 'center',
gap: '24px',
});
export const historyContent = style({
width: '100%',
});
export const planCard = style({
display: 'flex',
justifyContent: 'space-between',
padding: '12px',
border: `1px solid ${cssVar('borderColor')}`,
borderRadius: '8px',
});
export const currentPlan = style({
flex: '1 0 0',
});
export const planAction = style({
width: '100%',
marginTop: '8px',
});
export const planPrice = style({
fontSize: cssVar('fontH6'),
fontWeight: 600,
});
export const billingFrequency = style({
fontSize: cssVar('fontBase'),
});
export const paymentMethod = style({
marginTop: '24px',
});
globalStyle('.dangerous-setting .name', {
color: cssVar('errorColor'),
});
export const noInvoice = style({
color: cssVar('textSecondaryColor'),
fontSize: cssVar('fontXs'),
});
export const currentPlanName = style({
fontSize: cssVar('fontXs'),
fontWeight: 500,
color: cssVar('textEmphasisColor'),
cursor: 'pointer',
});
export const subscriptionSettingSkeleton = style({
display: 'flex',
flexDirection: 'column',
gap: '24px',
});
export const billingHistorySkeleton = style({
display: 'flex',
flexDirection: 'column',
minHeight: '72px',
alignItems: 'center',
justifyContent: 'center',
});
// believer-identification
export const believerHeader = style({
display: 'flex',
justifyContent: 'space-between',
marginBottom: 8,
});
export const believerTitle = style({
fontSize: cssVar('fontSm'),
fontWeight: 600,
lineHeight: '22px',
color: cssVar('textPrimaryColor'),
});
export const believerSubtitle = style({
fontSize: cssVar('fontXs'),
lineHeight: '20px',
fontWeight: 400,
color: cssVar('textSecondaryColor'),
});
globalStyle(`.${believerSubtitle} > a`, {
color: cssVar('brandColor'),
fontWeight: 500,
});
export const believerPriceWrapper = style({
display: 'flex',
flexDirection: 'column',
alignItems: 'end',
});
export const believerPrice = style({
fontSize: '18px',
fontWeight: 600,
lineHeight: '26px',
color: cssVar('textPrimaryColor'),
});
export const believerPriceCaption = style({
fontSize: cssVar('fontXs'),
lineHeight: '20px',
fontWeight: 500,
color: cssVar('textSecondaryColor'),
});
@@ -0,0 +1,566 @@
import {
MenuItem,
MenuTrigger,
RadioGroup,
type RadioItem,
Slider,
} from '@affine/component';
import { SettingRow } from '@affine/component/setting-components';
import { EditorSettingService } from '@affine/core/modules/editor-setting';
import { useI18n } from '@affine/i18n';
import {
ConnectorMode,
FontFamily,
FontFamilyMap,
FontStyle,
FontWeightMap,
LineColor,
LineColorMap,
PointStyle,
StrokeStyle,
TextAlign,
} from '@blocksuite/affine/blocks';
import type { Doc } from '@blocksuite/affine/store';
import { useFramework, useLiveData } from '@toeverything/infra';
import { useCallback, useMemo } from 'react';
import { DropdownMenu } from '../menu';
import { menuTrigger, settingWrapper } from '../style.css';
import { sortedFontWeightEntries, useColor } from '../utils';
import { Point } from './point';
import { EdgelessSnapshot } from './snapshot';
import { getSurfaceBlock } from './utils';
enum ConnecterStyle {
General = 'general',
Scribbled = 'scribbled',
}
enum ConnectorTextFontSize {
'16px' = '16',
'20px' = '20',
'24px' = '24',
'32px' = '32',
'40px' = '40',
'64px' = '64',
}
export const ConnectorSettings = () => {
const t = useI18n();
const framework = useFramework();
const { editorSetting } = framework.get(EditorSettingService);
const settings = useLiveData(editorSetting.settings$);
const getColorFromMap = useColor();
const connecterStyleItems = useMemo<RadioItem[]>(
() => [
{
value: ConnecterStyle.General,
label: t['com.affine.settings.editorSettings.edgeless.style.general'](),
},
{
value: ConnecterStyle.Scribbled,
label:
t['com.affine.settings.editorSettings.edgeless.style.scribbled'](),
},
],
[t]
);
const connecterStyle: ConnecterStyle = settings.connector.rough
? ConnecterStyle.Scribbled
: ConnecterStyle.General;
const setConnecterStyle = useCallback(
(value: ConnecterStyle) => {
const isRough = value === ConnecterStyle.Scribbled;
editorSetting.set('connector', {
rough: isRough,
});
},
[editorSetting]
);
const connectorShapeItems = useMemo<RadioItem[]>(
() => [
{
value: ConnectorMode.Orthogonal as any,
label:
t[
'com.affine.settings.editorSettings.edgeless.connecter.connector-shape.elbowed'
](),
},
{
value: ConnectorMode.Curve as any,
label:
t[
'com.affine.settings.editorSettings.edgeless.connecter.connector-shape.curve'
](),
},
{
value: ConnectorMode.Straight as any,
label:
t[
'com.affine.settings.editorSettings.edgeless.connecter.connector-shape.straight'
](),
},
],
[t]
);
const connectorShape: ConnectorMode = settings.connector.mode;
const setConnectorShape = useCallback(
(value: ConnectorMode) => {
editorSetting.set('connector', {
mode: value,
});
},
[editorSetting]
);
const borderStyleItems = useMemo<RadioItem[]>(
() => [
{
value: StrokeStyle.Solid,
label:
t['com.affine.settings.editorSettings.edgeless.note.border.solid'](),
},
{
value: StrokeStyle.Dash,
label:
t['com.affine.settings.editorSettings.edgeless.note.border.dash'](),
},
],
[t]
);
const borderStyle: StrokeStyle = settings.connector.strokeStyle;
const setBorderStyle = useCallback(
(value: StrokeStyle) => {
editorSetting.set('connector', {
strokeStyle: value,
});
},
[editorSetting]
);
const borderThickness = settings.connector.strokeWidth;
const setBorderThickness = useCallback(
(value: number[]) => {
editorSetting.set('connector', {
strokeWidth: value[0],
});
},
[editorSetting]
);
const currentColor = useMemo(() => {
const color = settings.connector.stroke;
return getColorFromMap(color, LineColorMap);
}, [getColorFromMap, settings.connector.stroke]);
const colorItems = useMemo(() => {
const { stroke } = settings.connector;
return Object.entries(LineColor).map(([name, value]) => {
const handler = () => {
editorSetting.set('connector', { stroke: value });
};
const isSelected = stroke === value;
return (
<MenuItem
key={name}
onSelect={handler}
selected={isSelected}
prefix={<Point color={value} />}
>
{name}
</MenuItem>
);
});
}, [editorSetting, settings]);
const startEndPointItems = useMemo(() => {
const { frontEndpointStyle } = settings.connector;
return Object.entries(PointStyle).map(([name, value]) => {
const handler = () => {
editorSetting.set('connector', { frontEndpointStyle: value });
};
const isSelected = frontEndpointStyle === value;
return (
<MenuItem key={name} onSelect={handler} selected={isSelected}>
{name}
</MenuItem>
);
});
}, [editorSetting, settings]);
const endEndPointItems = useMemo(() => {
const { rearEndpointStyle } = settings.connector;
return Object.entries(PointStyle).map(([name, value]) => {
const handler = () => {
editorSetting.set('connector', { rearEndpointStyle: value });
};
const isSelected = rearEndpointStyle === value;
return (
<MenuItem key={name} onSelect={handler} selected={isSelected}>
{name}
</MenuItem>
);
});
}, [editorSetting, settings]);
const alignItems = useMemo<RadioItem[]>(
() => [
{
value: TextAlign.Left,
label:
t[
'com.affine.settings.editorSettings.edgeless.text.alignment.left'
](),
},
{
value: TextAlign.Center,
label:
t[
'com.affine.settings.editorSettings.edgeless.text.alignment.center'
](),
},
{
value: TextAlign.Right,
label:
t[
'com.affine.settings.editorSettings.edgeless.text.alignment.right'
](),
},
],
[t]
);
const textAlignment = settings.connector.labelStyle.textAlign;
const setTextAlignment = useCallback(
(value: TextAlign) => {
editorSetting.set('connector', {
labelStyle: {
textAlign: value,
},
});
},
[editorSetting]
);
const fontFamilyItems = useMemo(() => {
const { fontFamily } = settings.connector.labelStyle;
return Object.entries(FontFamily).map(([name, value]) => {
const handler = () => {
editorSetting.set('connector', {
labelStyle: {
fontFamily: value,
},
});
};
const isSelected = fontFamily === value;
return (
<MenuItem key={name} onSelect={handler} selected={isSelected}>
{name}
</MenuItem>
);
});
}, [editorSetting, settings]);
const fontStyleItems = useMemo(() => {
const { fontStyle } = settings.connector.labelStyle;
return Object.entries(FontStyle).map(([name, value]) => {
const handler = () => {
editorSetting.set('connector', {
labelStyle: {
fontStyle: value,
},
});
};
const isSelected = fontStyle === value;
return (
<MenuItem key={name} onSelect={handler} selected={isSelected}>
{name}
</MenuItem>
);
});
}, [editorSetting, settings]);
const fontWeightItems = useMemo(() => {
const { fontWeight } = settings.connector.labelStyle;
return sortedFontWeightEntries.map(([name, value]) => {
const handler = () => {
editorSetting.set('connector', {
labelStyle: {
fontWeight: value,
},
});
};
const isSelected = fontWeight === value;
return (
<MenuItem key={name} onSelect={handler} selected={isSelected}>
{name}
</MenuItem>
);
});
}, [editorSetting, settings]);
const fontSizeItems = useMemo(() => {
const { fontSize } = settings.connector.labelStyle;
return Object.entries(ConnectorTextFontSize).map(([name, value]) => {
const handler = () => {
editorSetting.set('connector', {
labelStyle: {
fontSize: Number(value),
},
});
};
const isSelected = fontSize === Number(value);
return (
<MenuItem key={name} onSelect={handler} selected={isSelected}>
{name}
</MenuItem>
);
});
}, [editorSetting, settings]);
const textColorItems = useMemo(() => {
const { color } = settings.connector.labelStyle;
return Object.entries(LineColor).map(([name, value]) => {
const handler = () => {
editorSetting.set('connector', {
labelStyle: {
color: value,
},
});
};
const isSelected = color === value;
return (
<MenuItem
key={name}
onSelect={handler}
selected={isSelected}
prefix={<Point color={value} />}
>
{name}
</MenuItem>
);
});
}, [editorSetting, settings]);
const textColor = useMemo(() => {
const { color } = settings.connector.labelStyle;
return getColorFromMap(color, LineColorMap);
}, [getColorFromMap, settings]);
const getElements = useCallback((doc: Doc) => {
const surface = getSurfaceBlock(doc);
return surface?.getElementsByType('connector') || [];
}, []);
return (
<>
<EdgelessSnapshot
title={t['com.affine.settings.editorSettings.edgeless.connecter']()}
docName="connector"
keyName="connector"
getElements={getElements}
/>
<SettingRow
name={t[
'com.affine.settings.editorSettings.edgeless.connecter.color'
]()}
desc={''}
>
{currentColor ? (
<DropdownMenu
items={colorItems}
trigger={
<MenuTrigger
className={menuTrigger}
prefix={<Point color={currentColor.value} />}
>
{currentColor.key}
</MenuTrigger>
}
/>
) : null}
</SettingRow>
<SettingRow
name={t['com.affine.settings.editorSettings.edgeless.style']()}
desc={''}
>
<RadioGroup
items={connecterStyleItems}
value={connecterStyle}
width={250}
className={settingWrapper}
onChange={setConnecterStyle}
/>
</SettingRow>
<SettingRow
name={t[
'com.affine.settings.editorSettings.edgeless.connecter.connector-shape'
]()}
desc={''}
>
<RadioGroup
items={connectorShapeItems}
value={connectorShape}
width={250}
className={settingWrapper}
onChange={setConnectorShape}
/>
</SettingRow>
<SettingRow
name={t[
'com.affine.settings.editorSettings.edgeless.connecter.border-style'
]()}
desc={''}
>
<RadioGroup
items={borderStyleItems}
value={borderStyle}
width={250}
className={settingWrapper}
onChange={setBorderStyle}
/>
</SettingRow>
<SettingRow
name={t[
'com.affine.settings.editorSettings.edgeless.connecter.border-thickness'
]()}
desc={''}
>
<Slider
value={[borderThickness]}
onValueChange={setBorderThickness}
min={2}
max={12}
step={2}
nodes={[2, 4, 6, 8, 10, 12]}
/>
</SettingRow>
<SettingRow
name={t[
'com.affine.settings.editorSettings.edgeless.connecter.start-endpoint'
]()}
desc={''}
>
<DropdownMenu
items={startEndPointItems}
trigger={
<MenuTrigger className={menuTrigger}>
{String(settings.connector.frontEndpointStyle)}
</MenuTrigger>
}
/>
</SettingRow>
<SettingRow
name={t[
'com.affine.settings.editorSettings.edgeless.connecter.end-endpoint'
]()}
desc={''}
>
<DropdownMenu
items={endEndPointItems}
trigger={
<MenuTrigger className={menuTrigger}>
{String(settings.connector.rearEndpointStyle)}
</MenuTrigger>
}
/>
</SettingRow>
<SettingRow
name={t[
'com.affine.settings.editorSettings.edgeless.shape.text-color'
]()}
desc={''}
>
{textColor ? (
<DropdownMenu
items={textColorItems}
trigger={
<MenuTrigger
className={menuTrigger}
prefix={<Point color={textColor.value} />}
>
{textColor.key}
</MenuTrigger>
}
/>
) : null}
</SettingRow>
<SettingRow
name={t[
'com.affine.settings.editorSettings.edgeless.text.font-family'
]()}
desc={''}
>
<DropdownMenu
items={fontFamilyItems}
trigger={
<MenuTrigger className={menuTrigger}>
{FontFamilyMap[settings.connector.labelStyle.fontFamily]}
</MenuTrigger>
}
/>
</SettingRow>
<SettingRow
name={t[
'com.affine.settings.editorSettings.edgeless.shape.font-size'
]()}
desc={''}
>
<DropdownMenu
items={fontSizeItems}
trigger={
<MenuTrigger className={menuTrigger}>
{settings.connector.labelStyle.fontSize + 'px'}
</MenuTrigger>
}
/>
</SettingRow>
<SettingRow
name={t[
'com.affine.settings.editorSettings.edgeless.text.font-style'
]()}
desc={''}
>
<DropdownMenu
items={fontStyleItems}
trigger={
<MenuTrigger className={menuTrigger}>
{settings.connector.labelStyle.fontStyle}
</MenuTrigger>
}
/>
</SettingRow>
<SettingRow
name={t[
'com.affine.settings.editorSettings.edgeless.text.font-weight'
]()}
desc={''}
>
<DropdownMenu
items={fontWeightItems}
trigger={
<MenuTrigger className={menuTrigger}>
{FontWeightMap[settings.connector.labelStyle.fontWeight]}
</MenuTrigger>
}
/>
</SettingRow>
<SettingRow
name={t[
'com.affine.settings.editorSettings.edgeless.shape.text-alignment'
]()}
desc={''}
>
<RadioGroup
items={alignItems}
value={textAlignment}
width={250}
className={settingWrapper}
onChange={setTextAlignment}
/>
</SettingRow>
</>
);
};
@@ -0,0 +1,99 @@
{
"type": "page",
"meta": {
"id": "gJEFfmo4QJ",
"title": "",
"createDate": 1726038448921,
"tags": []
},
"blocks": {
"type": "block",
"id": "orzKfiHevj",
"flavour": "affine:page",
"version": 2,
"props": {
"title": {
"$blocksuite:internal:text$": true,
"delta": []
}
},
"children": [
{
"type": "block",
"id": "-48EmppaxI",
"flavour": "affine:surface",
"version": 5,
"props": {
"elements": {
"m_PwyyI76y": {
"index": "a0",
"seed": 44330892,
"frontEndpointStyle": "None",
"labelOffset": {
"distance": 0.5,
"anchor": "center"
},
"labelStyle": {
"color": "--affine-palette-line-black",
"fontSize": 16,
"fontFamily": "blocksuite:surface:Inter",
"fontWeight": "400",
"fontStyle": "normal",
"textAlign": "center"
},
"labelXYWH": [235.6484375, 65.23828125, 200, 20],
"mode": 2,
"rearEndpointStyle": "Arrow",
"rough": false,
"roughness": 1.4,
"source": {
"position": [120.8515625, 146.44921875]
},
"stroke": "--affine-palette-line-grey",
"strokeStyle": "solid",
"strokeWidth": 2,
"target": {
"position": [387.4453125, 4.02734375]
},
"text": {
"affine:surface:text": true,
"delta": [
{
"insert": "label"
}
]
},
"type": "connector",
"id": "m_PwyyI76y"
}
}
},
"children": [
{
"type": "block",
"id": "-6UhNH7qhy",
"flavour": "affine:frame",
"version": 1,
"props": {
"title": {
"$blocksuite:internal:text$": true,
"delta": [
{
"insert": "Frame 1"
}
]
},
"background": "--affine-palette-transparent",
"xywh": "[-12.04296875,-49.66796875,542.9765625,248.3984375]",
"index": "Zz",
"childElementIds": {
"m_PwyyI76y": true
}
},
"children": []
}
]
}
]
}
}
@@ -0,0 +1,470 @@
{
"type": "page",
"meta": {
"id": "IWDwzafoLh",
"title": "BlockSuite Playground",
"createDate": 1725541560333,
"tags": []
},
"blocks": {
"type": "block",
"id": "DBwzSu70O4",
"flavour": "affine:page",
"version": 2,
"props": {
"title": {
"$blocksuite:internal:text$": true,
"delta": [
{
"insert": "BlockSuite Playground"
}
]
}
},
"children": [
{
"type": "block",
"id": "VsLifn6v6J",
"flavour": "affine:surface",
"version": 5,
"props": {
"elements": {
"03ESqmbu0V": {
"index": "aF",
"seed": 456881005,
"color": "--affine-palette-line-black",
"fillColor": "--affine-palette-shape-yellow",
"filled": true,
"fontFamily": "blocksuite:surface:Inter",
"fontSize": 12,
"maxWidth": false,
"padding": [10, 20],
"radius": 0.1,
"rotate": 0,
"roughness": 1.4,
"shadow": null,
"shapeStyle": "General",
"shapeType": "rect",
"strokeColor": "--affine-palette-line-yellow",
"strokeStyle": "solid",
"strokeWidth": 2,
"text": {
"affine:surface:text": true,
"delta": [
{
"insert": "Start"
}
]
},
"textResizing": 1,
"xywh": "[252.60701568907035,-31.240866187007754,113.98566757331668,46.66666793823242]",
"type": "shape",
"id": "03ESqmbu0V"
},
"YQDvi-2y7O": {
"index": "aG",
"seed": 1257533881,
"color": "--affine-palette-line-black",
"fillColor": "--affine-palette-shape-yellow",
"filled": true,
"fontFamily": "blocksuite:surface:Inter",
"fontSize": 12,
"maxWidth": false,
"padding": [10, 20],
"radius": 0,
"rotate": 0,
"roughness": 1.4,
"shadow": null,
"shapeStyle": "General",
"shapeType": "rect",
"strokeColor": "--affine-palette-line-yellow",
"strokeStyle": "solid",
"strokeWidth": 2,
"text": {
"affine:surface:text": true,
"delta": [
{
"insert": "Action"
}
]
},
"textResizing": 1,
"xywh": "[251.34653068910762,63.28169306573089,116.50663757324219,46.66666793823242]",
"type": "shape",
"id": "YQDvi-2y7O"
},
"d16Jq63Ef-": {
"index": "aH",
"seed": 1257533881,
"color": "--affine-palette-line-black",
"fillColor": "--affine-palette-shape-yellow",
"filled": true,
"fontFamily": "blocksuite:surface:Inter",
"fontSize": 12,
"maxWidth": false,
"padding": [10, 20],
"radius": 0,
"rotate": 0,
"roughness": 1.4,
"shadow": null,
"shapeStyle": "General",
"shapeType": "rect",
"strokeColor": "--affine-palette-line-yellow",
"strokeStyle": "solid",
"strokeWidth": 2,
"text": {
"affine:surface:text": true,
"delta": [
{
"insert": "Action"
}
]
},
"textResizing": 1,
"xywh": "[480.71602985081984,63.28169306573089,116.50663757324219,46.66666793823242]",
"type": "shape",
"id": "d16Jq63Ef-"
},
"pFUd4r8HSb": {
"index": "aI",
"seed": 666256980,
"color": "--affine-palette-line-black",
"fillColor": "--affine-palette-shape-yellow",
"filled": true,
"fontFamily": "blocksuite:surface:Inter",
"fontSize": 12,
"maxWidth": false,
"padding": [10, 20],
"radius": 0,
"rotate": 0,
"roughness": 1.4,
"shadow": null,
"shapeStyle": "General",
"shapeType": "diamond",
"strokeColor": "--affine-palette-line-yellow",
"strokeStyle": "solid",
"strokeWidth": 2,
"text": {
"affine:surface:text": true,
"delta": [
{
"insert": "Yes/No"
}
]
},
"textResizing": 1,
"xywh": "[259.6798856390588,158.60141743948986,99.83992767333984,100]",
"type": "shape",
"id": "pFUd4r8HSb"
},
"WC0DtV40eR": {
"index": "aJ",
"seed": 1257533881,
"color": "--affine-palette-line-black",
"fillColor": "--affine-palette-shape-yellow",
"filled": true,
"fontFamily": "blocksuite:surface:Inter",
"fontSize": 12,
"maxWidth": false,
"padding": [10, 20],
"radius": 0,
"rotate": 0,
"roughness": 1.4,
"shadow": null,
"shapeStyle": "General",
"shapeType": "rect",
"strokeColor": "--affine-palette-line-yellow",
"strokeStyle": "solid",
"strokeWidth": 2,
"text": {
"affine:surface:text": true,
"delta": [
{
"insert": "Action"
}
]
},
"textResizing": 1,
"xywh": "[251.3465118415371,306.9211418132488,116.50663757324219,47]",
"type": "shape",
"id": "WC0DtV40eR"
},
"L8IRBW8JgC": {
"index": "aK",
"seed": 456881005,
"color": "--affine-palette-line-black",
"fillColor": "--affine-palette-shape-yellow",
"filled": true,
"fontFamily": "blocksuite:surface:Inter",
"fontSize": 12,
"maxWidth": false,
"padding": [10, 20],
"radius": 0.1,
"rotate": 0,
"roughness": 1.4,
"shadow": null,
"shapeStyle": "General",
"shapeType": "rect",
"strokeColor": "--affine-palette-line-yellow",
"strokeStyle": "solid",
"strokeWidth": 2,
"text": {
"affine:surface:text": true,
"delta": [
{
"insert": "End"
}
]
},
"textResizing": 1,
"xywh": "[251.21161577561134,402.2408661870077,116.77642970509373,47]",
"type": "shape",
"id": "L8IRBW8JgC"
},
"jnVx3KzCLh": {
"index": "aR",
"seed": 914130882,
"color": "--affine-palette-line-black",
"fillColor": "--affine-palette-shape-yellow",
"filled": true,
"fontFamily": "blocksuite:surface:Inter",
"fontSize": 16,
"maxWidth": false,
"padding": [10, 20],
"radius": 0,
"rotate": 0,
"roughness": 1.4,
"shadow": null,
"shapeStyle": "General",
"shapeType": "triangle",
"strokeColor": "--affine-palette-line-yellow",
"strokeStyle": "solid",
"strokeWidth": 2,
"textResizing": 1,
"xywh": "[16.110706599375533,93.96971522611781,99.95313668402997,80.60737422459296]",
"type": "shape",
"id": "jnVx3KzCLh"
},
"hXY08VN1b_": {
"index": "aS",
"seed": 1037406953,
"color": "--affine-palette-line-black",
"fillColor": "--affine-palette-shape-yellow",
"filled": true,
"fontFamily": "blocksuite:surface:Inter",
"fontSize": 16,
"maxWidth": false,
"padding": [10, 20],
"radius": 0,
"rotate": 0,
"roughness": 1.4,
"shadow": null,
"shapeStyle": "General",
"shapeType": "ellipse",
"strokeColor": "--affine-palette-line-yellow",
"strokeStyle": "solid",
"strokeWidth": 2,
"textResizing": 1,
"xywh": "[16.110706599375547,226.48127342070092,97.3493072661882,97.90333942510793]",
"type": "shape",
"id": "hXY08VN1b_"
},
"-aHPKrKHll": {
"index": "aL",
"seed": 1201328314,
"frontEndpointStyle": "None",
"mode": 1,
"rearEndpointStyle": "Arrow",
"rough": false,
"roughness": 1.4,
"source": {
"id": "03ESqmbu0V",
"position": [0.5, 1]
},
"stroke": "--affine-palette-line-grey",
"strokeStyle": "solid",
"strokeWidth": 2,
"target": {
"id": "YQDvi-2y7O",
"position": [0.5, 0]
},
"type": "connector",
"id": "-aHPKrKHll"
},
"9nFcoL2dRX": {
"index": "aM",
"seed": 1062067961,
"frontEndpointStyle": "None",
"mode": 1,
"rearEndpointStyle": "Arrow",
"rough": false,
"roughness": 1.4,
"source": {
"id": "YQDvi-2y7O",
"position": [1, 0.5]
},
"stroke": "--affine-palette-line-grey",
"strokeStyle": "solid",
"strokeWidth": 2,
"target": {
"id": "d16Jq63Ef-",
"position": [0, 0.49997133993897247]
},
"type": "connector",
"id": "9nFcoL2dRX"
},
"j4pebF9wC1": {
"index": "aN",
"seed": 970063880,
"frontEndpointStyle": "Arrow",
"mode": 1,
"rearEndpointStyle": "None",
"rough": false,
"roughness": 1.4,
"source": {
"id": "d16Jq63Ef-",
"position": [0.5, 1]
},
"stroke": "--affine-palette-line-grey",
"strokeStyle": "solid",
"strokeWidth": 2,
"target": {
"id": "pFUd4r8HSb",
"position": [1, 0.5]
},
"type": "connector",
"id": "j4pebF9wC1"
},
"WUp1wz3qjl": {
"index": "aO",
"seed": 628182641,
"frontEndpointStyle": "None",
"mode": 1,
"rearEndpointStyle": "Arrow",
"rough": false,
"roughness": 1.4,
"source": {
"id": "YQDvi-2y7O",
"position": [0.5, 1]
},
"stroke": "--affine-palette-line-grey",
"strokeStyle": "solid",
"strokeWidth": 2,
"target": {
"id": "pFUd4r8HSb",
"position": [0.5, 0]
},
"type": "connector",
"id": "WUp1wz3qjl"
},
"LW-DOsZkzx": {
"index": "aP",
"seed": 1308365768,
"frontEndpointStyle": "None",
"mode": 1,
"rearEndpointStyle": "Arrow",
"rough": false,
"roughness": 1.4,
"source": {
"id": "pFUd4r8HSb",
"position": [0.5, 1]
},
"stroke": "--affine-palette-line-grey",
"strokeStyle": "solid",
"strokeWidth": 2,
"target": {
"id": "WC0DtV40eR",
"position": [0.5, 0]
},
"type": "connector",
"id": "LW-DOsZkzx"
},
"iFv5piS4UF": {
"index": "aQ",
"seed": 782147341,
"frontEndpointStyle": "None",
"mode": 1,
"rearEndpointStyle": "Arrow",
"rough": false,
"roughness": 1.4,
"source": {
"id": "WC0DtV40eR",
"position": [0.5, 1]
},
"stroke": "--affine-palette-line-grey",
"strokeStyle": "solid",
"strokeWidth": 2,
"target": {
"id": "L8IRBW8JgC",
"position": [0.5, 0]
},
"type": "connector",
"id": "iFv5piS4UF"
}
}
},
"children": [
{
"type": "block",
"id": "LHrmmlDo4Q",
"flavour": "affine:edgeless-text",
"version": 1,
"props": {
"xywh": "[30.80080405395168,20.987504771361614,71.2421875,56]",
"index": "aE",
"color": "--affine-palette-line-black",
"fontFamily": "blocksuite:surface:Inter",
"fontStyle": "normal",
"fontWeight": "400",
"textAlign": "left",
"scale": 1,
"rotate": 0,
"hasMaxWidth": false
},
"children": [
{
"type": "block",
"id": "qc2TtDkPRq",
"flavour": "affine:paragraph",
"version": 1,
"props": {
"type": "text",
"text": {
"$blocksuite:internal:text$": true,
"delta": [
{
"insert": "Others"
}
]
}
},
"children": []
}
]
},
{
"type": "block",
"id": "cmuL9JhfVH",
"flavour": "affine:frame",
"version": 1,
"props": {
"title": {
"$blocksuite:internal:text$": true,
"delta": [
{
"insert": "Frame 1"
}
]
},
"background": "--affine-palette-transparent",
"xywh": "[-131.27862548828125,-112.8125,880.65234375,645.11328125]",
"index": "aEV",
"childElementIds": {}
},
"children": []
}
]
}
]
}
}
@@ -0,0 +1,87 @@
import { AffineSchemas } from '@blocksuite/affine/blocks';
import type { Doc, DocSnapshot } from '@blocksuite/affine/store';
import { DocCollection, Job, Schema } from '@blocksuite/affine/store';
const getCollection = (() => {
let collection: DocCollection | null = null;
return async function () {
if (collection) {
return collection;
}
const schema = new Schema();
schema.register(AffineSchemas);
collection = new DocCollection({ schema });
collection.meta.initialize();
return collection;
};
})();
export type DocName =
| 'note'
| 'pen'
| 'shape'
| 'flow'
| 'text'
| 'connector'
| 'mindmap';
const docMap = new Map<DocName, Promise<Doc | undefined>>();
async function loadNote() {
return (await import('./note.json')).default;
}
async function loadPen() {
return (await import('./pen.json')).default;
}
async function loadShape() {
return (await import('./shape.json')).default;
}
async function loadFlow() {
return (await import('./flow.json')).default;
}
async function loadText() {
return (await import('./text.json')).default;
}
async function loadConnector() {
return (await import('./connector.json')).default;
}
async function loadMindmap() {
return (await import('./mindmap.json')).default;
}
const loaders = {
note: loadNote,
pen: loadPen,
shape: loadShape,
flow: loadFlow,
text: loadText,
connector: loadConnector,
mindmap: loadMindmap,
};
export async function getDocByName(name: DocName) {
if (docMap.get(name)) {
return docMap.get(name);
}
const promise = initDoc(name);
docMap.set(name, promise);
return promise;
}
async function initDoc(name: DocName) {
const snapshot = (await loaders[name]()) as DocSnapshot;
const collection = await getCollection();
const job = new Job({
collection,
middlewares: [],
});
return await job.snapshotToDoc(snapshot);
}
@@ -0,0 +1,242 @@
{
"type": "page",
"meta": {
"id": "_JUmoI_F28",
"title": "BlockSuite Playground",
"createDate": 1725610677620,
"tags": []
},
"blocks": {
"type": "block",
"id": "FnaWN8Zm2_",
"flavour": "affine:page",
"version": 2,
"props": {
"title": {
"$blocksuite:internal:text$": true,
"delta": [
{
"insert": "BlockSuite Playground"
}
]
}
},
"children": [
{
"type": "block",
"id": "hevWf4ccWc",
"flavour": "affine:surface",
"version": 5,
"props": {
"elements": {
"MYyOCcLTWN": {
"index": "a0",
"seed": 739933670,
"children": {
"affine:surface:ymap": true,
"json": {
"BvanBL7O38": {
"index": "a0"
},
"QkmFfps45U": {
"index": "a0",
"parent": "BvanBL7O38"
},
"1IN8YOdsCP": {
"index": "a1",
"parent": "BvanBL7O38"
},
"iFQ9oVR0KN": {
"index": "a2",
"parent": "BvanBL7O38"
}
}
},
"layoutType": 0,
"style": 1,
"type": "mindmap",
"id": "MYyOCcLTWN"
},
"BvanBL7O38": {
"index": "a0",
"seed": 819595867,
"color": "--affine-black",
"fillColor": "--affine-white",
"filled": true,
"fontFamily": "blocksuite:surface:Poppins",
"fontSize": 20,
"fontWeight": "600",
"maxWidth": 400,
"padding": [11, 22],
"radius": 8,
"rotate": 0,
"roughness": 1.4,
"shadow": {
"offsetX": 0,
"offsetY": 6,
"blur": 12,
"color": "rgba(0, 0, 0, 0.14)"
},
"shapeStyle": "General",
"shapeType": "rect",
"strokeColor": "#84CFFF",
"strokeStyle": "solid",
"strokeWidth": 4,
"text": {
"affine:surface:text": true,
"delta": [
{
"insert": "Mind Map"
}
]
},
"textResizing": 0,
"xywh": "[-214.85955810546875,-113.65847778320312,144.7799530029297,52]",
"type": "shape",
"id": "BvanBL7O38"
},
"QkmFfps45U": {
"index": "a0",
"seed": 557026939,
"color": "--affine-black",
"fillColor": "--affine-white",
"filled": true,
"fontFamily": "blocksuite:surface:Poppins",
"fontSize": 16,
"fontWeight": "500",
"maxWidth": false,
"padding": [6, 22],
"radius": 8,
"rotate": 0,
"roughness": 1.4,
"shadow": {
"offsetX": 0,
"offsetY": 6,
"blur": 12,
"color": "rgba(0, 0, 0, 0.14)"
},
"shapeStyle": "General",
"shapeType": "rect",
"strokeColor": "--affine-palette-line-purple",
"strokeStyle": "solid",
"strokeWidth": 3,
"text": {
"affine:surface:text": true,
"delta": [
{
"insert": "Text"
}
]
},
"textResizing": 0,
"xywh": "[129.92039489746094,-186.65847778320312,76.83197021484375,36]",
"type": "shape",
"id": "QkmFfps45U"
},
"1IN8YOdsCP": {
"index": "a0",
"seed": 205695803,
"color": "--affine-black",
"fillColor": "--affine-white",
"filled": true,
"fontFamily": "blocksuite:surface:Poppins",
"fontSize": 16,
"fontWeight": "500",
"maxWidth": false,
"padding": [6, 22],
"radius": 8,
"rotate": 0,
"roughness": 1.4,
"shadow": {
"offsetX": 0,
"offsetY": 6,
"blur": 12,
"color": "rgba(0, 0, 0, 0.14)"
},
"shapeStyle": "General",
"shapeType": "rect",
"strokeColor": "--affine-palette-line-magenta",
"strokeStyle": "solid",
"strokeWidth": 3,
"text": {
"affine:surface:text": true,
"delta": [
{
"insert": "Text"
}
]
},
"textResizing": 0,
"xywh": "[129.92039489746094,-105.65847778320312,76.83197021484375,36]",
"type": "shape",
"id": "1IN8YOdsCP"
},
"iFQ9oVR0KN": {
"index": "a0",
"seed": 585656351,
"color": "--affine-black",
"fillColor": "--affine-white",
"filled": true,
"fontFamily": "blocksuite:surface:Poppins",
"fontSize": 16,
"fontWeight": "500",
"maxWidth": false,
"padding": [6, 22],
"radius": 8,
"rotate": 0,
"roughness": 1.4,
"shadow": {
"offsetX": 0,
"offsetY": 6,
"blur": 12,
"color": "rgba(0, 0, 0, 0.14)"
},
"shapeStyle": "General",
"shapeType": "rect",
"strokeColor": "--affine-palette-line-orange",
"strokeStyle": "solid",
"strokeWidth": 3,
"text": {
"affine:surface:text": true,
"delta": [
{
"insert": "Text"
}
]
},
"textResizing": 0,
"xywh": "[129.92039489746094,-24.658477783203125,76.83197021484375,36]",
"type": "shape",
"id": "iFQ9oVR0KN"
}
}
},
"children": [
{
"type": "block",
"id": "bVYX1z2q3T",
"flavour": "affine:frame",
"version": 1,
"props": {
"title": {
"$blocksuite:internal:text$": true,
"delta": [
{
"insert": "Frame 1"
}
]
},
"background": "--affine-palette-transparent",
"xywh": "[-542.4695816040039,-292.0061340332031,798.42578125,414.50390625]",
"index": "Zz",
"childElementIds": {
"MYyOCcLTWN": true
}
},
"children": []
}
]
}
]
}
}
@@ -0,0 +1,138 @@
{
"type": "page",
"meta": {
"id": "pfXt9oH_Rb",
"title": "Note",
"createDate": 1725451650158,
"tags": []
},
"blocks": {
"type": "block",
"id": "hNFCyKIXqH",
"flavour": "affine:page",
"version": 2,
"props": {
"title": {
"$blocksuite:internal:text$": true,
"delta": [
{
"insert": "Note"
}
]
}
},
"children": [
{
"type": "block",
"id": "odnFJlKwxt",
"flavour": "affine:surface",
"version": 5,
"props": {
"elements": {}
},
"children": [
{
"type": "block",
"id": "D1UhpqzS37",
"flavour": "affine:frame",
"version": 1,
"props": {
"title": {
"$blocksuite:internal:text$": true,
"delta": [
{
"insert": "Frame 1"
}
]
},
"background": "--affine-palette-transparent",
"xywh": "[361.3046875,-9.600906372070312,803.04296875,291.26953125]",
"index": "a0",
"childElementIds": {
"C5e-RyxlTI": true
}
},
"children": []
}
]
},
{
"type": "block",
"id": "C5e-RyxlTI",
"flavour": "affine:note",
"version": 1,
"props": {
"xywh": "[538.826171875,46.71159362792969,448,180]",
"background": "--affine-note-background-blue",
"index": "a3",
"hidden": false,
"displayMode": "edgeless",
"edgeless": {
"style": {
"borderRadius": 0,
"borderSize": 4,
"borderStyle": "none",
"shadowType": "--affine-note-shadow-sticker"
}
}
},
"children": [
{
"type": "block",
"id": "AjSEjoCq1Q",
"flavour": "affine:paragraph",
"version": 1,
"props": {
"type": "h1",
"text": {
"$blocksuite:internal:text$": true,
"delta": [
{
"insert": "Write, draw, plan all at once."
}
]
}
},
"children": []
},
{
"type": "block",
"id": "s5v28mktAO",
"flavour": "affine:paragraph",
"version": 1,
"props": {
"type": "text",
"text": {
"$blocksuite:internal:text$": true,
"delta": [
{
"insert": "AFFiNE is a workspace with fully merged docs,"
}
]
}
},
"children": []
},
{
"type": "block",
"id": "Jd9Unn0j4a",
"flavour": "affine:paragraph",
"version": 1,
"props": {
"type": "text",
"text": {
"$blocksuite:internal:text$": true,
"delta": [
{
"insert": "whiteboards and databases."
}
]
}
},
"children": []
}
]
}
]
}
}
@@ -0,0 +1,179 @@
{
"type": "page",
"meta": {
"id": "UpLwaWKe8S9s8krsxi3A8",
"title": "BlockSuite Playground",
"createDate": 1725435317986,
"tags": []
},
"blocks": {
"type": "block",
"id": "mUEhyeRfVdzFKHV3w897g",
"flavour": "affine:page",
"version": 2,
"props": {
"title": {
"$blocksuite:internal:text$": true,
"delta": [
{
"insert": "BlockSuite Playground"
}
]
}
},
"children": [
{
"type": "block",
"id": "oc5oq3DFWcqVjzyevWrx6",
"flavour": "affine:surface",
"version": 5,
"props": {
"elements": {
"e6t9tKz8Sy": {
"index": "a5",
"seed": 338503204,
"color": "--affine-palette-line-black",
"fillColor": "--affine-palette-shape-yellow",
"filled": true,
"fontFamily": "blocksuite:surface:Inter",
"fontSize": 12,
"maxWidth": false,
"padding": [10, 20],
"radius": 0,
"rotate": 0,
"roughness": 1.4,
"shadow": null,
"shapeStyle": "General",
"shapeType": "ellipse",
"strokeColor": "--affine-palette-line-yellow",
"strokeStyle": "solid",
"strokeWidth": 2,
"textResizing": 1,
"xywh": "[190.275413673033,378.1994245551321,100.11936661988601,100.11932857954827]",
"type": "shape",
"id": "e6t9tKz8Sy"
},
"F8qB_zDC5Q": {
"index": "a6",
"seed": 1896265661,
"color": "--affine-palette-line-black",
"fillColor": "--affine-palette-shape-yellow",
"filled": true,
"fontFamily": "blocksuite:surface:Inter",
"fontSize": 12,
"maxWidth": false,
"padding": [10, 20],
"radius": 0.1,
"rotate": 0,
"roughness": 1.4,
"shadow": null,
"shapeStyle": "General",
"shapeType": "rect",
"strokeColor": "--affine-palette-line-yellow",
"strokeStyle": "solid",
"strokeWidth": 2,
"textResizing": 1,
"xywh": "[717.3900484965246,378.29923435003775,100.25918467911882,99.76145851000166]",
"type": "shape",
"id": "F8qB_zDC5Q"
},
"mPR44JBpcd": {
"index": "a7",
"seed": 2073974140,
"color": "--affine-palette-line-black",
"fillColor": "--affine-palette-shape-yellow",
"filled": true,
"fontFamily": "blocksuite:surface:Inter",
"fontSize": 12,
"maxWidth": false,
"padding": [10, 20],
"radius": 0,
"rotate": 0,
"roughness": 1.4,
"shadow": null,
"shapeStyle": "General",
"shapeType": "diamond",
"strokeColor": "--affine-palette-line-yellow",
"strokeStyle": "solid",
"strokeWidth": 2,
"textResizing": 1,
"xywh": "[364.3172984791928,376.3029173497159,105.1083470248982,104.78589902189475]",
"type": "shape",
"id": "mPR44JBpcd"
},
"cmtluc3FWR": {
"index": "a8",
"seed": 1457248130,
"color": "--affine-palette-line-black",
"fillColor": "--affine-palette-shape-yellow",
"filled": true,
"fontFamily": "blocksuite:surface:Inter",
"fontSize": 12,
"maxWidth": false,
"padding": [10, 20],
"radius": 0,
"rotate": 0,
"roughness": 1.4,
"shadow": null,
"shapeStyle": "General",
"shapeType": "triangle",
"strokeColor": "--affine-palette-line-yellow",
"strokeStyle": "solid",
"strokeWidth": 2,
"textResizing": 1,
"xywh": "[543.3481636903648,382.77123494325747,99.95316838431143,93.33554571739376]",
"type": "shape",
"id": "cmtluc3FWR"
},
"knt_TKvACR": {
"index": "a9",
"seed": 1896265661,
"color": "--affine-palette-line-black",
"fillColor": "--affine-palette-shape-yellow",
"filled": true,
"fontFamily": "blocksuite:surface:Inter",
"fontSize": 12,
"maxWidth": false,
"padding": [10, 20],
"radius": 0,
"rotate": 0,
"roughness": 1.4,
"shadow": null,
"shapeStyle": "General",
"shapeType": "rect",
"strokeColor": "--affine-palette-line-yellow",
"strokeStyle": "solid",
"strokeWidth": 2,
"textResizing": 1,
"xywh": "[16.1402039335359,378.4574848297732,100.2126915532233,99.60320803026627]",
"type": "shape",
"id": "knt_TKvACR"
}
}
},
"children": [
{
"type": "block",
"id": "NSlTmqVn2NsIJCDvzIoUF",
"flavour": "affine:frame",
"version": 1,
"props": {
"title": {
"$blocksuite:internal:text$": true,
"delta": [
{
"insert": "Shapes"
}
]
},
"background": "--affine-palette-transparent",
"xywh": "[-23.859796066464128,332.87664265400565,881.5090292421075,190.55181066780085]",
"index": "a0"
},
"children": []
}
]
}
]
}
}
@@ -0,0 +1,117 @@
{
"type": "page",
"meta": {
"id": "NJwLoscdk4",
"title": "BlockSuite Playground",
"createDate": 1725610138401,
"tags": []
},
"blocks": {
"type": "block",
"id": "W-A_1geJy3",
"flavour": "affine:page",
"version": 2,
"props": {
"title": {
"$blocksuite:internal:text$": true,
"delta": [
{
"insert": "BlockSuite Playground"
}
]
}
},
"children": [
{
"type": "block",
"id": "KNi1ipoyX6",
"flavour": "affine:surface",
"version": 5,
"props": {
"elements": {}
},
"children": [
{
"type": "block",
"id": "bv-d4LA-Nr",
"flavour": "affine:edgeless-text",
"version": 1,
"props": {
"xywh": "[18.221401559354234,201.98389611782602,800.6283921393511,489.15472412109375]",
"index": "a0",
"color": "--affine-palette-line-blue",
"fontFamily": "blocksuite:surface:Inter",
"fontStyle": "normal",
"fontWeight": "400",
"textAlign": "left",
"scale": 5.435052533564726,
"rotate": 0,
"hasMaxWidth": true
},
"children": [
{
"type": "block",
"id": "vkzwo90EHS",
"flavour": "affine:paragraph",
"version": 1,
"props": {
"type": "text",
"text": {
"$blocksuite:internal:text$": true,
"delta": [
{
"insert": "To Shape,"
}
]
}
},
"children": []
},
{
"type": "block",
"id": "0ugn1XiO-U",
"flavour": "affine:paragraph",
"version": 1,
"props": {
"type": "text",
"text": {
"$blocksuite:internal:text$": true,
"delta": [
{
"insert": "Not to Adopt."
}
]
}
},
"children": []
}
]
},
{
"type": "block",
"id": "mkbmK_R1sC",
"flavour": "affine:frame",
"version": 1,
"props": {
"title": {
"$blocksuite:internal:text$": true,
"delta": [
{
"insert": "Frame 1"
}
]
},
"background": "--affine-palette-transparent",
"xywh": "[-21.778598440645766,126.5612581783729,880.6283921393511,640]",
"index": "a1",
"childElementIds": {
"bv-d4LA-Nr": true
}
},
"children": []
}
]
}
]
}
}
@@ -0,0 +1,25 @@
import { SettingWrapper } from '@affine/component/setting-components';
import { useI18n } from '@affine/i18n';
import { ConnectorSettings } from './connector';
import { GeneralEdgelessSetting } from './general';
import { MindMapSettings } from './mind-map';
import { NoteSettings } from './note';
import { PenSettings } from './pen';
import { ShapeSettings } from './shape';
import { TextSettings } from './text';
export const Edgeless = () => {
const t = useI18n();
return (
<SettingWrapper title={t['com.affine.settings.editorSettings.edgeless']()}>
<GeneralEdgelessSetting />
<NoteSettings />
<TextSettings />
<ShapeSettings />
<ConnectorSettings />
<PenSettings />
<MindMapSettings />
</SettingWrapper>
);
};
@@ -0,0 +1,106 @@
import { Menu, MenuItem, MenuTrigger } from '@affine/component';
import { SettingRow } from '@affine/component/setting-components';
import { EditorSettingService } from '@affine/core/modules/editor-setting';
import type { EdgelessDefaultTheme } from '@affine/core/modules/editor-setting/schema';
import { useI18n } from '@affine/i18n';
import { useLiveData, useService } from '@toeverything/infra';
import { useMemo } from 'react';
import { menuTrigger } from '../style.css';
const getThemeOptions = (
t: ReturnType<typeof useI18n>
): { value: EdgelessDefaultTheme; label: string }[] => [
{
value: 'specified' as EdgelessDefaultTheme,
label:
t[
'com.affine.settings.editorSettings.page.edgeless-default-theme.specified'
](),
},
{
value: 'dark' as EdgelessDefaultTheme,
label: t['com.affine.themeSettings.dark'](),
},
{
value: 'light' as EdgelessDefaultTheme,
label: t['com.affine.themeSettings.light'](),
},
{
value: 'auto' as EdgelessDefaultTheme,
label: t['com.affine.themeSettings.auto'](),
},
];
const getThemeValue = (
theme: EdgelessDefaultTheme,
t: ReturnType<typeof useI18n>
) => {
switch (theme) {
case 'dark':
return t['com.affine.themeSettings.dark']();
case 'light':
return t['com.affine.themeSettings.light']();
case 'auto':
return t['com.affine.themeSettings.auto']();
case 'specified':
return t[
'com.affine.settings.editorSettings.page.edgeless-default-theme.specified'
]();
default:
return '';
}
};
export const GeneralEdgelessSetting = () => {
const t = useI18n();
const editorSetting = useService(EditorSettingService).editorSetting;
const edgelessDefaultTheme = useLiveData(
editorSetting.settings$
).edgelessDefaultTheme;
const items = getThemeOptions(t);
const currentTheme = useMemo(() => {
return getThemeValue(edgelessDefaultTheme, t);
}, [edgelessDefaultTheme, t]);
const menuItems = useMemo(() => {
return items.map(item => {
const selected = edgelessDefaultTheme === item.value;
const onSelect = () => {
editorSetting.set('edgelessDefaultTheme', item.value);
};
return (
<MenuItem key={item.value} selected={selected} onSelect={onSelect}>
{item.label}
</MenuItem>
);
});
}, [editorSetting, items, edgelessDefaultTheme]);
return (
<SettingRow
name={t[
'com.affine.settings.editorSettings.page.edgeless-default-theme.title'
]()}
desc={t[
'com.affine.settings.editorSettings.page.edgeless-default-theme.description'
]()}
>
<Menu
items={menuItems}
contentOptions={{
align: 'end',
sideOffset: 16,
style: {
width: '280px',
},
}}
>
<MenuTrigger tooltip={currentTheme} className={menuTrigger}>
{currentTheme}
</MenuTrigger>
</Menu>
</SettingRow>
);
};
@@ -0,0 +1 @@
export * from './edgeless';
@@ -0,0 +1,139 @@
import {
MenuItem,
MenuTrigger,
RadioGroup,
type RadioItem,
} from '@affine/component';
import { SettingRow } from '@affine/component/setting-components';
import { EditorSettingService } from '@affine/core/modules/editor-setting';
import { useI18n } from '@affine/i18n';
import { LayoutType, MindmapStyle } from '@blocksuite/affine/blocks';
import type { Doc } from '@blocksuite/affine/store';
import { useFramework, useLiveData } from '@toeverything/infra';
import { useCallback, useMemo } from 'react';
import { DropdownMenu } from '../menu';
import { menuTrigger, settingWrapper } from '../style.css';
import { EdgelessSnapshot } from './snapshot';
import { getSurfaceBlock } from './utils';
const MINDMAP_STYLES = [
{
value: MindmapStyle.ONE,
name: 'Style 1',
},
{
value: MindmapStyle.TWO,
name: 'Style 2',
},
{
value: MindmapStyle.THREE,
name: 'Style 3',
},
{
value: MindmapStyle.FOUR,
name: 'Style 4',
},
];
export const MindMapSettings = () => {
const t = useI18n();
const framework = useFramework();
const { editorSetting } = framework.get(EditorSettingService);
const settings = useLiveData(editorSetting.settings$);
const { layoutType } = settings.mindmap;
const setLayoutType = useCallback(
(value: LayoutType) => {
editorSetting.set('mindmap', {
layoutType: value,
});
},
[editorSetting]
);
const layoutTypeItems = useMemo<RadioItem[]>(
() => [
{
value: LayoutType.LEFT as any,
label:
t[
'com.affine.settings.editorSettings.edgeless.mind-map.layout.left'
](),
},
{
value: LayoutType.BALANCE as any,
label:
t[
'com.affine.settings.editorSettings.edgeless.mind-map.layout.radial'
](),
},
{
value: LayoutType.RIGHT as any,
label:
t[
'com.affine.settings.editorSettings.edgeless.mind-map.layout.right'
](),
},
],
[t]
);
const styleItems = useMemo(() => {
const { style } = settings.mindmap;
return MINDMAP_STYLES.map(({ name, value }) => {
const handler = () => {
editorSetting.set('mindmap', { style: value });
};
const isSelected = style === value;
return (
<MenuItem key={name} onSelect={handler} selected={isSelected}>
{name}
</MenuItem>
);
});
}, [editorSetting, settings]);
const getElements = useCallback((doc: Doc) => {
const surface = getSurfaceBlock(doc);
return surface?.getElementsByType('mindmap') || [];
}, []);
return (
<>
<EdgelessSnapshot
title={t['com.affine.settings.editorSettings.edgeless.mind-map']()}
docName="mindmap"
keyName={'mindmap' as any}
getElements={getElements}
height={320}
/>
<SettingRow
name={t['com.affine.settings.editorSettings.edgeless.style']()}
desc={''}
>
<DropdownMenu
items={styleItems}
trigger={
<MenuTrigger className={menuTrigger}>
{`Style ${settings.mindmap.style}`}
</MenuTrigger>
}
/>
</SettingRow>
<SettingRow
name={t[
'com.affine.settings.editorSettings.edgeless.mind-map.layout'
]()}
desc={''}
>
<RadioGroup
items={layoutTypeItems}
value={layoutType}
width={250}
className={settingWrapper}
onChange={setLayoutType}
/>
</SettingRow>
</>
);
};
@@ -0,0 +1,264 @@
import {
MenuItem,
MenuTrigger,
RadioGroup,
type RadioItem,
Slider,
} from '@affine/component';
import { SettingRow } from '@affine/component/setting-components';
import { EditorSettingService } from '@affine/core/modules/editor-setting';
import { useI18n } from '@affine/i18n';
import {
createEnumMap,
NoteBackgroundColor,
NoteBackgroundColorMap,
NoteShadow,
NoteShadowMap,
StrokeStyle,
} from '@blocksuite/affine/blocks';
import type { Doc } from '@blocksuite/affine/store';
import { useFramework, useLiveData } from '@toeverything/infra';
import { useCallback, useMemo } from 'react';
import { DropdownMenu } from '../menu';
import { menuTrigger, settingWrapper } from '../style.css';
import { useColor } from '../utils';
import { Point } from './point';
import { EdgelessSnapshot } from './snapshot';
enum CornerSize {
None = 0,
Small = 8,
Medium = 16,
Large = 24,
Huge = 32,
}
const CornerSizeMap = createEnumMap(CornerSize);
const CORNER_SIZE = [
{ name: 'None', value: CornerSize.None },
{ name: 'Small', value: CornerSize.Small },
{ name: 'Medium', value: CornerSize.Medium },
{ name: 'Large', value: CornerSize.Large },
{ name: 'Huge', value: CornerSize.Huge },
] as const;
export const NoteSettings = () => {
const t = useI18n();
const framework = useFramework();
const { editorSetting } = framework.get(EditorSettingService);
const settings = useLiveData(editorSetting.settings$);
const getColorFromMap = useColor();
const borderStyleItems = useMemo<RadioItem[]>(
() => [
{
value: StrokeStyle.Solid,
label:
t['com.affine.settings.editorSettings.edgeless.note.border.solid'](),
},
{
value: StrokeStyle.Dash,
label:
t['com.affine.settings.editorSettings.edgeless.note.border.dash'](),
},
{
value: StrokeStyle.None,
label:
t['com.affine.settings.editorSettings.edgeless.note.border.none'](),
},
],
[t]
);
const { borderStyle } = settings['affine:note'].edgeless.style;
const setBorderStyle = useCallback(
(value: StrokeStyle) => {
editorSetting.set('affine:note', {
edgeless: {
style: {
borderStyle: value,
},
},
});
},
[editorSetting]
);
const { borderSize } = settings['affine:note'].edgeless.style;
const setBorderSize = useCallback(
(value: number[]) => {
editorSetting.set('affine:note', {
edgeless: {
style: {
borderSize: value[0],
},
},
});
},
[editorSetting]
);
const backgroundItems = useMemo(() => {
const { background } = settings['affine:note'];
return Object.entries(NoteBackgroundColor).map(([name, value]) => {
const handler = () => {
editorSetting.set('affine:note', { background: value });
};
const isSelected = background === value;
return (
<MenuItem
key={name}
onSelect={handler}
selected={isSelected}
prefix={<Point color={value} />}
>
{name}
</MenuItem>
);
});
}, [editorSetting, settings]);
const cornerItems = useMemo(() => {
const { borderRadius } = settings['affine:note'].edgeless.style;
return CORNER_SIZE.map(({ name, value }) => {
const handler = () => {
editorSetting.set('affine:note', {
edgeless: {
style: {
borderRadius: value,
},
},
});
};
const isSelected = borderRadius === value;
return (
<MenuItem key={name} onSelect={handler} selected={isSelected}>
{name}
</MenuItem>
);
});
}, [editorSetting, settings]);
const shadowItems = useMemo(() => {
const { shadowType } = settings['affine:note'].edgeless.style;
return Object.entries(NoteShadow).map(([name, value]) => {
const handler = () => {
editorSetting.set('affine:note', {
edgeless: {
style: {
shadowType: value,
},
},
});
};
const isSelected = shadowType === value;
return (
<MenuItem key={name} onSelect={handler} selected={isSelected}>
{name}
</MenuItem>
);
});
}, [editorSetting, settings]);
const currentColor = useMemo(() => {
const { background } = settings['affine:note'];
return getColorFromMap(background, NoteBackgroundColorMap);
}, [getColorFromMap, settings]);
const getElements = useCallback((doc: Doc) => {
return doc.getBlocksByFlavour('affine:note') || [];
}, []);
return (
<>
<EdgelessSnapshot
title={t['com.affine.settings.editorSettings.edgeless.note']()}
docName="note"
keyName="affine:note"
getElements={getElements}
height={240}
/>
<SettingRow
name={t[
'com.affine.settings.editorSettings.edgeless.note.background'
]()}
desc={''}
>
{currentColor ? (
<DropdownMenu
items={backgroundItems}
trigger={
<MenuTrigger
className={menuTrigger}
prefix={<Point color={currentColor.value} />}
>
{currentColor.key}
</MenuTrigger>
}
/>
) : null}
</SettingRow>
<SettingRow
name={t['com.affine.settings.editorSettings.edgeless.note.corners']()}
desc={''}
>
<DropdownMenu
items={cornerItems}
trigger={
<MenuTrigger className={menuTrigger}>
{
CornerSizeMap[
settings['affine:note'].edgeless.style
.borderRadius as CornerSize
]
}
</MenuTrigger>
}
/>
</SettingRow>
<SettingRow
name={t['com.affine.settings.editorSettings.edgeless.note.shadow']()}
desc={''}
>
<DropdownMenu
items={shadowItems}
trigger={
<MenuTrigger className={menuTrigger}>
{NoteShadowMap[settings['affine:note'].edgeless.style.shadowType]}
</MenuTrigger>
}
/>
</SettingRow>
<SettingRow
name={t['com.affine.settings.editorSettings.edgeless.note.border']()}
desc={''}
>
<RadioGroup
items={borderStyleItems}
value={borderStyle}
width={250}
className={settingWrapper}
onChange={setBorderStyle}
/>
</SettingRow>
<SettingRow
name={t[
'com.affine.settings.editorSettings.edgeless.note.border-thickness'
]()}
desc={''}
>
<Slider
value={[borderSize]}
onValueChange={setBorderSize}
min={2}
max={12}
step={2}
nodes={[2, 4, 6, 8, 10, 12]}
disabled={borderStyle === StrokeStyle.None}
/>
</SettingRow>
</>
);
};
@@ -0,0 +1,104 @@
import { MenuItem, MenuTrigger, Slider } from '@affine/component';
import { SettingRow } from '@affine/component/setting-components';
import { EditorSettingService } from '@affine/core/modules/editor-setting';
import { useI18n } from '@affine/i18n';
import { LineColor, LineColorMap } from '@blocksuite/affine/blocks';
import type { Doc } from '@blocksuite/affine/store';
import { useFramework, useLiveData } from '@toeverything/infra';
import { useCallback, useMemo } from 'react';
import { DropdownMenu } from '../menu';
import { menuTrigger } from '../style.css';
import { useColor } from '../utils';
import { Point } from './point';
import { EdgelessSnapshot } from './snapshot';
import { getSurfaceBlock } from './utils';
export const PenSettings = () => {
const t = useI18n();
const framework = useFramework();
const { editorSetting } = framework.get(EditorSettingService);
const settings = useLiveData(editorSetting.settings$);
const getColorFromMap = useColor();
const currentColor = useMemo(() => {
return getColorFromMap(settings.brush.color, LineColorMap);
}, [getColorFromMap, settings.brush.color]);
const colorItems = useMemo(() => {
const { color } = settings.brush;
return Object.entries(LineColor).map(([name, value]) => {
const handler = () => {
editorSetting.set('brush', { color: value });
};
const isSelected = color === value;
return (
<MenuItem
key={name}
onSelect={handler}
selected={isSelected}
prefix={<Point color={value} />}
>
{name}
</MenuItem>
);
});
}, [editorSetting, settings.brush]);
const borderThickness = settings.brush.lineWidth;
const setBorderThickness = useCallback(
(value: number[]) => {
editorSetting.set('brush', {
lineWidth: value[0],
});
},
[editorSetting]
);
const getElements = useCallback((doc: Doc) => {
const surface = getSurfaceBlock(doc);
return surface?.getElementsByType('brush') || [];
}, []);
return (
<>
<EdgelessSnapshot
title={t['com.affine.settings.editorSettings.edgeless.pen']()}
docName="pen"
keyName="brush"
getElements={getElements}
/>
<SettingRow
name={t['com.affine.settings.editorSettings.edgeless.pen.color']()}
desc={''}
>
{currentColor ? (
<DropdownMenu
items={colorItems}
trigger={
<MenuTrigger
className={menuTrigger}
prefix={<Point color={currentColor.value} />}
>
{currentColor.key}
</MenuTrigger>
}
/>
) : null}
</SettingRow>
<SettingRow
name={t['com.affine.settings.editorSettings.edgeless.pen.thickness']()}
desc={''}
>
<Slider
value={[borderThickness]}
onValueChange={setBorderThickness}
min={2}
max={12}
step={2}
nodes={[2, 4, 6, 8, 10, 12]}
/>
</SettingRow>
</>
);
};
@@ -0,0 +1,21 @@
import { cssVarV2 } from '@toeverything/theme/v2';
export const Point = ({
color,
size = 8,
}: {
color: string;
size?: number;
}) => {
return (
<div
style={{
width: size,
height: size,
borderRadius: '50%',
backgroundColor: `var(${color})`,
border: `1px solid ${cssVarV2('layer/insideBorder/blackBorder')}`,
}}
></div>
);
};
@@ -0,0 +1,598 @@
import {
MenuItem,
MenuTrigger,
RadioGroup,
type RadioItem,
Slider,
} from '@affine/component';
import { SettingRow } from '@affine/component/setting-components';
import { EditorSettingService } from '@affine/core/modules/editor-setting';
import { useI18n } from '@affine/i18n';
import type { EditorHost } from '@blocksuite/affine/block-std';
import type {
EdgelessRootService,
ShapeElementModel,
ShapeName,
} from '@blocksuite/affine/blocks';
import {
createEnumMap,
FontFamily,
FontFamilyMap,
FontStyle,
FontWeightMap,
getShapeName,
LineColor,
LineColorMap,
ShapeFillColor,
ShapeStyle,
ShapeType,
StrokeStyle,
TextAlign,
} from '@blocksuite/affine/blocks';
import type { Doc } from '@blocksuite/affine/store';
import { useFramework, useLiveData } from '@toeverything/infra';
import { useCallback, useMemo, useState } from 'react';
import { DropdownMenu } from '../menu';
import {
menuTrigger,
preViewLabelWrapper,
settingWrapper,
shapeIndicator,
} from '../style.css';
import { sortedFontWeightEntries, useColor } from '../utils';
import type { DocName } from './docs';
import { Point } from './point';
import { EdgelessSnapshot } from './snapshot';
import { getSurfaceBlock } from './utils';
enum ShapeTextFontSize {
'16px' = '16',
'20px' = '20',
'24px' = '24',
'32px' = '32',
'40px' = '40',
'64px' = '64',
}
const ShapeFillColorMap = createEnumMap(ShapeFillColor);
export const ShapeSettings = () => {
const t = useI18n();
const framework = useFramework();
const { editorSetting } = framework.get(EditorSettingService);
const settings = useLiveData(editorSetting.settings$);
const getColorFromMap = useColor();
const [currentShape, setCurrentShape] = useState<ShapeName>(ShapeType.Rect);
const shapeStyleItems = useMemo<RadioItem[]>(
() => [
{
value: ShapeStyle.General,
label: t['com.affine.settings.editorSettings.edgeless.style.general'](),
},
{
value: ShapeStyle.Scribbled,
label:
t['com.affine.settings.editorSettings.edgeless.style.scribbled'](),
},
],
[t]
);
const { shapeStyle } = settings[`shape:${currentShape}`];
const setShapeStyle = useCallback(
(value: ShapeStyle) => {
editorSetting.set(`shape:${currentShape}`, {
shapeStyle: value,
});
},
[editorSetting, currentShape]
);
const borderStyleItems = useMemo<RadioItem[]>(
() => [
{
value: StrokeStyle.Solid,
label:
t['com.affine.settings.editorSettings.edgeless.note.border.solid'](),
},
{
value: StrokeStyle.Dash,
label:
t['com.affine.settings.editorSettings.edgeless.note.border.dash'](),
},
{
value: StrokeStyle.None,
label:
t['com.affine.settings.editorSettings.edgeless.note.border.none'](),
},
],
[t]
);
const borderStyle = settings[`shape:${currentShape}`].strokeStyle;
const setBorderStyle = useCallback(
(value: StrokeStyle) => {
editorSetting.set(`shape:${currentShape}`, {
strokeStyle: value,
});
},
[editorSetting, currentShape]
);
const alignItems = useMemo<RadioItem[]>(
() => [
{
value: TextAlign.Left,
label:
t[
'com.affine.settings.editorSettings.edgeless.text.alignment.left'
](),
},
{
value: TextAlign.Center,
label:
t[
'com.affine.settings.editorSettings.edgeless.text.alignment.center'
](),
},
{
value: TextAlign.Right,
label:
t[
'com.affine.settings.editorSettings.edgeless.text.alignment.right'
](),
},
],
[t]
);
const textAlignment = settings[`shape:${currentShape}`].textAlign;
const setTextAlignment = useCallback(
(value: TextAlign) => {
editorSetting.set(`shape:${currentShape}`, {
textAlign: value,
});
},
[editorSetting, currentShape]
);
const shapes = useMemo<RadioItem[]>(
() => [
{
value: ShapeType.Rect,
label: t['com.affine.settings.editorSettings.edgeless.shape.square'](),
},
{
value: ShapeType.Ellipse,
label: t['com.affine.settings.editorSettings.edgeless.shape.ellipse'](),
},
{
value: ShapeType.Diamond,
label: t['com.affine.settings.editorSettings.edgeless.shape.diamond'](),
},
{
value: ShapeType.Triangle,
label:
t['com.affine.settings.editorSettings.edgeless.shape.triangle'](),
},
{
value: 'roundedRect',
label:
t[
'com.affine.settings.editorSettings.edgeless.shape.rounded-rectangle'
](),
},
],
[t]
);
const docs = useMemo<RadioItem[]>(
() => [
{
value: 'shape',
label: t['com.affine.settings.editorSettings.edgeless.shape.list'](),
},
{
value: 'flow',
label: t['com.affine.settings.editorSettings.edgeless.shape.flow'](),
},
],
[t]
);
const [currentDoc, setCurrentDoc] = useState<DocName>('shape');
const fillColorItems = useMemo(() => {
const { fillColor } = settings[`shape:${currentShape}`];
return Object.entries(ShapeFillColor).map(([name, value]) => {
const handler = () => {
editorSetting.set(`shape:${currentShape}`, { fillColor: value });
};
const isSelected = fillColor === value;
return (
<MenuItem
key={name}
onSelect={handler}
selected={isSelected}
prefix={<Point color={value} />}
>
{name}
</MenuItem>
);
});
}, [editorSetting, settings, currentShape]);
const borderColorItems = useMemo(() => {
const { strokeColor } = settings[`shape:${currentShape}`];
return Object.entries(LineColor).map(([name, value]) => {
const handler = () => {
editorSetting.set(`shape:${currentShape}`, { strokeColor: value });
};
const isSelected = strokeColor === value;
return (
<MenuItem
key={name}
onSelect={handler}
selected={isSelected}
prefix={<Point color={value} />}
>
{name}
</MenuItem>
);
});
}, [editorSetting, settings, currentShape]);
const borderThickness = settings[`shape:${currentShape}`].strokeWidth;
const setBorderThickness = useCallback(
(value: number[]) => {
editorSetting.set(`shape:${currentShape}`, {
strokeWidth: value[0],
});
},
[editorSetting, currentShape]
);
const fontFamilyItems = useMemo(() => {
const { fontFamily } = settings[`shape:${currentShape}`];
return Object.entries(FontFamily).map(([name, value]) => {
const handler = () => {
editorSetting.set(`shape:${currentShape}`, { fontFamily: value });
};
const isSelected = fontFamily === value;
return (
<MenuItem key={name} onSelect={handler} selected={isSelected}>
{name}
</MenuItem>
);
});
}, [editorSetting, settings, currentShape]);
const fontStyleItems = useMemo(() => {
const { fontStyle } = settings[`shape:${currentShape}`];
return Object.entries(FontStyle).map(([name, value]) => {
const handler = () => {
editorSetting.set(`shape:${currentShape}`, { fontStyle: value });
};
const isSelected = fontStyle === value;
return (
<MenuItem key={name} onSelect={handler} selected={isSelected}>
{name}
</MenuItem>
);
});
}, [editorSetting, settings, currentShape]);
const fontWeightItems = useMemo(() => {
const { fontWeight } = settings[`shape:${currentShape}`];
return sortedFontWeightEntries.map(([name, value]) => {
const handler = () => {
editorSetting.set(`shape:${currentShape}`, { fontWeight: value });
};
const isSelected = fontWeight === value;
return (
<MenuItem key={name} onSelect={handler} selected={isSelected}>
{name}
</MenuItem>
);
});
}, [editorSetting, settings, currentShape]);
const fontSizeItems = useMemo(() => {
const { fontSize } = settings[`shape:${currentShape}`];
return Object.entries(ShapeTextFontSize).map(([name, value]) => {
const handler = () => {
editorSetting.set(`shape:${currentShape}`, { fontSize: Number(value) });
};
const isSelected = fontSize === Number(value);
return (
<MenuItem key={name} onSelect={handler} selected={isSelected}>
{name}
</MenuItem>
);
});
}, [editorSetting, settings, currentShape]);
const textColorItems = useMemo(() => {
const { color } = settings[`shape:${currentShape}`];
return Object.entries(LineColor).map(([name, value]) => {
const handler = () => {
editorSetting.set(`shape:${currentShape}`, { color: value });
};
const isSelected = color === value;
return (
<MenuItem
key={name}
onSelect={handler}
selected={isSelected}
prefix={<Point color={value} />}
>
{name}
</MenuItem>
);
});
}, [editorSetting, settings, currentShape]);
const getElements = useCallback(
(doc: Doc) => {
const surface = getSurfaceBlock(doc);
if (!surface) return [];
return surface.getElementsByType('shape').filter(node => {
const shape = node as ShapeElementModel;
const { shapeType, radius } = shape;
const shapeName = getShapeName(shapeType, radius);
return shapeName === currentShape;
});
},
[currentShape]
);
const firstUpdate = useCallback(
(doc: Doc, editorHost: EditorHost) => {
const edgelessService = editorHost.std.getService(
'affine:page'
) as EdgelessRootService;
const surface = getSurfaceBlock(doc);
if (!surface) return;
surface.getElementsByType('shape').forEach(node => {
const shape = node as ShapeElementModel;
const { shapeType, radius } = shape;
const shapeName = getShapeName(shapeType, radius);
const props = editorSetting.get(`shape:${shapeName}`);
edgelessService.updateElement(shape.id, props);
});
},
[editorSetting]
);
const fillColor = useMemo(() => {
const color = settings[`shape:${currentShape}`].fillColor;
return getColorFromMap(color, ShapeFillColorMap);
}, [currentShape, getColorFromMap, settings]);
const borderColor = useMemo(() => {
const color = settings[`shape:${currentShape}`].strokeColor;
return getColorFromMap(color, LineColorMap);
}, [currentShape, getColorFromMap, settings]);
const textColor = useMemo(() => {
const color = settings[`shape:${currentShape}`].color;
return getColorFromMap(color, LineColorMap);
}, [currentShape, getColorFromMap, settings]);
const height = currentDoc === 'flow' ? 456 : 180;
return (
<>
<EdgelessSnapshot
key={currentDoc}
title={t['com.affine.settings.editorSettings.edgeless.shape']()}
docName={currentDoc}
keyName={`shape:${currentShape}`}
height={height}
getElements={getElements}
firstUpdate={firstUpdate}
>
<RadioGroup
value={currentDoc}
items={docs}
onChange={setCurrentDoc}
style={{
position: 'absolute',
right: '10px',
bottom: '10px',
}}
className={preViewLabelWrapper}
/>
</EdgelessSnapshot>
<RadioGroup
padding={0}
gap={4}
itemHeight={28}
borderRadius={8}
value={currentShape}
items={shapes}
onChange={setCurrentShape}
style={{ background: 'transparent', marginBottom: '16px' }}
indicatorClassName={shapeIndicator}
/>
<SettingRow
name={t['com.affine.settings.editorSettings.edgeless.style']()}
desc={''}
>
<RadioGroup
items={shapeStyleItems}
value={shapeStyle}
width={250}
className={settingWrapper}
onChange={setShapeStyle}
/>
</SettingRow>
<SettingRow
name={t[
'com.affine.settings.editorSettings.edgeless.shape.fill-color'
]()}
desc={''}
>
{fillColor ? (
<DropdownMenu
items={fillColorItems}
trigger={
<MenuTrigger
className={menuTrigger}
prefix={<Point color={fillColor.value} />}
>
{fillColor.key}
</MenuTrigger>
}
/>
) : null}
</SettingRow>
<SettingRow
name={t[
'com.affine.settings.editorSettings.edgeless.shape.border-color'
]()}
desc={''}
>
{borderColor ? (
<DropdownMenu
items={borderColorItems}
trigger={
<MenuTrigger
className={menuTrigger}
prefix={<Point color={borderColor.value} />}
>
{borderColor.key}
</MenuTrigger>
}
/>
) : null}
</SettingRow>
<SettingRow
name={t[
'com.affine.settings.editorSettings.edgeless.shape.border-style'
]()}
desc={''}
>
<RadioGroup
items={borderStyleItems}
value={borderStyle}
width={250}
className={settingWrapper}
onChange={setBorderStyle}
/>
</SettingRow>
<SettingRow
name={t[
'com.affine.settings.editorSettings.edgeless.shape.border-thickness'
]()}
desc={''}
>
<Slider
value={[borderThickness]}
onValueChange={setBorderThickness}
min={2}
max={12}
step={2}
nodes={[2, 4, 6, 8, 10, 12]}
disabled={borderStyle === StrokeStyle.None}
/>
</SettingRow>
<SettingRow
name={t[
'com.affine.settings.editorSettings.edgeless.shape.text-color'
]()}
desc={''}
>
{textColor ? (
<DropdownMenu
items={textColorItems}
trigger={
<MenuTrigger
className={menuTrigger}
prefix={<Point color={textColor.value} />}
>
{textColor.key}
</MenuTrigger>
}
/>
) : null}
</SettingRow>
<SettingRow
name={t[
'com.affine.settings.editorSettings.edgeless.text.font-family'
]()}
desc={''}
>
<DropdownMenu
items={fontFamilyItems}
trigger={
<MenuTrigger className={menuTrigger}>
{FontFamilyMap[settings[`shape:${currentShape}`].fontFamily]}
</MenuTrigger>
}
/>
</SettingRow>
<SettingRow
name={t[
'com.affine.settings.editorSettings.edgeless.shape.font-size'
]()}
desc={''}
>
<DropdownMenu
items={fontSizeItems}
trigger={
<MenuTrigger className={menuTrigger}>
{settings[`shape:${currentShape}`].fontSize + 'px'}
</MenuTrigger>
}
/>
</SettingRow>
<SettingRow
name={t[
'com.affine.settings.editorSettings.edgeless.text.font-style'
]()}
desc={''}
>
<DropdownMenu
items={fontStyleItems}
trigger={
<MenuTrigger className={menuTrigger}>
{settings[`shape:${currentShape}`].fontStyle}
</MenuTrigger>
}
/>
</SettingRow>
<SettingRow
name={t[
'com.affine.settings.editorSettings.edgeless.text.font-weight'
]()}
desc={''}
>
<DropdownMenu
items={fontWeightItems}
trigger={
<MenuTrigger className={menuTrigger}>
{FontWeightMap[settings[`shape:${currentShape}`].fontWeight]}
</MenuTrigger>
}
/>
</SettingRow>
<SettingRow
name={t[
'com.affine.settings.editorSettings.edgeless.shape.text-alignment'
]()}
desc={''}
>
<RadioGroup
items={alignItems}
value={textAlignment}
width={250}
className={settingWrapper}
onChange={setTextAlignment}
/>
</SettingRow>
</>
);
};
@@ -0,0 +1,155 @@
import { Skeleton } from '@affine/component';
import type { EditorSettingSchema } from '@affine/core/modules/editor-setting';
import { EditorSettingService } from '@affine/core/modules/editor-setting';
import type { EditorHost } from '@blocksuite/affine/block-std';
import { BlockStdScope } from '@blocksuite/affine/block-std';
import type { GfxPrimitiveElementModel } from '@blocksuite/affine/block-std/gfx';
import type { EdgelessRootService } from '@blocksuite/affine/blocks';
import { SpecProvider } from '@blocksuite/affine/blocks';
import { Bound } from '@blocksuite/affine/global/utils';
import type { Block, Doc } from '@blocksuite/affine/store';
import { useFramework } from '@toeverything/infra';
import { isEqual } from 'lodash-es';
import { useCallback, useEffect, useRef } from 'react';
import { map, pairwise } from 'rxjs';
import {
snapshotContainer,
snapshotLabel,
snapshotSkeleton,
snapshotTitle,
} from '../style.css';
import { type DocName, getDocByName } from './docs';
import { getFrameBlock } from './utils';
interface Props {
title: string;
docName: DocName;
keyName: keyof EditorSettingSchema;
height?: number;
getElements: (doc: Doc) => Array<Block | GfxPrimitiveElementModel>;
firstUpdate?: (doc: Doc, editorHost: EditorHost) => void;
children?: React.ReactElement;
}
const boundMap = new Map<DocName, Bound>();
export const EdgelessSnapshot = (props: Props) => {
const {
title,
docName,
keyName,
height = 180,
getElements,
firstUpdate,
children,
} = props;
const wrapperRef = useRef<HTMLDivElement | null>(null);
const docRef = useRef<Doc | null>(null);
const editorHostRef = useRef<EditorHost | null>(null);
const framework = useFramework();
const { editorSetting } = framework.get(EditorSettingService);
const updateElements = useCallback(() => {
const editorHost = editorHostRef.current;
const doc = docRef.current;
if (!editorHost || !doc) return;
const edgelessService = editorHost.std.getService(
'affine:page'
) as EdgelessRootService;
const elements = getElements(doc);
const props = editorSetting.get(keyName) as any;
elements.forEach(element => {
edgelessService.updateElement(element.id, props);
});
}, [editorSetting, getElements, keyName]);
const renderEditor = useCallback(async () => {
if (!wrapperRef.current) return;
const doc = await getDocByName(docName);
if (!doc) return;
const editorHost = new BlockStdScope({
doc,
extensions: SpecProvider.getInstance().getSpec('edgeless:preview').value,
}).render();
docRef.current = doc;
editorHostRef.current = editorHost;
if (firstUpdate) {
firstUpdate(doc, editorHost);
} else {
updateElements();
}
// refresh viewport
const edgelessService = editorHost.std.getService(
'affine:page'
) as EdgelessRootService;
edgelessService.specSlots.viewConnected.once(({ component }) => {
const edgelessBlock = component as any;
edgelessBlock.editorViewportSelector = 'ref-viewport';
const frame = getFrameBlock(doc);
if (frame) {
boundMap.set(docName, Bound.deserialize(frame.xywh));
doc.deleteBlock(frame);
}
const bound = boundMap.get(docName);
bound && edgelessService.viewport.setViewportByBound(bound);
});
// append to dom node
wrapperRef.current.append(editorHost);
}, [docName, firstUpdate, updateElements]);
useEffect(() => {
// eslint-disable-next-line @typescript-eslint/no-floating-promises
renderEditor();
return () => editorHostRef.current?.remove();
}, [renderEditor]);
// observe editor settings change
useEffect(() => {
const sub = editorSetting.provider
.watchAll()
.pipe(
map(settings => {
if (typeof settings[keyName] === 'string') {
return JSON.parse(settings[keyName]);
}
return keyName;
}),
pairwise()
)
.subscribe(([prev, current]) => {
if (!isEqual(prev, current)) {
updateElements();
}
});
return () => sub.unsubscribe();
}, [editorSetting.provider, keyName, updateElements]);
return (
<div className={snapshotContainer}>
<div className={snapshotTitle}>{title}</div>
<div className={snapshotLabel}>{title}</div>
<div
ref={wrapperRef}
style={{
position: 'relative',
pointerEvents: 'none',
userSelect: 'none',
overflow: 'hidden',
height,
}}
>
<Skeleton
className={snapshotSkeleton}
variant="rounded"
height={'100%'}
/>
</div>
{children}
</div>
);
};
@@ -0,0 +1,232 @@
import {
MenuItem,
MenuTrigger,
RadioGroup,
type RadioItem,
} from '@affine/component';
import { SettingRow } from '@affine/component/setting-components';
import { EditorSettingService } from '@affine/core/modules/editor-setting';
import { useI18n } from '@affine/i18n';
import {
FontFamily,
FontFamilyMap,
FontStyle,
FontWeightMap,
LineColor,
LineColorMap,
TextAlign,
} from '@blocksuite/affine/blocks';
import type { Doc } from '@blocksuite/affine/store';
import { useFramework, useLiveData } from '@toeverything/infra';
import { useCallback, useMemo } from 'react';
import { DropdownMenu } from '../menu';
import { menuTrigger, settingWrapper } from '../style.css';
import { sortedFontWeightEntries, useColor } from '../utils';
import { Point } from './point';
import { EdgelessSnapshot } from './snapshot';
export const TextSettings = () => {
const t = useI18n();
const framework = useFramework();
const { editorSetting } = framework.get(EditorSettingService);
const settings = useLiveData(editorSetting.settings$);
const getColorFromMap = useColor();
const alignItems = useMemo<RadioItem[]>(
() => [
{
value: TextAlign.Left,
label:
t[
'com.affine.settings.editorSettings.edgeless.text.alignment.left'
](),
},
{
value: TextAlign.Center,
label:
t[
'com.affine.settings.editorSettings.edgeless.text.alignment.center'
](),
},
{
value: TextAlign.Right,
label:
t[
'com.affine.settings.editorSettings.edgeless.text.alignment.right'
](),
},
],
[t]
);
const { textAlign } = settings['affine:edgeless-text'];
const setTextAlign = useCallback(
(value: TextAlign) => {
editorSetting.set('affine:edgeless-text', {
textAlign: value,
});
},
[editorSetting]
);
const colorItems = useMemo(() => {
const { color } = settings['affine:edgeless-text'];
return Object.entries(LineColor).map(([name, value]) => {
const handler = () => {
editorSetting.set('affine:edgeless-text', { color: value });
};
const isSelected = color === value;
return (
<MenuItem
key={name}
onSelect={handler}
selected={isSelected}
prefix={<Point color={value} />}
>
{name}
</MenuItem>
);
});
}, [editorSetting, settings]);
const fontFamilyItems = useMemo(() => {
const { fontFamily } = settings['affine:edgeless-text'];
return Object.entries(FontFamily).map(([name, value]) => {
const handler = () => {
editorSetting.set('affine:edgeless-text', { fontFamily: value });
};
const isSelected = fontFamily === value;
return (
<MenuItem key={name} onSelect={handler} selected={isSelected}>
{name}
</MenuItem>
);
});
}, [editorSetting, settings]);
const fontStyleItems = useMemo(() => {
const { fontStyle } = settings['affine:edgeless-text'];
return Object.entries(FontStyle).map(([name, value]) => {
const handler = () => {
editorSetting.set('affine:edgeless-text', { fontStyle: value });
};
const isSelected = fontStyle === value;
return (
<MenuItem key={name} onSelect={handler} selected={isSelected}>
{name}
</MenuItem>
);
});
}, [editorSetting, settings]);
const fontWeightItems = useMemo(() => {
const { fontWeight } = settings['affine:edgeless-text'];
return sortedFontWeightEntries.map(([name, value]) => {
const handler = () => {
editorSetting.set('affine:edgeless-text', { fontWeight: value });
};
const isSelected = fontWeight === value;
return (
<MenuItem key={name} onSelect={handler} selected={isSelected}>
{name}
</MenuItem>
);
});
}, [editorSetting, settings]);
const currentColor = useMemo(() => {
const { color } = settings['affine:edgeless-text'];
return getColorFromMap(color, LineColorMap);
}, [getColorFromMap, settings]);
const getElements = useCallback((doc: Doc) => {
return doc.getBlocksByFlavour('affine:edgeless-text') || [];
}, []);
return (
<>
<EdgelessSnapshot
title={t['com.affine.settings.editorSettings.edgeless.text']()}
docName="text"
keyName="affine:edgeless-text"
getElements={getElements}
/>
<SettingRow
name={t['com.affine.settings.editorSettings.edgeless.text.color']()}
desc={''}
>
{currentColor ? (
<DropdownMenu
items={colorItems}
trigger={
<MenuTrigger
className={menuTrigger}
prefix={<Point color={currentColor.value} />}
>
{currentColor.key}
</MenuTrigger>
}
/>
) : null}
</SettingRow>
<SettingRow
name={t[
'com.affine.settings.editorSettings.edgeless.text.font-family'
]()}
desc={''}
>
<DropdownMenu
items={fontFamilyItems}
trigger={
<MenuTrigger className={menuTrigger}>
{FontFamilyMap[settings['affine:edgeless-text'].fontFamily]}
</MenuTrigger>
}
/>
</SettingRow>
<SettingRow
name={t[
'com.affine.settings.editorSettings.edgeless.text.font-style'
]()}
desc={''}
>
<DropdownMenu
items={fontStyleItems}
trigger={
<MenuTrigger className={menuTrigger}>
{String(settings['affine:edgeless-text'].fontStyle)}
</MenuTrigger>
}
/>
</SettingRow>
<SettingRow
name={t[
'com.affine.settings.editorSettings.edgeless.text.font-weight'
]()}
desc={''}
>
<DropdownMenu
items={fontWeightItems}
trigger={
<MenuTrigger className={menuTrigger}>
{FontWeightMap[settings['affine:edgeless-text'].fontWeight]}
</MenuTrigger>
}
/>
</SettingRow>
<SettingRow
name={t['com.affine.settings.editorSettings.edgeless.text.alignment']()}
desc={''}
>
<RadioGroup
items={alignItems}
value={textAlign}
width={250}
className={settingWrapper}
onChange={setTextAlign}
/>
</SettingRow>
</>
);
};
@@ -0,0 +1,13 @@
import type { SurfaceBlockModel } from '@blocksuite/affine/block-std/gfx';
import type { FrameBlockModel } from '@blocksuite/affine/blocks';
import type { Doc } from '@blocksuite/affine/store';
export function getSurfaceBlock(doc: Doc) {
const blocks = doc.getBlocksByFlavour('affine:surface');
return blocks.length !== 0 ? (blocks[0].model as SurfaceBlockModel) : null;
}
export function getFrameBlock(doc: Doc) {
const blocks = doc.getBlocksByFlavour('affine:frame');
return blocks.length !== 0 ? (blocks[0].model as FrameBlockModel) : null;
}
@@ -0,0 +1,477 @@
import {
Loading,
Menu,
MenuItem,
MenuSeparator,
MenuTrigger,
RadioGroup,
type RadioItem,
RowInput,
Scrollable,
Switch,
useConfirmModal,
} from '@affine/component';
import {
SettingRow,
SettingWrapper,
} from '@affine/component/setting-components';
import { ServerConfigService } from '@affine/core/modules/cloud';
import {
EditorSettingService,
type FontFamily,
fontStyleOptions,
} from '@affine/core/modules/editor-setting';
import {
type FontData,
SystemFontFamilyService,
} from '@affine/core/modules/system-font-family';
import { useI18n } from '@affine/i18n';
import type { DocMode } from '@blocksuite/affine/blocks';
import { DoneIcon, SearchIcon } from '@blocksuite/icons/rc';
import {
FeatureFlagService,
useLiveData,
useServices,
} from '@toeverything/infra';
import clsx from 'clsx';
import {
forwardRef,
type HTMLAttributes,
type PropsWithChildren,
useCallback,
useEffect,
useMemo,
} from 'react';
import { Virtuoso } from 'react-virtuoso';
import { DropdownMenu } from './menu';
import * as styles from './style.css';
const getLabel = (fontKey: FontFamily, t: ReturnType<typeof useI18n>) => {
switch (fontKey) {
case 'Sans':
return t['com.affine.appearanceSettings.fontStyle.sans']();
case 'Serif':
return t['com.affine.appearanceSettings.fontStyle.serif']();
case 'Mono':
return t[`com.affine.appearanceSettings.fontStyle.mono`]();
case 'Custom':
return t['com.affine.settings.editorSettings.edgeless.custom']();
default:
return '';
}
};
export const getBaseFontStyleOptions = (
t: ReturnType<typeof useI18n>
): Array<Omit<RadioItem, 'value'> & { value: FontFamily }> => {
return fontStyleOptions
.map(({ key, value }) => {
if (key === 'Custom') {
return null;
}
const label = getLabel(key, t);
return {
value: key,
label,
testId: 'system-font-style-trigger',
style: {
fontFamily: value,
},
} satisfies RadioItem;
})
.filter(item => item !== null);
};
const FontFamilySettings = () => {
const t = useI18n();
const { editorSettingService } = useServices({ EditorSettingService });
const settings = useLiveData(editorSettingService.editorSetting.settings$);
const radioItems = useMemo(() => {
const items = getBaseFontStyleOptions(t);
if (!BUILD_CONFIG.isElectron) return items;
// resolve custom fonts
const customOption = fontStyleOptions.find(opt => opt.key === 'Custom');
if (customOption) {
const fontFamily = settings.customFontFamily
? `${settings.customFontFamily}, ${customOption.value}`
: customOption.value;
items.push({
value: customOption.key,
label: getLabel(customOption.key, t),
testId: 'system-font-style-trigger',
style: { fontFamily },
});
}
return items;
}, [settings.customFontFamily, t]);
const handleFontFamilyChange = useCallback(
(value: FontFamily) => {
editorSettingService.editorSetting.set('fontFamily', value);
},
[editorSettingService.editorSetting]
);
return (
<SettingRow
name={t['com.affine.appearanceSettings.font.title']()}
desc={t['com.affine.appearanceSettings.font.description']()}
>
<RadioGroup
items={radioItems}
value={settings.fontFamily}
width={250}
className={styles.settingWrapper}
onChange={handleFontFamilyChange}
/>
</SettingRow>
);
};
const getFontFamily = (font: string) => `${font}, ${fontStyleOptions[0].value}`;
const Scroller = forwardRef<
HTMLDivElement,
PropsWithChildren<HTMLAttributes<HTMLDivElement>>
>(({ children, ...props }, ref) => {
return (
<Scrollable.Root>
<Scrollable.Viewport {...props} ref={ref}>
{children}
</Scrollable.Viewport>
<Scrollable.Scrollbar />
</Scrollable.Root>
);
});
Scroller.displayName = 'Scroller';
const FontMenuItems = ({ onSelect }: { onSelect: (font: string) => void }) => {
const { systemFontFamilyService, editorSettingService } = useServices({
SystemFontFamilyService,
EditorSettingService,
});
const systemFontFamily = systemFontFamilyService.systemFontFamily;
const currentCustomFont = useLiveData(
editorSettingService.editorSetting.settings$
).customFontFamily;
useEffect(() => {
if (systemFontFamily.fontList$.value.length === 0) {
systemFontFamily.loadFontList();
}
systemFontFamily.clearSearch();
}, [systemFontFamily]);
const isLoading = useLiveData(systemFontFamily.isLoading$);
const result = useLiveData(systemFontFamily.result$);
const searchText = useLiveData(systemFontFamily.searchText$);
const onInputChange = useCallback(
(value: string) => {
systemFontFamily.search(value);
},
[systemFontFamily]
);
const onInputKeyDown = useCallback(
(e: React.KeyboardEvent<HTMLInputElement>) => {
e.stopPropagation(); // avoid typeahead search built-in in the menu
},
[]
);
return (
<div>
<div className={styles.InputContainer}>
<SearchIcon className={styles.searchIcon} />
<RowInput
value={searchText ?? ''}
onChange={onInputChange}
onKeyDown={onInputKeyDown}
autoFocus
className={styles.searchInput}
placeholder="Fonts"
/>
</div>
<MenuSeparator />
{isLoading ? (
<Loading />
) : (
<Scrollable.Root style={{ height: '330px' }}>
<Scrollable.Viewport>
{result.length > 0 ? (
<Virtuoso
totalCount={result.length}
components={{
Scroller: Scroller,
}}
itemContent={index => (
<FontMenuItem
key={result[index].fullName}
font={result[index]}
onSelect={onSelect}
currentFont={currentCustomFont}
/>
)}
/>
) : (
<div className={styles.notFound}>No results found.</div>
)}
</Scrollable.Viewport>
<Scrollable.Scrollbar />
</Scrollable.Root>
)}
</div>
);
};
const FontMenuItem = ({
font,
currentFont,
onSelect,
}: {
font: FontData;
currentFont: string;
onSelect: (font: string) => void;
}) => {
const handleFontSelect = useCallback(
() => onSelect(font.family),
[font, onSelect]
);
const fontFamily = getFontFamily(font.family);
const selected = currentFont === font.fullName;
return (
<div style={{ marginTop: '4px' }}>
<MenuItem key={font.fullName} onSelect={handleFontSelect}>
<div className={styles.fontItemContainer}>
<div className={styles.fontItem}>
<div className={styles.fontLabel} style={{ fontFamily }}>
{font.fullName}
</div>
<div className={clsx(styles.fontLabel, 'secondary')}>
{font.fullName}
</div>
</div>
{selected && (
<DoneIcon fontSize={20} className={styles.selectedIcon} />
)}
</div>
</MenuItem>
</div>
);
};
const CustomFontFamilySettings = () => {
const t = useI18n();
const { editorSettingService } = useServices({ EditorSettingService });
const settings = useLiveData(editorSettingService.editorSetting.settings$);
const fontFamily = getFontFamily(settings.customFontFamily);
const onCustomFontFamilyChange = useCallback(
(fontFamily: string) => {
editorSettingService.editorSetting.set('customFontFamily', fontFamily);
},
[editorSettingService.editorSetting]
);
if (settings.fontFamily !== 'Custom' || !BUILD_CONFIG.isElectron) {
return null;
}
return (
<SettingRow
name={t[
'com.affine.settings.editorSettings.general.font-family.custom.title'
]()}
desc={t[
'com.affine.settings.editorSettings.general.font-family.custom.description'
]()}
>
<Menu
items={<FontMenuItems onSelect={onCustomFontFamilyChange} />}
contentOptions={{
align: 'end',
style: { width: '250px', height: '380px' },
}}
>
<MenuTrigger className={styles.menuTrigger} style={{ fontFamily }}>
{settings.customFontFamily || 'Select a font'}
</MenuTrigger>
</Menu>
</SettingRow>
);
};
const NewDocDefaultModeSettings = () => {
const t = useI18n();
const { editorSettingService } = useServices({ EditorSettingService });
const settings = useLiveData(editorSettingService.editorSetting.settings$);
const radioItems = useMemo<RadioItem[]>(
() => [
{
value: 'page',
label: t['Page'](),
testId: 'page-mode-trigger',
},
{
value: 'edgeless',
label: t['Edgeless'](),
testId: 'edgeless-mode-trigger',
},
],
[t]
);
const updateNewDocDefaultMode = useCallback(
(value: DocMode) => {
editorSettingService.editorSetting.set('newDocDefaultMode', value);
},
[editorSettingService.editorSetting]
);
return (
<SettingRow
name={t[
'com.affine.settings.editorSettings.general.default-new-doc.title'
]()}
desc={t[
'com.affine.settings.editorSettings.general.default-new-doc.description'
]()}
>
<RadioGroup
items={radioItems}
value={settings.newDocDefaultMode}
width={250}
className={styles.settingWrapper}
onChange={updateNewDocDefaultMode}
/>
</SettingRow>
);
};
export const DeFaultCodeBlockSettings = () => {
const t = useI18n();
return (
<>
<SettingRow
name={t[
'com.affine.settings.editorSettings.general.default-code-block.language.title'
]()}
desc={t[
'com.affine.settings.editorSettings.general.default-code-block.language.description'
]()}
>
<DropdownMenu
items={<MenuItem>Plain Text</MenuItem>}
trigger={
<MenuTrigger className={styles.menuTrigger} disabled>
Plain Text
</MenuTrigger>
}
/>
</SettingRow>
<SettingRow
name={t[
'com.affine.settings.editorSettings.general.default-code-block.wrap.title'
]()}
desc={t[
'com.affine.settings.editorSettings.general.default-code-block.wrap.description'
]()}
>
<Switch />
</SettingRow>
</>
);
};
export const SpellCheckSettings = () => {
const t = useI18n();
return (
<SettingRow
name={t['com.affine.settings.editorSettings.general.spell-check.title']()}
desc={t[
'com.affine.settings.editorSettings.general.spell-check.description'
]()}
>
<Switch />
</SettingRow>
);
};
const AISettings = () => {
const t = useI18n();
const { openConfirmModal } = useConfirmModal();
const { featureFlagService, serverConfigService } = useServices({
FeatureFlagService,
ServerConfigService,
});
const serverFeatures = useLiveData(
serverConfigService.serverConfig.features$
);
const enableAI = useLiveData(featureFlagService.flags.enable_ai.$);
const onAIChange = useCallback(
(checked: boolean) => {
featureFlagService.flags.enable_ai.set(checked); // this will trigger page reload, see `FeatureFlagService`
},
[featureFlagService]
);
const onToggleAI = useCallback(
(checked: boolean) => {
openConfirmModal({
title: checked
? t['com.affine.settings.editorSettings.general.ai.enable.title']()
: t['com.affine.settings.editorSettings.general.ai.disable.title'](),
description: checked
? t[
'com.affine.settings.editorSettings.general.ai.enable.description'
]()
: t[
'com.affine.settings.editorSettings.general.ai.disable.description'
](),
confirmText: checked
? t['com.affine.settings.editorSettings.general.ai.enable.confirm']()
: t[
'com.affine.settings.editorSettings.general.ai.disable.confirm'
](),
cancelText: t['Cancel'](),
onConfirm: () => onAIChange(checked),
confirmButtonOptions: {
variant: checked ? 'primary' : 'error',
},
});
},
[openConfirmModal, t, onAIChange]
);
if (!serverFeatures?.copilot) {
return null;
}
return (
<SettingRow
name={t['com.affine.settings.editorSettings.general.ai.title']()}
desc={t['com.affine.settings.editorSettings.general.ai.description']()}
>
<Switch checked={enableAI} onChange={onToggleAI} />
</SettingRow>
);
};
export const General = () => {
const t = useI18n();
return (
<SettingWrapper title={t['com.affine.settings.editorSettings.general']()}>
<AISettings />
<FontFamilySettings />
<CustomFontFamilySettings />
<NewDocDefaultModeSettings />
{/* // TODO(@akumatus): implement these settings
<DeFaultCodeBlockSettings />
<SpellCheckSettings /> */}
</SettingWrapper>
);
};
@@ -0,0 +1,25 @@
import { SettingHeader } from '@affine/component/setting-components';
import { useI18n } from '@affine/i18n';
import { Edgeless } from './edgeless';
import { General } from './general';
import { Page } from './page';
export const EditorSettings = () => {
const t = useI18n();
return (
<>
<SettingHeader
title={t['com.affine.settings.editorSettings.title']()}
subtitle={t['com.affine.settings.editorSettings.subtitle']()}
/>
<General />
<Page />
<Edgeless />
{/* // TODO(@EYHN): implement export and import
<Preferences /> */}
</>
);
};
@@ -0,0 +1,23 @@
import { Menu } from '@affine/component';
import { type ReactNode } from 'react';
export const DropdownMenu = ({
items,
trigger,
}: {
items: ReactNode;
trigger: ReactNode;
}) => {
return (
<Menu
items={items}
contentOptions={{
style: {
width: '250px',
},
}}
>
{trigger}
</Menu>
);
};
@@ -0,0 +1,79 @@
import { Switch } from '@affine/component';
import {
SettingRow,
SettingWrapper,
} from '@affine/component/setting-components';
import { EditorSettingService } from '@affine/core/modules/editor-setting';
import { useI18n } from '@affine/i18n';
import { useLiveData, useService } from '@toeverything/infra';
import { useCallback } from 'react';
export const Page = () => {
const t = useI18n();
const editorSetting = useService(EditorSettingService).editorSetting;
const settings = useLiveData(editorSetting.settings$);
const handleFullWidthLayoutChange = useCallback(
(checked: boolean) => {
editorSetting.set('fullWidthLayout', checked);
},
[editorSetting]
);
const handleDisplayDocInfoChange = useCallback(
(checked: boolean) => {
editorSetting.set('displayDocInfo', checked);
},
[editorSetting]
);
const handleDisplayBiDirectionalLinkChange = useCallback(
(checked: boolean) => {
editorSetting.set('displayBiDirectionalLink', checked);
},
[editorSetting]
);
return (
<SettingWrapper title={t['com.affine.settings.editorSettings.page']()}>
<SettingRow
name={t['com.affine.settings.editorSettings.page.full-width.title']()}
desc={t[
'com.affine.settings.editorSettings.page.full-width.description'
]()}
>
<Switch
data-testid="full-width-layout-trigger"
checked={settings.fullWidthLayout}
onChange={handleFullWidthLayoutChange}
/>
</SettingRow>
<SettingRow
name={t[
'com.affine.settings.editorSettings.page.display-doc-info.title'
]()}
desc={t[
'com.affine.settings.editorSettings.page.display-doc-info.description'
]()}
>
<Switch
data-testid="display-doc-info-trigger"
checked={settings.displayDocInfo}
onChange={handleDisplayDocInfoChange}
/>
</SettingRow>
<SettingRow
name={t[
'com.affine.settings.editorSettings.page.display-bi-link.title'
]()}
desc={t[
'com.affine.settings.editorSettings.page.display-bi-link.description'
]()}
>
<Switch
data-testid="display-bi-link-trigger"
checked={settings.displayBiDirectionalLink}
onChange={handleDisplayBiDirectionalLinkChange}
/>
</SettingRow>
</SettingWrapper>
);
};
@@ -0,0 +1,36 @@
import { Button } from '@affine/component';
import {
SettingRow,
SettingWrapper,
} from '@affine/component/setting-components';
import { useI18n } from '@affine/i18n';
export const Preferences = () => {
const t = useI18n();
return (
<SettingWrapper
title={t['com.affine.settings.editorSettings.preferences']()}
>
<SettingRow
name={t[
'com.affine.settings.editorSettings.preferences.export.title'
]()}
desc={t[
'com.affine.settings.editorSettings.preferences.export.description'
]()}
>
<Button>Export</Button>
</SettingRow>
<SettingRow
name={t[
'com.affine.settings.editorSettings.preferences.import.title'
]()}
desc={t[
'com.affine.settings.editorSettings.preferences.import.description'
]()}
>
<Button>Import</Button>
</SettingRow>
</SettingWrapper>
);
};
@@ -0,0 +1,149 @@
import { cssVar } from '@toeverything/theme';
import { cssVarV2 } from '@toeverything/theme/v2';
import { style } from '@vanilla-extract/css';
export const settingWrapper = style({
flexGrow: 1,
display: 'flex',
justifyContent: 'flex-end',
minWidth: '150px',
maxWidth: '250px',
});
export const preViewLabelWrapper = style({
flexGrow: 1,
display: 'flex',
justifyContent: 'flex-end',
minWidth: '100px',
maxWidth: '250px',
});
export const menu = style({
background: 'white',
width: '250px',
maxHeight: '30vh',
overflowY: 'auto',
});
export const menuTrigger = style({
textTransform: 'capitalize',
fontWeight: 600,
width: '250px',
});
export const snapshotContainer = style({
position: 'relative',
display: 'flex',
flexDirection: 'column',
marginBottom: '24px',
});
export const snapshotTitle = style({
marginBottom: '8px',
fontSize: cssVar('fontSm'),
fontWeight: 500,
color: cssVarV2('text/secondary'),
});
export const snapshotSkeleton = style({
position: 'absolute',
top: 0,
left: 0,
});
export const snapshot = style({
width: '100%',
height: '180px',
border: `1px solid ${cssVarV2('layer/insideBorder/border')}`,
borderRadius: '4px',
display: 'flex',
justifyContent: 'center',
alignItems: 'center',
});
export const snapshotLabel = style({
position: 'absolute',
bottom: '12px',
left: '50%',
transform: 'translateX(-50%)',
fontSize: cssVar('fontXs'),
color: cssVarV2('text/secondary'),
backgroundColor: cssVarV2('layer/background/hoverOverlay'),
padding: '2px 8px',
display: 'inline-flex',
alignItems: 'center',
justifyContent: 'center',
borderRadius: '4px',
zIndex: 1,
height: '24px',
});
export const shapeIndicator = style({
boxShadow: 'none',
backgroundColor: cssVarV2('layer/background/tertiary'),
});
export const InputContainer = style({
display: 'flex',
alignItems: 'center',
padding: '4px',
width: '100%',
justifyContent: 'flex-start',
gap: '6px',
});
export const searchInput = style({
flexGrow: 1,
border: 'none',
outline: 'none',
fontSize: cssVar('fontSm'),
fontFamily: 'inherit',
color: 'inherit',
backgroundColor: 'transparent',
'::placeholder': {
color: cssVarV2('text/placeholder'),
},
});
export const searchIcon = style({
color: cssVarV2('icon/primary'),
fontSize: '20px',
});
export const fontItemContainer = style({
display: 'flex',
justifyContent: 'space-between',
alignItems: 'center',
overflow: 'hidden',
width: '100%',
});
export const fontItem = style({
display: 'flex',
flexDirection: 'column',
alignItems: 'flex-start',
overflow: 'hidden',
whiteSpace: 'nowrap',
textOverflow: 'ellipsis',
});
export const fontLabel = style({
display: 'inline-flex',
alignItems: 'center',
color: cssVarV2('text/primary'),
width: '100%',
whiteSpace: 'nowrap',
textOverflow: 'ellipsis',
overflow: 'hidden',
fontSize: cssVar('fontSm'),
lineHeight: 'normal',
height: '22px',
selectors: {
'&.secondary': {
color: cssVarV2('text/secondary'),
fontSize: cssVar('fontXs'),
height: '20px',
},
},
});
export const selectedIcon = style({
color: cssVarV2('button/primary'),
marginLeft: '8px',
});
export const notFound = style({
color: cssVarV2('text/secondary'),
fontSize: cssVar('fontXs'),
padding: '4px',
});
@@ -0,0 +1,50 @@
import { FontWeight } from '@blocksuite/affine/blocks';
import { useTheme } from 'next-themes';
function getColorFromMap(
color: string | { normal: string } | { light: string; dark: string },
colorMap: { [key: string]: string },
theme: 'light' | 'dark' = 'light'
):
| {
value: string;
key: string;
}
| undefined {
if (typeof color === 'string') {
return { value: color, key: colorMap[color] };
}
if ('normal' in color) {
return {
value: color.normal,
key: colorMap[color.normal],
};
}
if ('light' in color && 'dark' in color) {
return {
value: color[theme],
key: colorMap[color[theme]],
};
}
return undefined;
}
export const useColor = () => {
const { resolvedTheme } = useTheme();
return (
color: string | { normal: string } | { light: string; dark: string },
colorMap: { [key: string]: string }
) =>
getColorFromMap(
color,
colorMap,
resolvedTheme as 'light' | 'dark' | undefined
);
};
export const sortedFontWeightEntries = Object.entries(FontWeight).sort(
(a, b) => Number(a[1]) - Number(b[1])
);
@@ -0,0 +1,6 @@
import { style } from '@vanilla-extract/css';
export const root = style({
height: 100,
display: 'flex',
justifyContent: 'center',
});
@@ -0,0 +1,18 @@
import { useTheme } from 'next-themes';
import * as styles from './arts.css';
import DarkSvg from './dark-art-svg';
import LightSvg from './light-art-svg';
export const ExperimentalFeatureArts = () => {
const { resolvedTheme } = useTheme();
return (
<div
className={styles.root}
dangerouslySetInnerHTML={{
__html: resolvedTheme === 'dark' ? DarkSvg : LightSvg,
}}
/>
);
};
@@ -0,0 +1,857 @@
export default `<svg
width="429"
height="101"
viewBox="0 0 429 101"
fill="none"
xmlns="http://www.w3.org/2000/svg"
>
<g opacity="0.3" filter="url(#filter0_i_909_33931)">
<g clip-path="url(#clip0_909_33931)">
<rect
x="0.505859"
y="30.9414"
width="40"
height="40"
rx="6.06061"
fill="#454545"
fill-opacity="0.42"
/>
<g filter="url(#filter1_d_909_33931)">
<rect
x="2.32397"
y="32.7595"
width="36.3636"
height="36.3636"
rx="3.96118"
fill="url(#paint0_linear_909_33931)"
/>
<rect
x="2.47549"
y="32.911"
width="36.0606"
height="36.0606"
rx="3.80966"
stroke="#757575"
stroke-width="0.30303"
/>
<g filter="url(#filter2_i_909_33931)">
<path
d="M29.6836 40.9414H20.0521C20.0521 43.3415 21.998 45.2874 24.398 45.2874H26.1719V47.0019C26.1739 49.3966 28.1125 51.3379 30.5052 51.3445H30.5059V41.7797C30.5059 41.7783 30.5059 41.7777 30.5059 41.7763C30.5059 41.3195 30.1391 40.9487 29.6843 40.9421H29.6836V40.9414ZM24.9182 45.7388H15.2866C15.2873 48.1389 17.2325 50.0841 19.6326 50.0841H21.4065V51.7993C21.4085 54.1987 23.353 56.1426 25.7524 56.1446V46.5738C25.7524 46.113 25.379 45.7395 24.9182 45.7395V45.7388ZM20.1488 50.5356H10.5059C10.5072 52.9363 12.4538 54.8816 14.8545 54.8816C14.8585 54.8816 14.8625 54.8816 14.8665 54.8816H16.641V56.5954C16.641 58.9948 18.5856 60.9401 20.985 60.9414V51.3732C20.985 50.9104 20.6102 50.5356 20.1474 50.5356H20.1488Z"
fill="#D0CFCF"
/>
</g>
</g>
</g>
</g>
<g opacity="0.5" filter="url(#filter3_i_909_33931)">
<g clip-path="url(#clip1_909_33931)">
<rect
x="72.5059"
y="20.9414"
width="60"
height="60"
rx="9.09091"
fill="#454545"
fill-opacity="0.42"
/>
<g filter="url(#filter4_d_909_33931)">
<rect
x="75.2332"
y="23.6687"
width="54.5455"
height="54.5455"
rx="5.94177"
fill="url(#paint1_linear_909_33931)"
/>
<rect
x="75.4604"
y="23.896"
width="54.0909"
height="54.0909"
rx="5.7145"
stroke="#757575"
stroke-width="0.454545"
/>
<g filter="url(#filter5_i_909_33931)">
<path
d="M107.701 47.2596H101.514V35.1914H107.701C111.04 35.1914 113.756 37.8977 113.756 41.2248C113.756 44.552 111.04 47.2596 107.701 47.2596ZM103.498 45.283H107.701C109.947 45.283 111.772 43.4622 111.772 41.2262C111.772 38.9902 109.945 37.1694 107.701 37.1694H103.498V45.283ZM103.498 47.2596H97.3123C93.9734 47.2596 91.2575 44.5533 91.2575 41.2262C91.2575 37.8991 93.9734 35.1914 97.3123 35.1914H103.499V47.2596H103.498ZM97.3123 37.1681C95.067 37.1681 93.2411 38.9888 93.2411 41.2248C93.2411 43.4608 95.067 45.283 97.3123 45.283H101.516V37.1681H97.3123ZM103.498 57.3498H97.3123C93.9734 57.3498 91.2575 54.6435 91.2575 51.3164C91.2575 47.9893 93.9734 45.283 97.3123 45.283H103.499V57.3498H103.498ZM97.3123 47.2596C95.067 47.2596 93.2411 49.0804 93.2411 51.3164C93.2411 53.5524 95.0684 55.3732 97.3123 55.3732H101.516V47.2596H97.3123ZM97.3447 67.4414C93.9882 67.4414 91.2561 64.7351 91.2561 61.408C91.2561 58.0808 93.972 55.3745 97.3109 55.3745H103.498V61.3421C103.498 64.7055 100.738 67.4414 97.3447 67.4414ZM97.3123 57.3498C96.233 57.3513 95.1983 57.7791 94.4351 58.5396C93.6719 59.3001 93.2425 60.3311 93.2411 61.4066C93.2411 63.644 95.0818 65.4634 97.346 65.4634C99.6452 65.4634 101.517 63.6144 101.517 61.3408V57.3498H97.3123ZM107.701 57.3498H107.569C104.23 57.3498 101.514 54.6435 101.514 51.3164C101.514 47.9893 104.23 45.283 107.569 45.283H107.701C111.04 45.283 113.756 47.9893 113.756 51.3164C113.756 54.6435 111.04 57.3498 107.701 57.3498ZM107.57 47.2596C105.325 47.2596 103.499 49.0804 103.499 51.3164C103.499 53.5524 105.327 55.3732 107.57 55.3732H107.703C109.948 55.3732 111.774 53.5524 111.774 51.3164C111.774 49.0804 109.945 47.2596 107.701 47.2596H107.57Z"
fill="#3E3E3E"
/>
<path
d="M107.701 47.2596H101.514V35.1914H107.701C111.04 35.1914 113.756 37.8977 113.756 41.2248C113.756 44.552 111.04 47.2596 107.701 47.2596ZM103.498 45.283H107.701C109.947 45.283 111.772 43.4622 111.772 41.2262C111.772 38.9902 109.945 37.1694 107.701 37.1694H103.498V45.283ZM103.498 47.2596H97.3123C93.9734 47.2596 91.2575 44.5533 91.2575 41.2262C91.2575 37.8991 93.9734 35.1914 97.3123 35.1914H103.499V47.2596H103.498ZM97.3123 37.1681C95.067 37.1681 93.2411 38.9888 93.2411 41.2248C93.2411 43.4608 95.067 45.283 97.3123 45.283H101.516V37.1681H97.3123ZM103.498 57.3498H97.3123C93.9734 57.3498 91.2575 54.6435 91.2575 51.3164C91.2575 47.9893 93.9734 45.283 97.3123 45.283H103.499V57.3498H103.498ZM97.3123 47.2596C95.067 47.2596 93.2411 49.0804 93.2411 51.3164C93.2411 53.5524 95.0684 55.3732 97.3123 55.3732H101.516V47.2596H97.3123ZM97.3447 67.4414C93.9882 67.4414 91.2561 64.7351 91.2561 61.408C91.2561 58.0808 93.972 55.3745 97.3109 55.3745H103.498V61.3421C103.498 64.7055 100.738 67.4414 97.3447 67.4414ZM97.3123 57.3498C96.233 57.3513 95.1983 57.7791 94.4351 58.5396C93.6719 59.3001 93.2425 60.3311 93.2411 61.4066C93.2411 63.644 95.0818 65.4634 97.346 65.4634C99.6452 65.4634 101.517 63.6144 101.517 61.3408V57.3498H97.3123ZM107.701 57.3498H107.569C104.23 57.3498 101.514 54.6435 101.514 51.3164C101.514 47.9893 104.23 45.283 107.569 45.283H107.701C111.04 45.283 113.756 47.9893 113.756 51.3164C113.756 54.6435 111.04 57.3498 107.701 57.3498ZM107.57 47.2596C105.325 47.2596 103.499 49.0804 103.499 51.3164C103.499 53.5524 105.327 55.3732 107.57 55.3732H107.703C109.948 55.3732 111.774 53.5524 111.774 51.3164C111.774 49.0804 109.945 47.2596 107.701 47.2596H107.57Z"
fill="#D0CFCF"
/>
</g>
</g>
</g>
</g>
<g filter="url(#filter6_i_909_33931)">
<g clip-path="url(#clip2_909_33931)">
<rect
x="164.506"
y="0.941406"
width="100"
height="100"
rx="15.1515"
fill="#454545"
fill-opacity="0.42"
/>
<g filter="url(#filter7_d_909_33931)">
<rect
x="169.051"
y="5.48682"
width="90.9091"
height="90.9091"
rx="9.90295"
fill="url(#paint2_linear_909_33931)"
/>
<rect
x="169.43"
y="5.8656"
width="90.1515"
height="90.1515"
rx="9.52416"
stroke="#595959"
stroke-width="0.757576"
/>
<g filter="url(#filter8_i_909_33931)">
<path
d="M194.424 41.464C194.424 39.6297 195.152 37.8706 196.449 36.5736C197.746 35.2766 199.506 34.548 201.34 34.548H207.917C208.602 34.548 209.023 33.8356 208.761 33.204C208.628 32.8934 208.523 32.5717 208.447 32.2426C207.684 28.8146 210.273 25.3267 214.019 25.3267C217.768 25.3267 220.354 28.8146 219.591 32.2426C219.515 32.5717 219.41 32.8934 219.278 33.204C219.012 33.8356 219.437 34.548 220.121 34.548H224.393C226.227 34.548 227.986 35.2766 229.283 36.5736C230.58 37.8706 231.309 39.6297 231.309 41.464V45.7357C231.309 46.4204 232.019 46.8423 232.653 46.5795C232.964 46.4504 233.282 46.3397 233.614 46.266C237.042 45.5029 240.53 48.0895 240.53 51.8379C240.53 55.5864 237.042 58.173 233.614 57.4099C233.285 57.3341 232.964 57.2291 232.653 57.0964C232.021 56.8313 231.309 57.2554 231.309 57.9401V64.5172C231.309 66.3515 230.58 68.1106 229.283 69.4076C227.986 70.7046 226.227 71.4332 224.393 71.4332H220.029C219.365 71.4332 218.939 70.7624 219.13 70.1284C219.225 69.8057 219.296 69.4737 219.326 69.1279C219.387 68.3938 219.296 67.655 219.057 66.9581C218.818 66.2613 218.437 65.6216 217.938 65.0795C217.439 64.5373 216.834 64.1046 216.159 63.8086C215.484 63.5126 214.756 63.3598 214.019 63.3598C213.282 63.3598 212.554 63.5126 211.879 63.8086C211.205 64.1046 210.599 64.5373 210.1 65.0795C209.601 65.6216 209.22 66.2613 208.982 66.9581C208.743 67.655 208.651 68.3938 208.712 69.1279C208.742 69.4737 208.814 69.8057 208.911 70.1284C209.1 70.7624 208.673 71.4332 208.011 71.4332H201.34C199.506 71.4332 197.746 70.7046 196.449 69.4076C195.152 68.1106 194.424 66.3515 194.424 64.5172V57.8479C194.424 57.184 195.095 56.7575 195.729 56.9488C196.051 57.0434 196.383 57.1148 196.729 57.1448C197.463 57.206 198.202 57.1143 198.899 56.8754C199.596 56.6366 200.235 56.2557 200.778 55.7569C201.32 55.2582 201.752 54.6523 202.048 53.9778C202.344 53.3032 202.497 52.5746 202.497 51.8379C202.497 51.1013 202.344 50.3726 202.048 49.6981C201.752 49.0235 201.32 48.4177 200.778 47.9189C200.235 47.4202 199.596 47.0393 198.899 46.8004C198.202 46.5615 197.463 46.4698 196.729 46.5311C196.383 46.561 196.051 46.6325 195.729 46.7293C195.095 46.9184 194.424 46.4919 194.424 45.8302V41.464Z"
fill="#D0CFCF"
/>
</g>
</g>
</g>
</g>
<g opacity="0.5" filter="url(#filter9_i_909_33931)">
<g clip-path="url(#clip3_909_33931)">
<rect
x="296.506"
y="20.9414"
width="60"
height="60"
rx="9.09091"
fill="#454545"
fill-opacity="0.42"
/>
<g filter="url(#filter10_d_909_33931)">
<rect
x="299.233"
y="23.6687"
width="54.5455"
height="54.5455"
rx="5.94177"
fill="url(#paint3_linear_909_33931)"
/>
<rect
x="299.46"
y="23.896"
width="54.0909"
height="54.0909"
rx="5.7145"
stroke="#757575"
stroke-width="0.454545"
/>
<g
clip-path="url(#clip4_909_33931)"
filter="url(#filter11_i_909_33931)"
>
<path
d="M326.539 32.9546C316.589 32.9546 308.519 41.023 308.519 50.9746C308.519 58.9364 313.682 65.6909 320.843 68.074C321.743 68.2407 322.034 67.6821 322.034 67.2076V63.8528C317.022 64.943 315.978 61.7265 315.978 61.7265C315.158 59.6437 313.976 59.0896 313.976 59.0896C312.341 57.9708 314.101 57.9948 314.101 57.9948C315.91 58.121 316.863 59.8524 316.863 59.8524C318.469 62.6065 321.078 61.8106 322.106 61.3496C322.267 60.1858 322.734 59.3899 323.251 58.9409C319.249 58.4829 315.041 56.9377 315.041 50.0345C315.041 48.0658 315.745 46.4591 316.897 45.1977C316.711 44.7427 316.094 42.9091 317.073 40.4284C317.073 40.4284 318.586 39.9448 322.03 42.2754C323.467 41.876 325.008 41.6763 326.539 41.6687C328.071 41.6763 329.613 41.876 331.053 42.2754C334.494 39.9448 336.004 40.4284 336.004 40.4284C336.985 42.9106 336.368 44.7442 336.181 45.1977C337.338 46.4591 338.036 48.0673 338.036 50.0345C338.036 56.9557 333.821 58.4799 329.808 58.9259C330.454 59.4845 331.044 60.5807 331.044 62.2626V67.2076C331.044 67.6866 331.333 68.2497 332.247 68.0725C339.403 65.6864 344.559 58.9334 344.559 50.9746C344.559 41.023 336.491 32.9546 326.539 32.9546Z"
fill="#D0CFCF"
/>
</g>
</g>
</g>
</g>
<g opacity="0.3" filter="url(#filter12_i_909_33931)">
<g clip-path="url(#clip5_909_33931)">
<rect
x="388.506"
y="30.9414"
width="40"
height="40"
rx="6.06061"
fill="#454545"
fill-opacity="0.42"
/>
<g filter="url(#filter13_d_909_33931)">
<rect
x="390.324"
y="32.7595"
width="36.3636"
height="36.3636"
rx="3.96118"
fill="url(#paint4_linear_909_33931)"
/>
<rect
x="390.475"
y="32.911"
width="36.0606"
height="36.0606"
rx="3.80966"
stroke="#757575"
stroke-width="0.30303"
/>
<g filter="url(#filter14_i_909_33931)">
<path
fill-rule="evenodd"
clip-rule="evenodd"
d="M414.856 44.5774C411.355 41.0764 405.687 41.0628 402.169 44.5366L414.897 57.2642C418.37 53.7463 418.357 48.0785 414.856 44.5774ZM400.373 47.0514L412.382 59.06C413.005 58.7615 413.6 58.3872 414.155 57.937L401.496 45.278C401.046 45.8326 400.672 46.4284 400.373 47.0514ZM408.998 59.9272L399.506 50.4352C399.551 49.6297 399.704 48.8291 399.964 48.0569L411.376 59.4687C410.604 59.7293 409.803 59.8821 408.998 59.9272ZM402.128 57.3054C400.611 55.7882 399.748 53.8641 399.541 51.8845L407.549 59.8922C405.569 59.6848 403.645 58.8225 402.128 57.3054Z"
fill="#D0CFCF"
/>
</g>
</g>
</g>
</g>
<defs>
<filter
id="filter0_i_909_33931"
x="0.505859"
y="30.7394"
width="40"
height="40.202"
filterUnits="userSpaceOnUse"
color-interpolation-filters="sRGB"
>
<feFlood flood-opacity="0" result="BackgroundImageFix" />
<feBlend
mode="normal"
in="SourceGraphic"
in2="BackgroundImageFix"
result="shape"
/>
<feColorMatrix
in="SourceAlpha"
type="matrix"
values="0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 127 0"
result="hardAlpha"
/>
<feOffset dy="-0.20202" />
<feGaussianBlur stdDeviation="0.707071" />
<feComposite in2="hardAlpha" operator="arithmetic" k2="-1" k3="1" />
<feColorMatrix
type="matrix"
values="0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0.05 0"
/>
<feBlend
mode="normal"
in2="shape"
result="effect1_innerShadow_909_33931"
/>
</filter>
<filter
id="filter1_d_909_33931"
x="0.808823"
y="31.7494"
width="39.3938"
height="39.3938"
filterUnits="userSpaceOnUse"
color-interpolation-filters="sRGB"
>
<feFlood flood-opacity="0" result="BackgroundImageFix" />
<feColorMatrix
in="SourceAlpha"
type="matrix"
values="0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 127 0"
result="hardAlpha"
/>
<feOffset dy="0.505051" />
<feGaussianBlur stdDeviation="0.757576" />
<feComposite in2="hardAlpha" operator="out" />
<feColorMatrix
type="matrix"
values="0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0.1 0"
/>
<feBlend
mode="normal"
in2="BackgroundImageFix"
result="effect1_dropShadow_909_33931"
/>
<feBlend
mode="normal"
in="SourceGraphic"
in2="effect1_dropShadow_909_33931"
result="shape"
/>
</filter>
<filter
id="filter2_i_909_33931"
x="8.51465"
y="38.9502"
width="24.2725"
height="24.2725"
filterUnits="userSpaceOnUse"
color-interpolation-filters="sRGB"
>
<feFlood flood-opacity="0" result="BackgroundImageFix" />
<feBlend
mode="normal"
in="SourceGraphic"
in2="BackgroundImageFix"
result="shape"
/>
<feColorMatrix
in="SourceAlpha"
type="matrix"
values="0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 127 0"
result="hardAlpha"
/>
<feOffset dx="0.245902" dy="0.245902" />
<feGaussianBlur stdDeviation="0.327869" />
<feComposite in2="hardAlpha" operator="arithmetic" k2="-1" k3="1" />
<feColorMatrix
type="matrix"
values="0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0.15 0"
/>
<feBlend
mode="normal"
in2="shape"
result="effect1_innerShadow_909_33931"
/>
</filter>
<filter
id="filter3_i_909_33931"
x="72.5059"
y="20.6384"
width="60"
height="60.303"
filterUnits="userSpaceOnUse"
color-interpolation-filters="sRGB"
>
<feFlood flood-opacity="0" result="BackgroundImageFix" />
<feBlend
mode="normal"
in="SourceGraphic"
in2="BackgroundImageFix"
result="shape"
/>
<feColorMatrix
in="SourceAlpha"
type="matrix"
values="0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 127 0"
result="hardAlpha"
/>
<feOffset dy="-0.30303" />
<feGaussianBlur stdDeviation="1.06061" />
<feComposite in2="hardAlpha" operator="arithmetic" k2="-1" k3="1" />
<feColorMatrix
type="matrix"
values="0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0.05 0"
/>
<feBlend
mode="normal"
in2="shape"
result="effect1_innerShadow_909_33931"
/>
</filter>
<filter
id="filter4_d_909_33931"
x="72.9604"
y="22.1535"
width="59.0909"
height="59.0909"
filterUnits="userSpaceOnUse"
color-interpolation-filters="sRGB"
>
<feFlood flood-opacity="0" result="BackgroundImageFix" />
<feColorMatrix
in="SourceAlpha"
type="matrix"
values="0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 127 0"
result="hardAlpha"
/>
<feOffset dy="0.757576" />
<feGaussianBlur stdDeviation="1.13636" />
<feComposite in2="hardAlpha" operator="out" />
<feColorMatrix
type="matrix"
values="0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0.1 0"
/>
<feBlend
mode="normal"
in2="BackgroundImageFix"
result="effect1_dropShadow_909_33931"
/>
<feBlend
mode="normal"
in="SourceGraphic"
in2="effect1_dropShadow_909_33931"
result="shape"
/>
</filter>
<filter
id="filter5_i_909_33931"
x="84.5193"
y="32.9546"
width="36.4089"
height="36.4089"
filterUnits="userSpaceOnUse"
color-interpolation-filters="sRGB"
>
<feFlood flood-opacity="0" result="BackgroundImageFix" />
<feBlend
mode="normal"
in="SourceGraphic"
in2="BackgroundImageFix"
result="shape"
/>
<feColorMatrix
in="SourceAlpha"
type="matrix"
values="0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 127 0"
result="hardAlpha"
/>
<feOffset dx="0.368852" dy="0.368852" />
<feGaussianBlur stdDeviation="0.491803" />
<feComposite in2="hardAlpha" operator="arithmetic" k2="-1" k3="1" />
<feColorMatrix
type="matrix"
values="0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0.15 0"
/>
<feBlend
mode="normal"
in2="shape"
result="effect1_innerShadow_909_33931"
/>
</filter>
<filter
id="filter6_i_909_33931"
x="164.506"
y="0.436356"
width="100"
height="100.505"
filterUnits="userSpaceOnUse"
color-interpolation-filters="sRGB"
>
<feFlood flood-opacity="0" result="BackgroundImageFix" />
<feBlend
mode="normal"
in="SourceGraphic"
in2="BackgroundImageFix"
result="shape"
/>
<feColorMatrix
in="SourceAlpha"
type="matrix"
values="0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 127 0"
result="hardAlpha"
/>
<feOffset dy="-0.50505" />
<feGaussianBlur stdDeviation="1.76768" />
<feComposite in2="hardAlpha" operator="arithmetic" k2="-1" k3="1" />
<feColorMatrix
type="matrix"
values="0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0.05 0"
/>
<feBlend
mode="normal"
in2="shape"
result="effect1_innerShadow_909_33931"
/>
</filter>
<filter
id="filter7_d_909_33931"
x="165.263"
y="2.96156"
width="98.4849"
height="98.4849"
filterUnits="userSpaceOnUse"
color-interpolation-filters="sRGB"
>
<feFlood flood-opacity="0" result="BackgroundImageFix" />
<feColorMatrix
in="SourceAlpha"
type="matrix"
values="0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 127 0"
result="hardAlpha"
/>
<feOffset dy="1.26263" />
<feGaussianBlur stdDeviation="1.89394" />
<feComposite in2="hardAlpha" operator="out" />
<feColorMatrix
type="matrix"
values="0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0.1 0"
/>
<feBlend
mode="normal"
in2="BackgroundImageFix"
result="effect1_dropShadow_909_33931"
/>
<feBlend
mode="normal"
in="SourceGraphic"
in2="effect1_dropShadow_909_33931"
result="shape"
/>
</filter>
<filter
id="filter8_i_909_33931"
x="194.424"
y="25.3267"
width="46.7212"
height="46.7212"
filterUnits="userSpaceOnUse"
color-interpolation-filters="sRGB"
>
<feFlood flood-opacity="0" result="BackgroundImageFix" />
<feBlend
mode="normal"
in="SourceGraphic"
in2="BackgroundImageFix"
result="shape"
/>
<feColorMatrix
in="SourceAlpha"
type="matrix"
values="0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 127 0"
result="hardAlpha"
/>
<feOffset dx="0.614754" dy="0.614754" />
<feGaussianBlur stdDeviation="0.819672" />
<feComposite in2="hardAlpha" operator="arithmetic" k2="-1" k3="1" />
<feColorMatrix
type="matrix"
values="0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0.15 0"
/>
<feBlend
mode="normal"
in2="shape"
result="effect1_innerShadow_909_33931"
/>
</filter>
<filter
id="filter9_i_909_33931"
x="296.506"
y="20.6384"
width="60"
height="60.303"
filterUnits="userSpaceOnUse"
color-interpolation-filters="sRGB"
>
<feFlood flood-opacity="0" result="BackgroundImageFix" />
<feBlend
mode="normal"
in="SourceGraphic"
in2="BackgroundImageFix"
result="shape"
/>
<feColorMatrix
in="SourceAlpha"
type="matrix"
values="0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 127 0"
result="hardAlpha"
/>
<feOffset dy="-0.30303" />
<feGaussianBlur stdDeviation="1.06061" />
<feComposite in2="hardAlpha" operator="arithmetic" k2="-1" k3="1" />
<feColorMatrix
type="matrix"
values="0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0.05 0"
/>
<feBlend
mode="normal"
in2="shape"
result="effect1_innerShadow_909_33931"
/>
</filter>
<filter
id="filter10_d_909_33931"
x="296.96"
y="22.1535"
width="59.0909"
height="59.0909"
filterUnits="userSpaceOnUse"
color-interpolation-filters="sRGB"
>
<feFlood flood-opacity="0" result="BackgroundImageFix" />
<feColorMatrix
in="SourceAlpha"
type="matrix"
values="0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 127 0"
result="hardAlpha"
/>
<feOffset dy="0.757576" />
<feGaussianBlur stdDeviation="1.13636" />
<feComposite in2="hardAlpha" operator="out" />
<feColorMatrix
type="matrix"
values="0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0.1 0"
/>
<feBlend
mode="normal"
in2="BackgroundImageFix"
result="effect1_dropShadow_909_33931"
/>
<feBlend
mode="normal"
in="SourceGraphic"
in2="effect1_dropShadow_909_33931"
result="shape"
/>
</filter>
<filter
id="filter11_i_909_33931"
x="308.519"
y="32.9546"
width="36.4089"
height="36.4089"
filterUnits="userSpaceOnUse"
color-interpolation-filters="sRGB"
>
<feFlood flood-opacity="0" result="BackgroundImageFix" />
<feBlend
mode="normal"
in="SourceGraphic"
in2="BackgroundImageFix"
result="shape"
/>
<feColorMatrix
in="SourceAlpha"
type="matrix"
values="0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 127 0"
result="hardAlpha"
/>
<feOffset dx="0.368852" dy="0.368852" />
<feGaussianBlur stdDeviation="0.491803" />
<feComposite in2="hardAlpha" operator="arithmetic" k2="-1" k3="1" />
<feColorMatrix
type="matrix"
values="0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0.15 0"
/>
<feBlend
mode="normal"
in2="shape"
result="effect1_innerShadow_909_33931"
/>
</filter>
<filter
id="filter12_i_909_33931"
x="388.506"
y="30.7394"
width="40"
height="40.202"
filterUnits="userSpaceOnUse"
color-interpolation-filters="sRGB"
>
<feFlood flood-opacity="0" result="BackgroundImageFix" />
<feBlend
mode="normal"
in="SourceGraphic"
in2="BackgroundImageFix"
result="shape"
/>
<feColorMatrix
in="SourceAlpha"
type="matrix"
values="0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 127 0"
result="hardAlpha"
/>
<feOffset dy="-0.20202" />
<feGaussianBlur stdDeviation="0.707071" />
<feComposite in2="hardAlpha" operator="arithmetic" k2="-1" k3="1" />
<feColorMatrix
type="matrix"
values="0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0.05 0"
/>
<feBlend
mode="normal"
in2="shape"
result="effect1_innerShadow_909_33931"
/>
</filter>
<filter
id="filter13_d_909_33931"
x="388.809"
y="31.7494"
width="39.3938"
height="39.3938"
filterUnits="userSpaceOnUse"
color-interpolation-filters="sRGB"
>
<feFlood flood-opacity="0" result="BackgroundImageFix" />
<feColorMatrix
in="SourceAlpha"
type="matrix"
values="0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 127 0"
result="hardAlpha"
/>
<feOffset dy="0.505051" />
<feGaussianBlur stdDeviation="0.757576" />
<feComposite in2="hardAlpha" operator="out" />
<feColorMatrix
type="matrix"
values="0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0.1 0"
/>
<feBlend
mode="normal"
in2="BackgroundImageFix"
result="effect1_dropShadow_909_33931"
/>
<feBlend
mode="normal"
in="SourceGraphic"
in2="effect1_dropShadow_909_33931"
result="shape"
/>
</filter>
<filter
id="filter14_i_909_33931"
x="396.515"
y="38.9502"
width="24.2725"
height="24.2725"
filterUnits="userSpaceOnUse"
color-interpolation-filters="sRGB"
>
<feFlood flood-opacity="0" result="BackgroundImageFix" />
<feBlend
mode="normal"
in="SourceGraphic"
in2="BackgroundImageFix"
result="shape"
/>
<feColorMatrix
in="SourceAlpha"
type="matrix"
values="0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 127 0"
result="hardAlpha"
/>
<feOffset dx="0.245902" dy="0.245902" />
<feGaussianBlur stdDeviation="0.327869" />
<feComposite in2="hardAlpha" operator="arithmetic" k2="-1" k3="1" />
<feColorMatrix
type="matrix"
values="0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0.15 0"
/>
<feBlend
mode="normal"
in2="shape"
result="effect1_innerShadow_909_33931"
/>
</filter>
<linearGradient
id="paint0_linear_909_33931"
x1="7.37448"
y1="33.5171"
x2="28.6876"
y2="69.5777"
gradientUnits="userSpaceOnUse"
>
<stop stop-color="#383838" />
<stop offset="0.260417" stop-color="#2A2A2A" />
<stop offset="0.619792" stop-color="#393939" />
<stop offset="1" stop-color="#414141" />
</linearGradient>
<linearGradient
id="paint1_linear_909_33931"
x1="82.8089"
y1="24.8051"
x2="114.779"
y2="78.896"
gradientUnits="userSpaceOnUse"
>
<stop stop-color="#383838" />
<stop offset="0.260417" stop-color="#2A2A2A" />
<stop offset="0.619792" stop-color="#393939" />
<stop offset="1" stop-color="#414141" />
</linearGradient>
<linearGradient
id="paint2_linear_909_33931"
x1="181.678"
y1="7.38076"
x2="234.96"
y2="97.5323"
gradientUnits="userSpaceOnUse"
>
<stop stop-color="#383838" />
<stop offset="0.260417" stop-color="#2A2A2A" />
<stop offset="0.619792" stop-color="#393939" />
<stop offset="1" stop-color="#414141" />
</linearGradient>
<linearGradient
id="paint3_linear_909_33931"
x1="306.809"
y1="24.8051"
x2="338.779"
y2="78.896"
gradientUnits="userSpaceOnUse"
>
<stop stop-color="#383838" />
<stop offset="0.260417" stop-color="#2A2A2A" />
<stop offset="0.619792" stop-color="#393939" />
<stop offset="1" stop-color="#414141" />
</linearGradient>
<linearGradient
id="paint4_linear_909_33931"
x1="395.374"
y1="33.5171"
x2="416.688"
y2="69.5777"
gradientUnits="userSpaceOnUse"
>
<stop stop-color="#383838" />
<stop offset="0.260417" stop-color="#2A2A2A" />
<stop offset="0.619792" stop-color="#393939" />
<stop offset="1" stop-color="#414141" />
</linearGradient>
<clipPath id="clip0_909_33931">
<rect
x="0.505859"
y="30.9414"
width="40"
height="40"
rx="6.06061"
fill="white"
/>
</clipPath>
<clipPath id="clip1_909_33931">
<rect
x="72.5059"
y="20.9414"
width="60"
height="60"
rx="9.09091"
fill="white"
/>
</clipPath>
<clipPath id="clip2_909_33931">
<rect
x="164.506"
y="0.941406"
width="100"
height="100"
rx="15.1515"
fill="white"
/>
</clipPath>
<clipPath id="clip3_909_33931">
<rect
x="296.506"
y="20.9414"
width="60"
height="60"
rx="9.09091"
fill="white"
/>
</clipPath>
<clipPath id="clip4_909_33931">
<rect
width="36.0399"
height="36.0399"
fill="white"
transform="translate(308.519 32.9546)"
/>
</clipPath>
<clipPath id="clip5_909_33931">
<rect
x="388.506"
y="30.9414"
width="40"
height="40"
rx="6.06061"
fill="white"
/>
</clipPath>
</defs>
</svg>`;
@@ -0,0 +1,94 @@
import { cssVar } from '@toeverything/theme';
import { style } from '@vanilla-extract/css';
export const promptRoot = style({
position: 'absolute',
display: 'flex',
flexDirection: 'column',
height: '100%',
});
export const promptTitle = style({
fontSize: cssVar('fontH4'),
fontWeight: '600',
marginBottom: 48,
});
export const promptArt = style({
marginBottom: 68,
});
export const promptWarning = style({
backgroundColor: cssVar('backgroundTertiaryColor'),
fontSize: cssVar('fontXs'),
display: 'flex',
flexDirection: 'column',
justifyContent: 'center',
gap: 14,
padding: 10,
borderRadius: 8,
});
export const promptWarningTitle = style({
color: cssVar('errorColor'),
fontWeight: 600,
});
export const spacer = style({
flexGrow: 1,
minHeight: 12,
});
export const promptDisclaimer = style({
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
marginBottom: 32,
gap: 4,
});
export const settingsContainer = style({
display: 'flex',
flexDirection: 'column',
gap: 24,
});
export const promptDisclaimerConfirm = style({
display: 'flex',
justifyContent: 'center',
});
export const switchRow = style({
display: 'flex',
alignItems: 'center',
justifyContent: 'space-between',
width: '100%',
});
export const subHeader = style({
fontWeight: '600',
color: cssVar('textSecondaryColor'),
marginBottom: 8,
});
export const rowContainer = style({
display: 'flex',
flexDirection: 'column',
alignItems: 'center',
gap: 10,
});
export const description = style({
color: cssVar('textSecondaryColor'),
fontSize: cssVar('fontXs'),
// 2 lines
overflow: 'hidden',
textOverflow: 'ellipsis',
display: '-webkit-box',
WebkitLineClamp: 2,
WebkitBoxOrient: 'vertical',
width: '100%',
});
export const feedback = style({
width: '100%',
display: 'flex',
alignItems: 'center',
fontSize: cssVar('fontXs'),
color: cssVar('textSecondaryColor'),
gap: 8,
});
export const arrowRightIcon = style({
marginLeft: 'auto',
marginRight: 0,
});
@@ -0,0 +1,206 @@
import { Button, Checkbox, Loading, Switch, Tooltip } from '@affine/component';
import { SettingHeader } from '@affine/component/setting-components';
import { useAsyncCallback } from '@affine/core/components/hooks/affine-async-hooks';
import { useI18n } from '@affine/i18n';
import {
ArrowRightSmallIcon,
DiscordIcon,
EmailIcon,
GithubIcon,
} from '@blocksuite/icons/rc';
import {
AFFINE_FLAGS,
FeatureFlagService,
type Flag,
useLiveData,
useServices,
} from '@toeverything/infra';
import { useAtom } from 'jotai';
import { atomWithStorage } from 'jotai/utils';
import { Suspense, useCallback, useState } from 'react';
import { ExperimentalFeatureArts } from './arts';
import * as styles from './index.css';
const ExperimentalFeaturesPrompt = ({
onConfirm,
}: {
onConfirm: () => void;
}) => {
const t = useI18n();
const [checked, setChecked] = useState(false);
const onChange: (
event: React.ChangeEvent<HTMLInputElement>,
checked: boolean
) => void = useCallback((_, checked) => {
setChecked(checked);
}, []);
return (
<div className={styles.promptRoot} data-testid="experimental-prompt">
<div className={styles.promptTitle}>
{t[
'com.affine.settings.workspace.experimental-features.prompt-header'
]()}
</div>
<div className={styles.promptArt}>
<ExperimentalFeatureArts />
</div>
<div className={styles.promptWarning}>
<div className={styles.promptWarningTitle}>
{t[
'com.affine.settings.workspace.experimental-features.prompt-warning-title'
]()}
</div>
{t[
'com.affine.settings.workspace.experimental-features.prompt-warning'
]()}
</div>
<div className={styles.spacer} />
<label className={styles.promptDisclaimer}>
<Checkbox
checked={checked}
onChange={onChange}
data-testid="experimental-prompt-disclaimer"
/>
{t[
'com.affine.settings.workspace.experimental-features.prompt-disclaimer'
]()}
</label>
<div className={styles.promptDisclaimerConfirm}>
<Button
disabled={!checked}
onClick={onConfirm}
variant="primary"
data-testid="experimental-confirm-button"
>
{t[
'com.affine.settings.workspace.experimental-features.get-started'
]()}
</Button>
</div>
</div>
);
};
const FeedbackIcon = ({ type }: { type: Flag['feedbackType'] }) => {
switch (type) {
case 'discord':
return <DiscordIcon fontSize={16} />;
case 'email':
return <EmailIcon fontSize={16} />;
case 'github':
return <GithubIcon fontSize={16} />;
default:
return null;
}
};
const feedbackLink: Record<NonNullable<Flag['feedbackType']>, string> = {
discord: 'https://discord.gg/whd5mjYqVw',
email: 'mailto:support@toeverything.info',
github: 'https://github.com/toeverything/AFFiNE/issues',
};
const ExperimentalFeaturesItem = ({ flag }: { flag: Flag }) => {
const value = useLiveData(flag.$);
const onChange = useCallback(
(checked: boolean) => {
flag.set(checked);
},
[flag]
);
const link = flag.feedbackType
? flag.feedbackLink
? flag.feedbackLink
: feedbackLink[flag.feedbackType]
: undefined;
if (flag.configurable === false || flag.hide) {
return null;
}
return (
<div className={styles.rowContainer}>
<div className={styles.switchRow}>
{flag.displayName}
<Switch checked={value} onChange={onChange} />
</div>
{!!flag.description && (
<Tooltip content={flag.description}>
<div className={styles.description}>{flag.description}</div>
</Tooltip>
)}
{!!flag.feedbackType && (
<a
className={styles.feedback}
href={link}
target="_blank"
rel="noreferrer"
>
<FeedbackIcon type={flag.feedbackType} />
<span>Discussion about this feature</span>
<ArrowRightSmallIcon
fontSize={20}
className={styles.arrowRightIcon}
/>
</a>
)}
</div>
);
};
const ExperimentalFeaturesMain = () => {
const t = useI18n();
const { featureFlagService } = useServices({ FeatureFlagService });
return (
<>
<SettingHeader
title={t[
'com.affine.settings.workspace.experimental-features.header.plugins'
]()}
subtitle={t[
'com.affine.settings.workspace.experimental-features.header.subtitle'
]()}
/>
<div
className={styles.settingsContainer}
data-testid="experimental-settings"
>
{Object.keys(AFFINE_FLAGS).map(key => (
<ExperimentalFeaturesItem
key={key}
flag={featureFlagService.flags[key as keyof AFFINE_FLAGS]}
/>
))}
</div>
</>
);
};
// TODO(@Peng): save to workspace meta instead?
const experimentalFeaturesDisclaimerAtom = atomWithStorage(
'affine:experimental-features-disclaimer',
false
);
export const ExperimentalFeatures = () => {
const [enabled, setEnabled] = useAtom(experimentalFeaturesDisclaimerAtom);
const handleConfirm = useAsyncCallback(async () => {
setEnabled(true);
}, [setEnabled]);
if (!enabled) {
return <ExperimentalFeaturesPrompt onConfirm={handleConfirm} />;
} else {
return (
<Suspense fallback={<Loading />}>
<ExperimentalFeaturesMain />
</Suspense>
);
}
};
@@ -0,0 +1,843 @@
export default `<svg
width="429"
height="100"
viewBox="0 0 429 100"
fill="none"
xmlns="http://www.w3.org/2000/svg"
>
<g opacity="0.6" filter="url(#filter0_i_907_33313)">
<g clip-path="url(#clip0_907_33313)">
<rect
x="0.624512"
y="30"
width="40"
height="40"
rx="6.06061"
fill="#F8F8F8"
/>
<g filter="url(#filter1_d_907_33313)">
<rect
x="2.44263"
y="31.8181"
width="36.3636"
height="36.3636"
rx="3.96118"
fill="url(#paint0_linear_907_33313)"
/>
<rect
x="2.59414"
y="31.9697"
width="36.0606"
height="36.0606"
rx="3.80966"
stroke="#E4E4E4"
stroke-width="0.30303"
/>
<g filter="url(#filter2_i_907_33313)">
<path
d="M29.8023 40H20.1707C20.1707 42.4001 22.1166 44.346 24.5167 44.346H26.2905V46.0605C26.2925 48.4552 28.2311 50.3965 30.6238 50.4031H30.6245V40.8383C30.6245 40.8369 30.6245 40.8363 30.6245 40.8349C30.6245 40.3781 30.2577 40.0073 29.8029 40.0007H29.8023V40ZM25.0368 44.7974H15.4053C15.4059 47.1975 17.3512 49.1427 19.7512 49.1427H21.5251V50.8579C21.5271 53.2573 23.4717 55.2012 25.8711 55.2032V45.6324C25.8711 45.1716 25.4976 44.7981 25.0368 44.7981V44.7974ZM20.2674 49.5942H10.6245C10.6258 51.9949 12.5724 53.9402 14.9731 53.9402C14.9772 53.9402 14.9812 53.9402 14.9852 53.9402H16.7597V55.654C16.7597 58.0534 18.7043 59.9987 21.1037 60V50.4318C21.1037 49.969 20.7289 49.5942 20.2661 49.5942H20.2674Z"
fill="#E2E5E6"
/>
</g>
</g>
</g>
</g>
<g opacity="0.8" filter="url(#filter3_i_907_33313)">
<g clip-path="url(#clip1_907_33313)">
<rect
x="72.6245"
y="20"
width="60"
height="60"
rx="9.09091"
fill="#F8F8F8"
/>
<g filter="url(#filter4_d_907_33313)">
<rect
x="75.3518"
y="22.7272"
width="54.5455"
height="54.5455"
rx="5.94177"
fill="url(#paint1_linear_907_33313)"
/>
<rect
x="75.5791"
y="22.9545"
width="54.0909"
height="54.0909"
rx="5.7145"
stroke="#E4E4E4"
stroke-width="0.454545"
/>
<g filter="url(#filter5_i_907_33313)">
<path
d="M107.82 46.3182H101.633V34.25H107.82C111.159 34.25 113.875 36.9563 113.875 40.2834C113.875 43.6105 111.159 46.3182 107.82 46.3182ZM103.617 44.3415H107.82C110.065 44.3415 111.891 42.5208 111.891 40.2848C111.891 38.0488 110.064 36.228 107.82 36.228H103.617V44.3415ZM103.617 46.3182H97.4309C94.092 46.3182 91.3761 43.6119 91.3761 40.2848C91.3761 36.9576 94.092 34.25 97.4309 34.25H103.618V46.3182H103.617ZM97.4309 36.2266C95.1857 36.2266 93.3598 38.0474 93.3598 40.2834C93.3598 42.5194 95.1857 44.3415 97.4309 44.3415H101.634V36.2266H97.4309ZM103.617 56.4084H97.4309C94.092 56.4084 91.3761 53.7021 91.3761 50.375C91.3761 47.0478 94.092 44.3415 97.4309 44.3415H103.618V56.4084H103.617ZM97.4309 46.3182C95.1857 46.3182 93.3598 48.139 93.3598 50.375C93.3598 52.611 95.187 54.4318 97.4309 54.4318H101.634V46.3182H97.4309ZM97.4633 66.5C94.1069 66.5 91.3748 63.7937 91.3748 60.4665C91.3748 57.1394 94.0907 54.4331 97.4296 54.4331H103.617V60.4007C103.617 63.7641 100.856 66.5 97.4633 66.5ZM97.4309 56.4084C96.3516 56.4098 95.317 56.8377 94.5538 57.5982C93.7906 58.3587 93.3612 59.3897 93.3598 60.4652C93.3598 62.7025 95.2005 64.522 97.4647 64.522C99.7639 64.522 101.636 62.673 101.636 60.3993V56.4084H97.4309ZM107.82 56.4084H107.688C104.349 56.4084 101.633 53.7021 101.633 50.375C101.633 47.0478 104.349 44.3415 107.688 44.3415H107.82C111.159 44.3415 113.875 47.0478 113.875 50.375C113.875 53.7021 111.159 56.4084 107.82 56.4084ZM107.689 46.3182C105.444 46.3182 103.618 48.139 103.618 50.375C103.618 52.611 105.445 54.4318 107.689 54.4318H107.821C110.067 54.4318 111.892 52.611 111.892 50.375C111.892 48.139 110.064 46.3182 107.82 46.3182H107.689Z"
fill="black"
/>
<path
d="M107.82 46.3182H101.633V34.25H107.82C111.159 34.25 113.875 36.9563 113.875 40.2834C113.875 43.6105 111.159 46.3182 107.82 46.3182ZM103.617 44.3415H107.82C110.065 44.3415 111.891 42.5208 111.891 40.2848C111.891 38.0488 110.064 36.228 107.82 36.228H103.617V44.3415ZM103.617 46.3182H97.4309C94.092 46.3182 91.3761 43.6119 91.3761 40.2848C91.3761 36.9576 94.092 34.25 97.4309 34.25H103.618V46.3182H103.617ZM97.4309 36.2266C95.1857 36.2266 93.3598 38.0474 93.3598 40.2834C93.3598 42.5194 95.1857 44.3415 97.4309 44.3415H101.634V36.2266H97.4309ZM103.617 56.4084H97.4309C94.092 56.4084 91.3761 53.7021 91.3761 50.375C91.3761 47.0478 94.092 44.3415 97.4309 44.3415H103.618V56.4084H103.617ZM97.4309 46.3182C95.1857 46.3182 93.3598 48.139 93.3598 50.375C93.3598 52.611 95.187 54.4318 97.4309 54.4318H101.634V46.3182H97.4309ZM97.4633 66.5C94.1069 66.5 91.3748 63.7937 91.3748 60.4665C91.3748 57.1394 94.0907 54.4331 97.4296 54.4331H103.617V60.4007C103.617 63.7641 100.856 66.5 97.4633 66.5ZM97.4309 56.4084C96.3516 56.4098 95.317 56.8377 94.5538 57.5982C93.7906 58.3587 93.3612 59.3897 93.3598 60.4652C93.3598 62.7025 95.2005 64.522 97.4647 64.522C99.7639 64.522 101.636 62.673 101.636 60.3993V56.4084H97.4309ZM107.82 56.4084H107.688C104.349 56.4084 101.633 53.7021 101.633 50.375C101.633 47.0478 104.349 44.3415 107.688 44.3415H107.82C111.159 44.3415 113.875 47.0478 113.875 50.375C113.875 53.7021 111.159 56.4084 107.82 56.4084ZM107.689 46.3182C105.444 46.3182 103.618 48.139 103.618 50.375C103.618 52.611 105.445 54.4318 107.689 54.4318H107.821C110.067 54.4318 111.892 52.611 111.892 50.375C111.892 48.139 110.064 46.3182 107.82 46.3182H107.689Z"
fill="#E2E5E6"
/>
</g>
</g>
</g>
</g>
<g filter="url(#filter6_i_907_33313)">
<g clip-path="url(#clip2_907_33313)">
<rect x="164.625" width="100" height="100" rx="15.1515" fill="#F8F8F8" />
<g filter="url(#filter7_d_907_33313)">
<rect
x="169.17"
y="4.54544"
width="90.9091"
height="90.9091"
rx="9.90295"
fill="url(#paint2_linear_907_33313)"
/>
<rect
x="169.549"
y="4.92423"
width="90.1515"
height="90.1515"
rx="9.52416"
stroke="#E4E4E4"
stroke-width="0.757576"
/>
<g filter="url(#filter8_i_907_33313)">
<path
d="M194.542 40.5226C194.542 38.6884 195.271 36.9293 196.568 35.6323C197.865 34.3353 199.624 33.6066 201.458 33.6066H208.036C208.72 33.6066 209.142 32.8943 208.879 32.2626C208.747 31.9521 208.642 31.6304 208.566 31.3013C207.803 27.8733 210.392 24.3853 214.138 24.3853C217.886 24.3853 220.473 27.8733 219.71 31.3013C219.634 31.6304 219.529 31.9521 219.396 32.2626C219.131 32.8943 219.555 33.6066 220.24 33.6066H224.512C226.346 33.6066 228.105 34.3353 229.402 35.6323C230.699 36.9293 231.428 38.6884 231.428 40.5226V44.7944C231.428 45.4791 232.138 45.9009 232.772 45.6381C233.083 45.509 233.401 45.3984 233.733 45.3246C237.161 44.5615 240.649 47.1481 240.649 50.8966C240.649 54.645 237.161 57.2316 233.733 56.4686C233.404 56.3927 233.082 56.2878 232.772 56.155C232.14 55.8899 231.428 56.3141 231.428 56.9988V63.5759C231.428 65.4101 230.699 67.1692 229.402 68.4662C228.105 69.7632 226.346 70.4919 224.512 70.4919H220.148C219.484 70.4919 219.057 69.821 219.249 69.1871C219.343 68.8643 219.415 68.5323 219.445 68.1865C219.506 67.4524 219.414 66.7136 219.175 66.0168C218.936 65.3199 218.556 64.6802 218.057 64.1381C217.558 63.596 216.952 63.1633 216.278 62.8673C215.603 62.5713 214.874 62.4184 214.138 62.4184C213.401 62.4184 212.672 62.5713 211.998 62.8673C211.323 63.1633 210.718 63.596 210.219 64.1381C209.72 64.6802 209.339 65.3199 209.1 66.0168C208.861 66.7136 208.77 67.4524 208.831 68.1865C208.861 68.5323 208.932 68.8643 209.029 69.1871C209.218 69.821 208.792 70.4919 208.13 70.4919H201.458C199.624 70.4919 197.865 69.7632 196.568 68.4662C195.271 67.1692 194.542 65.4101 194.542 63.5759V56.9066C194.542 56.2426 195.213 55.8162 195.847 56.0075C196.17 56.102 196.502 56.1735 196.848 56.2034C197.582 56.2647 198.321 56.173 199.018 55.9341C199.714 55.6952 200.354 55.3143 200.896 54.8156C201.438 54.3168 201.871 53.711 202.167 53.0364C202.463 52.3619 202.616 51.6332 202.616 50.8966C202.616 50.1599 202.463 49.4313 202.167 48.7567C201.871 48.0822 201.438 47.4764 200.896 46.9776C200.354 46.4788 199.714 46.098 199.018 45.8591C198.321 45.6202 197.582 45.5285 196.848 45.5897C196.502 45.6197 196.17 45.6912 195.847 45.788C195.213 45.977 194.542 45.5505 194.542 44.8889V40.5226Z"
fill="#DEE2E3"
/>
</g>
</g>
</g>
</g>
<g opacity="0.8" filter="url(#filter9_i_907_33313)">
<g clip-path="url(#clip3_907_33313)">
<rect
x="296.625"
y="20"
width="60"
height="60"
rx="9.09091"
fill="#F8F8F8"
/>
<g filter="url(#filter10_d_907_33313)">
<rect
x="299.352"
y="22.7272"
width="54.5455"
height="54.5455"
rx="5.94177"
fill="url(#paint3_linear_907_33313)"
/>
<rect
x="299.579"
y="22.9545"
width="54.0909"
height="54.0909"
rx="5.7145"
stroke="#E4E4E4"
stroke-width="0.454545"
/>
<g
clip-path="url(#clip4_907_33313)"
filter="url(#filter11_i_907_33313)"
>
<path
d="M326.658 32.0132C316.708 32.0132 308.638 40.0817 308.638 50.0332C308.638 57.995 313.801 64.7495 320.962 67.1327C321.862 67.2994 322.153 66.7407 322.153 66.2662V62.9115C317.14 64.0017 316.097 60.7851 316.097 60.7851C315.277 58.7023 314.095 58.1482 314.095 58.1482C312.46 57.0295 314.22 57.0535 314.22 57.0535C316.029 57.1796 316.981 58.9111 316.981 58.9111C318.588 61.6651 321.196 60.8692 322.225 60.4082C322.386 59.2444 322.853 58.4485 323.369 57.9995C319.367 57.5415 315.16 55.9963 315.16 49.0932C315.16 47.1245 315.864 45.5177 317.016 44.2563C316.83 43.8013 316.212 41.9678 317.191 39.487C317.191 39.487 318.705 39.0035 322.148 41.3341C323.586 40.9346 325.126 40.7349 326.658 40.7274C328.19 40.7349 329.732 40.9346 331.172 41.3341C334.612 39.0035 336.123 39.487 336.123 39.487C337.103 41.9693 336.486 43.8028 336.3 44.2563C337.456 45.5177 338.155 47.126 338.155 49.0932C338.155 56.0143 333.939 57.5385 329.927 57.9845C330.573 58.5432 331.163 59.6394 331.163 61.3212V66.2662C331.163 66.7452 331.451 67.3084 332.366 67.1312C339.521 64.745 344.678 57.992 344.678 50.0332C344.678 40.0817 336.609 32.0132 326.658 32.0132Z"
fill="#E2E5E6"
/>
</g>
</g>
</g>
</g>
<g opacity="0.6" filter="url(#filter12_i_907_33313)">
<g clip-path="url(#clip5_907_33313)">
<rect
x="388.625"
y="30"
width="40"
height="40"
rx="6.06061"
fill="#F8F8F8"
/>
<g filter="url(#filter13_d_907_33313)">
<rect
x="390.443"
y="31.8181"
width="36.3636"
height="36.3636"
rx="3.96118"
fill="url(#paint4_linear_907_33313)"
/>
<rect
x="390.594"
y="31.9697"
width="36.0606"
height="36.0606"
rx="3.80966"
stroke="#E4E4E4"
stroke-width="0.30303"
/>
<g filter="url(#filter14_i_907_33313)">
<path
fill-rule="evenodd"
clip-rule="evenodd"
d="M414.974 43.636C411.473 40.135 405.805 40.1214 402.287 43.5952L415.015 56.3228C418.489 52.8049 418.475 47.1371 414.974 43.636ZM400.492 46.1099L412.5 58.1186C413.123 57.8201 413.719 57.4458 414.274 56.9956L401.615 44.3365C401.165 44.8912 400.79 45.487 400.492 46.1099ZM409.117 58.9858L399.625 49.4938C399.67 48.6883 399.822 47.8877 400.083 47.1155L411.495 58.5273C410.723 58.7879 409.922 58.9407 409.117 58.9858ZM402.246 56.364C400.729 54.8468 399.867 52.9227 399.66 50.9431L407.667 58.9508C405.688 58.7434 403.763 57.8811 402.246 56.364Z"
fill="#DEE2E3"
/>
</g>
</g>
</g>
</g>
<defs>
<filter
id="filter0_i_907_33313"
x="0.624512"
y="29.798"
width="40"
height="40.202"
filterUnits="userSpaceOnUse"
color-interpolation-filters="sRGB"
>
<feFlood flood-opacity="0" result="BackgroundImageFix" />
<feBlend
mode="normal"
in="SourceGraphic"
in2="BackgroundImageFix"
result="shape"
/>
<feColorMatrix
in="SourceAlpha"
type="matrix"
values="0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 127 0"
result="hardAlpha"
/>
<feOffset dy="-0.20202" />
<feGaussianBlur stdDeviation="0.707071" />
<feComposite in2="hardAlpha" operator="arithmetic" k2="-1" k3="1" />
<feColorMatrix
type="matrix"
values="0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0.05 0"
/>
<feBlend
mode="normal"
in2="shape"
result="effect1_innerShadow_907_33313"
/>
</filter>
<filter
id="filter1_d_907_33313"
x="0.927476"
y="30.808"
width="39.3938"
height="39.394"
filterUnits="userSpaceOnUse"
color-interpolation-filters="sRGB"
>
<feFlood flood-opacity="0" result="BackgroundImageFix" />
<feColorMatrix
in="SourceAlpha"
type="matrix"
values="0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 127 0"
result="hardAlpha"
/>
<feOffset dy="0.505051" />
<feGaussianBlur stdDeviation="0.757576" />
<feComposite in2="hardAlpha" operator="out" />
<feColorMatrix
type="matrix"
values="0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0.1 0"
/>
<feBlend
mode="normal"
in2="BackgroundImageFix"
result="effect1_dropShadow_907_33313"
/>
<feBlend
mode="normal"
in="SourceGraphic"
in2="effect1_dropShadow_907_33313"
result="shape"
/>
</filter>
<filter
id="filter2_i_907_33313"
x="8.6333"
y="38.0088"
width="24.2725"
height="24.2725"
filterUnits="userSpaceOnUse"
color-interpolation-filters="sRGB"
>
<feFlood flood-opacity="0" result="BackgroundImageFix" />
<feBlend
mode="normal"
in="SourceGraphic"
in2="BackgroundImageFix"
result="shape"
/>
<feColorMatrix
in="SourceAlpha"
type="matrix"
values="0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 127 0"
result="hardAlpha"
/>
<feOffset dx="0.245902" dy="0.245902" />
<feGaussianBlur stdDeviation="0.327869" />
<feComposite in2="hardAlpha" operator="arithmetic" k2="-1" k3="1" />
<feColorMatrix
type="matrix"
values="0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0.15 0"
/>
<feBlend
mode="normal"
in2="shape"
result="effect1_innerShadow_907_33313"
/>
</filter>
<filter
id="filter3_i_907_33313"
x="72.6245"
y="19.697"
width="60"
height="60.303"
filterUnits="userSpaceOnUse"
color-interpolation-filters="sRGB"
>
<feFlood flood-opacity="0" result="BackgroundImageFix" />
<feBlend
mode="normal"
in="SourceGraphic"
in2="BackgroundImageFix"
result="shape"
/>
<feColorMatrix
in="SourceAlpha"
type="matrix"
values="0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 127 0"
result="hardAlpha"
/>
<feOffset dy="-0.30303" />
<feGaussianBlur stdDeviation="1.06061" />
<feComposite in2="hardAlpha" operator="arithmetic" k2="-1" k3="1" />
<feColorMatrix
type="matrix"
values="0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0.05 0"
/>
<feBlend
mode="normal"
in2="shape"
result="effect1_innerShadow_907_33313"
/>
</filter>
<filter
id="filter4_d_907_33313"
x="73.0791"
y="21.2121"
width="59.0909"
height="59.0909"
filterUnits="userSpaceOnUse"
color-interpolation-filters="sRGB"
>
<feFlood flood-opacity="0" result="BackgroundImageFix" />
<feColorMatrix
in="SourceAlpha"
type="matrix"
values="0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 127 0"
result="hardAlpha"
/>
<feOffset dy="0.757576" />
<feGaussianBlur stdDeviation="1.13636" />
<feComposite in2="hardAlpha" operator="out" />
<feColorMatrix
type="matrix"
values="0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0.1 0"
/>
<feBlend
mode="normal"
in2="BackgroundImageFix"
result="effect1_dropShadow_907_33313"
/>
<feBlend
mode="normal"
in="SourceGraphic"
in2="effect1_dropShadow_907_33313"
result="shape"
/>
</filter>
<filter
id="filter5_i_907_33313"
x="84.6379"
y="32.0132"
width="36.4089"
height="36.4088"
filterUnits="userSpaceOnUse"
color-interpolation-filters="sRGB"
>
<feFlood flood-opacity="0" result="BackgroundImageFix" />
<feBlend
mode="normal"
in="SourceGraphic"
in2="BackgroundImageFix"
result="shape"
/>
<feColorMatrix
in="SourceAlpha"
type="matrix"
values="0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 127 0"
result="hardAlpha"
/>
<feOffset dx="0.368852" dy="0.368852" />
<feGaussianBlur stdDeviation="0.491803" />
<feComposite in2="hardAlpha" operator="arithmetic" k2="-1" k3="1" />
<feColorMatrix
type="matrix"
values="0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0.15 0"
/>
<feBlend
mode="normal"
in2="shape"
result="effect1_innerShadow_907_33313"
/>
</filter>
<filter
id="filter6_i_907_33313"
x="164.625"
y="-0.50505"
width="100"
height="100.505"
filterUnits="userSpaceOnUse"
color-interpolation-filters="sRGB"
>
<feFlood flood-opacity="0" result="BackgroundImageFix" />
<feBlend
mode="normal"
in="SourceGraphic"
in2="BackgroundImageFix"
result="shape"
/>
<feColorMatrix
in="SourceAlpha"
type="matrix"
values="0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 127 0"
result="hardAlpha"
/>
<feOffset dy="-0.50505" />
<feGaussianBlur stdDeviation="1.76768" />
<feComposite in2="hardAlpha" operator="arithmetic" k2="-1" k3="1" />
<feColorMatrix
type="matrix"
values="0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0.05 0"
/>
<feBlend
mode="normal"
in2="shape"
result="effect1_innerShadow_907_33313"
/>
</filter>
<filter
id="filter7_d_907_33313"
x="165.382"
y="2.02019"
width="98.4849"
height="98.4848"
filterUnits="userSpaceOnUse"
color-interpolation-filters="sRGB"
>
<feFlood flood-opacity="0" result="BackgroundImageFix" />
<feColorMatrix
in="SourceAlpha"
type="matrix"
values="0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 127 0"
result="hardAlpha"
/>
<feOffset dy="1.26263" />
<feGaussianBlur stdDeviation="1.89394" />
<feComposite in2="hardAlpha" operator="out" />
<feColorMatrix
type="matrix"
values="0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0.1 0"
/>
<feBlend
mode="normal"
in2="BackgroundImageFix"
result="effect1_dropShadow_907_33313"
/>
<feBlend
mode="normal"
in="SourceGraphic"
in2="effect1_dropShadow_907_33313"
result="shape"
/>
</filter>
<filter
id="filter8_i_907_33313"
x="194.542"
y="24.3853"
width="46.7212"
height="46.7213"
filterUnits="userSpaceOnUse"
color-interpolation-filters="sRGB"
>
<feFlood flood-opacity="0" result="BackgroundImageFix" />
<feBlend
mode="normal"
in="SourceGraphic"
in2="BackgroundImageFix"
result="shape"
/>
<feColorMatrix
in="SourceAlpha"
type="matrix"
values="0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 127 0"
result="hardAlpha"
/>
<feOffset dx="0.614754" dy="0.614754" />
<feGaussianBlur stdDeviation="0.819672" />
<feComposite in2="hardAlpha" operator="arithmetic" k2="-1" k3="1" />
<feColorMatrix
type="matrix"
values="0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0.15 0"
/>
<feBlend
mode="normal"
in2="shape"
result="effect1_innerShadow_907_33313"
/>
</filter>
<filter
id="filter9_i_907_33313"
x="296.625"
y="19.697"
width="60"
height="60.303"
filterUnits="userSpaceOnUse"
color-interpolation-filters="sRGB"
>
<feFlood flood-opacity="0" result="BackgroundImageFix" />
<feBlend
mode="normal"
in="SourceGraphic"
in2="BackgroundImageFix"
result="shape"
/>
<feColorMatrix
in="SourceAlpha"
type="matrix"
values="0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 127 0"
result="hardAlpha"
/>
<feOffset dy="-0.30303" />
<feGaussianBlur stdDeviation="1.06061" />
<feComposite in2="hardAlpha" operator="arithmetic" k2="-1" k3="1" />
<feColorMatrix
type="matrix"
values="0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0.05 0"
/>
<feBlend
mode="normal"
in2="shape"
result="effect1_innerShadow_907_33313"
/>
</filter>
<filter
id="filter10_d_907_33313"
x="297.079"
y="21.2121"
width="59.0909"
height="59.0909"
filterUnits="userSpaceOnUse"
color-interpolation-filters="sRGB"
>
<feFlood flood-opacity="0" result="BackgroundImageFix" />
<feColorMatrix
in="SourceAlpha"
type="matrix"
values="0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 127 0"
result="hardAlpha"
/>
<feOffset dy="0.757576" />
<feGaussianBlur stdDeviation="1.13636" />
<feComposite in2="hardAlpha" operator="out" />
<feColorMatrix
type="matrix"
values="0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0.1 0"
/>
<feBlend
mode="normal"
in2="BackgroundImageFix"
result="effect1_dropShadow_907_33313"
/>
<feBlend
mode="normal"
in="SourceGraphic"
in2="effect1_dropShadow_907_33313"
result="shape"
/>
</filter>
<filter
id="filter11_i_907_33313"
x="308.638"
y="32.0132"
width="36.4089"
height="36.4088"
filterUnits="userSpaceOnUse"
color-interpolation-filters="sRGB"
>
<feFlood flood-opacity="0" result="BackgroundImageFix" />
<feBlend
mode="normal"
in="SourceGraphic"
in2="BackgroundImageFix"
result="shape"
/>
<feColorMatrix
in="SourceAlpha"
type="matrix"
values="0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 127 0"
result="hardAlpha"
/>
<feOffset dx="0.368852" dy="0.368852" />
<feGaussianBlur stdDeviation="0.491803" />
<feComposite in2="hardAlpha" operator="arithmetic" k2="-1" k3="1" />
<feColorMatrix
type="matrix"
values="0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0.15 0"
/>
<feBlend
mode="normal"
in2="shape"
result="effect1_innerShadow_907_33313"
/>
</filter>
<filter
id="filter12_i_907_33313"
x="388.625"
y="29.798"
width="40"
height="40.202"
filterUnits="userSpaceOnUse"
color-interpolation-filters="sRGB"
>
<feFlood flood-opacity="0" result="BackgroundImageFix" />
<feBlend
mode="normal"
in="SourceGraphic"
in2="BackgroundImageFix"
result="shape"
/>
<feColorMatrix
in="SourceAlpha"
type="matrix"
values="0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 127 0"
result="hardAlpha"
/>
<feOffset dy="-0.20202" />
<feGaussianBlur stdDeviation="0.707071" />
<feComposite in2="hardAlpha" operator="arithmetic" k2="-1" k3="1" />
<feColorMatrix
type="matrix"
values="0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0.05 0"
/>
<feBlend
mode="normal"
in2="shape"
result="effect1_innerShadow_907_33313"
/>
</filter>
<filter
id="filter13_d_907_33313"
x="388.927"
y="30.808"
width="39.3938"
height="39.394"
filterUnits="userSpaceOnUse"
color-interpolation-filters="sRGB"
>
<feFlood flood-opacity="0" result="BackgroundImageFix" />
<feColorMatrix
in="SourceAlpha"
type="matrix"
values="0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 127 0"
result="hardAlpha"
/>
<feOffset dy="0.505051" />
<feGaussianBlur stdDeviation="0.757576" />
<feComposite in2="hardAlpha" operator="out" />
<feColorMatrix
type="matrix"
values="0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0.1 0"
/>
<feBlend
mode="normal"
in2="BackgroundImageFix"
result="effect1_dropShadow_907_33313"
/>
<feBlend
mode="normal"
in="SourceGraphic"
in2="effect1_dropShadow_907_33313"
result="shape"
/>
</filter>
<filter
id="filter14_i_907_33313"
x="396.633"
y="38.0088"
width="24.2725"
height="24.2725"
filterUnits="userSpaceOnUse"
color-interpolation-filters="sRGB"
>
<feFlood flood-opacity="0" result="BackgroundImageFix" />
<feBlend
mode="normal"
in="SourceGraphic"
in2="BackgroundImageFix"
result="shape"
/>
<feColorMatrix
in="SourceAlpha"
type="matrix"
values="0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 127 0"
result="hardAlpha"
/>
<feOffset dx="0.245902" dy="0.245902" />
<feGaussianBlur stdDeviation="0.327869" />
<feComposite in2="hardAlpha" operator="arithmetic" k2="-1" k3="1" />
<feColorMatrix
type="matrix"
values="0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0.15 0"
/>
<feBlend
mode="normal"
in2="shape"
result="effect1_innerShadow_907_33313"
/>
</filter>
<linearGradient
id="paint0_linear_907_33313"
x1="7.49313"
y1="32.5757"
x2="28.8063"
y2="68.6363"
gradientUnits="userSpaceOnUse"
>
<stop stop-color="#F8F8F8" />
<stop offset="0.348555" stop-color="white" />
<stop offset="0.672956" stop-color="white" />
<stop offset="1" stop-color="#FAFAFA" />
<stop offset="1" stop-color="#F8F8F8" />
</linearGradient>
<linearGradient
id="paint1_linear_907_33313"
x1="82.9276"
y1="23.8636"
x2="114.897"
y2="77.9545"
gradientUnits="userSpaceOnUse"
>
<stop stop-color="#F8F8F8" />
<stop offset="0.348555" stop-color="white" />
<stop offset="0.672956" stop-color="white" />
<stop offset="1" stop-color="#FAFAFA" />
<stop offset="1" stop-color="#F8F8F8" />
</linearGradient>
<linearGradient
id="paint2_linear_907_33313"
x1="181.796"
y1="6.43938"
x2="235.079"
y2="96.5909"
gradientUnits="userSpaceOnUse"
>
<stop stop-color="#F8F8F8" />
<stop offset="0.348555" stop-color="white" />
<stop offset="0.672956" stop-color="white" />
<stop offset="1" stop-color="#FAFAFA" />
<stop offset="1" stop-color="#F8F8F8" />
</linearGradient>
<linearGradient
id="paint3_linear_907_33313"
x1="306.928"
y1="23.8636"
x2="338.897"
y2="77.9545"
gradientUnits="userSpaceOnUse"
>
<stop stop-color="#F8F8F8" />
<stop offset="0.348555" stop-color="white" />
<stop offset="0.672956" stop-color="white" />
<stop offset="1" stop-color="#FAFAFA" />
<stop offset="1" stop-color="#F8F8F8" />
</linearGradient>
<linearGradient
id="paint4_linear_907_33313"
x1="395.493"
y1="32.5757"
x2="416.806"
y2="68.6363"
gradientUnits="userSpaceOnUse"
>
<stop stop-color="#F8F8F8" />
<stop offset="0.348555" stop-color="white" />
<stop offset="0.672956" stop-color="white" />
<stop offset="1" stop-color="#FAFAFA" />
<stop offset="1" stop-color="#F8F8F8" />
</linearGradient>
<clipPath id="clip0_907_33313">
<rect
x="0.624512"
y="30"
width="40"
height="40"
rx="6.06061"
fill="white"
/>
</clipPath>
<clipPath id="clip1_907_33313">
<rect
x="72.6245"
y="20"
width="60"
height="60"
rx="9.09091"
fill="white"
/>
</clipPath>
<clipPath id="clip2_907_33313">
<rect x="164.625" width="100" height="100" rx="15.1515" fill="white" />
</clipPath>
<clipPath id="clip3_907_33313">
<rect
x="296.625"
y="20"
width="60"
height="60"
rx="9.09091"
fill="white"
/>
</clipPath>
<clipPath id="clip4_907_33313">
<rect
width="36.0399"
height="36.0399"
fill="white"
transform="translate(308.638 32.0132)"
/>
</clipPath>
<clipPath id="clip5_907_33313">
<rect
x="388.625"
y="30"
width="40"
height="40"
rx="6.06061"
fill="white"
/>
</clipPath>
</defs>
</svg>`;
@@ -0,0 +1,41 @@
import type { SVGProps } from 'react';
export const UpgradeIcon = ({ width, height }: SVGProps<SVGElement>) => {
return (
<svg
className="icon"
width={width || 16}
height={height || 16}
viewBox="0 0 16 16"
fill="none"
xmlns="http://www.w3.org/2000/svg"
>
<path
fillRule="evenodd"
clipRule="evenodd"
d="M1.5 8C1.5 4.41022 4.41022 1.5 8 1.5C11.5898 1.5 14.5 4.41022 14.5 8C14.5 11.5898 11.5898 14.5 8 14.5C4.41022 14.5 1.5 11.5898 1.5 8ZM8 2.5C4.96251 2.5 2.5 4.96251 2.5 8C2.5 11.0375 4.96251 13.5 8 13.5C11.0375 13.5 13.5 11.0375 13.5 8C13.5 4.96251 11.0375 2.5 8 2.5ZM4.91917 7.64645L7.64645 4.91917C7.84171 4.72391 8.15829 4.72391 8.35355 4.91917L11.0808 7.64645C11.2761 7.84171 11.2761 8.15829 11.0808 8.35355C10.8856 8.54882 10.569 8.54882 10.3737 8.35355L8.5 6.47983V11C8.5 11.2761 8.27614 11.5 8 11.5C7.72386 11.5 7.5 11.2761 7.5 11V6.47983L5.62628 8.35355C5.43102 8.54882 5.11444 8.54882 4.91917 8.35355C4.72391 8.15829 4.72391 7.84171 4.91917 7.64645Z"
fill="currentColor"
/>
</svg>
);
};
export const PaymentIcon = ({ width, height }: SVGProps<SVGElement>) => {
return (
<svg
className="icon"
width={width || 16}
height={height || 16}
viewBox="0 0 16 16"
fill="none"
xmlns="http://www.w3.org/2000/svg"
>
<path
fillRule="evenodd"
clipRule="evenodd"
d="M1.5 4.66634C1.5 3.65382 2.32081 2.83301 3.33333 2.83301H12.6667C13.6792 2.83301 14.5 3.65382 14.5 4.66634V11.333C14.5 12.3455 13.6792 13.1663 12.6667 13.1663H3.33333C2.32081 13.1663 1.5 12.3455 1.5 11.333V4.66634ZM3.33333 3.83301C2.8731 3.83301 2.5 4.2061 2.5 4.66634V5.49967H13.5V4.66634C13.5 4.2061 13.1269 3.83301 12.6667 3.83301H3.33333ZM13.5 6.49967H2.5V11.333C2.5 11.7932 2.8731 12.1663 3.33333 12.1663H12.6667C13.1269 12.1663 13.5 11.7932 13.5 11.333V6.49967ZM9.5 9.99967C9.5 9.72353 9.72386 9.49967 10 9.49967H12C12.2761 9.49967 12.5 9.72353 12.5 9.99967C12.5 10.2758 12.2761 10.4997 12 10.4997H10C9.72386 10.4997 9.5 10.2758 9.5 9.99967Z"
fill="currentColor"
/>
</svg>
);
};
@@ -0,0 +1,150 @@
import { UserFeatureService } from '@affine/core/modules/cloud/services/user-feature';
import type { SettingTab } from '@affine/core/modules/dialogs/constant';
import { useI18n } from '@affine/i18n';
import {
AppearanceIcon,
ExperimentIcon,
InformationIcon,
KeyboardIcon,
PenIcon,
} from '@blocksuite/icons/rc';
import {
FeatureFlagService,
useLiveData,
useServices,
} from '@toeverything/infra';
import type { ReactElement, SVGProps } from 'react';
import { useEffect } from 'react';
import { AuthService, ServerConfigService } from '../../../../modules/cloud';
import type { SettingState } from '../types';
import { AboutAffine } from './about';
import { AppearanceSettings } from './appearance';
import { BillingSettings } from './billing';
import { EditorSettings } from './editor';
import { ExperimentalFeatures } from './experimental-features';
import { PaymentIcon, UpgradeIcon } from './icons';
import { AFFiNEPricingPlans } from './plans';
import { Shortcuts } from './shortcuts';
interface GeneralSettingListItem {
key: SettingTab;
title: string;
icon: (props: SVGProps<SVGSVGElement>) => ReactElement;
testId: string;
}
export type GeneralSettingList = GeneralSettingListItem[];
export const useGeneralSettingList = (): GeneralSettingList => {
const t = useI18n();
const {
authService,
serverConfigService,
userFeatureService,
featureFlagService,
} = useServices({
AuthService,
ServerConfigService,
UserFeatureService,
FeatureFlagService,
});
const status = useLiveData(authService.session.status$);
const hasPaymentFeature = useLiveData(
serverConfigService.serverConfig.features$.map(f => f?.payment)
);
const enableEditorSettings = useLiveData(
featureFlagService.flags.enable_editor_settings.$
);
useEffect(() => {
userFeatureService.userFeature.revalidate();
}, [userFeatureService]);
const settings: GeneralSettingListItem[] = [
{
key: 'appearance',
title: t['com.affine.settings.appearance'](),
icon: AppearanceIcon,
testId: 'appearance-panel-trigger',
},
{
key: 'shortcuts',
title: t['com.affine.keyboardShortcuts.title'](),
icon: KeyboardIcon,
testId: 'shortcuts-panel-trigger',
},
{
key: 'about',
title: t['com.affine.aboutAFFiNE.title'](),
icon: InformationIcon,
testId: 'about-panel-trigger',
},
];
if (enableEditorSettings) {
// add editor settings to second position
settings.splice(1, 0, {
key: 'editor',
title: t['com.affine.settings.editorSettings'](),
icon: PenIcon,
testId: 'editor-panel-trigger',
});
}
if (hasPaymentFeature) {
settings.splice(3, 0, {
key: 'plans',
title: t['com.affine.payment.title'](),
icon: UpgradeIcon,
testId: 'plans-panel-trigger',
});
if (status === 'authenticated') {
settings.splice(3, 0, {
key: 'billing',
title: t['com.affine.payment.billing-setting.title'](),
icon: PaymentIcon,
testId: 'billing-panel-trigger',
});
}
}
settings.push({
key: 'experimental-features',
title: t['com.affine.settings.workspace.experimental-features'](),
icon: ExperimentIcon,
testId: 'experimental-features-trigger',
});
return settings;
};
interface GeneralSettingProps {
activeTab: SettingTab;
scrollAnchor?: string;
onChangeSettingState: (settingState: SettingState) => void;
}
export const GeneralSetting = ({
activeTab,
scrollAnchor,
onChangeSettingState,
}: GeneralSettingProps) => {
switch (activeTab) {
case 'shortcuts':
return <Shortcuts />;
case 'editor':
return <EditorSettings />;
case 'appearance':
return <AppearanceSettings />;
case 'about':
return <AboutAffine />;
case 'plans':
return <AFFiNEPricingPlans scrollAnchor={scrollAnchor} />;
case 'billing':
return <BillingSettings onChangeSettingState={onChangeSettingState} />;
case 'experimental-features':
return <ExperimentalFeatures />;
default:
return null;
}
};
@@ -0,0 +1,146 @@
import { useDowngradeNotify } from '@affine/core/components/affine/subscription-landing/notify';
import { getDowngradeQuestionnaireLink } from '@affine/core/components/hooks/affine/use-subscription-notify';
import { useAsyncCallback } from '@affine/core/components/hooks/affine-async-hooks';
import { SubscriptionPlan } from '@affine/graphql';
import { track } from '@affine/track';
import { useLiveData, useService } from '@toeverything/infra';
import { nanoid } from 'nanoid';
import type { PropsWithChildren } from 'react';
import { useEffect, useState } from 'react';
import { AuthService, SubscriptionService } from '../../../../../modules/cloud';
import { ConfirmLoadingModal, DowngradeModal } from './modals';
/**
* Cancel action with modal & request
* @param param0
* @returns
*/
export const CancelAction = ({
children,
open,
onOpenChange,
}: {
open: boolean;
onOpenChange: (open: boolean) => void;
} & PropsWithChildren) => {
const [idempotencyKey, setIdempotencyKey] = useState(nanoid());
const [isMutating, setIsMutating] = useState(false);
const subscription = useService(SubscriptionService).subscription;
const proSubscription = useLiveData(subscription.pro$);
const authService = useService(AuthService);
const downgradeNotify = useDowngradeNotify();
useEffect(() => {
if (!open || !proSubscription) return;
track.$.settingsPanel.plans.cancelSubscription({
plan: proSubscription.plan,
recurring: proSubscription.recurring,
});
}, [open, proSubscription]);
const downgrade = useAsyncCallback(async () => {
try {
const account = authService.session.account$.value;
const prevRecurring = subscription.pro$.value?.recurring;
setIsMutating(true);
await subscription.cancelSubscription(idempotencyKey);
subscription.revalidate();
await subscription.isRevalidating$.waitFor(v => !v);
// refresh idempotency key
setIdempotencyKey(nanoid());
onOpenChange(false);
const proSubscription = subscription.pro$.value;
if (proSubscription) {
track.$.settingsPanel.plans.confirmCancelingSubscription({
plan: proSubscription.plan,
recurring: proSubscription.recurring,
});
}
if (account && prevRecurring) {
downgradeNotify(
getDowngradeQuestionnaireLink({
email: account.email ?? '',
id: account.id,
name: account.info?.name ?? '',
plan: SubscriptionPlan.Pro,
recurring: prevRecurring,
})
);
}
} finally {
setIsMutating(false);
}
}, [
authService.session.account$.value,
subscription,
idempotencyKey,
onOpenChange,
downgradeNotify,
]);
return (
<>
{children}
<DowngradeModal
open={open}
onCancel={downgrade}
onOpenChange={onOpenChange}
loading={isMutating}
/>
</>
);
};
/**
* Resume payment action with modal & request
* @param param0
* @returns
*/
export const ResumeAction = ({
children,
open,
onOpenChange,
}: {
open: boolean;
onOpenChange: (open: boolean) => void;
} & PropsWithChildren) => {
// allow replay request on network error until component unmount or success
const [idempotencyKey, setIdempotencyKey] = useState(nanoid());
const [isMutating, setIsMutating] = useState(false);
const subscription = useService(SubscriptionService).subscription;
const resume = useAsyncCallback(async () => {
try {
setIsMutating(true);
await subscription.resumeSubscription(idempotencyKey);
subscription.revalidate();
await subscription.isRevalidating$.waitFor(v => !v);
// refresh idempotency key
setIdempotencyKey(nanoid());
onOpenChange(false);
const proSubscription = subscription.pro$.value;
if (proSubscription) {
track.$.settingsPanel.plans.confirmResumingSubscription({
plan: proSubscription.plan,
recurring: proSubscription.recurring,
});
}
} finally {
setIsMutating(false);
}
}, [subscription, idempotencyKey, onOpenChange]);
return (
<>
{children}
<ConfirmLoadingModal
type={'resume'}
open={open}
onConfirm={resume}
onOpenChange={onOpenChange}
loading={isMutating}
/>
</>
);
};
@@ -0,0 +1,95 @@
import { Button, type ButtonProps, useConfirmModal } from '@affine/component';
import { useDowngradeNotify } from '@affine/core/components/affine/subscription-landing/notify';
import { getDowngradeQuestionnaireLink } from '@affine/core/components/hooks/affine/use-subscription-notify';
import { useAsyncCallback } from '@affine/core/components/hooks/affine-async-hooks';
import { AuthService, SubscriptionService } from '@affine/core/modules/cloud';
import { SubscriptionPlan } from '@affine/graphql';
import { useI18n } from '@affine/i18n';
import { track } from '@affine/track';
import { useService } from '@toeverything/infra';
import { nanoid } from 'nanoid';
import { useState } from 'react';
export const AICancel = (btnProps: ButtonProps) => {
const t = useI18n();
const [isMutating, setMutating] = useState(false);
const [idempotencyKey, setIdempotencyKey] = useState(nanoid());
const subscription = useService(SubscriptionService).subscription;
const authService = useService(AuthService);
const { openConfirmModal } = useConfirmModal();
const downgradeNotify = useDowngradeNotify();
const cancel = useAsyncCallback(async () => {
const aiSubscription = subscription.ai$.value;
if (aiSubscription) {
track.$.settingsPanel.plans.cancelSubscription({
plan: SubscriptionPlan.AI,
recurring: aiSubscription.recurring,
});
}
openConfirmModal({
title: t['com.affine.payment.ai.action.cancel.confirm.title'](),
description:
t['com.affine.payment.ai.action.cancel.confirm.description'](),
reverseFooter: true,
confirmText:
t['com.affine.payment.ai.action.cancel.confirm.confirm-text'](),
confirmButtonOptions: {
variant: 'secondary',
},
cancelText:
t['com.affine.payment.ai.action.cancel.confirm.cancel-text'](),
cancelButtonOptions: {
variant: 'primary',
},
onConfirm: async () => {
try {
setMutating(true);
await subscription.cancelSubscription(
idempotencyKey,
SubscriptionPlan.AI
);
setIdempotencyKey(nanoid());
track.$.settingsPanel.plans.confirmCancelingSubscription({
plan: SubscriptionPlan.AI,
recurring: aiSubscription?.recurring,
});
const account = authService.session.account$.value;
const prevRecurring = subscription.ai$.value?.recurring;
if (account && prevRecurring) {
downgradeNotify(
getDowngradeQuestionnaireLink({
email: account.email,
name: account.info?.name,
id: account.id,
plan: SubscriptionPlan.AI,
recurring: prevRecurring,
})
);
}
} finally {
setMutating(false);
}
},
});
}, [
subscription,
openConfirmModal,
t,
idempotencyKey,
authService.session.account$.value,
downgradeNotify,
]);
return (
<Button
onClick={cancel}
loading={isMutating}
variant="primary"
{...btnProps}
>
{t['com.affine.payment.ai.action.cancel.button-label']()}
</Button>
);
};
@@ -0,0 +1,4 @@
export * from './cancel';
export * from './login';
export * from './resume';
export * from './subscribe';
@@ -0,0 +1,23 @@
import { Button, type ButtonProps } from '@affine/component';
import { authAtom } from '@affine/core/components/atoms';
import { useI18n } from '@affine/i18n';
import { useSetAtom } from 'jotai';
import { useCallback } from 'react';
export const AILogin = (btnProps: ButtonProps) => {
const t = useI18n();
const setOpen = useSetAtom(authAtom);
const onClickSignIn = useCallback(() => {
setOpen(state => ({
...state,
openModal: true,
}));
}, [setOpen]);
return (
<Button onClick={onClickSignIn} variant="primary" {...btnProps}>
{t['com.affine.payment.ai.action.login.button-label']()}
</Button>
);
};
@@ -0,0 +1,52 @@
import { Button, type ButtonProps } from '@affine/component';
import { generateSubscriptionCallbackLink } from '@affine/core/components/hooks/affine/use-subscription-notify';
import { AuthService } from '@affine/core/modules/cloud';
import {
SubscriptionPlan,
SubscriptionRecurring,
SubscriptionVariant,
} from '@affine/graphql';
import { useI18n } from '@affine/i18n';
import track from '@affine/track';
import { useService } from '@toeverything/infra';
import { useCallback, useMemo } from 'react';
import { CheckoutSlot } from '../../checkout-slot';
export const AIRedeemCodeButton = (btnProps: ButtonProps) => {
const t = useI18n();
const authService = useService(AuthService);
const onBeforeCheckout = useCallback(() => {
track.$.settingsPanel.plans.checkout({
plan: SubscriptionPlan.AI,
recurring: SubscriptionRecurring.Yearly,
});
}, []);
const checkoutOptions = useMemo(
() => ({
recurring: SubscriptionRecurring.Yearly,
plan: SubscriptionPlan.AI,
variant: SubscriptionVariant.Onetime,
coupon: null,
successCallbackLink: generateSubscriptionCallbackLink(
authService.session.account$.value,
SubscriptionPlan.AI,
SubscriptionRecurring.Yearly
),
}),
[authService.session.account$.value]
);
return (
<CheckoutSlot
onBeforeCheckout={onBeforeCheckout}
checkoutOptions={checkoutOptions}
renderer={props => (
<Button variant="primary" {...btnProps} {...props}>
{t['com.affine.payment.redeem-code']()}
</Button>
)}
/>
);
};
@@ -0,0 +1,82 @@
import {
Button,
type ButtonProps,
notify,
useConfirmModal,
} from '@affine/component';
import { useAsyncCallback } from '@affine/core/components/hooks/affine-async-hooks';
import { SubscriptionService } from '@affine/core/modules/cloud';
import { SubscriptionPlan } from '@affine/graphql';
import { useI18n } from '@affine/i18n';
import { track } from '@affine/track';
import { SingleSelectSelectSolidIcon } from '@blocksuite/icons/rc';
import { useService } from '@toeverything/infra';
import { cssVar } from '@toeverything/theme';
import { nanoid } from 'nanoid';
import { useState } from 'react';
export const AIResume = (btnProps: ButtonProps) => {
const t = useI18n();
const [idempotencyKey, setIdempotencyKey] = useState(nanoid());
const subscription = useService(SubscriptionService).subscription;
const [isMutating, setIsMutating] = useState(false);
const { openConfirmModal } = useConfirmModal();
const resume = useAsyncCallback(async () => {
const aiSubscription = subscription.ai$.value;
if (aiSubscription) {
track.$.settingsPanel.plans.resumeSubscription({
plan: SubscriptionPlan.AI,
recurring: aiSubscription.recurring,
});
}
openConfirmModal({
title: t['com.affine.payment.ai.action.resume.confirm.title'](),
description:
t['com.affine.payment.ai.action.resume.confirm.description'](),
confirmText:
t['com.affine.payment.ai.action.resume.confirm.confirm-text'](),
confirmButtonOptions: {
variant: 'primary',
},
cancelText:
t['com.affine.payment.ai.action.resume.confirm.cancel-text'](),
onConfirm: async () => {
setIsMutating(true);
await subscription.resumeSubscription(
idempotencyKey,
SubscriptionPlan.AI
);
if (aiSubscription) {
track.$.settingsPanel.plans.confirmResumingSubscription({
plan: aiSubscription.plan,
recurring: aiSubscription.recurring,
});
}
notify({
icon: <SingleSelectSelectSolidIcon />,
iconColor: cssVar('processingColor'),
title:
t['com.affine.payment.ai.action.resume.confirm.notify.title'](),
message:
t['com.affine.payment.ai.action.resume.confirm.notify.msg'](),
});
setIdempotencyKey(nanoid());
},
});
}, [subscription, openConfirmModal, t, idempotencyKey]);
return (
<Button
loading={isMutating}
onClick={resume}
variant="primary"
{...btnProps}
>
{t['com.affine.payment.ai.action.resume.button-label']()}
</Button>
);
};
@@ -0,0 +1,95 @@
import { Button, type ButtonProps, Skeleton } from '@affine/component';
import { generateSubscriptionCallbackLink } from '@affine/core/components/hooks/affine/use-subscription-notify';
import { AuthService, SubscriptionService } from '@affine/core/modules/cloud';
import { SubscriptionPlan, SubscriptionRecurring } from '@affine/graphql';
import { useI18n } from '@affine/i18n';
import { track } from '@affine/track';
import { useLiveData, useService } from '@toeverything/infra';
import { useCallback, useMemo } from 'react';
import { CheckoutSlot } from '../../checkout-slot';
export interface AISubscribeProps extends ButtonProps {
displayedFrequency?: 'yearly' | 'monthly' | null;
}
export const AISubscribe = ({
displayedFrequency = 'yearly',
...btnProps
}: AISubscribeProps) => {
const authService = useService(AuthService);
const subscriptionService = useService(SubscriptionService);
const price = useLiveData(subscriptionService.prices.aiPrice$);
const t = useI18n();
const onBeforeCheckout = useCallback(() => {
track.$.settingsPanel.plans.checkout({
plan: SubscriptionPlan.AI,
recurring: SubscriptionRecurring.Yearly,
});
}, []);
const checkoutOptions = useMemo(
() => ({
recurring: SubscriptionRecurring.Yearly,
plan: SubscriptionPlan.AI,
variant: null,
coupon: null,
successCallbackLink: generateSubscriptionCallbackLink(
authService.session.account$.value,
SubscriptionPlan.AI,
SubscriptionRecurring.Yearly
),
}),
[authService.session.account$.value]
);
if (!price || !price.yearlyAmount) {
return (
<Skeleton
className={btnProps.className}
width={160}
height={36}
style={{
borderRadius: 18,
...btnProps.style,
}}
/>
);
}
const priceReadable = `$${(
price.yearlyAmount /
100 /
(displayedFrequency === 'yearly' ? 1 : 12)
).toFixed(2)}`;
const priceFrequency =
displayedFrequency === 'yearly'
? t['com.affine.payment.billing-setting.year']()
: t['com.affine.payment.billing-setting.month']();
return (
<CheckoutSlot
onBeforeCheckout={onBeforeCheckout}
checkoutOptions={checkoutOptions}
renderer={props => (
<Button variant="primary" {...props} {...btnProps}>
{btnProps.children ?? `${priceReadable} / ${priceFrequency}`}
{displayedFrequency === 'monthly' ? (
<span
style={{
fontSize: 10,
opacity: 0.75,
letterSpacing: -0.2,
paddingLeft: 4,
}}
>
{t['com.affine.payment.ai.subscribe.billed-annually']()}
</span>
) : null}
</Button>
)}
/>
);
};
@@ -0,0 +1,122 @@
import { cssVar } from '@toeverything/theme';
import { globalStyle, style } from '@vanilla-extract/css';
export const card = style({
border: `1px solid ${cssVar('borderColor')}`,
borderRadius: 16,
padding: 36,
});
export const titleBlock = style({
display: 'flex',
flexDirection: 'column',
gap: 8,
marginBottom: 12,
});
export const titleCaption1 = style({
fontWeight: 500,
fontSize: cssVar('fontXs'),
lineHeight: '20px',
color: cssVar('brandColor'),
});
export const titleCaption2 = style({
fontWeight: 500,
fontSize: cssVar('fontSm'),
lineHeight: '22px',
color: cssVar('textPrimaryColor'),
});
export const title = style({
color: cssVar('textPrimaryColor'),
fontWeight: 600,
fontSize: '28px',
lineHeight: '36px',
});
// action button
export const actionBlock = style({
display: 'flex',
flexDirection: 'column',
gap: 12,
alignItems: 'start',
marginBottom: 24,
});
export const actionButtons = style({
display: 'flex',
gap: 12,
});
export const purchaseButton = style({
minWidth: 160,
height: 37,
borderRadius: 18,
fontWeight: 500,
fontSize: cssVar('fontSm'),
lineHeight: '14px',
letterSpacing: '-1%',
});
export const learnAIButton = style([
purchaseButton,
{
color: cssVar('textEmphasisColor'),
paddingLeft: 16,
paddingRight: 16,
},
]);
export const agreement = style({
fontSize: cssVar('fontXs'),
fontWeight: 400,
lineHeight: '20px',
color: cssVar('textSecondaryColor'),
});
globalStyle(`.${agreement} > a`, {
color: cssVar('textPrimaryColor'),
textDecoration: 'underline',
});
// benefits
export const benefits = style({
display: 'flex',
flexDirection: 'column',
gap: 12,
});
export const benefitGroup = style({
display: 'flex',
flexDirection: 'column',
gap: 12,
});
export const benefitTitle = style({
fontWeight: 500,
fontSize: cssVar('fontSm'),
lineHeight: '20px',
color: cssVar('textPrimaryColor'),
display: 'flex',
alignItems: 'center',
gap: 4,
});
globalStyle(`.${benefitTitle} > svg`, {
color: cssVar('brandColor'),
width: 20,
height: 20,
});
export const benefitList = style({
display: 'flex',
flexDirection: 'column',
gap: 4,
});
export const benefitItem = style({
fontWeight: 400,
fontSize: cssVar('fontXs'),
color: cssVar('textSecondaryColor'),
lineHeight: '24px',
paddingLeft: 22,
position: 'relative',
'::before': {
content: '""',
width: 4,
height: 4,
borderRadius: 2,
background: 'currentColor',
position: 'absolute',
left: '10px',
top: '10px',
},
});
@@ -0,0 +1,84 @@
import { Button } from '@affine/component';
import { AuthService, SubscriptionService } from '@affine/core/modules/cloud';
import { i18nTime, useI18n } from '@affine/i18n';
import { useLiveData, useService } from '@toeverything/infra';
import { useEffect } from 'react';
import { AICancel, AILogin, AIResume, AISubscribe } from './actions';
import { AIRedeemCodeButton } from './actions/redeem';
import * as styles from './ai-plan.css';
import { AIPlanLayout } from './layout';
export const AIPlan = () => {
const t = useI18n();
const authService = useService(AuthService);
const subscriptionService = useService(SubscriptionService);
const subscription = useLiveData(subscriptionService.subscription.ai$);
const price = useLiveData(subscriptionService.prices.aiPrice$);
const isLoggedIn =
useLiveData(authService.session.status$) === 'authenticated';
const isOnetime = useLiveData(subscriptionService.subscription.isOnetimeAI$);
useEffect(() => {
subscriptionService.subscription.revalidate();
subscriptionService.prices.revalidate();
}, [subscriptionService]);
// yearly subscription should always be available
if (!price?.yearlyAmount) {
return null;
}
const billingTip = subscription?.nextBillAt
? t['com.affine.payment.ai.billing-tip.next-bill-at']({
due: i18nTime(subscription.nextBillAt, {
absolute: { accuracy: 'day' },
}),
})
: subscription?.canceledAt && subscription.end
? t['com.affine.payment.ai.billing-tip.end-at']({
end: i18nTime(subscription.end, {
absolute: { accuracy: 'day' },
}),
})
: null;
return (
<AIPlanLayout
caption={
subscription
? t['com.affine.payment.ai.pricing-plan.caption-purchased']()
: t['com.affine.payment.ai.pricing-plan.caption-free']()
}
actionButtons={
isLoggedIn ? (
subscription ? (
isOnetime ? (
<AIRedeemCodeButton className={styles.purchaseButton} />
) : subscription.canceledAt ? (
<AIResume className={styles.purchaseButton} />
) : (
<AICancel className={styles.purchaseButton} />
)
) : (
<>
<AISubscribe
className={styles.purchaseButton}
displayedFrequency="monthly"
/>
<a href="https://ai.affine.pro" target="_blank" rel="noreferrer">
<Button className={styles.learnAIButton}>
{t['com.affine.payment.ai.pricing-plan.learn']()}
</Button>
</a>
</>
)
) : (
<AILogin className={styles.purchaseButton} />
)
}
billingTip={billingTip}
/>
);
};
@@ -0,0 +1,66 @@
import { useI18n } from '@affine/i18n';
import {
CheckBoxCheckLinearIcon,
PenIcon,
TextIcon,
} from '@blocksuite/icons/rc';
import { useMemo } from 'react';
import * as styles from './ai-plan.css';
const benefitsGetter = (t: ReturnType<typeof useI18n>) => [
{
name: t['com.affine.payment.ai.benefit.g1'](),
icon: <TextIcon />,
items: [
t['com.affine.payment.ai.benefit.g1-1'](),
t['com.affine.payment.ai.benefit.g1-2'](),
t['com.affine.payment.ai.benefit.g1-3'](),
],
},
{
name: t['com.affine.payment.ai.benefit.g2'](),
icon: <PenIcon />,
items: [
t['com.affine.payment.ai.benefit.g2-1'](),
t['com.affine.payment.ai.benefit.g2-2'](),
t['com.affine.payment.ai.benefit.g2-3'](),
],
},
{
name: t['com.affine.payment.ai.benefit.g3'](),
icon: <CheckBoxCheckLinearIcon />,
items: [
t['com.affine.payment.ai.benefit.g3-1'](),
t['com.affine.payment.ai.benefit.g3-2'](),
t['com.affine.payment.ai.benefit.g3-3'](),
],
},
];
export const AIBenefits = () => {
const t = useI18n();
const benefits = useMemo(() => benefitsGetter(t), [t]);
return (
<div className={styles.benefits}>
{benefits.map(({ name, icon, items }) => {
return (
<div key={name} className={styles.benefitGroup}>
<div className={styles.benefitTitle}>
{icon}
{name}
</div>
<ul className={styles.benefitList}>
{items.map(item => (
<li className={styles.benefitItem} key={item}>
{item}
</li>
))}
</ul>
</div>
);
})}
</div>
);
};
@@ -0,0 +1,47 @@
import { useI18n } from '@affine/i18n';
import type { ReactNode } from 'react';
import { PricingCollapsible } from '../layout';
import * as styles from './ai-plan.css';
import { AIBenefits } from './benefits';
export interface AIPlanLayoutProps {
caption?: ReactNode;
actionButtons?: ReactNode;
billingTip?: ReactNode;
}
export const AIPlanLayout = ({
caption,
actionButtons,
billingTip,
}: AIPlanLayoutProps) => {
const t = useI18n();
const title = t['com.affine.payment.ai.pricing-plan.title']();
return (
<PricingCollapsible title={title} caption={caption}>
<div className={styles.card}>
<div className={styles.titleBlock}>
<section className={styles.titleCaption1}>
{t['com.affine.payment.ai.pricing-plan.title-caption-1']()}
</section>
<section className={styles.title}>
{t['com.affine.payment.ai.pricing-plan.title']()}
</section>
<section className={styles.titleCaption2}>
{t['com.affine.payment.ai.pricing-plan.title-caption-2']()}
</section>
</div>
<div className={styles.actionBlock}>
<div className={styles.actionButtons}>{actionButtons}</div>
{billingTip ? (
<div className={styles.agreement}>{billingTip}</div>
) : null}
</div>
<AIBenefits />
</div>
</PricingCollapsible>
);
};
@@ -0,0 +1,87 @@
import { useAsyncCallback } from '@affine/core/components/hooks/affine-async-hooks';
import { SubscriptionService } from '@affine/core/modules/cloud';
import { UrlService } from '@affine/core/modules/url';
import type { CreateCheckoutSessionInput } from '@affine/graphql';
import { useService } from '@toeverything/infra';
import { nanoid } from 'nanoid';
import {
type PropsWithChildren,
type ReactNode,
useEffect,
useState,
} from 'react';
export interface CheckoutSlotProps extends PropsWithChildren {
checkoutOptions: Omit<CreateCheckoutSessionInput, 'idempotencyKey'>;
onBeforeCheckout?: () => void;
onCheckoutError?: (error: any) => void;
onCheckoutSuccess?: () => void;
renderer: (props: { onClick: () => void; loading: boolean }) => ReactNode;
}
/**
* A wrapper component for checkout action
*/
export const CheckoutSlot = ({
checkoutOptions,
onBeforeCheckout,
onCheckoutError,
onCheckoutSuccess,
renderer: Renderer,
}: CheckoutSlotProps) => {
const [idempotencyKey, setIdempotencyKey] = useState(nanoid());
const [isMutating, setMutating] = useState(false);
const [isOpenedExternalWindow, setOpenedExternalWindow] = useState(false);
const urlService = useService(UrlService);
const subscriptionService = useService(SubscriptionService);
useEffect(() => {
subscriptionService.prices.revalidate();
}, [subscriptionService]);
useEffect(() => {
if (isOpenedExternalWindow) {
// when the external window is opened, revalidate the subscription when window get focus
window.addEventListener(
'focus',
subscriptionService.subscription.revalidate
);
return () => {
window.removeEventListener(
'focus',
subscriptionService.subscription.revalidate
);
};
}
return;
}, [isOpenedExternalWindow, subscriptionService]);
const subscribe = useAsyncCallback(async () => {
setMutating(true);
onBeforeCheckout?.();
try {
const session = await subscriptionService.createCheckoutSession({
idempotencyKey,
...checkoutOptions,
});
urlService.openPopupWindow(session);
setOpenedExternalWindow(true);
setIdempotencyKey(nanoid());
onCheckoutSuccess?.();
} catch (e) {
onCheckoutError?.(e);
} finally {
setMutating(false);
}
}, [
checkoutOptions,
idempotencyKey,
onBeforeCheckout,
onCheckoutError,
onCheckoutSuccess,
subscriptionService,
urlService,
]);
return <Renderer onClick={subscribe} loading={isMutating} />;
};
@@ -0,0 +1,333 @@
import { Switch } from '@affine/component';
import { AuthService, SubscriptionService } from '@affine/core/modules/cloud';
import { SubscriptionPlan, SubscriptionRecurring } from '@affine/graphql';
import { Trans, useI18n } from '@affine/i18n';
import { AfFiNeIcon } from '@blocksuite/icons/rc';
import { useLiveData, useServices } from '@toeverything/infra';
import { type ReactNode, useEffect, useMemo, useRef, useState } from 'react';
import { CloudPlanLayout } from './layout';
import { LifetimePlan } from './lifetime/lifetime-plan';
import { PlanCard } from './plan-card';
import { planTitleTitleCaption } from './style.css';
import * as styles from './style.css';
type T = ReturnType<typeof useI18n>;
export type Benefits = Record<
string,
Array<{
icon?: ReactNode;
title: ReactNode;
}>
>;
type BenefitsGetter = (t: T) => Benefits;
interface BasePrice {
plan: SubscriptionPlan;
name: string;
description: string;
benefits: Benefits;
}
export interface FixedPrice extends BasePrice {
type: 'fixed';
price: string;
yearlyPrice: string;
discount?: string;
titleRenderer: (
recurring: SubscriptionRecurring,
detail: FixedPrice
) => ReactNode;
}
export interface DynamicPrice extends BasePrice {
type: 'dynamic';
contact: boolean;
titleRenderer: (
recurring: SubscriptionRecurring,
detail: DynamicPrice
) => ReactNode;
}
const freeBenefits: BenefitsGetter = t => ({
[t['com.affine.payment.cloud.free.benefit.g1']()]: ([1, 2, 3] as const).map(
i => ({
title: t[`com.affine.payment.cloud.free.benefit.g1-${i}`](),
})
),
[t['com.affine.payment.cloud.free.benefit.g2']()]: (
[1, 2, 3, 4, 5] as const
).map(i => ({
title: t[`com.affine.payment.cloud.free.benefit.g2-${i}`](),
})),
});
const proBenefits: BenefitsGetter = t => ({
[t['com.affine.payment.cloud.pro.benefit.g1']()]: [
{
title: t['com.affine.payment.cloud.pro.benefit.g1-1'](),
icon: <AfFiNeIcon />,
},
...([2, 3, 4, 5, 7, 8] as const).map(i => ({
title: t[`com.affine.payment.cloud.pro.benefit.g1-${i}`](),
})),
],
});
const teamBenefits: BenefitsGetter = t => ({
[t['com.affine.payment.cloud.team.benefit.g1']()]: [
{
title: t['com.affine.payment.cloud.team.benefit.g1-1'](),
icon: <AfFiNeIcon />,
},
...([2, 3, 4] as const).map(i => ({
title: t[`com.affine.payment.cloud.team.benefit.g1-${i}`](),
})),
],
[t['com.affine.payment.cloud.team.benefit.g2']()]: [
{ title: t['com.affine.payment.cloud.team.benefit.g2-1']() },
{ title: t['com.affine.payment.cloud.team.benefit.g2-2']() },
{ title: t['com.affine.payment.cloud.team.benefit.g2-3']() },
],
});
export function getPlanDetail(t: T) {
return new Map<SubscriptionPlan, FixedPrice | DynamicPrice>([
[
SubscriptionPlan.Free,
{
type: 'fixed',
plan: SubscriptionPlan.Free,
price: '0',
yearlyPrice: '0',
name: t['com.affine.payment.cloud.free.name'](),
description: t['com.affine.payment.cloud.free.description'](),
titleRenderer: () => t['com.affine.payment.cloud.free.title'](),
benefits: freeBenefits(t),
},
],
[
SubscriptionPlan.Pro,
{
type: 'fixed',
plan: SubscriptionPlan.Pro,
price: '1',
yearlyPrice: '1',
name: t['com.affine.payment.cloud.pro.name'](),
description: t['com.affine.payment.cloud.pro.description'](),
titleRenderer: (recurring, detail) => {
const price =
recurring === SubscriptionRecurring.Yearly
? detail.yearlyPrice
: detail.price;
return (
<>
{t['com.affine.payment.cloud.pro.title.price-monthly']({
price: '$' + price,
})}
{recurring === SubscriptionRecurring.Yearly ? (
<span className={planTitleTitleCaption}>
{t['com.affine.payment.cloud.pro.title.billed-yearly']()}
</span>
) : null}
</>
);
},
benefits: proBenefits(t),
},
],
[
SubscriptionPlan.Team,
{
type: 'dynamic',
plan: SubscriptionPlan.Team,
contact: true,
name: t['com.affine.payment.cloud.team.name'](),
description: t['com.affine.payment.cloud.team.description'](),
titleRenderer: () => t['com.affine.payment.cloud.team.title'](),
benefits: teamBenefits(t),
},
],
]);
}
const getRecurringLabel = ({
recurring,
t,
}: {
recurring: SubscriptionRecurring;
t: ReturnType<typeof useI18n>;
}) => {
return recurring === SubscriptionRecurring.Monthly
? t['com.affine.payment.recurring-monthly']()
: t['com.affine.payment.recurring-yearly']();
};
export const CloudPlans = () => {
const t = useI18n();
const scrollWrapper = useRef<HTMLDivElement>(null);
const { authService, subscriptionService } = useServices({
AuthService,
SubscriptionService,
});
const prices = useLiveData(subscriptionService.prices.prices$);
const loggedIn = useLiveData(authService.session.status$) === 'authenticated';
const proSubscription = useLiveData(subscriptionService.subscription.pro$);
const isOnetimePro = useLiveData(
subscriptionService.subscription.isOnetimePro$
);
const [recurring, setRecurring] = useState<SubscriptionRecurring>(
proSubscription?.recurring ?? SubscriptionRecurring.Yearly
);
const planDetail = useMemo(() => {
const rawMap = getPlanDetail(t);
const clonedMap = new Map<SubscriptionPlan, FixedPrice | DynamicPrice>();
rawMap.forEach((detail, plan) => {
clonedMap.set(plan, { ...detail });
});
prices?.forEach(price => {
const detail = clonedMap.get(price.plan);
if (detail?.type === 'fixed') {
detail.price = ((price.amount ?? 0) / 100).toFixed(2);
detail.yearlyPrice = ((price.yearlyAmount ?? 0) / 100 / 12).toFixed(2);
detail.discount =
price.yearlyAmount && price.amount
? Math.floor(
(1 - price.yearlyAmount / 12 / price.amount) * 100
).toString()
: undefined;
}
});
return clonedMap;
}, [prices, t]);
const currentPlan = proSubscription?.plan ?? SubscriptionPlan.Free;
const isCanceled = !!proSubscription?.canceledAt;
const currentRecurring =
proSubscription?.recurring ?? SubscriptionRecurring.Monthly;
const yearlyDiscount = (
planDetail.get(SubscriptionPlan.Pro) as FixedPrice | undefined
)?.discount;
// auto scroll to current plan card
useEffect(() => {
if (!scrollWrapper.current) return;
const currentPlanCard = scrollWrapper.current?.querySelector(
'[data-current="true"]'
);
const wrapperComputedStyle = getComputedStyle(scrollWrapper.current);
const left = currentPlanCard
? currentPlanCard.getBoundingClientRect().left -
scrollWrapper.current.getBoundingClientRect().left -
parseInt(wrapperComputedStyle.paddingLeft)
: 0;
const appeared = scrollWrapper.current.dataset.appeared === 'true';
const animationFrameId = requestAnimationFrame(() => {
scrollWrapper.current?.scrollTo({
behavior: appeared ? 'smooth' : 'instant',
left,
});
scrollWrapper.current?.setAttribute('data-appeared', 'true');
});
return () => {
cancelAnimationFrame(animationFrameId);
};
}, [recurring]);
// caption
const cloudCaption = loggedIn ? (
isCanceled ? (
<p>
{t['com.affine.payment.subtitle-canceled']({
plan: `${getRecurringLabel({
recurring: currentRecurring,
t,
})} ${currentPlan}`,
})}
</p>
) : (
<p>
<Trans
plan={currentPlan}
i18nKey="com.affine.payment.subtitle-active"
values={{ currentPlan }}
>
You are currently on the {{ currentPlan }} plan. If you have any
questions, please contact our&nbsp;
<a
href="mailto:support@toeverything.info"
style={{ color: 'var(--affine-link-color)' }}
>
customer support
</a>
.
</Trans>
</p>
)
) : (
<p>{t['com.affine.payment.subtitle-not-signed-in']()}</p>
);
// toggle
const cloudToggle = (
<div className={styles.recurringToggleWrapper}>
<div>
<div className={styles.recurringToggleRecurring}>
<span>
{t['com.affine.payment.cloud.pricing-plan.toggle-billed-yearly']()}
</span>
</div>
{yearlyDiscount ? (
<div className={styles.recurringToggleDiscount}>
{t['com.affine.payment.cloud.pricing-plan.toggle-discount']({
discount: yearlyDiscount,
})}
</div>
) : null}
</div>
<Switch
checked={recurring === SubscriptionRecurring.Yearly}
onChange={checked =>
setRecurring(
checked
? SubscriptionRecurring.Yearly
: SubscriptionRecurring.Monthly
)
}
/>
</div>
);
const cloudScroll = (
<div className={styles.planCardsWrapper} ref={scrollWrapper}>
{Array.from(planDetail.values()).map(detail => {
return <PlanCard key={detail.plan} {...{ detail, recurring }} />;
})}
</div>
);
const cloudSelect = (
<div className={styles.cloudSelect}>
<b>{t['com.affine.payment.cloud.pricing-plan.select.title']()}</b>
<span>{t['com.affine.payment.cloud.pricing-plan.select.caption']()}</span>
</div>
);
return (
<CloudPlanLayout
caption={cloudCaption}
select={cloudSelect}
toggle={cloudToggle}
scroll={cloudScroll}
scrollRef={scrollWrapper}
lifetime={isOnetimePro ? null : <LifetimePlan />}
/>
);
};
@@ -0,0 +1,19 @@
export function BulledListIcon({ color = 'currentColor' }: { color: string }) {
return (
<svg
width="16"
height="16"
viewBox="0 0 16 16"
fill="none"
xmlns="http://www.w3.org/2000/svg"
>
<ellipse
cx="4.00008"
cy="7.99984"
rx="1.33333"
ry="1.33333"
fill={color}
/>
</svg>
);
}
@@ -0,0 +1,61 @@
import { useI18n } from '@affine/i18n';
import { useLiveData, useService } from '@toeverything/infra';
import { useEffect } from 'react';
import type { FallbackProps } from 'react-error-boundary';
import { SWRErrorBoundary } from '../../../../../components/pure/swr-error-bundary';
import { SubscriptionService } from '../../../../../modules/cloud';
import { AIPlan } from './ai/ai-plan';
import { CloudPlans } from './cloud-plans';
import { CloudPlanLayout, PlanLayout } from './layout';
import { PlansSkeleton } from './skeleton';
import * as styles from './style.css';
const Settings = ({ scrollAnchor }: { scrollAnchor?: string }) => {
const subscriptionService = useService(SubscriptionService);
const prices = useLiveData(subscriptionService.prices.prices$);
useEffect(() => {
subscriptionService.subscription.revalidate();
subscriptionService.prices.revalidate();
}, [subscriptionService]);
if (prices === null) {
return <PlansSkeleton />;
}
return (
<PlanLayout
cloud={<CloudPlans />}
ai={<AIPlan />}
scrollAnchor={scrollAnchor}
/>
);
};
export const AFFiNEPricingPlans = ({
scrollAnchor,
}: {
scrollAnchor?: string;
}) => {
return (
<SWRErrorBoundary FallbackComponent={PlansErrorBoundary}>
<Settings scrollAnchor={scrollAnchor} />
</SWRErrorBoundary>
);
};
const PlansErrorBoundary = ({ resetErrorBoundary }: FallbackProps) => {
const t = useI18n();
const scroll = (
<div className={styles.errorTip}>
<span>{t['com.affine.payment.plans-error-tip']()}</span>
<a onClick={resetErrorBoundary} className={styles.errorTipRetry}>
{t['com.affine.payment.plans-error-retry']()}
</a>
</div>
);
return <PlanLayout cloud={<CloudPlanLayout scroll={scroll} />} />;
};
@@ -0,0 +1,130 @@
import { cssVar } from '@toeverything/theme';
import { globalStyle, keyframes, style } from '@vanilla-extract/css';
export const plansLayoutRoot = style({
display: 'flex',
flexDirection: 'column',
gap: '24px',
});
export const scrollArea = style({
marginLeft: 'calc(-1 * var(--setting-modal-gap-x))',
paddingLeft: 'var(--setting-modal-gap-x)',
width: 'var(--setting-modal-width)',
overflowX: 'auto',
// scrollSnapType: 'x mandatory',
paddingBottom: '21px',
/** Avoid box-shadow clipping */
paddingTop: '21px',
marginTop: '-21px',
});
export const scrollBar = style({
display: 'flex',
alignItems: 'center',
userSelect: 'none',
touchAction: 'none',
height: '9px',
width: '100%',
});
export const scrollThumb = style({
background: cssVar('iconSecondary'),
opacity: 0.6,
overflow: 'hidden',
height: '4px',
borderRadius: '4px',
vars: {
'--radix-scroll-area-thumb-height': '4px',
},
});
export const allPlansLink = style({
display: 'flex',
alignItems: 'center',
gap: '4px',
color: cssVar('linkColor'),
background: 'transparent',
borderColor: 'transparent',
fontSize: cssVar('fontXs'),
});
export const collapsibleHeader = style({
display: 'flex',
alignItems: 'start',
marginBottom: 8,
});
export const collapsibleHeaderContent = style({
width: 0,
flex: 1,
});
export const collapsibleHeaderTitle = style({
fontWeight: 600,
fontSize: cssVar('fontBase'),
lineHeight: '22px',
});
export const collapsibleHeaderCaption = style({
fontWeight: 400,
fontSize: cssVar('fontXs'),
lineHeight: '20px',
color: cssVar('textSecondaryColor'),
});
export const affineCloudHeader = style({
display: 'flex',
justifyContent: 'space-between',
alignItems: 'center',
marginBottom: 24,
});
export const aiDivider = style({
opacity: 0,
selectors: {
'[data-cloud-visible] &': {
opacity: 1,
},
},
});
const slideInBottom = keyframes({
from: {
marginBottom: -100,
},
to: {
marginBottom: 0,
},
});
export const aiScrollTip = style({
position: 'absolute',
zIndex: 1,
bottom: 12,
width: 'var(--setting-modal-content-width)',
background: cssVar('white'),
borderRadius: 8,
border: `1px solid ${cssVar('borderColor')}`,
transition: 'transform 0.36s ease 0.4s, opacity 0.3s ease 0.46s',
alignItems: 'center',
justifyContent: 'space-between',
padding: '12px 20px 12px 16px',
boxShadow: cssVar('shadow1'),
marginBottom: -100,
animation: `${slideInBottom} 0.3s ease 0.5s forwards`,
selectors: {
'[data-cloud-visible] &': {
transform: 'translateY(100px)',
opacity: 0,
},
},
});
// to override `display: contents !important` in `scrollable.tsx`
globalStyle(`div.${aiScrollTip}`, {
display: 'flex !important',
});
export const cloudScrollTipTitle = style({
fontSize: cssVar('fontSm'),
fontWeight: 600,
lineHeight: '22px',
color: cssVar('textPrimaryColor'),
});
export const cloudScrollTipCaption = style({
fontSize: cssVar('fontXs'),
fontWeight: 400,
lineHeight: '20px',
color: cssVar('textSecondaryColor'),
});
@@ -0,0 +1,151 @@
import { Divider, IconButton } from '@affine/component';
import { SettingHeader } from '@affine/component/setting-components';
import { useI18n } from '@affine/i18n';
import { ArrowRightBigIcon, ArrowUpSmallIcon } from '@blocksuite/icons/rc';
import * as Collapsible from '@radix-ui/react-collapsible';
import * as ScrollArea from '@radix-ui/react-scroll-area';
import {
type HtmlHTMLAttributes,
type ReactNode,
useCallback,
useLayoutEffect,
useRef,
useState,
} from 'react';
import { flushSync } from 'react-dom';
import * as styles from './layout.css';
export const SeeAllLink = () => {
const t = useI18n();
return (
<a
className={styles.allPlansLink}
href="https://affine.pro/pricing"
target="_blank"
rel="noopener noreferrer"
>
{t['com.affine.payment.see-all-plans']()}
{<ArrowRightBigIcon width="16" height="16" />}
</a>
);
};
interface PricingCollapsibleProps
extends Omit<HtmlHTMLAttributes<HTMLDivElement>, 'title'> {
title?: ReactNode;
caption?: ReactNode;
}
export const PricingCollapsible = ({
title,
caption,
children,
}: PricingCollapsibleProps) => {
const [open, setOpen] = useState(true);
const toggle = useCallback(() => setOpen(prev => !prev), []);
return (
<Collapsible.Root open={open} onOpenChange={setOpen}>
<section className={styles.collapsibleHeader}>
<div className={styles.collapsibleHeaderContent}>
<div className={styles.collapsibleHeaderTitle}>{title}</div>
<div className={styles.collapsibleHeaderCaption}>{caption}</div>
</div>
<IconButton onClick={toggle} size="20">
<ArrowUpSmallIcon
style={{
transform: open ? 'rotate(0deg)' : 'rotate(180deg)',
transition: 'transform 0.23s ease',
}}
/>
</IconButton>
</section>
<Collapsible.Content>{children}</Collapsible.Content>
</Collapsible.Root>
);
};
export interface PlanLayoutProps {
cloud?: ReactNode;
ai?: ReactNode;
scrollAnchor?: string;
}
export const PlanLayout = ({ cloud, ai, scrollAnchor }: PlanLayoutProps) => {
const t = useI18n();
const plansRootRef = useRef<HTMLDivElement>(null);
// TODO(@catsjuice): Need a better solution to handle this situation
useLayoutEffect(() => {
if (!scrollAnchor) return;
flushSync(() => {
const target = plansRootRef.current?.querySelector(`#${scrollAnchor}`);
if (target) {
target.scrollIntoView();
}
});
}, [scrollAnchor]);
return (
<div className={styles.plansLayoutRoot} ref={plansRootRef}>
{/* TODO(@catsjuice): SettingHeader component shouldn't have margin itself */}
<SettingHeader
style={{ marginBottom: '0px' }}
title={t['com.affine.payment.title']()}
/>
{ai ? (
<>
<div id="aiPricingPlan">{ai}</div>
<Divider className={styles.aiDivider} />
</>
) : null}
<div id="cloudPricingPlan">{cloud}</div>
</div>
);
};
export interface PlanCardProps {
title?: ReactNode;
caption?: ReactNode;
select?: ReactNode;
toggle?: ReactNode;
scroll?: ReactNode;
lifetime?: ReactNode;
scrollRef?: React.RefObject<HTMLDivElement>;
}
export const CloudPlanLayout = ({
title = 'AFFiNE Cloud',
caption,
select,
toggle,
scroll,
lifetime,
scrollRef,
}: PlanCardProps) => {
return (
<PricingCollapsible title={title} caption={caption}>
<div className={styles.affineCloudHeader}>
<div>{select}</div>
<div>{toggle}</div>
</div>
<ScrollArea.Root>
<ScrollArea.Viewport ref={scrollRef} className={styles.scrollArea}>
{scroll}
</ScrollArea.Viewport>
<ScrollArea.Scrollbar
forceMount
orientation="horizontal"
className={styles.scrollBar}
>
<ScrollArea.Thumb className={styles.scrollThumb}></ScrollArea.Thumb>
</ScrollArea.Scrollbar>
</ScrollArea.Root>
{lifetime ? (
<div style={{ paddingTop: 12 }} id="lifetimePricingPlan">
{lifetime}
</div>
) : null}
</PricingCollapsible>
);
};

Some files were not shown because too many files have changed in this diff Show More