mirror of
https://github.com/toeverything/AFFiNE.git
synced 2026-07-18 18:46:19 +08:00
@@ -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<DialogContentProps['onPointerDownOutside'], undefined>
|
||||
@@ -144,6 +148,7 @@ export const ModalInner = forwardRef<HTMLDivElement, ModalProps>(
|
||||
animation = environment.isBrowser && environment.isMobile
|
||||
? 'slideBottom'
|
||||
: 'fadeScaleTop',
|
||||
fullScreen,
|
||||
...otherProps
|
||||
} = props;
|
||||
const { className: closeButtonClassName, ...otherCloseButtonProps } =
|
||||
@@ -209,6 +214,7 @@ export const ModalInner = forwardRef<HTMLDivElement, ModalProps>(
|
||||
{...otherOverlayOptions}
|
||||
/>
|
||||
<div
|
||||
data-full-screen={fullScreen}
|
||||
data-modal={modal}
|
||||
className={clsx(
|
||||
`anim-${animation}`,
|
||||
@@ -223,8 +229,14 @@ export const ModalInner = forwardRef<HTMLDivElement, ModalProps>(
|
||||
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,
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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);
|
||||
|
||||
+20
-20
@@ -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<typeof useI18n>) =>
|
||||
[
|
||||
{
|
||||
value: 'system',
|
||||
label: t['com.affine.themeSettings.system'](),
|
||||
testId: 'system-theme-trigger',
|
||||
},
|
||||
{
|
||||
value: 'light',
|
||||
label: t['com.affine.themeSettings.light'](),
|
||||
testId: 'light-theme-trigger',
|
||||
},
|
||||
{
|
||||
value: 'dark',
|
||||
label: t['com.affine.themeSettings.dark'](),
|
||||
testId: 'dark-theme-trigger',
|
||||
},
|
||||
] satisfies RadioItem[];
|
||||
|
||||
export const ThemeSettings = () => {
|
||||
const t = useI18n();
|
||||
const { setTheme, theme } = useTheme();
|
||||
|
||||
const radioItems = useMemo<RadioItem[]>(
|
||||
() => [
|
||||
{
|
||||
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<RadioItem[]>(() => getThemeOptions(t), [t]);
|
||||
|
||||
return (
|
||||
<RadioGroup
|
||||
|
||||
+55
-39
@@ -41,51 +41,67 @@ import { Virtuoso } from 'react-virtuoso';
|
||||
import { DropdownMenu } from './menu';
|
||||
import * as styles from './style.css';
|
||||
|
||||
const getLabel = (fontKey: FontFamily, t: ReturnType<typeof useI18n>) => {
|
||||
switch (fontKey) {
|
||||
case 'Sans':
|
||||
return t['com.affine.appearanceSettings.fontStyle.sans']();
|
||||
case 'Serif':
|
||||
return t['com.affine.appearanceSettings.fontStyle.serif']();
|
||||
case 'Mono':
|
||||
return t[`com.affine.appearanceSettings.fontStyle.mono`]();
|
||||
case 'Custom':
|
||||
return t['com.affine.settings.editorSettings.edgeless.custom']();
|
||||
default:
|
||||
return '';
|
||||
}
|
||||
};
|
||||
|
||||
export const getBaseFontStyleOptions = (
|
||||
t: ReturnType<typeof useI18n>
|
||||
): Array<Omit<RadioItem, 'value'> & { value: FontFamily }> => {
|
||||
return fontStyleOptions
|
||||
.map(({ key, value }) => {
|
||||
if (key === 'Custom') {
|
||||
return null;
|
||||
}
|
||||
const label = getLabel(key, t);
|
||||
return {
|
||||
value: key,
|
||||
label,
|
||||
testId: 'system-font-style-trigger',
|
||||
style: {
|
||||
fontFamily: value,
|
||||
},
|
||||
} satisfies RadioItem;
|
||||
})
|
||||
.filter(item => item !== null);
|
||||
};
|
||||
|
||||
const FontFamilySettings = () => {
|
||||
const t = useI18n();
|
||||
const { editorSettingService } = useServices({ EditorSettingService });
|
||||
const settings = useLiveData(editorSettingService.editorSetting.settings$);
|
||||
|
||||
const 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) => {
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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;
|
||||
};
|
||||
@@ -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"
|
||||
}
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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';
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
@@ -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,
|
||||
});
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
);
|
||||
@@ -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>
|
||||
</>
|
||||
);
|
||||
};
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
@@ -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
|
||||
);
|
||||
};
|
||||
@@ -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]);
|
||||
@@ -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,
|
||||
});
|
||||
@@ -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:
|
||||
|
||||
Reference in New Issue
Block a user