feat(mobile): setting page ui (#8048)

AF-1275
This commit is contained in:
CatsJuice
2024-09-03 03:27:18 +00:00
parent bea3d42f40
commit ad110078ac
35 changed files with 1205 additions and 80 deletions

View File

@@ -39,6 +39,10 @@ export interface ModalProps extends DialogProps {
* @default 'fadeScaleTop' * @default 'fadeScaleTop'
*/ */
animation?: 'fadeScaleTop' | 'none' | 'slideBottom'; animation?: 'fadeScaleTop' | 'none' | 'slideBottom';
/**
* Whether to show the modal in full screen mode
*/
fullScreen?: boolean;
} }
type PointerDownOutsideEvent = Parameters< type PointerDownOutsideEvent = Parameters<
Exclude<DialogContentProps['onPointerDownOutside'], undefined> Exclude<DialogContentProps['onPointerDownOutside'], undefined>
@@ -144,6 +148,7 @@ export const ModalInner = forwardRef<HTMLDivElement, ModalProps>(
animation = environment.isBrowser && environment.isMobile animation = environment.isBrowser && environment.isMobile
? 'slideBottom' ? 'slideBottom'
: 'fadeScaleTop', : 'fadeScaleTop',
fullScreen,
...otherProps ...otherProps
} = props; } = props;
const { className: closeButtonClassName, ...otherCloseButtonProps } = const { className: closeButtonClassName, ...otherCloseButtonProps } =
@@ -209,6 +214,7 @@ export const ModalInner = forwardRef<HTMLDivElement, ModalProps>(
{...otherOverlayOptions} {...otherOverlayOptions}
/> />
<div <div
data-full-screen={fullScreen}
data-modal={modal} data-modal={modal}
className={clsx( className={clsx(
`anim-${animation}`, `anim-${animation}`,
@@ -223,8 +229,14 @@ export const ModalInner = forwardRef<HTMLDivElement, ModalProps>(
className={clsx(styles.modalContent, contentClassName)} className={clsx(styles.modalContent, contentClassName)}
style={{ style={{
...assignInlineVars({ ...assignInlineVars({
[styles.widthVar]: getVar(width, '50vw'), [styles.widthVar]: getVar(
[styles.heightVar]: getVar(height, 'unset'), width,
fullScreen ? '100dvw' : '50dvw'
),
[styles.heightVar]: getVar(
height,
fullScreen ? '100dvh' : 'unset'
),
[styles.minHeightVar]: getVar(minHeight, '26px'), [styles.minHeightVar]: getVar(minHeight, '26px'),
}), }),
...contentStyle, ...contentStyle,

View File

@@ -84,6 +84,9 @@ export const modalContentWrapper = style({
}, },
selectors: { selectors: {
'&[data-full-screen="true"]': {
padding: '0 !important',
},
'&.anim-none': { '&.anim-none': {
animation: 'none', animation: 'none',
}, },
@@ -136,6 +139,19 @@ export const modalContent = style({
borderRadius: '12px', borderRadius: '12px',
// :focus-visible will set outline // :focus-visible will set outline
outline: 'none', outline: 'none',
selectors: {
'[data-full-screen="true"] &': {
vars: {
[widthVar]: '100vw',
[heightVar]: '100vh',
[minHeightVar]: '100vh',
},
maxWidth: '100vw',
maxHeight: '100vh',
borderRadius: 0,
},
},
}); });
export const closeButton = style({ export const closeButton = style({
position: 'absolute', position: 'absolute',

View File

@@ -5,6 +5,9 @@ import type { SettingProps } from '../components/affine/setting-modal';
import type { ActiveTab } from '../components/affine/setting-modal/types'; import type { ActiveTab } from '../components/affine/setting-modal/types';
// modal atoms // modal atoms
export const openWorkspacesModalAtom = atom(false); export const openWorkspacesModalAtom = atom(false);
/**
* @deprecated use `useSignOut` hook instated
*/
export const openSignOutModalAtom = atom(false); export const openSignOutModalAtom = atom(false);
export const openQuotaModalAtom = atom(false); export const openQuotaModalAtom = atom(false);
export const openStarAFFiNEModalAtom = atom(false); export const openStarAFFiNEModalAtom = atom(false);

View File

@@ -17,30 +17,30 @@ import { DateFormatSetting } from './date-format-setting';
import { settingWrapper } from './style.css'; import { settingWrapper } from './style.css';
import { ThemeEditorSetting } from './theme-editor-setting'; 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 = () => { export const ThemeSettings = () => {
const t = useI18n(); const t = useI18n();
const { setTheme, theme } = useTheme(); const { setTheme, theme } = useTheme();
const radioItems = useMemo<RadioItem[]>( const radioItems = useMemo<RadioItem[]>(() => getThemeOptions(t), [t]);
() => [
{
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',
},
],
[t]
);
return ( return (
<RadioGroup <RadioGroup

View File

@@ -41,51 +41,67 @@ import { Virtuoso } from 'react-virtuoso';
import { DropdownMenu } from './menu'; import { DropdownMenu } from './menu';
import * as styles from './style.css'; 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 FontFamilySettings = () => {
const t = useI18n(); const t = useI18n();
const { editorSettingService } = useServices({ EditorSettingService }); const { editorSettingService } = useServices({ EditorSettingService });
const settings = useLiveData(editorSettingService.editorSetting.settings$); const settings = useLiveData(editorSettingService.editorSetting.settings$);
const getLabel = useCallback(
(fontKey: FontFamily) => {
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 '';
}
},
[t]
);
const radioItems = useMemo(() => { const radioItems = useMemo(() => {
return fontStyleOptions const items = getBaseFontStyleOptions(t);
.map(({ key, value }) => { if (!environment.isDesktop) return items;
if (key === 'Custom' && !environment.isDesktop) {
return null; // resolve custom fonts
} const customOption = fontStyleOptions.find(opt => opt.key === 'Custom');
const label = getLabel(key); if (customOption) {
let fontFamily = value; const fontFamily = settings.customFontFamily
if (key === 'Custom' && settings.customFontFamily) { ? `${settings.customFontFamily}, ${customOption.value}`
fontFamily = `${settings.customFontFamily}, ${value}`; : customOption.value;
} items.push({
return { value: customOption.key,
value: key, label: getLabel(customOption.key, t),
label, testId: 'system-font-style-trigger',
testId: 'system-font-style-trigger', style: { fontFamily },
style: { });
fontFamily, }
},
} satisfies RadioItem; return items;
}) }, [settings.customFontFamily, t]);
.filter(item => item !== null);
}, [getLabel, settings.customFontFamily]);
const handleFontFamilyChange = useCallback( const handleFontFamilyChange = useCallback(
(value: FontFamily) => { (value: FontFamily) => {

View File

@@ -9,6 +9,9 @@ type SignOutConfirmModalI18NKeys =
| 'cancel' | 'cancel'
| 'confirm'; | 'confirm';
/**
* @deprecated use `useSignOut` instead
*/
export const SignOutModal = ({ ...props }: ConfirmModalProps) => { export const SignOutModal = ({ ...props }: ConfirmModalProps) => {
const { title, description, cancelText, confirmText } = props; const { title, description, cancelText, confirmText } = props;
const t = useI18n(); const t = useI18n();

View File

@@ -0,0 +1,114 @@
import {
type ConfirmModalProps,
notify,
useConfirmModal,
} from '@affine/component';
import { AuthService } from '@affine/core/modules/cloud';
import { WorkspaceSubPath } from '@affine/core/shared';
import { WorkspaceFlavour } from '@affine/env/workspace';
import { useI18n } from '@affine/i18n';
import {
GlobalContextService,
useLiveData,
useService,
WorkspacesService,
} from '@toeverything/infra';
import { useCallback } from 'react';
import { useNavigateHelper } from '../use-navigate-helper';
type SignOutConfirmModalI18NKeys =
| 'title'
| 'description'
| 'cancel'
| 'confirm';
export const useSignOut = ({
onConfirm,
confirmButtonOptions,
contentOptions,
...props
}: ConfirmModalProps = {}) => {
const t = useI18n();
const { openConfirmModal } = useConfirmModal();
const { openPage } = useNavigateHelper();
const authService = useService(AuthService);
const workspacesService = useService(WorkspacesService);
const globalContextService = useService(GlobalContextService);
const workspaces = useLiveData(workspacesService.list.workspaces$);
const currentWorkspaceId = useLiveData(
globalContextService.globalContext.workspaceId.$
);
const currentWorkspaceMetadata = useLiveData(
currentWorkspaceId
? workspacesService.list.workspace$(currentWorkspaceId)
: undefined
);
const signOut = useCallback(async () => {
onConfirm?.()?.catch(console.error);
try {
await authService.signOut();
} catch (err) {
console.error(err);
// TODO(@eyhn): i18n
notify.error({
title: 'Failed to sign out',
});
}
// if current workspace is affine cloud, switch to local workspace
if (currentWorkspaceMetadata?.flavour === WorkspaceFlavour.AFFINE_CLOUD) {
const localWorkspace = workspaces.find(
w => w.flavour === WorkspaceFlavour.LOCAL
);
if (localWorkspace) {
openPage(localWorkspace.id, WorkspaceSubPath.ALL);
}
}
}, [
authService,
currentWorkspaceMetadata?.flavour,
onConfirm,
openPage,
workspaces,
]);
const getDefaultText = useCallback(
(key: SignOutConfirmModalI18NKeys) => {
return t[`com.affine.auth.sign-out.confirm-modal.${key}`]();
},
[t]
);
const confirmSignOut = useCallback(() => {
openConfirmModal({
title: getDefaultText('title'),
description: getDefaultText('description'),
cancelText: getDefaultText('cancel'),
confirmText: getDefaultText('confirm'),
confirmButtonOptions: {
...confirmButtonOptions,
variant: 'error',
['data-testid' as string]: 'confirm-sign-out-button',
},
contentOptions: {
...contentOptions,
['data-testid' as string]: 'confirm-sign-out-modal',
},
onConfirm: signOut,
...props,
});
}, [
confirmButtonOptions,
contentOptions,
getDefaultText,
openConfirmModal,
props,
signOut,
]);
return confirmSignOut;
};

View File

@@ -1567,5 +1567,18 @@
"com.affine.import-template.dialog.createDocToWorkspace": "Create doc to \"{{workspace}}\"", "com.affine.import-template.dialog.createDocToWorkspace": "Create doc to \"{{workspace}}\"",
"com.affine.import-template.dialog.createDocToNewWorkspace": "Create into a New Workspace", "com.affine.import-template.dialog.createDocToNewWorkspace": "Create into a New Workspace",
"com.affine.import-template.dialog.createDocWithTemplate": "Create doc with \"{{templateName}}\" template", "com.affine.import-template.dialog.createDocWithTemplate": "Create doc with \"{{templateName}}\" template",
"com.affine.mobile.setting.header-title": "Settings",
"com.affine.mobile.setting.appearance.title": "Appearance",
"com.affine.mobile.setting.appearance.theme": "Color mode",
"com.affine.mobile.setting.appearance.font": "Font style",
"com.affine.mobile.setting.appearance.language": "Display language",
"com.affine.mobile.setting.about.title": "About",
"com.affine.mobile.setting.about.appVersion": "App version",
"com.affine.mobile.setting.about.editorVersion": "Editor version",
"com.affine.mobile.setting.others.title": "Privacy & others",
"com.affine.mobile.setting.others.github": "Star us on GitHub",
"com.affine.mobile.setting.others.website": "Official website",
"com.affine.mobile.setting.others.privacy": "Privacy",
"com.affine.mobile.setting.others.terms": "Terms of use",
"com.affine.mobile.search.empty": "No results found" "com.affine.mobile.search.empty": "No results found"
} }

View File

@@ -20,7 +20,12 @@
"core-js": "^3.36.1", "core-js": "^3.36.1",
"figma-squircle": "^0.3.1", "figma-squircle": "^0.3.1",
"intl-segmenter-polyfill-rs": "^0.1.7", "intl-segmenter-polyfill-rs": "^0.1.7",
"jotai": "^2.9.3",
"jotai-devtools": "^0.10.1",
"jotai-effect": "^1.0.2",
"jotai-scope": "^0.7.2",
"lodash-es": "^4.17.21", "lodash-es": "^4.17.21",
"next-themes": "^0.3.0",
"react": "^18.2.0", "react": "^18.2.0",
"react-dom": "^18.2.0", "react-dom": "^18.2.0",
"react-router-dom": "^6.26.1" "react-router-dom": "^6.26.1"

View File

@@ -3,4 +3,5 @@ export * from './doc-card';
export * from './page-header'; export * from './page-header';
export * from './search-input'; export * from './search-input';
export * from './search-result'; export * from './search-result';
export * from './user-plan-tag';
export * from './workspace-selector'; export * from './workspace-selector';

View File

@@ -16,6 +16,10 @@ export interface PageHeaderProps
* whether to show back button * whether to show back button
*/ */
back?: boolean; back?: boolean;
/**
* Override back button action
*/
backAction?: () => void;
/** /**
* prefix content, shown after back button(if exists) * prefix content, shown after back button(if exists)
@@ -42,6 +46,7 @@ export const PageHeader = forwardRef<HTMLHeadElement, PageHeaderProps>(
function PageHeader( function PageHeader(
{ {
back, back,
backAction,
prefix, prefix,
suffix, suffix,
children, children,
@@ -56,8 +61,8 @@ export const PageHeader = forwardRef<HTMLHeadElement, PageHeaderProps>(
ref ref
) { ) {
const handleRouteBack = useCallback(() => { const handleRouteBack = useCallback(() => {
history.back(); backAction ? backAction() : history.back();
}, []); }, [backAction]);
return ( return (
<header <header

View File

@@ -0,0 +1,52 @@
import {
ServerConfigService,
SubscriptionService,
} from '@affine/core/modules/cloud';
import { SubscriptionPlan } from '@affine/graphql';
import { useLiveData, useServices } from '@toeverything/infra';
import clsx from 'clsx';
import { forwardRef, type HTMLProps, useEffect } from 'react';
import { tag } from './style.css';
export const UserPlanTag = forwardRef<
HTMLDivElement,
HTMLProps<HTMLDivElement>
>(function UserPlanTag({ className, ...attrs }, ref) {
const { serverConfigService, subscriptionService } = useServices({
ServerConfigService,
SubscriptionService,
});
const hasPayment = useLiveData(
serverConfigService.serverConfig.features$.map(r => r?.payment)
);
const plan = useLiveData(
subscriptionService.subscription.pro$.map(subscription =>
subscription !== null ? subscription?.plan : null
)
);
const isBeliever = useLiveData(subscriptionService.subscription.isBeliever$);
const isLoading = plan === null;
useEffect(() => {
// revalidate subscription to get the latest status
subscriptionService.subscription.revalidate();
}, [subscriptionService]);
if (!hasPayment) return null;
if (isLoading) return null;
const planLabel = isBeliever ? 'Believer' : (plan ?? SubscriptionPlan.Free);
return (
<div
ref={ref}
className={clsx(tag, className)}
data-is-believer={isBeliever}
{...attrs}
>
{planLabel}
</div>
);
});

View File

@@ -0,0 +1,22 @@
import { cssVar } from '@toeverything/theme';
import { style } from '@vanilla-extract/css';
export const tag = style({
display: 'flex',
fontSize: cssVar('fontXs'),
height: 20,
fontWeight: 500,
cursor: 'pointer',
color: cssVar('pureWhite'),
backgroundColor: cssVar('brandColor'),
padding: '0 4px',
borderRadius: 4,
justifyContent: 'center',
alignItems: 'center',
selectors: {
'&[data-is-believer="true"]': {
backgroundColor: '#374151',
},
},
});

View File

@@ -1,7 +1,6 @@
import { AffineErrorBoundary } from '@affine/core/components/affine/affine-error-boundary'; import { AffineErrorBoundary } from '@affine/core/components/affine/affine-error-boundary';
import { AppFallback } from '@affine/core/components/affine/app-container'; import { AppFallback } from '@affine/core/components/affine/app-container';
import { WorkspaceLayoutProviders } from '@affine/core/layouts/workspace-layout'; import { WorkspaceLayoutProviders } from '@affine/core/layouts/workspace-layout';
import { CurrentWorkspaceModals } from '@affine/core/providers/modal-provider';
import { SWRConfigProvider } from '@affine/core/providers/swr-config-provider'; import { SWRConfigProvider } from '@affine/core/providers/swr-config-provider';
import type { Workspace, WorkspaceMetadata } from '@toeverything/infra'; import type { Workspace, WorkspaceMetadata } from '@toeverything/infra';
import { import {
@@ -18,6 +17,8 @@ import {
useState, useState,
} from 'react'; } from 'react';
import { MobileCurrentWorkspaceModals } from '../../provider/model-provider';
export const WorkspaceLayout = ({ export const WorkspaceLayout = ({
meta, meta,
children, children,
@@ -78,7 +79,7 @@ export const WorkspaceLayout = ({
<FrameworkScope scope={workspace.scope}> <FrameworkScope scope={workspace.scope}>
<AffineErrorBoundary height="100vh"> <AffineErrorBoundary height="100vh">
<SWRConfigProvider> <SWRConfigProvider>
<CurrentWorkspaceModals /> <MobileCurrentWorkspaceModals />
<WorkspaceLayoutProviders>{children}</WorkspaceLayoutProviders> <WorkspaceLayoutProviders>{children}</WorkspaceLayoutProviders>
</SWRConfigProvider> </SWRConfigProvider>
</AffineErrorBoundary> </AffineErrorBoundary>

View File

@@ -0,0 +1,60 @@
import { AiLoginRequiredModal } from '@affine/core/components/affine/auth/ai-login-required';
import { HistoryTipsModal } from '@affine/core/components/affine/history-tips-modal';
import { IssueFeedbackModal } from '@affine/core/components/affine/issue-feedback-modal';
import {
CloudQuotaModal,
LocalQuotaModal,
} from '@affine/core/components/affine/quota-reached-modal';
import { StarAFFiNEModal } from '@affine/core/components/affine/star-affine-modal';
import { MoveToTrash } from '@affine/core/components/page-list';
import { useTrashModalHelper } from '@affine/core/hooks/affine/use-trash-modal-helper';
import { PeekViewManagerModal } from '@affine/core/modules/peek-view';
import { WorkspaceFlavour } from '@affine/env/workspace';
import { useService, WorkspaceService } from '@toeverything/infra';
import { useCallback } from 'react';
import { MobileSettingModal } from '../views';
export function MobileCurrentWorkspaceModals() {
const currentWorkspace = useService(WorkspaceService).workspace;
const { trashModal, setTrashModal, handleOnConfirm } = useTrashModalHelper(
currentWorkspace.docCollection
);
const deletePageTitles = trashModal.pageTitles;
const trashConfirmOpen = trashModal.open;
const onTrashConfirmOpenChange = useCallback(
(open: boolean) => {
setTrashModal({
...trashModal,
open,
});
},
[trashModal, setTrashModal]
);
return (
<>
<StarAFFiNEModal />
<IssueFeedbackModal />
{currentWorkspace ? <MobileSettingModal /> : null}
{currentWorkspace?.flavour === WorkspaceFlavour.LOCAL && (
<>
<LocalQuotaModal />
<HistoryTipsModal />
</>
)}
{currentWorkspace?.flavour === WorkspaceFlavour.AFFINE_CLOUD && (
<CloudQuotaModal />
)}
<AiLoginRequiredModal />
<PeekViewManagerModal />
<MoveToTrash.ConfirmModal
open={trashConfirmOpen}
onConfirm={handleOnConfirm}
onOpenChange={onTrashConfirmOpenChange}
titles={deletePageTitles}
/>
</>
);
}

View File

@@ -1,11 +1,12 @@
import { IconButton } from '@affine/component'; import { IconButton } from '@affine/component';
import { openSettingModalAtom } from '@affine/core/atoms';
import { WorkbenchService } from '@affine/core/modules/workbench'; import { WorkbenchService } from '@affine/core/modules/workbench';
import { useI18n } from '@affine/i18n'; import { useI18n } from '@affine/i18n';
import { SettingsIcon } from '@blocksuite/icons/rc'; import { SettingsIcon } from '@blocksuite/icons/rc';
import { useService } from '@toeverything/infra'; import { useService } from '@toeverything/infra';
import clsx from 'clsx'; import clsx from 'clsx';
import { useSetAtom } from 'jotai';
import { useCallback, useState } from 'react'; import { useCallback, useState } from 'react';
import { Link } from 'react-router-dom';
import { SearchInput, WorkspaceSelector } from '../../components'; import { SearchInput, WorkspaceSelector } from '../../components';
import { useGlobalEvent } from '../../hooks/use-global-events'; import { useGlobalEvent } from '../../hooks/use-global-events';
@@ -20,6 +21,7 @@ import * as styles from './styles.css';
export const HomeHeader = () => { export const HomeHeader = () => {
const t = useI18n(); const t = useI18n();
const workbench = useService(WorkbenchService).workbench; const workbench = useService(WorkbenchService).workbench;
const openSetting = useSetAtom(openSettingModalAtom);
const [dense, setDense] = useState(false); const [dense, setDense] = useState(false);
@@ -49,13 +51,14 @@ export const HomeHeader = () => {
<WorkspaceSelector /> <WorkspaceSelector />
</div> </div>
<div className={styles.settingWrapper}> <div className={styles.settingWrapper}>
<Link to="/settings"> <IconButton
<IconButton onClick={() => {
size="24" openSetting({ open: true, activeTab: 'appearance' });
style={{ padding: 10 }} }}
icon={<SettingsIcon />} size="24"
/> style={{ padding: 10 }}
</Link> icon={<SettingsIcon />}
/>
</div> </div>
</div> </div>
<div className={styles.searchWrapper}> <div className={styles.searchWrapper}>

View File

@@ -1,3 +1,4 @@
export * from './all-docs'; export * from './all-docs';
export * from './home-header'; export * from './home-header';
export * from './recent-docs'; export * from './recent-docs';
export * from './settings';

View File

@@ -0,0 +1,22 @@
import { useI18n } from '@affine/i18n';
import { SettingGroup } from '../group';
import { RowLayout } from '../row.layout';
const { appVersion, editorVersion } = runtimeConfig;
export const AboutGroup = () => {
const t = useI18n();
return (
<SettingGroup title={t['com.affine.mobile.setting.about.title']()}>
<RowLayout label={t['com.affine.mobile.setting.about.appVersion']()}>
{appVersion}
</RowLayout>
<RowLayout label={t['com.affine.mobile.setting.about.editorVersion']()}>
{editorVersion}
</RowLayout>
</SettingGroup>
);
};

View File

@@ -0,0 +1,35 @@
import { getBaseFontStyleOptions } from '@affine/core/components/affine/setting-modal/general-setting/editor/general';
import {
EditorSettingService,
type FontFamily,
} from '@affine/core/modules/editor-settting';
import { useI18n } from '@affine/i18n';
import { useLiveData, useService } from '@toeverything/infra';
import { useCallback, useMemo } from 'react';
import { SettingDropdownSelect } from '../dropdown-select';
import { RowLayout } from '../row.layout';
export const FontStyleSetting = () => {
const t = useI18n();
const editorSetting = useService(EditorSettingService).editorSetting;
const settings = useLiveData(editorSetting.settings$);
const options = useMemo(() => getBaseFontStyleOptions(t), [t]);
const handleEdit = useCallback(
(v: FontFamily) => {
editorSetting.set('fontFamily', v);
},
[editorSetting]
);
return (
<RowLayout label={t['com.affine.mobile.setting.appearance.font']()}>
<SettingDropdownSelect<FontFamily>
options={options}
value={settings.fontFamily}
onChange={handleEdit}
/>
</RowLayout>
);
};

View File

@@ -0,0 +1,17 @@
import { useI18n } from '@affine/i18n';
import { SettingGroup } from '../group';
import { FontStyleSetting } from './font';
import { LanguageSetting } from './language';
import { ThemeSetting } from './theme';
export const AppearanceGroup = () => {
const t = useI18n();
return (
<SettingGroup title={t['com.affine.mobile.setting.appearance.title']()}>
<ThemeSetting />
<FontStyleSetting />
<LanguageSetting />
</SettingGroup>
);
};

View File

@@ -0,0 +1,39 @@
import { useLanguageHelper } from '@affine/core/hooks/affine/use-language-helper';
import { useI18n } from '@affine/i18n';
import { useMemo } from 'react';
import { SettingDropdownSelect } from '../dropdown-select';
import { RowLayout } from '../row.layout';
export const LanguageSetting = () => {
const t = useI18n();
const { currentLanguage, languagesList, onLanguageChange } =
useLanguageHelper();
const languageOptions = useMemo(
() =>
languagesList.map(language => ({
label: language.originalName,
value: language.tag,
})),
[languagesList]
);
return (
<RowLayout label={t['com.affine.mobile.setting.appearance.language']()}>
<SettingDropdownSelect
options={languageOptions}
value={currentLanguage?.tag}
onChange={onLanguageChange}
menuOptions={{
contentOptions: {
style: {
maxHeight: '60dvh',
overflowY: 'auto',
},
},
}}
/>
</RowLayout>
);
};

View File

@@ -0,0 +1,24 @@
import { getThemeOptions } from '@affine/core/components/affine/setting-modal/general-setting/appearance';
import { useI18n } from '@affine/i18n';
import { useTheme } from 'next-themes';
import { useMemo } from 'react';
import { SettingDropdownSelect } from '../dropdown-select';
import { RowLayout } from '../row.layout';
export const ThemeSetting = () => {
const t = useI18n();
const options = useMemo(() => getThemeOptions(t), [t]);
const { setTheme, theme } = useTheme();
return (
<RowLayout label={t['com.affine.mobile.setting.appearance.theme']()}>
<SettingDropdownSelect
options={options}
value={theme}
onChange={setTheme}
/>
</RowLayout>
);
};

View File

@@ -0,0 +1,19 @@
import { cssVarV2 } from '@toeverything/theme/v2';
import { style } from '@vanilla-extract/css';
export const root = style({
display: 'flex',
alignItems: 'center',
gap: 8,
});
export const label = style({
fontSize: 17,
lineHeight: '22px',
fontWeight: 400,
letterSpacing: -0.43,
color: cssVarV2('text/placeholder'),
});
export const icon = style({
fontSize: 24,
color: cssVarV2('icon/primary'),
});

View File

@@ -0,0 +1,73 @@
import { type MenuProps, MobileMenu, MobileMenuItem } from '@affine/component';
import { ArrowDownSmallIcon } from '@blocksuite/icons/rc';
import clsx from 'clsx';
import {
type CSSProperties,
type HTMLAttributes,
type ReactNode,
useMemo,
} from 'react';
import * as styles from './dropdown-select.css';
interface DropdownItem<V extends string> {
label?: ReactNode;
value: V;
testId?: string;
style?: CSSProperties;
[key: string]: any;
}
export interface SettingDropdownSelectProps<
V extends string,
E extends boolean | undefined,
> extends Omit<HTMLAttributes<HTMLDivElement>, 'onChange'> {
options?: Array<DropdownItem<V>>;
value?: V;
onChange?: (
v: E extends true ? DropdownItem<V>['value'] : DropdownItem<V>
) => void;
emitValue?: E;
menuOptions?: Omit<MenuProps, 'items' | 'children'>;
}
export const SettingDropdownSelect = <
V extends string = string,
E extends boolean | undefined = true,
>({
options = [],
value,
emitValue = true,
onChange,
className,
menuOptions,
...attrs
}: SettingDropdownSelectProps<V, E>) => {
const selectedItem = useMemo(
() => options.find(opt => opt.value === value),
[options, value]
);
return (
<MobileMenu
items={options.map(opt => (
<MobileMenuItem
key={opt.value}
selected={value === opt.value}
data-testid={opt.testId}
onSelect={() =>
emitValue ? onChange?.(opt.value as any) : onChange?.(opt as any)
}
style={opt.style}
>
{opt.label}
</MobileMenuItem>
))}
{...menuOptions}
>
<div className={clsx(styles.root, className)} {...attrs}>
<span className={styles.label}>{selectedItem?.label ?? ''}</span>
<ArrowDownSmallIcon className={styles.icon} />
</div>
</MobileMenu>
);
};

View File

@@ -0,0 +1,27 @@
import { cssVarV2 } from '@toeverything/theme/v2';
import { style } from '@vanilla-extract/css';
export const group = style({
display: 'flex',
flexDirection: 'column',
gap: 4,
width: '100%',
});
export const title = style({
padding: '0px 8px',
color: cssVarV2('text/tertiary'),
fontSize: 13,
lineHeight: '18px',
letterSpacing: -0.08,
fontWeight: 400,
});
export const content = style({
background: cssVarV2('layer/background/primary'),
borderRadius: 12,
padding: '10px 16px',
display: 'flex',
flexDirection: 'column',
gap: 8,
});

View File

@@ -0,0 +1,35 @@
import clsx from 'clsx';
import {
type CSSProperties,
forwardRef,
type HTMLProps,
type ReactNode,
} from 'react';
import * as styles from './group.css';
export interface SettingGroupProps
extends Omit<HTMLProps<HTMLDivElement>, 'title'> {
title?: ReactNode;
contentClassName?: string;
contentStyle?: CSSProperties;
}
export const SettingGroup = forwardRef<HTMLDivElement, SettingGroupProps>(
function SettingGroup(
{ children, title, className, contentClassName, contentStyle, ...attrs },
ref
) {
return (
<div className={clsx(styles.group, className)} ref={ref} {...attrs}>
{title ? <h6 className={styles.title}>{title}</h6> : null}
<div
className={clsx(styles.content, contentClassName)}
style={contentStyle}
>
{children}
</div>
</div>
);
}
);

View File

@@ -0,0 +1,70 @@
import { Modal } from '@affine/component';
import { openSettingModalAtom } from '@affine/core/atoms';
import { AuthService } from '@affine/core/modules/cloud';
import { useI18n } from '@affine/i18n';
import { useService } from '@toeverything/infra';
import { cssVarV2 } from '@toeverything/theme/v2';
import { useAtom } from 'jotai';
import { useCallback, useEffect } from 'react';
import { PageHeader } from '../../components';
import { AboutGroup } from './about';
import { AppearanceGroup } from './appearance';
import { OthersGroup } from './others';
import * as styles from './style.css';
import { UserProfile } from './user-profile';
import { UserUsage } from './user-usage';
export const MobileSettingModal = () => {
const [{ open }, setOpen] = useAtom(openSettingModalAtom);
const onOpenChange = useCallback(
(open: boolean) => setOpen(prev => ({ ...prev, open })),
[setOpen]
);
const closeModal = useCallback(() => onOpenChange(false), [onOpenChange]);
return (
<Modal
fullScreen
animation="slideBottom"
open={open}
onOpenChange={onOpenChange}
contentOptions={{
style: {
padding: 0,
overflowY: 'auto',
backgroundColor: cssVarV2('layer/background/secondary'),
},
}}
withoutCloseButton
>
<MobileSetting onClose={closeModal} />
</Modal>
);
};
const MobileSetting = ({ onClose }: { onClose: () => void }) => {
const t = useI18n();
const session = useService(AuthService).session;
useEffect(() => session.revalidate(), [session]);
return (
<>
<PageHeader back backAction={onClose}>
<span className={styles.pageTitle}>
{t['com.affine.mobile.setting.header-title']()}
</span>
</PageHeader>
<div className={styles.root}>
<UserProfile />
<UserUsage />
<AppearanceGroup />
<AboutGroup />
<OthersGroup />
</div>
</>
);
};

View File

@@ -0,0 +1,32 @@
import { useI18n } from '@affine/i18n';
import { SettingGroup } from '../group';
import { RowLayout } from '../row.layout';
export const OthersGroup = () => {
const t = useI18n();
return (
<SettingGroup title={t['com.affine.mobile.setting.others.title']()}>
<RowLayout
label={t['com.affine.mobile.setting.others.github']()}
href="https://github.com/toeverything/AFFiNE"
/>
<RowLayout
label={t['com.affine.mobile.setting.others.website']()}
href="https://affine.pro/"
/>
<RowLayout
label={t['com.affine.mobile.setting.others.privacy']()}
href="https://affine.pro/privacy"
/>
<RowLayout
label={t['com.affine.mobile.setting.others.terms']()}
href="https://affine.pro/terms"
/>
</SettingGroup>
);
};

View File

@@ -0,0 +1,28 @@
import { DualLinkIcon } from '@blocksuite/icons/rc';
import type { PropsWithChildren, ReactNode } from 'react';
import * as styles from './style.css';
export const RowLayout = ({
label,
children,
href,
}: PropsWithChildren<{ label: ReactNode; href?: string }>) => {
const content = (
<div className={styles.baseSettingItem}>
<div className={styles.baseSettingItemName}>{label}</div>
<div className={styles.baseSettingItemAction}>
{children ||
(href ? <DualLinkIcon className={styles.linkIcon} /> : null)}
</div>
</div>
);
return href ? (
<a target="_blank" href={href} rel="noreferrer">
{content}
</a>
) : (
content
);
};

View File

@@ -0,0 +1,49 @@
import { cssVarV2 } from '@toeverything/theme/v2';
import { style } from '@vanilla-extract/css';
export const pageTitle = style({
fontSize: 17,
lineHeight: '22px',
fontWeight: 600,
letterSpacing: -0.43,
});
export const root = style({
padding: '24px 16px',
display: 'flex',
flexDirection: 'column',
gap: 16,
});
export const baseSettingItem = style({
display: 'flex',
justifyContent: 'space-between',
alignItems: 'center',
gap: 32,
padding: '8px 0',
});
export const baseSettingItemName = style({
fontSize: 17,
lineHeight: '22px',
letterSpacing: -0.43,
fontWeight: 400,
color: cssVarV2('text/primary'),
flexShrink: 0,
whiteSpace: 'nowrap',
});
export const baseSettingItemAction = style([
baseSettingItemName,
{
color: cssVarV2('text/placeholder'),
whiteSpace: 'nowrap',
textOverflow: 'ellipsis',
overflow: 'hidden',
flexShrink: 1,
},
]);
export const linkIcon = style({
fontSize: 24,
color: cssVarV2('icon/primary'),
});

View File

@@ -0,0 +1,92 @@
import { Avatar } from '@affine/component';
import { authAtom } from '@affine/core/atoms';
import { useSignOut } from '@affine/core/hooks/affine/use-sign-out';
import { AuthService } from '@affine/core/modules/cloud';
import { ArrowRightSmallIcon } from '@blocksuite/icons/rc';
import {
useEnsureLiveData,
useLiveData,
useService,
} from '@toeverything/infra';
import { useSetAtom } from 'jotai';
import { type ReactNode } from 'react';
import { UserPlanTag } from '../../../components';
import { SettingGroup } from '../group';
import * as styles from './style.css';
export const UserProfile = () => {
const session = useService(AuthService).session;
const loginStatus = useLiveData(session.status$);
return loginStatus === 'authenticated' ? (
<AuthorizedUserProfile />
) : (
<UnauthorizedUserProfile />
);
};
const BaseLayout = ({
avatar,
title,
caption,
onClick,
}: {
avatar: ReactNode;
title: ReactNode;
caption: ReactNode;
onClick?: () => void;
}) => {
return (
<SettingGroup contentStyle={{ padding: '10px 8px 10px 10px' }}>
<div className={styles.profile} onClick={onClick}>
<div className={styles.avatarWrapper}>{avatar}</div>
<div className={styles.content}>
<div className={styles.title}>{title}</div>
<div className={styles.caption}>{caption}</div>
</div>
<ArrowRightSmallIcon className={styles.suffixIcon} />
</div>
</SettingGroup>
);
};
const AuthorizedUserProfile = () => {
const session = useService(AuthService).session;
const account = useEnsureLiveData(session.account$);
const confirmSignOut = useSignOut();
return (
<BaseLayout
avatar={
<Avatar
size={48}
rounded={4}
url={account.avatar}
name={account.label}
/>
}
caption={<span className={styles.emailInfo}>{account.email}</span>}
title={
<div className={styles.nameWithTag}>
<span className={styles.name}>{account.label}</span>
<UserPlanTag />
</div>
}
onClick={confirmSignOut}
/>
);
};
const UnauthorizedUserProfile = () => {
const setAuthModal = useSetAtom(authAtom);
return (
<BaseLayout
onClick={() => setAuthModal({ openModal: true, state: 'signIn' })}
avatar={<Avatar size={48} rounded={4} />}
title="Sign up / Sign in"
caption="Sync with AFFiNE Cloud"
/>
);
};

View File

@@ -0,0 +1,58 @@
import { cssVarV2 } from '@toeverything/theme/v2';
import { style } from '@vanilla-extract/css';
export const profile = style({
display: 'flex',
alignItems: 'center',
gap: 13,
});
export const avatarWrapper = style({
width: 48,
height: 48,
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
});
export const content = style({
width: 0,
flex: 1,
display: 'flex',
flexDirection: 'column',
gap: 6,
});
const ellipsis = style({
textOverflow: 'ellipsis',
overflow: 'hidden',
whiteSpace: 'nowrap',
});
export const title = style({
fontSize: 17,
lineHeight: '22px',
fontWeight: 400,
letterSpacing: -0.43,
color: cssVarV2('text/primary'),
});
export const caption = style({
fontSize: 15,
lineHeight: '20px',
fontWeight: 400,
letterSpacing: -0.23,
color: cssVarV2('text/secondary'),
});
export const suffixIcon = style({
fontSize: 30,
color: cssVarV2('icon/primary'),
});
export const emailInfo = style([ellipsis, { width: '100%' }]);
export const nameWithTag = style({
display: 'flex',
gap: 8,
});
export const name = style([ellipsis]);

View File

@@ -0,0 +1,138 @@
import {
AuthService,
ServerConfigService,
UserCopilotQuotaService,
UserQuotaService,
} from '@affine/core/modules/cloud';
import { useLiveData, useService } from '@toeverything/infra';
import { cssVar } from '@toeverything/theme';
import { cssVarV2 } from '@toeverything/theme/v2';
import { useEffect } from 'react';
import { SettingGroup } from '../group';
import * as styles from './style.css';
export const UserUsage = () => {
const session = useService(AuthService).session;
const loginStatus = useLiveData(session.status$);
if (loginStatus !== 'authenticated') {
return null;
}
return <UsagePanel />;
};
const Progress = ({
name,
percent,
desc,
color,
}: {
name: string;
percent: number;
desc: string;
color: string | null;
}) => {
return (
<div className={styles.progressRoot}>
<div className={styles.progressInfoRow}>
<span className={styles.progressName}>{name}</span>
<span className={styles.progressDesc}>{desc}</span>
</div>
<div className={styles.progressTrack}>
<div
className={styles.progressBar}
style={{
width: `${percent}%`,
backgroundColor: color ?? cssVarV2('button/primary'),
}}
/>
</div>
</div>
);
};
const UsagePanel = () => {
const serverConfigService = useService(ServerConfigService);
const serverFeatures = useLiveData(
serverConfigService.serverConfig.features$
);
return (
<SettingGroup title="Storage">
<CloudUsage />
{serverFeatures?.copilot ? <AiUsage /> : null}
</SettingGroup>
);
};
const CloudUsage = () => {
const quota = useService(UserQuotaService).quota;
const color = useLiveData(quota.color$);
const usedFormatted = useLiveData(quota.usedFormatted$);
const maxFormatted = useLiveData(quota.maxFormatted$);
const percent = useLiveData(quota.percent$);
useEffect(() => {
// revalidate quota to get the latest status
quota.revalidate();
}, [quota]);
const loading = percent === null;
if (loading) return null;
return (
<Progress
name="Cloud"
percent={percent}
desc={`${usedFormatted}/${maxFormatted}`}
color={color}
/>
);
};
const AiUsage = () => {
const copilotQuotaService = useService(UserCopilotQuotaService);
const copilotActionLimit = useLiveData(
copilotQuotaService.copilotQuota.copilotActionLimit$
);
const copilotActionUsed = useLiveData(
copilotQuotaService.copilotQuota.copilotActionUsed$
);
const loading = copilotActionLimit === null || copilotActionUsed === null;
const loadError = useLiveData(copilotQuotaService.copilotQuota.error$);
useEffect(() => {
copilotQuotaService.copilotQuota.revalidate();
}, [copilotQuotaService]);
if (loading || loadError) {
return null;
}
if (copilotActionLimit === 'unlimited') {
return null;
}
const percent = Math.min(
100,
Math.max(
0.5,
Number(((copilotActionUsed / copilotActionLimit) * 100).toFixed(4))
)
);
const color = percent > 80 ? cssVar('errorColor') : cssVar('processingColor');
return (
<Progress
name="AI"
percent={percent}
desc={`${copilotActionUsed}/${copilotActionLimit}`}
color={color}
/>
);
};

View File

@@ -0,0 +1,35 @@
import { cssVarV2 } from '@toeverything/theme/v2';
import { style } from '@vanilla-extract/css';
export const progressRoot = style({
paddingBottom: 8,
});
export const progressInfoRow = style({
display: 'flex',
justifyContent: 'space-between',
alignItems: 'center',
paddingBottom: 4,
});
export const progressName = style({
fontSize: 17,
lineHeight: '22px',
letterSpacing: -0.43,
color: cssVarV2('text/primary'),
});
export const progressDesc = style({
fontSize: 12,
lineHeight: '16px',
color: cssVarV2('text/secondary'),
});
export const progressTrack = style({
width: '100%',
height: 10,
borderRadius: 5,
backgroundColor: cssVarV2('layer/background/hoverOverlay'),
overflow: 'hidden',
});
export const progressBar = style({
height: 'inherit',
borderTopRightRadius: 5,
borderBottomRightRadius: 5,
});

View File

@@ -683,7 +683,12 @@ __metadata:
cross-env: "npm:^7.0.3" cross-env: "npm:^7.0.3"
figma-squircle: "npm:^0.3.1" figma-squircle: "npm:^0.3.1"
intl-segmenter-polyfill-rs: "npm:^0.1.7" intl-segmenter-polyfill-rs: "npm:^0.1.7"
jotai: "npm:^2.9.3"
jotai-devtools: "npm:^0.10.1"
jotai-effect: "npm:^1.0.2"
jotai-scope: "npm:^0.7.2"
lodash-es: "npm:^4.17.21" lodash-es: "npm:^4.17.21"
next-themes: "npm:^0.3.0"
react: "npm:^18.2.0" react: "npm:^18.2.0"
react-dom: "npm:^18.2.0" react-dom: "npm:^18.2.0"
react-router-dom: "npm:^6.26.1" react-router-dom: "npm:^6.26.1"
@@ -25076,7 +25081,7 @@ __metadata:
languageName: node languageName: node
linkType: hard linkType: hard
"jotai-devtools@npm:^0.10.0": "jotai-devtools@npm:^0.10.0, jotai-devtools@npm:^0.10.1":
version: 0.10.1 version: 0.10.1
resolution: "jotai-devtools@npm:0.10.1" resolution: "jotai-devtools@npm:0.10.1"
dependencies: dependencies:
@@ -25097,16 +25102,16 @@ __metadata:
languageName: node languageName: node
linkType: hard linkType: hard
"jotai-effect@npm:^1.0.0": "jotai-effect@npm:^1.0.0, jotai-effect@npm:^1.0.2":
version: 1.0.0 version: 1.0.2
resolution: "jotai-effect@npm:1.0.0" resolution: "jotai-effect@npm:1.0.2"
peerDependencies: peerDependencies:
jotai: ">=2.5.0" jotai: ">=2.5.0"
checksum: 10/4393c88deaebbfd4e8fad46ac1b7d03235d0dd684c56049e8c57b14a1298695e80b047c0b1561cbb5af694db9ea819aaaf6ae9c1a35b27d4ef98532b39065d15 checksum: 10/8435562902ff633138cb45ca987837c291dd4ab56d408affa5e14e167c4ddf33dd0d70dcffaef0520e38e84334583221b262fce4d45cc8ece3a195144e7a58e6
languageName: node languageName: node
linkType: hard linkType: hard
"jotai-scope@npm:^0.7.0": "jotai-scope@npm:^0.7.0, jotai-scope@npm:^0.7.2":
version: 0.7.2 version: 0.7.2
resolution: "jotai-scope@npm:0.7.2" resolution: "jotai-scope@npm:0.7.2"
peerDependencies: peerDependencies:
@@ -25116,7 +25121,7 @@ __metadata:
languageName: node languageName: node
linkType: hard linkType: hard
"jotai@npm:^2.8.0": "jotai@npm:^2.8.0, jotai@npm:^2.9.3":
version: 2.9.3 version: 2.9.3
resolution: "jotai@npm:2.9.3" resolution: "jotai@npm:2.9.3"
peerDependencies: peerDependencies: