From ad110078ac3d7f7652b2597bf75e6e798a776623 Mon Sep 17 00:00:00 2001 From: CatsJuice Date: Tue, 3 Sep 2024 03:27:18 +0000 Subject: [PATCH] feat(mobile): setting page ui (#8048) AF-1275 --- .../frontend/component/src/ui/modal/modal.tsx | 16 +- .../component/src/ui/modal/styles.css.ts | 16 ++ packages/frontend/core/src/atoms/index.ts | 3 + .../general-setting/appearance/index.tsx | 40 ++--- .../general-setting/editor/general.tsx | 94 +++++++----- .../affine/sign-out-modal/index.tsx | 3 + .../core/src/hooks/affine/use-sign-out.ts | 114 +++++++++++++++ packages/frontend/i18n/src/resources/en.json | 13 ++ packages/frontend/mobile/package.json | 5 + .../frontend/mobile/src/components/index.ts | 1 + .../src/components/page-header/index.tsx | 9 +- .../src/components/user-plan-tag/index.tsx | 52 +++++++ .../src/components/user-plan-tag/style.css.ts | 22 +++ .../mobile/src/pages/workspace/layout.tsx | 5 +- .../mobile/src/provider/model-provider.tsx | 60 ++++++++ .../mobile/src/views/home-header/index.tsx | 19 ++- packages/frontend/mobile/src/views/index.ts | 1 + .../mobile/src/views/settings/about/index.tsx | 22 +++ .../src/views/settings/appearance/font.tsx | 35 +++++ .../src/views/settings/appearance/index.tsx | 17 +++ .../views/settings/appearance/language.tsx | 39 +++++ .../src/views/settings/appearance/theme.tsx | 24 +++ .../src/views/settings/dropdown-select.css.ts | 19 +++ .../src/views/settings/dropdown-select.tsx | 73 +++++++++ .../mobile/src/views/settings/group.css.ts | 27 ++++ .../mobile/src/views/settings/group.tsx | 35 +++++ .../mobile/src/views/settings/index.tsx | 70 +++++++++ .../src/views/settings/others/index.tsx | 32 ++++ .../mobile/src/views/settings/row.layout.tsx | 28 ++++ .../mobile/src/views/settings/style.css.ts | 49 +++++++ .../src/views/settings/user-profile/index.tsx | 92 ++++++++++++ .../views/settings/user-profile/style.css.ts | 58 ++++++++ .../src/views/settings/user-usage/index.tsx | 138 ++++++++++++++++++ .../views/settings/user-usage/style.css.ts | 35 +++++ yarn.lock | 19 ++- 35 files changed, 1205 insertions(+), 80 deletions(-) create mode 100644 packages/frontend/core/src/hooks/affine/use-sign-out.ts create mode 100644 packages/frontend/mobile/src/components/user-plan-tag/index.tsx create mode 100644 packages/frontend/mobile/src/components/user-plan-tag/style.css.ts create mode 100644 packages/frontend/mobile/src/provider/model-provider.tsx create mode 100644 packages/frontend/mobile/src/views/settings/about/index.tsx create mode 100644 packages/frontend/mobile/src/views/settings/appearance/font.tsx create mode 100644 packages/frontend/mobile/src/views/settings/appearance/index.tsx create mode 100644 packages/frontend/mobile/src/views/settings/appearance/language.tsx create mode 100644 packages/frontend/mobile/src/views/settings/appearance/theme.tsx create mode 100644 packages/frontend/mobile/src/views/settings/dropdown-select.css.ts create mode 100644 packages/frontend/mobile/src/views/settings/dropdown-select.tsx create mode 100644 packages/frontend/mobile/src/views/settings/group.css.ts create mode 100644 packages/frontend/mobile/src/views/settings/group.tsx create mode 100644 packages/frontend/mobile/src/views/settings/index.tsx create mode 100644 packages/frontend/mobile/src/views/settings/others/index.tsx create mode 100644 packages/frontend/mobile/src/views/settings/row.layout.tsx create mode 100644 packages/frontend/mobile/src/views/settings/style.css.ts create mode 100644 packages/frontend/mobile/src/views/settings/user-profile/index.tsx create mode 100644 packages/frontend/mobile/src/views/settings/user-profile/style.css.ts create mode 100644 packages/frontend/mobile/src/views/settings/user-usage/index.tsx create mode 100644 packages/frontend/mobile/src/views/settings/user-usage/style.css.ts diff --git a/packages/frontend/component/src/ui/modal/modal.tsx b/packages/frontend/component/src/ui/modal/modal.tsx index 883f978bc9..47eb29a779 100644 --- a/packages/frontend/component/src/ui/modal/modal.tsx +++ b/packages/frontend/component/src/ui/modal/modal.tsx @@ -39,6 +39,10 @@ export interface ModalProps extends DialogProps { * @default 'fadeScaleTop' */ animation?: 'fadeScaleTop' | 'none' | 'slideBottom'; + /** + * Whether to show the modal in full screen mode + */ + fullScreen?: boolean; } type PointerDownOutsideEvent = Parameters< Exclude @@ -144,6 +148,7 @@ export const ModalInner = forwardRef( animation = environment.isBrowser && environment.isMobile ? 'slideBottom' : 'fadeScaleTop', + fullScreen, ...otherProps } = props; const { className: closeButtonClassName, ...otherCloseButtonProps } = @@ -209,6 +214,7 @@ export const ModalInner = forwardRef( {...otherOverlayOptions} />
( className={clsx(styles.modalContent, contentClassName)} style={{ ...assignInlineVars({ - [styles.widthVar]: getVar(width, '50vw'), - [styles.heightVar]: getVar(height, 'unset'), + [styles.widthVar]: getVar( + width, + fullScreen ? '100dvw' : '50dvw' + ), + [styles.heightVar]: getVar( + height, + fullScreen ? '100dvh' : 'unset' + ), [styles.minHeightVar]: getVar(minHeight, '26px'), }), ...contentStyle, diff --git a/packages/frontend/component/src/ui/modal/styles.css.ts b/packages/frontend/component/src/ui/modal/styles.css.ts index 0add40f619..2b4ef0b9af 100644 --- a/packages/frontend/component/src/ui/modal/styles.css.ts +++ b/packages/frontend/component/src/ui/modal/styles.css.ts @@ -84,6 +84,9 @@ export const modalContentWrapper = style({ }, selectors: { + '&[data-full-screen="true"]': { + padding: '0 !important', + }, '&.anim-none': { animation: 'none', }, @@ -136,6 +139,19 @@ export const modalContent = style({ borderRadius: '12px', // :focus-visible will set outline outline: 'none', + + selectors: { + '[data-full-screen="true"] &': { + vars: { + [widthVar]: '100vw', + [heightVar]: '100vh', + [minHeightVar]: '100vh', + }, + maxWidth: '100vw', + maxHeight: '100vh', + borderRadius: 0, + }, + }, }); export const closeButton = style({ position: 'absolute', diff --git a/packages/frontend/core/src/atoms/index.ts b/packages/frontend/core/src/atoms/index.ts index 1ca54b96df..094d0c323d 100644 --- a/packages/frontend/core/src/atoms/index.ts +++ b/packages/frontend/core/src/atoms/index.ts @@ -5,6 +5,9 @@ import type { SettingProps } from '../components/affine/setting-modal'; import type { ActiveTab } from '../components/affine/setting-modal/types'; // modal atoms export const openWorkspacesModalAtom = atom(false); +/** + * @deprecated use `useSignOut` hook instated + */ export const openSignOutModalAtom = atom(false); export const openQuotaModalAtom = atom(false); export const openStarAFFiNEModalAtom = atom(false); diff --git a/packages/frontend/core/src/components/affine/setting-modal/general-setting/appearance/index.tsx b/packages/frontend/core/src/components/affine/setting-modal/general-setting/appearance/index.tsx index a6186bbb13..cb4d6ed016 100644 --- a/packages/frontend/core/src/components/affine/setting-modal/general-setting/appearance/index.tsx +++ b/packages/frontend/core/src/components/affine/setting-modal/general-setting/appearance/index.tsx @@ -17,30 +17,30 @@ import { DateFormatSetting } from './date-format-setting'; import { settingWrapper } from './style.css'; import { ThemeEditorSetting } from './theme-editor-setting'; +export const getThemeOptions = (t: ReturnType) => + [ + { + 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( - () => [ - { - 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] - ); + const radioItems = useMemo(() => getThemeOptions(t), [t]); return ( ) => { + 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 +): Array & { 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 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(() => { - return fontStyleOptions - .map(({ key, value }) => { - if (key === 'Custom' && !environment.isDesktop) { - return null; - } - const label = getLabel(key); - let fontFamily = value; - if (key === 'Custom' && settings.customFontFamily) { - fontFamily = `${settings.customFontFamily}, ${value}`; - } - return { - value: key, - label, - testId: 'system-font-style-trigger', - style: { - fontFamily, - }, - } satisfies RadioItem; - }) - .filter(item => item !== null); - }, [getLabel, settings.customFontFamily]); + const items = getBaseFontStyleOptions(t); + if (!environment.isDesktop) 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) => { diff --git a/packages/frontend/core/src/components/affine/sign-out-modal/index.tsx b/packages/frontend/core/src/components/affine/sign-out-modal/index.tsx index 9275b0876e..c4b9a9ae2d 100644 --- a/packages/frontend/core/src/components/affine/sign-out-modal/index.tsx +++ b/packages/frontend/core/src/components/affine/sign-out-modal/index.tsx @@ -9,6 +9,9 @@ type SignOutConfirmModalI18NKeys = | 'cancel' | 'confirm'; +/** + * @deprecated use `useSignOut` instead + */ export const SignOutModal = ({ ...props }: ConfirmModalProps) => { const { title, description, cancelText, confirmText } = props; const t = useI18n(); diff --git a/packages/frontend/core/src/hooks/affine/use-sign-out.ts b/packages/frontend/core/src/hooks/affine/use-sign-out.ts new file mode 100644 index 0000000000..706fa1f39c --- /dev/null +++ b/packages/frontend/core/src/hooks/affine/use-sign-out.ts @@ -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; +}; diff --git a/packages/frontend/i18n/src/resources/en.json b/packages/frontend/i18n/src/resources/en.json index 1ddb0bea2c..face279d6b 100644 --- a/packages/frontend/i18n/src/resources/en.json +++ b/packages/frontend/i18n/src/resources/en.json @@ -1567,5 +1567,18 @@ "com.affine.import-template.dialog.createDocToWorkspace": "Create doc to \"{{workspace}}\"", "com.affine.import-template.dialog.createDocToNewWorkspace": "Create into a New Workspace", "com.affine.import-template.dialog.createDocWithTemplate": "Create doc with \"{{templateName}}\" template", + "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" } diff --git a/packages/frontend/mobile/package.json b/packages/frontend/mobile/package.json index f15c89d1fc..52f9f79aec 100644 --- a/packages/frontend/mobile/package.json +++ b/packages/frontend/mobile/package.json @@ -20,7 +20,12 @@ "core-js": "^3.36.1", "figma-squircle": "^0.3.1", "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", + "next-themes": "^0.3.0", "react": "^18.2.0", "react-dom": "^18.2.0", "react-router-dom": "^6.26.1" diff --git a/packages/frontend/mobile/src/components/index.ts b/packages/frontend/mobile/src/components/index.ts index b60e40c2eb..c6d2724372 100644 --- a/packages/frontend/mobile/src/components/index.ts +++ b/packages/frontend/mobile/src/components/index.ts @@ -3,4 +3,5 @@ export * from './doc-card'; export * from './page-header'; export * from './search-input'; export * from './search-result'; +export * from './user-plan-tag'; export * from './workspace-selector'; diff --git a/packages/frontend/mobile/src/components/page-header/index.tsx b/packages/frontend/mobile/src/components/page-header/index.tsx index b2884410fa..9cf509887e 100644 --- a/packages/frontend/mobile/src/components/page-header/index.tsx +++ b/packages/frontend/mobile/src/components/page-header/index.tsx @@ -16,6 +16,10 @@ export interface PageHeaderProps * whether to show back button */ back?: boolean; + /** + * Override back button action + */ + backAction?: () => void; /** * prefix content, shown after back button(if exists) @@ -42,6 +46,7 @@ export const PageHeader = forwardRef( function PageHeader( { back, + backAction, prefix, suffix, children, @@ -56,8 +61,8 @@ export const PageHeader = forwardRef( ref ) { const handleRouteBack = useCallback(() => { - history.back(); - }, []); + backAction ? backAction() : history.back(); + }, [backAction]); return (
+>(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 ( +
+ {planLabel} +
+ ); +}); diff --git a/packages/frontend/mobile/src/components/user-plan-tag/style.css.ts b/packages/frontend/mobile/src/components/user-plan-tag/style.css.ts new file mode 100644 index 0000000000..9119c05605 --- /dev/null +++ b/packages/frontend/mobile/src/components/user-plan-tag/style.css.ts @@ -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', + }, + }, +}); diff --git a/packages/frontend/mobile/src/pages/workspace/layout.tsx b/packages/frontend/mobile/src/pages/workspace/layout.tsx index 7a0eaaa771..97e466e049 100644 --- a/packages/frontend/mobile/src/pages/workspace/layout.tsx +++ b/packages/frontend/mobile/src/pages/workspace/layout.tsx @@ -1,7 +1,6 @@ import { AffineErrorBoundary } from '@affine/core/components/affine/affine-error-boundary'; import { AppFallback } from '@affine/core/components/affine/app-container'; import { WorkspaceLayoutProviders } from '@affine/core/layouts/workspace-layout'; -import { CurrentWorkspaceModals } from '@affine/core/providers/modal-provider'; import { SWRConfigProvider } from '@affine/core/providers/swr-config-provider'; import type { Workspace, WorkspaceMetadata } from '@toeverything/infra'; import { @@ -18,6 +17,8 @@ import { useState, } from 'react'; +import { MobileCurrentWorkspaceModals } from '../../provider/model-provider'; + export const WorkspaceLayout = ({ meta, children, @@ -78,7 +79,7 @@ export const WorkspaceLayout = ({ - + {children} diff --git a/packages/frontend/mobile/src/provider/model-provider.tsx b/packages/frontend/mobile/src/provider/model-provider.tsx new file mode 100644 index 0000000000..51247ce438 --- /dev/null +++ b/packages/frontend/mobile/src/provider/model-provider.tsx @@ -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 ( + <> + + + {currentWorkspace ? : null} + {currentWorkspace?.flavour === WorkspaceFlavour.LOCAL && ( + <> + + + + )} + {currentWorkspace?.flavour === WorkspaceFlavour.AFFINE_CLOUD && ( + + )} + + + + + ); +} diff --git a/packages/frontend/mobile/src/views/home-header/index.tsx b/packages/frontend/mobile/src/views/home-header/index.tsx index 85c7d67ac8..7d14f1cd9f 100644 --- a/packages/frontend/mobile/src/views/home-header/index.tsx +++ b/packages/frontend/mobile/src/views/home-header/index.tsx @@ -1,11 +1,12 @@ import { IconButton } from '@affine/component'; +import { openSettingModalAtom } from '@affine/core/atoms'; import { WorkbenchService } from '@affine/core/modules/workbench'; import { useI18n } from '@affine/i18n'; import { SettingsIcon } from '@blocksuite/icons/rc'; import { useService } from '@toeverything/infra'; import clsx from 'clsx'; +import { useSetAtom } from 'jotai'; import { useCallback, useState } from 'react'; -import { Link } from 'react-router-dom'; import { SearchInput, WorkspaceSelector } from '../../components'; import { useGlobalEvent } from '../../hooks/use-global-events'; @@ -20,6 +21,7 @@ import * as styles from './styles.css'; export const HomeHeader = () => { const t = useI18n(); const workbench = useService(WorkbenchService).workbench; + const openSetting = useSetAtom(openSettingModalAtom); const [dense, setDense] = useState(false); @@ -49,13 +51,14 @@ export const HomeHeader = () => {
- - } - /> - + { + openSetting({ open: true, activeTab: 'appearance' }); + }} + size="24" + style={{ padding: 10 }} + icon={} + />
diff --git a/packages/frontend/mobile/src/views/index.ts b/packages/frontend/mobile/src/views/index.ts index 7b9275ccbe..85a42b5e19 100644 --- a/packages/frontend/mobile/src/views/index.ts +++ b/packages/frontend/mobile/src/views/index.ts @@ -1,3 +1,4 @@ export * from './all-docs'; export * from './home-header'; export * from './recent-docs'; +export * from './settings'; diff --git a/packages/frontend/mobile/src/views/settings/about/index.tsx b/packages/frontend/mobile/src/views/settings/about/index.tsx new file mode 100644 index 0000000000..fcb225fd02 --- /dev/null +++ b/packages/frontend/mobile/src/views/settings/about/index.tsx @@ -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 ( + + + {appVersion} + + + + {editorVersion} + + + ); +}; diff --git a/packages/frontend/mobile/src/views/settings/appearance/font.tsx b/packages/frontend/mobile/src/views/settings/appearance/font.tsx new file mode 100644 index 0000000000..a9afea52f5 --- /dev/null +++ b/packages/frontend/mobile/src/views/settings/appearance/font.tsx @@ -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 ( + + + options={options} + value={settings.fontFamily} + onChange={handleEdit} + /> + + ); +}; diff --git a/packages/frontend/mobile/src/views/settings/appearance/index.tsx b/packages/frontend/mobile/src/views/settings/appearance/index.tsx new file mode 100644 index 0000000000..c57a563d35 --- /dev/null +++ b/packages/frontend/mobile/src/views/settings/appearance/index.tsx @@ -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 ( + + + + + + ); +}; diff --git a/packages/frontend/mobile/src/views/settings/appearance/language.tsx b/packages/frontend/mobile/src/views/settings/appearance/language.tsx new file mode 100644 index 0000000000..a6cb4038ee --- /dev/null +++ b/packages/frontend/mobile/src/views/settings/appearance/language.tsx @@ -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 ( + + + + ); +}; diff --git a/packages/frontend/mobile/src/views/settings/appearance/theme.tsx b/packages/frontend/mobile/src/views/settings/appearance/theme.tsx new file mode 100644 index 0000000000..2c4a90ac3c --- /dev/null +++ b/packages/frontend/mobile/src/views/settings/appearance/theme.tsx @@ -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 ( + + + + ); +}; diff --git a/packages/frontend/mobile/src/views/settings/dropdown-select.css.ts b/packages/frontend/mobile/src/views/settings/dropdown-select.css.ts new file mode 100644 index 0000000000..11d4ec9aab --- /dev/null +++ b/packages/frontend/mobile/src/views/settings/dropdown-select.css.ts @@ -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'), +}); diff --git a/packages/frontend/mobile/src/views/settings/dropdown-select.tsx b/packages/frontend/mobile/src/views/settings/dropdown-select.tsx new file mode 100644 index 0000000000..5e54afdf56 --- /dev/null +++ b/packages/frontend/mobile/src/views/settings/dropdown-select.tsx @@ -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 { + label?: ReactNode; + value: V; + testId?: string; + style?: CSSProperties; + [key: string]: any; +} +export interface SettingDropdownSelectProps< + V extends string, + E extends boolean | undefined, +> extends Omit, 'onChange'> { + options?: Array>; + value?: V; + onChange?: ( + v: E extends true ? DropdownItem['value'] : DropdownItem + ) => void; + emitValue?: E; + menuOptions?: Omit; +} + +export const SettingDropdownSelect = < + V extends string = string, + E extends boolean | undefined = true, +>({ + options = [], + value, + emitValue = true, + onChange, + className, + menuOptions, + ...attrs +}: SettingDropdownSelectProps) => { + const selectedItem = useMemo( + () => options.find(opt => opt.value === value), + [options, value] + ); + return ( + ( + + emitValue ? onChange?.(opt.value as any) : onChange?.(opt as any) + } + style={opt.style} + > + {opt.label} + + ))} + {...menuOptions} + > +
+ {selectedItem?.label ?? ''} + + +
+
+ ); +}; diff --git a/packages/frontend/mobile/src/views/settings/group.css.ts b/packages/frontend/mobile/src/views/settings/group.css.ts new file mode 100644 index 0000000000..4850a7b220 --- /dev/null +++ b/packages/frontend/mobile/src/views/settings/group.css.ts @@ -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, +}); diff --git a/packages/frontend/mobile/src/views/settings/group.tsx b/packages/frontend/mobile/src/views/settings/group.tsx new file mode 100644 index 0000000000..fbcdd17db1 --- /dev/null +++ b/packages/frontend/mobile/src/views/settings/group.tsx @@ -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, 'title'> { + title?: ReactNode; + contentClassName?: string; + contentStyle?: CSSProperties; +} + +export const SettingGroup = forwardRef( + function SettingGroup( + { children, title, className, contentClassName, contentStyle, ...attrs }, + ref + ) { + return ( +
+ {title ?
{title}
: null} +
+ {children} +
+
+ ); + } +); diff --git a/packages/frontend/mobile/src/views/settings/index.tsx b/packages/frontend/mobile/src/views/settings/index.tsx new file mode 100644 index 0000000000..8ddaf40fc5 --- /dev/null +++ b/packages/frontend/mobile/src/views/settings/index.tsx @@ -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 ( + + + + ); +}; + +const MobileSetting = ({ onClose }: { onClose: () => void }) => { + const t = useI18n(); + const session = useService(AuthService).session; + + useEffect(() => session.revalidate(), [session]); + + return ( + <> + + + {t['com.affine.mobile.setting.header-title']()} + + + +
+ + + + + +
+ + ); +}; diff --git a/packages/frontend/mobile/src/views/settings/others/index.tsx b/packages/frontend/mobile/src/views/settings/others/index.tsx new file mode 100644 index 0000000000..38b362bc73 --- /dev/null +++ b/packages/frontend/mobile/src/views/settings/others/index.tsx @@ -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 ( + + + + + + + + + + ); +}; diff --git a/packages/frontend/mobile/src/views/settings/row.layout.tsx b/packages/frontend/mobile/src/views/settings/row.layout.tsx new file mode 100644 index 0000000000..8640ddc345 --- /dev/null +++ b/packages/frontend/mobile/src/views/settings/row.layout.tsx @@ -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 = ( +
+
{label}
+
+ {children || + (href ? : null)} +
+
+ ); + + return href ? ( + + {content} + + ) : ( + content + ); +}; diff --git a/packages/frontend/mobile/src/views/settings/style.css.ts b/packages/frontend/mobile/src/views/settings/style.css.ts new file mode 100644 index 0000000000..436b6c5d9f --- /dev/null +++ b/packages/frontend/mobile/src/views/settings/style.css.ts @@ -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'), +}); diff --git a/packages/frontend/mobile/src/views/settings/user-profile/index.tsx b/packages/frontend/mobile/src/views/settings/user-profile/index.tsx new file mode 100644 index 0000000000..0e95131598 --- /dev/null +++ b/packages/frontend/mobile/src/views/settings/user-profile/index.tsx @@ -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' ? ( + + ) : ( + + ); +}; + +const BaseLayout = ({ + avatar, + title, + caption, + onClick, +}: { + avatar: ReactNode; + title: ReactNode; + caption: ReactNode; + onClick?: () => void; +}) => { + return ( + +
+
{avatar}
+
+
{title}
+
{caption}
+
+ +
+
+ ); +}; + +const AuthorizedUserProfile = () => { + const session = useService(AuthService).session; + const account = useEnsureLiveData(session.account$); + const confirmSignOut = useSignOut(); + + return ( + + } + caption={{account.email}} + title={ +
+ {account.label} + +
+ } + onClick={confirmSignOut} + /> + ); +}; + +const UnauthorizedUserProfile = () => { + const setAuthModal = useSetAtom(authAtom); + + return ( + setAuthModal({ openModal: true, state: 'signIn' })} + avatar={} + title="Sign up / Sign in" + caption="Sync with AFFiNE Cloud" + /> + ); +}; diff --git a/packages/frontend/mobile/src/views/settings/user-profile/style.css.ts b/packages/frontend/mobile/src/views/settings/user-profile/style.css.ts new file mode 100644 index 0000000000..f6b9d35955 --- /dev/null +++ b/packages/frontend/mobile/src/views/settings/user-profile/style.css.ts @@ -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]); diff --git a/packages/frontend/mobile/src/views/settings/user-usage/index.tsx b/packages/frontend/mobile/src/views/settings/user-usage/index.tsx new file mode 100644 index 0000000000..50074f0dcb --- /dev/null +++ b/packages/frontend/mobile/src/views/settings/user-usage/index.tsx @@ -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 ; +}; + +const Progress = ({ + name, + percent, + desc, + color, +}: { + name: string; + percent: number; + desc: string; + color: string | null; +}) => { + return ( +
+
+ {name} + {desc} +
+
+
+
+
+ ); +}; + +const UsagePanel = () => { + const serverConfigService = useService(ServerConfigService); + const serverFeatures = useLiveData( + serverConfigService.serverConfig.features$ + ); + + return ( + + + {serverFeatures?.copilot ? : null} + + ); +}; + +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 ( + + ); +}; +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 ( + + ); +}; diff --git a/packages/frontend/mobile/src/views/settings/user-usage/style.css.ts b/packages/frontend/mobile/src/views/settings/user-usage/style.css.ts new file mode 100644 index 0000000000..3829f1b59c --- /dev/null +++ b/packages/frontend/mobile/src/views/settings/user-usage/style.css.ts @@ -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, +}); diff --git a/yarn.lock b/yarn.lock index c0a8bb98d7..2a78c43ae8 100644 --- a/yarn.lock +++ b/yarn.lock @@ -683,7 +683,12 @@ __metadata: cross-env: "npm:^7.0.3" figma-squircle: "npm:^0.3.1" 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" + next-themes: "npm:^0.3.0" react: "npm:^18.2.0" react-dom: "npm:^18.2.0" react-router-dom: "npm:^6.26.1" @@ -25076,7 +25081,7 @@ __metadata: languageName: node linkType: hard -"jotai-devtools@npm:^0.10.0": +"jotai-devtools@npm:^0.10.0, jotai-devtools@npm:^0.10.1": version: 0.10.1 resolution: "jotai-devtools@npm:0.10.1" dependencies: @@ -25097,16 +25102,16 @@ __metadata: languageName: node linkType: hard -"jotai-effect@npm:^1.0.0": - version: 1.0.0 - resolution: "jotai-effect@npm:1.0.0" +"jotai-effect@npm:^1.0.0, jotai-effect@npm:^1.0.2": + version: 1.0.2 + resolution: "jotai-effect@npm:1.0.2" peerDependencies: jotai: ">=2.5.0" - checksum: 10/4393c88deaebbfd4e8fad46ac1b7d03235d0dd684c56049e8c57b14a1298695e80b047c0b1561cbb5af694db9ea819aaaf6ae9c1a35b27d4ef98532b39065d15 + checksum: 10/8435562902ff633138cb45ca987837c291dd4ab56d408affa5e14e167c4ddf33dd0d70dcffaef0520e38e84334583221b262fce4d45cc8ece3a195144e7a58e6 languageName: node linkType: hard -"jotai-scope@npm:^0.7.0": +"jotai-scope@npm:^0.7.0, jotai-scope@npm:^0.7.2": version: 0.7.2 resolution: "jotai-scope@npm:0.7.2" peerDependencies: @@ -25116,7 +25121,7 @@ __metadata: languageName: node linkType: hard -"jotai@npm:^2.8.0": +"jotai@npm:^2.8.0, jotai@npm:^2.9.3": version: 2.9.3 resolution: "jotai@npm:2.9.3" peerDependencies: