mirror of
https://github.com/toeverything/AFFiNE.git
synced 2026-02-25 10:22:55 +08:00
@@ -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';
|
||||
|
||||
@@ -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<HTMLHeadElement, PageHeaderProps>(
|
||||
function PageHeader(
|
||||
{
|
||||
back,
|
||||
backAction,
|
||||
prefix,
|
||||
suffix,
|
||||
children,
|
||||
@@ -56,8 +61,8 @@ export const PageHeader = forwardRef<HTMLHeadElement, PageHeaderProps>(
|
||||
ref
|
||||
) {
|
||||
const handleRouteBack = useCallback(() => {
|
||||
history.back();
|
||||
}, []);
|
||||
backAction ? backAction() : history.back();
|
||||
}, [backAction]);
|
||||
|
||||
return (
|
||||
<header
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
});
|
||||
@@ -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',
|
||||
},
|
||||
},
|
||||
});
|
||||
@@ -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 = ({
|
||||
<FrameworkScope scope={workspace.scope}>
|
||||
<AffineErrorBoundary height="100vh">
|
||||
<SWRConfigProvider>
|
||||
<CurrentWorkspaceModals />
|
||||
<MobileCurrentWorkspaceModals />
|
||||
<WorkspaceLayoutProviders>{children}</WorkspaceLayoutProviders>
|
||||
</SWRConfigProvider>
|
||||
</AffineErrorBoundary>
|
||||
|
||||
60
packages/frontend/mobile/src/provider/model-provider.tsx
Normal file
60
packages/frontend/mobile/src/provider/model-provider.tsx
Normal 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}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -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 = () => {
|
||||
<WorkspaceSelector />
|
||||
</div>
|
||||
<div className={styles.settingWrapper}>
|
||||
<Link to="/settings">
|
||||
<IconButton
|
||||
size="24"
|
||||
style={{ padding: 10 }}
|
||||
icon={<SettingsIcon />}
|
||||
/>
|
||||
</Link>
|
||||
<IconButton
|
||||
onClick={() => {
|
||||
openSetting({ open: true, activeTab: 'appearance' });
|
||||
}}
|
||||
size="24"
|
||||
style={{ padding: 10 }}
|
||||
icon={<SettingsIcon />}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div className={styles.searchWrapper}>
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
export * from './all-docs';
|
||||
export * from './home-header';
|
||||
export * from './recent-docs';
|
||||
export * from './settings';
|
||||
|
||||
22
packages/frontend/mobile/src/views/settings/about/index.tsx
Normal file
22
packages/frontend/mobile/src/views/settings/about/index.tsx
Normal 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>
|
||||
);
|
||||
};
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
@@ -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'),
|
||||
});
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
27
packages/frontend/mobile/src/views/settings/group.css.ts
Normal file
27
packages/frontend/mobile/src/views/settings/group.css.ts
Normal 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,
|
||||
});
|
||||
35
packages/frontend/mobile/src/views/settings/group.tsx
Normal file
35
packages/frontend/mobile/src/views/settings/group.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
);
|
||||
70
packages/frontend/mobile/src/views/settings/index.tsx
Normal file
70
packages/frontend/mobile/src/views/settings/index.tsx
Normal 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>
|
||||
</>
|
||||
);
|
||||
};
|
||||
32
packages/frontend/mobile/src/views/settings/others/index.tsx
Normal file
32
packages/frontend/mobile/src/views/settings/others/index.tsx
Normal 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>
|
||||
);
|
||||
};
|
||||
28
packages/frontend/mobile/src/views/settings/row.layout.tsx
Normal file
28
packages/frontend/mobile/src/views/settings/row.layout.tsx
Normal 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
|
||||
);
|
||||
};
|
||||
49
packages/frontend/mobile/src/views/settings/style.css.ts
Normal file
49
packages/frontend/mobile/src/views/settings/style.css.ts
Normal 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'),
|
||||
});
|
||||
@@ -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"
|
||||
/>
|
||||
);
|
||||
};
|
||||
@@ -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]);
|
||||
138
packages/frontend/mobile/src/views/settings/user-usage/index.tsx
Normal file
138
packages/frontend/mobile/src/views/settings/user-usage/index.tsx
Normal 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}
|
||||
/>
|
||||
);
|
||||
};
|
||||
@@ -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,
|
||||
});
|
||||
Reference in New Issue
Block a user