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

@@ -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';

View File

@@ -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

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 { 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>

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 { 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}>

View File

@@ -1,3 +1,4 @@
export * from './all-docs';
export * from './home-header';
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,
});