mirror of
https://github.com/toeverything/AFFiNE.git
synced 2026-02-04 00:28:33 +00:00
feat(core): sub-page for setting panel (#11678)
**setting panel sub-page impl, with cascading pages support.**
## Usage
```tsx
// inside setting content
const island = useSubPageIsland();
const [open, setOpen] = useState(false);
if (!island) {
return null;
}
return (
<SubPageProvider
island={island}
open={open}
onClose={() => setOpen(false)}
backText="Back"
/>
);
```
### Preview

This commit is contained in:
@@ -13,6 +13,7 @@ import type {
|
||||
WORKSPACE_DIALOG_SCHEMA,
|
||||
} from '@affine/core/modules/dialogs/constant';
|
||||
import { GlobalContextService } from '@affine/core/modules/global-context';
|
||||
import { createIsland, type Island } from '@affine/core/utils/island';
|
||||
import { ServerDeploymentType } from '@affine/graphql';
|
||||
import { Trans } from '@affine/i18n';
|
||||
import { ContactWithUsIcon } from '@blocksuite/icons/rc';
|
||||
@@ -23,6 +24,7 @@ import {
|
||||
useCallback,
|
||||
useEffect,
|
||||
useLayoutEffect,
|
||||
useMemo,
|
||||
useRef,
|
||||
useState,
|
||||
} from 'react';
|
||||
@@ -34,6 +36,11 @@ import { IssueFeedbackModal } from './issue-feedback-modal';
|
||||
import { SettingSidebar } from './setting-sidebar';
|
||||
import { StarAFFiNEModal } from './star-affine-modal';
|
||||
import * as style from './style.css';
|
||||
import {
|
||||
SubPageContext,
|
||||
type SubPageContextType,
|
||||
SubPageTarget,
|
||||
} from './sub-page';
|
||||
import type { SettingState } from './types';
|
||||
import { WorkspaceSetting } from './workspace-setting';
|
||||
|
||||
@@ -59,6 +66,7 @@ const SettingModalInner = ({
|
||||
onCloseSetting,
|
||||
scrollAnchor: initialScrollAnchor,
|
||||
}: SettingProps) => {
|
||||
const [subPageIslands, setSubPageIslands] = useState<Island[]>([]);
|
||||
const [settingState, setSettingState] = useState<SettingState>({
|
||||
activeTab: initialActiveTab,
|
||||
scrollAnchor: initialScrollAnchor,
|
||||
@@ -143,6 +151,24 @@ const SettingModalInner = ({
|
||||
setOpenStarAFFiNEModal(true);
|
||||
}, [setOpenStarAFFiNEModal]);
|
||||
|
||||
const addSubPageIsland = useCallback(() => {
|
||||
const island = createIsland();
|
||||
setSubPageIslands(prev => [...prev, island]);
|
||||
const dispose = () => {
|
||||
setSubPageIslands(prev => prev.filter(i => i !== island));
|
||||
};
|
||||
return { island, dispose };
|
||||
}, []);
|
||||
|
||||
const contextValue = useMemo(
|
||||
() =>
|
||||
({
|
||||
islands: subPageIslands,
|
||||
addIsland: addSubPageIsland,
|
||||
}) satisfies SubPageContextType,
|
||||
[subPageIslands, addSubPageIsland]
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
if (
|
||||
isSelfhosted &&
|
||||
@@ -171,64 +197,69 @@ const SettingModalInner = ({
|
||||
activeTab={settingState.activeTab}
|
||||
onTabChange={onTabChange}
|
||||
/>
|
||||
<Scrollable.Root>
|
||||
<Scrollable.Viewport
|
||||
data-testid="setting-modal-content"
|
||||
className={style.wrapper}
|
||||
ref={modalContentWrapperRef}
|
||||
>
|
||||
<div className={style.centerContainer}>
|
||||
<div ref={modalContentRef} className={style.content}>
|
||||
<Suspense fallback={<WorkspaceDetailSkeleton />}>
|
||||
{settingState.activeTab === 'account' &&
|
||||
loginStatus === 'authenticated' ? (
|
||||
<AccountSetting onChangeSettingState={setSettingState} />
|
||||
) : isWorkspaceSetting(settingState.activeTab) ? (
|
||||
<WorkspaceSetting
|
||||
activeTab={settingState.activeTab}
|
||||
onCloseSetting={onCloseSetting}
|
||||
onChangeSettingState={setSettingState}
|
||||
/>
|
||||
) : !isWorkspaceSetting(settingState.activeTab) ? (
|
||||
<GeneralSetting
|
||||
activeTab={settingState.activeTab}
|
||||
onChangeSettingState={setSettingState}
|
||||
/>
|
||||
) : null}
|
||||
</Suspense>
|
||||
</div>
|
||||
<div className={style.footer}>
|
||||
<ContactWithUsIcon fontSize={16} />
|
||||
<Trans
|
||||
i18nKey={'com.affine.settings.suggestion-2'}
|
||||
components={{
|
||||
1: (
|
||||
<span
|
||||
className={style.link}
|
||||
onClick={handleOpenStarAFFiNEModal}
|
||||
<SubPageContext.Provider value={contextValue}>
|
||||
<Scrollable.Root>
|
||||
<Scrollable.Viewport
|
||||
data-testid="setting-modal-content"
|
||||
className={style.wrapper}
|
||||
ref={modalContentWrapperRef}
|
||||
data-setting-page
|
||||
data-open
|
||||
>
|
||||
<div className={style.centerContainer}>
|
||||
<div ref={modalContentRef} className={style.content}>
|
||||
<Suspense fallback={<WorkspaceDetailSkeleton />}>
|
||||
{settingState.activeTab === 'account' &&
|
||||
loginStatus === 'authenticated' ? (
|
||||
<AccountSetting onChangeSettingState={setSettingState} />
|
||||
) : isWorkspaceSetting(settingState.activeTab) ? (
|
||||
<WorkspaceSetting
|
||||
activeTab={settingState.activeTab}
|
||||
onCloseSetting={onCloseSetting}
|
||||
onChangeSettingState={setSettingState}
|
||||
/>
|
||||
),
|
||||
2: (
|
||||
<span
|
||||
className={style.link}
|
||||
onClick={handleOpenIssueFeedbackModal}
|
||||
) : !isWorkspaceSetting(settingState.activeTab) ? (
|
||||
<GeneralSetting
|
||||
activeTab={settingState.activeTab}
|
||||
onChangeSettingState={setSettingState}
|
||||
/>
|
||||
),
|
||||
}}
|
||||
) : null}
|
||||
</Suspense>
|
||||
</div>
|
||||
<div className={style.footer}>
|
||||
<ContactWithUsIcon fontSize={16} />
|
||||
<Trans
|
||||
i18nKey={'com.affine.settings.suggestion-2'}
|
||||
components={{
|
||||
1: (
|
||||
<span
|
||||
className={style.link}
|
||||
onClick={handleOpenStarAFFiNEModal}
|
||||
/>
|
||||
),
|
||||
2: (
|
||||
<span
|
||||
className={style.link}
|
||||
onClick={handleOpenIssueFeedbackModal}
|
||||
/>
|
||||
),
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
<StarAFFiNEModal
|
||||
open={openStarAFFiNEModal}
|
||||
setOpen={setOpenStarAFFiNEModal}
|
||||
/>
|
||||
<IssueFeedbackModal
|
||||
open={openIssueFeedbackModal}
|
||||
setOpen={setOpenIssueFeedbackModal}
|
||||
/>
|
||||
</div>
|
||||
<StarAFFiNEModal
|
||||
open={openStarAFFiNEModal}
|
||||
setOpen={setOpenStarAFFiNEModal}
|
||||
/>
|
||||
<IssueFeedbackModal
|
||||
open={openIssueFeedbackModal}
|
||||
setOpen={setOpenIssueFeedbackModal}
|
||||
/>
|
||||
</div>
|
||||
<Scrollable.Scrollbar />
|
||||
</Scrollable.Viewport>
|
||||
</Scrollable.Root>
|
||||
<Scrollable.Scrollbar />
|
||||
</Scrollable.Viewport>
|
||||
<SubPageTarget />
|
||||
</Scrollable.Root>
|
||||
</SubPageContext.Provider>
|
||||
</FrameworkScope>
|
||||
);
|
||||
};
|
||||
@@ -254,6 +285,9 @@ export const SettingDialog = ({
|
||||
}}
|
||||
open
|
||||
onOpenChange={() => close()}
|
||||
closeButtonOptions={{
|
||||
style: { right: 14, top: 14 },
|
||||
}}
|
||||
>
|
||||
<Suspense fallback={<CenteredLoading />}>
|
||||
<SettingModalInner
|
||||
|
||||
@@ -0,0 +1,55 @@
|
||||
import { cssVarV2 } from '@toeverything/theme/v2';
|
||||
import { style } from '@vanilla-extract/css';
|
||||
|
||||
export const root = style({
|
||||
width: '100%',
|
||||
position: 'absolute',
|
||||
left: 0,
|
||||
top: 0,
|
||||
height: '100%',
|
||||
pointerEvents: 'none',
|
||||
});
|
||||
|
||||
export const mask = style({
|
||||
width: '100%',
|
||||
height: '100%',
|
||||
position: 'absolute',
|
||||
top: 0,
|
||||
left: 0,
|
||||
zIndex: 1,
|
||||
backgroundColor: 'rgba(0, 0, 0, 0.2)',
|
||||
pointerEvents: 'none',
|
||||
opacity: 0,
|
||||
});
|
||||
|
||||
export const page = style({
|
||||
position: 'absolute',
|
||||
top: 0,
|
||||
left: 0,
|
||||
width: '100%',
|
||||
height: '100%',
|
||||
zIndex: 1,
|
||||
backgroundColor: cssVarV2.layer.background.primary,
|
||||
transform: 'translateX(100%)',
|
||||
pointerEvents: 'auto',
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
});
|
||||
|
||||
export const viewport = style({
|
||||
paddingTop: 16,
|
||||
});
|
||||
|
||||
export const header = style({
|
||||
padding: '12px 0px 8px 16px',
|
||||
flexShrink: 0,
|
||||
});
|
||||
export const content = style({
|
||||
height: '0 !important',
|
||||
flex: 1,
|
||||
});
|
||||
export const backButton = style({
|
||||
fontWeight: 500,
|
||||
fontSize: 12,
|
||||
lineHeight: '20px',
|
||||
});
|
||||
238
packages/frontend/core/src/desktop/dialogs/setting/sub-page.tsx
Normal file
238
packages/frontend/core/src/desktop/dialogs/setting/sub-page.tsx
Normal file
@@ -0,0 +1,238 @@
|
||||
import { Button, Scrollable } from '@affine/component';
|
||||
import { FeatureFlagService } from '@affine/core/modules/feature-flag';
|
||||
import { type Island } from '@affine/core/utils/island';
|
||||
import { ArrowLeftBigIcon } from '@blocksuite/icons/rc';
|
||||
import { useLiveData, useService } from '@toeverything/infra';
|
||||
import { eases, waapi } from 'animejs';
|
||||
import clsx from 'clsx';
|
||||
import {
|
||||
createContext,
|
||||
useCallback,
|
||||
useContext,
|
||||
useEffect,
|
||||
useRef,
|
||||
useState,
|
||||
} from 'react';
|
||||
|
||||
import { centerContainer, content, wrapper } from './style.css';
|
||||
import * as styles from './sub-page.css';
|
||||
|
||||
export interface SubPageContextType {
|
||||
islands: Island[];
|
||||
addIsland: () => {
|
||||
island: Island;
|
||||
dispose: () => void;
|
||||
};
|
||||
}
|
||||
export const SubPageContext = createContext<SubPageContextType>({
|
||||
islands: [],
|
||||
addIsland: () => ({
|
||||
// must be initialized
|
||||
island: null as unknown as Island,
|
||||
dispose: () => {},
|
||||
}),
|
||||
});
|
||||
|
||||
const SubPageTargetItem = ({ island }: { island: Island }) => {
|
||||
const provided = useLiveData(island.provided$);
|
||||
|
||||
return (
|
||||
<island.Target
|
||||
data-open={provided}
|
||||
data-setting-page
|
||||
className={styles.root}
|
||||
></island.Target>
|
||||
);
|
||||
};
|
||||
export const SubPageTarget = () => {
|
||||
const context = useContext(SubPageContext);
|
||||
const islands = context.islands;
|
||||
return islands.map(island => (
|
||||
<SubPageTargetItem key={island.id} island={island} />
|
||||
));
|
||||
};
|
||||
|
||||
const ease = eases.cubicBezier(0.25, 0.36, 0.24, 0.97);
|
||||
|
||||
export const SubPageProvider = ({
|
||||
island,
|
||||
open,
|
||||
onClose,
|
||||
children,
|
||||
backText = 'Back',
|
||||
animation = true,
|
||||
}: {
|
||||
island: Island;
|
||||
open: boolean;
|
||||
onClose: () => void;
|
||||
children: React.ReactNode;
|
||||
backText?: string;
|
||||
animation?: boolean;
|
||||
}) => {
|
||||
const featureFlagService = useService(FeatureFlagService);
|
||||
const enableSettingSubpageAnimation = useLiveData(
|
||||
featureFlagService.flags.enable_setting_subpage_animation.$
|
||||
);
|
||||
const duration = enableSettingSubpageAnimation ? (animation ? 320 : 0) : 0;
|
||||
|
||||
const maskRef = useRef<HTMLDivElement>(null);
|
||||
const pageRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
const [innerOpen, setInnerOpen] = useState(open);
|
||||
const [animateState, setAnimateState] = useState<
|
||||
'idle' | 'ready' | 'animating' | 'finished'
|
||||
>('idle');
|
||||
const [played, setPlayed] = useState(false);
|
||||
|
||||
const prevPageRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
const getPrevPage = useCallback((_root?: HTMLDivElement) => {
|
||||
const root = _root ?? pageRef.current?.parentElement;
|
||||
if (!root) {
|
||||
return null;
|
||||
}
|
||||
const prevPage = root.previousElementSibling as HTMLDivElement;
|
||||
if (!prevPage) {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (prevPage.dataset.settingPage && prevPage.dataset.open === 'true') {
|
||||
prevPageRef.current = prevPage;
|
||||
return prevPage;
|
||||
}
|
||||
return getPrevPage(prevPage);
|
||||
}, []);
|
||||
|
||||
const animateOpen = useCallback(() => {
|
||||
setAnimateState('animating');
|
||||
requestAnimationFrame(() => {
|
||||
const mask = maskRef.current;
|
||||
const page = pageRef.current;
|
||||
if (!mask || !page) {
|
||||
setAnimateState('idle');
|
||||
return;
|
||||
}
|
||||
waapi.animate(mask, { opacity: [0, 1], duration, ease });
|
||||
waapi
|
||||
.animate(page, { x: ['100%', 0], duration, ease })
|
||||
.then(() => setAnimateState('finished'))
|
||||
.catch(console.error)
|
||||
.finally(() => {
|
||||
setAnimateState('finished');
|
||||
setPlayed(true);
|
||||
});
|
||||
|
||||
const prevPage = getPrevPage();
|
||||
if (!prevPage) return;
|
||||
waapi.animate(prevPage, { x: [0, '-20%'], duration, ease });
|
||||
});
|
||||
}, [duration, getPrevPage]);
|
||||
|
||||
const animateClose = useCallback(() => {
|
||||
setAnimateState('animating');
|
||||
requestAnimationFrame(() => {
|
||||
const mask = maskRef.current;
|
||||
const page = pageRef.current;
|
||||
if (!mask || !page) {
|
||||
setAnimateState('idle');
|
||||
return;
|
||||
}
|
||||
waapi.animate(mask, { opacity: [1, 0], duration, ease });
|
||||
waapi
|
||||
.animate(page, { x: [0, '100%'], duration, ease })
|
||||
.then(() => setAnimateState('idle'))
|
||||
.catch(console.error)
|
||||
.finally(() => setAnimateState('idle'));
|
||||
|
||||
const prevPage = getPrevPage();
|
||||
if (!prevPage) return;
|
||||
waapi.animate(prevPage, { x: ['-20%', 0], duration, ease });
|
||||
});
|
||||
}, [duration, getPrevPage]);
|
||||
|
||||
useEffect(() => {
|
||||
setAnimateState('ready');
|
||||
setInnerOpen(open);
|
||||
}, [open]);
|
||||
|
||||
useEffect(() => {
|
||||
if (animateState !== 'ready') return;
|
||||
|
||||
if (innerOpen) {
|
||||
animateOpen();
|
||||
return;
|
||||
}
|
||||
// the first played animation must be open
|
||||
if (!played) {
|
||||
setAnimateState('idle');
|
||||
return;
|
||||
}
|
||||
animateClose();
|
||||
}, [animateClose, animateOpen, animateState, innerOpen, played]);
|
||||
|
||||
/**
|
||||
* for some situation like:
|
||||
*
|
||||
* ```tsx
|
||||
* const [open, setOpen] = useState(false);
|
||||
* if (!open) return null;
|
||||
* return <SubPageProvider open={open} onClose={() => setOpen(false)} />
|
||||
* ```
|
||||
*
|
||||
* The subpage is closed unexpectedly, so we need to reset the previous page's position.
|
||||
*/
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
const prevPage = prevPageRef.current;
|
||||
if (!prevPage) return;
|
||||
waapi.animate(prevPage, { x: 0, duration: 0 });
|
||||
};
|
||||
}, []);
|
||||
|
||||
if (animateState === 'idle') {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<island.Provider>
|
||||
<div className={styles.mask} ref={maskRef}></div>
|
||||
<div className={styles.page} ref={pageRef}>
|
||||
<header className={styles.header}>
|
||||
<Button
|
||||
className={styles.backButton}
|
||||
onClick={onClose}
|
||||
prefix={<ArrowLeftBigIcon />}
|
||||
variant="plain"
|
||||
>
|
||||
{backText}
|
||||
</Button>
|
||||
</header>
|
||||
<Scrollable.Root className={styles.content}>
|
||||
<Scrollable.Viewport className={clsx(wrapper, styles.viewport)}>
|
||||
<div className={centerContainer}>
|
||||
<div className={content}>{children}</div>
|
||||
</div>
|
||||
<Scrollable.Scrollbar />
|
||||
</Scrollable.Viewport>
|
||||
</Scrollable.Root>
|
||||
</div>
|
||||
</island.Provider>
|
||||
);
|
||||
};
|
||||
|
||||
/**
|
||||
* Create a new island when the component is mounted,
|
||||
* and dispose it when the component is unmounted.
|
||||
*/
|
||||
export const useSubPageIsland = () => {
|
||||
const { addIsland } = useContext(SubPageContext);
|
||||
const [island, setIsland] = useState<Island | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
const { island, dispose } = addIsland();
|
||||
setIsland(island);
|
||||
return dispose;
|
||||
}, [addIsland]);
|
||||
|
||||
return island;
|
||||
};
|
||||
@@ -8,10 +8,11 @@ export const card = style({
|
||||
padding: '8px 12px 12px 12px',
|
||||
borderRadius: 8,
|
||||
border: '1px solid ' + cssVarV2.layer.insideBorder.border,
|
||||
height: 186,
|
||||
height: 150,
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
background: cssVarV2.layer.background.overlayPanel,
|
||||
cursor: 'pointer',
|
||||
});
|
||||
export const cardHeader = style({
|
||||
display: 'flex',
|
||||
@@ -48,6 +49,12 @@ export const cardTitle = style({
|
||||
lineHeight: '22px',
|
||||
color: cssVarV2.text.primary,
|
||||
});
|
||||
export const cardStatus = style({
|
||||
fontSize: 12,
|
||||
lineHeight: '20px',
|
||||
fontWeight: 400,
|
||||
color: cssVarV2.text.secondary,
|
||||
});
|
||||
export const cardDesc = style([
|
||||
spaceY,
|
||||
{
|
||||
@@ -69,6 +76,3 @@ export const cardFooter = style({
|
||||
},
|
||||
},
|
||||
});
|
||||
export const settingIcon = style({
|
||||
color: cssVarV2.icon.secondary,
|
||||
});
|
||||
|
||||
@@ -1,5 +1,3 @@
|
||||
import { IconButton, type IconButtonProps } from '@affine/component';
|
||||
import { SettingsIcon } from '@blocksuite/icons/rc';
|
||||
import clsx from 'clsx';
|
||||
import type { HTMLAttributes, ReactNode } from 'react';
|
||||
|
||||
@@ -10,16 +8,15 @@ import {
|
||||
cardFooter,
|
||||
cardHeader,
|
||||
cardIcon,
|
||||
cardStatus,
|
||||
cardTitle,
|
||||
settingIcon,
|
||||
} from './card.css';
|
||||
import { spaceX } from './index.css';
|
||||
|
||||
export const IntegrationCard = ({
|
||||
className,
|
||||
...props
|
||||
}: HTMLAttributes<HTMLLIElement>) => {
|
||||
return <li className={clsx(className, card)} {...props} />;
|
||||
}: HTMLAttributes<HTMLDivElement>) => {
|
||||
return <div className={clsx(className, card)} {...props} />;
|
||||
};
|
||||
|
||||
export const IntegrationCardIcon = ({
|
||||
@@ -29,52 +26,37 @@ export const IntegrationCardIcon = ({
|
||||
return <div className={clsx(cardIcon, className)} {...props} />;
|
||||
};
|
||||
|
||||
export const IntegrationSettingIcon = ({
|
||||
className,
|
||||
...props
|
||||
}: IconButtonProps) => {
|
||||
return (
|
||||
<IconButton
|
||||
className={className}
|
||||
icon={<SettingsIcon className={settingIcon} />}
|
||||
variant="plain"
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
export const IntegrationCardHeader = ({
|
||||
className,
|
||||
icon,
|
||||
onSettingClick,
|
||||
showSetting = true,
|
||||
title,
|
||||
status,
|
||||
...props
|
||||
}: HTMLAttributes<HTMLHeadElement> & {
|
||||
showSetting?: boolean;
|
||||
onSettingClick?: () => void;
|
||||
icon?: ReactNode;
|
||||
title?: string;
|
||||
status?: ReactNode;
|
||||
}) => {
|
||||
return (
|
||||
<header className={clsx(cardHeader, className)} {...props}>
|
||||
<IntegrationCardIcon>{icon}</IntegrationCardIcon>
|
||||
<div className={spaceX} />
|
||||
{showSetting ? <IntegrationSettingIcon onClick={onSettingClick} /> : null}
|
||||
<div>
|
||||
<div className={cardTitle}>{title}</div>
|
||||
{status ? <div className={cardStatus}>{status}</div> : null}
|
||||
</div>
|
||||
</header>
|
||||
);
|
||||
};
|
||||
|
||||
export const IntegrationCardContent = ({
|
||||
className,
|
||||
title,
|
||||
desc,
|
||||
...props
|
||||
}: HTMLAttributes<HTMLDivElement> & {
|
||||
title?: string;
|
||||
desc?: string;
|
||||
}) => {
|
||||
return (
|
||||
<div className={clsx(cardContent, className)} {...props}>
|
||||
<div className={cardTitle}>{title}</div>
|
||||
<div className={cardDesc}>{desc}</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -0,0 +1,23 @@
|
||||
import { IntegrationTypeIcon } from '@affine/core/modules/integration';
|
||||
import type { I18nString } from '@affine/i18n';
|
||||
import type { ReactNode } from 'react';
|
||||
|
||||
import { ReadwiseSettingPanel } from './readwise/setting-panel';
|
||||
|
||||
export type IntegrationCard = {
|
||||
id: string;
|
||||
name: I18nString;
|
||||
desc: I18nString;
|
||||
icon: ReactNode;
|
||||
setting: ReactNode;
|
||||
};
|
||||
|
||||
export const INTEGRATION_LIST: IntegrationCard[] = [
|
||||
{
|
||||
id: 'readwise',
|
||||
name: 'com.affine.integration.readwise.name',
|
||||
desc: 'com.affine.integration.readwise.desc',
|
||||
icon: <IntegrationTypeIcon type="readwise" />,
|
||||
setting: <ReadwiseSettingPanel />,
|
||||
},
|
||||
];
|
||||
@@ -10,6 +10,6 @@ export const spaceY = style({
|
||||
});
|
||||
export const list = style({
|
||||
display: 'grid',
|
||||
gridTemplateColumns: 'repeat(auto-fill, minmax(250px, 1fr))',
|
||||
gridTemplateColumns: 'repeat(auto-fill, minmax(175px, 1fr))',
|
||||
gap: '16px',
|
||||
});
|
||||
|
||||
@@ -1,11 +1,19 @@
|
||||
import { SettingHeader } from '@affine/component/setting-components';
|
||||
import { useI18n } from '@affine/i18n';
|
||||
import { type ReactNode, useState } from 'react';
|
||||
|
||||
import { SubPageProvider, useSubPageIsland } from '../../sub-page';
|
||||
import {
|
||||
IntegrationCard,
|
||||
IntegrationCardContent,
|
||||
IntegrationCardHeader,
|
||||
} from './card';
|
||||
import { INTEGRATION_LIST } from './constants';
|
||||
import { list } from './index.css';
|
||||
import { ReadwiseIntegration } from './readwise';
|
||||
|
||||
export const IntegrationSetting = () => {
|
||||
const t = useI18n();
|
||||
const [opened, setOpened] = useState<string | null>(null);
|
||||
return (
|
||||
<>
|
||||
<SettingHeader
|
||||
@@ -19,8 +27,59 @@ export const IntegrationSetting = () => {
|
||||
}
|
||||
/>
|
||||
<ul className={list}>
|
||||
<ReadwiseIntegration />
|
||||
{INTEGRATION_LIST.map(item => {
|
||||
const title =
|
||||
typeof item.name === 'string'
|
||||
? t[item.name]()
|
||||
: t[item.name.i18nKey]();
|
||||
const desc =
|
||||
typeof item.desc === 'string'
|
||||
? t[item.desc]()
|
||||
: t[item.desc.i18nKey]();
|
||||
return (
|
||||
<li key={item.id}>
|
||||
<IntegrationCard onClick={() => setOpened(item.id)}>
|
||||
<IntegrationCardHeader icon={item.icon} title={title} />
|
||||
<IntegrationCardContent desc={desc} />
|
||||
</IntegrationCard>
|
||||
<IntegrationSettingPage
|
||||
open={opened === item.id}
|
||||
onClose={() => setOpened(null)}
|
||||
>
|
||||
{item.setting}
|
||||
</IntegrationSettingPage>
|
||||
</li>
|
||||
);
|
||||
})}
|
||||
</ul>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
const IntegrationSettingPage = ({
|
||||
children,
|
||||
open,
|
||||
onClose,
|
||||
}: {
|
||||
children: ReactNode;
|
||||
open: boolean;
|
||||
onClose: () => void;
|
||||
}) => {
|
||||
const t = useI18n();
|
||||
const island = useSubPageIsland();
|
||||
|
||||
if (!island) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<SubPageProvider
|
||||
backText={t['com.affine.integration.integrations']()}
|
||||
island={island}
|
||||
open={open}
|
||||
onClose={onClose}
|
||||
>
|
||||
{children}
|
||||
</SubPageProvider>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -1,11 +1,19 @@
|
||||
import { Button, Input, Modal, notify } from '@affine/component';
|
||||
import {
|
||||
Button,
|
||||
type ButtonProps,
|
||||
Input,
|
||||
Modal,
|
||||
notify,
|
||||
} from '@affine/component';
|
||||
import { useAsyncCallback } from '@affine/core/components/hooks/affine-async-hooks';
|
||||
import { IntegrationService } from '@affine/core/modules/integration';
|
||||
import { Trans, useI18n } from '@affine/i18n';
|
||||
import { ReadwiseLogoDuotoneIcon } from '@blocksuite/icons/rc';
|
||||
import { useService } from '@toeverything/infra';
|
||||
import clsx from 'clsx';
|
||||
import {
|
||||
type FormEvent,
|
||||
type MouseEvent,
|
||||
useCallback,
|
||||
useEffect,
|
||||
useRef,
|
||||
@@ -156,11 +164,14 @@ const ConnectDialog = ({
|
||||
);
|
||||
};
|
||||
|
||||
export const ConnectButton = ({
|
||||
export const ReadwiseConnectButton = ({
|
||||
onSuccess,
|
||||
className,
|
||||
onClick,
|
||||
...buttonProps
|
||||
}: {
|
||||
onSuccess: (token: string) => void;
|
||||
}) => {
|
||||
} & ButtonProps) => {
|
||||
const t = useI18n();
|
||||
const [open, setOpen] = useState(false);
|
||||
|
||||
@@ -168,14 +179,22 @@ export const ConnectButton = ({
|
||||
setOpen(false);
|
||||
}, []);
|
||||
|
||||
const handleOpen = useCallback(() => {
|
||||
setOpen(true);
|
||||
}, []);
|
||||
const handleOpen = useCallback(
|
||||
(e: MouseEvent<HTMLButtonElement>) => {
|
||||
onClick?.(e);
|
||||
setOpen(true);
|
||||
},
|
||||
[onClick]
|
||||
);
|
||||
|
||||
return (
|
||||
<>
|
||||
{open && <ConnectDialog onClose={handleClose} onSuccess={onSuccess} />}
|
||||
<Button variant="primary" className={actionButton} onClick={handleOpen}>
|
||||
<Button
|
||||
className={clsx(actionButton, className)}
|
||||
onClick={handleOpen}
|
||||
{...buttonProps}
|
||||
>
|
||||
{t['com.affine.integration.readwise.connect']()}
|
||||
</Button>
|
||||
</>
|
||||
|
||||
@@ -9,7 +9,11 @@ import * as styles from './connected.css';
|
||||
import { actionButton } from './index.css';
|
||||
import { readwiseTrack } from './track';
|
||||
|
||||
export const DisconnectDialog = ({ onClose }: { onClose: () => void }) => {
|
||||
export const ReadwiseDisconnectDialog = ({
|
||||
onClose,
|
||||
}: {
|
||||
onClose: () => void;
|
||||
}) => {
|
||||
const t = useI18n();
|
||||
const readwise = useService(IntegrationService).readwise;
|
||||
|
||||
@@ -59,18 +63,16 @@ export const DisconnectDialog = ({ onClose }: { onClose: () => void }) => {
|
||||
);
|
||||
};
|
||||
|
||||
export const ConnectedActions = ({ onImport }: { onImport: () => void }) => {
|
||||
export const ReadwiseDisconnectButton = () => {
|
||||
const t = useI18n();
|
||||
const [showDisconnectDialog, setShowDisconnectDialog] = useState(false);
|
||||
|
||||
return (
|
||||
<>
|
||||
{showDisconnectDialog && (
|
||||
<DisconnectDialog onClose={() => setShowDisconnectDialog(false)} />
|
||||
<ReadwiseDisconnectDialog
|
||||
onClose={() => setShowDisconnectDialog(false)}
|
||||
/>
|
||||
)}
|
||||
<Button className={actionButton} onClick={onImport}>
|
||||
{t['com.affine.integration.readwise.import']()}
|
||||
</Button>
|
||||
<Button
|
||||
variant="error"
|
||||
className={actionButton}
|
||||
|
||||
@@ -1,11 +1,7 @@
|
||||
import { cssVarV2 } from '@toeverything/theme/v2';
|
||||
import { style } from '@vanilla-extract/css';
|
||||
|
||||
export const actionButton = style({
|
||||
width: 0,
|
||||
flex: 1,
|
||||
height: '100%',
|
||||
});
|
||||
export const actionButton = style({});
|
||||
|
||||
export const connectDialog = style({
|
||||
width: 480,
|
||||
|
||||
@@ -1,85 +0,0 @@
|
||||
import {
|
||||
IntegrationService,
|
||||
IntegrationTypeIcon,
|
||||
} from '@affine/core/modules/integration';
|
||||
import { useI18n } from '@affine/i18n';
|
||||
import { useLiveData, useService } from '@toeverything/infra';
|
||||
import { useCallback, useState } from 'react';
|
||||
|
||||
import {
|
||||
IntegrationCard,
|
||||
IntegrationCardContent,
|
||||
IntegrationCardFooter,
|
||||
IntegrationCardHeader,
|
||||
} from '../card';
|
||||
import { ConnectButton } from './connect';
|
||||
import { ConnectedActions } from './connected';
|
||||
import { ImportDialog } from './import-dialog';
|
||||
import { SettingDialog } from './setting-dialog';
|
||||
import { readwiseTrack } from './track';
|
||||
|
||||
export const ReadwiseIntegration = () => {
|
||||
const t = useI18n();
|
||||
const readwise = useService(IntegrationService).readwise;
|
||||
|
||||
const [openSetting, setOpenSetting] = useState(false);
|
||||
const [openImportDialog, setOpenImportDialog] = useState(false);
|
||||
const settings = useLiveData(readwise.settings$);
|
||||
const token = settings?.token;
|
||||
|
||||
const handleOpenSetting = useCallback(() => setOpenSetting(true), []);
|
||||
const handleCloseSetting = useCallback(() => setOpenSetting(false), []);
|
||||
|
||||
const handleConnectSuccess = useCallback(
|
||||
(token: string) => {
|
||||
readwise.connect(token);
|
||||
handleOpenSetting();
|
||||
},
|
||||
[handleOpenSetting, readwise]
|
||||
);
|
||||
|
||||
const handleImport = useCallback(() => {
|
||||
setOpenSetting(false);
|
||||
setOpenImportDialog(true);
|
||||
}, []);
|
||||
|
||||
const onImportClick = useCallback(() => {
|
||||
readwiseTrack.startIntegrationImport({
|
||||
method: settings?.lastImportedAt ? 'withtimestamp' : 'new',
|
||||
control: 'Readwise Card',
|
||||
});
|
||||
handleImport();
|
||||
}, [handleImport, settings?.lastImportedAt]);
|
||||
|
||||
return (
|
||||
<IntegrationCard>
|
||||
<IntegrationCardHeader
|
||||
icon={<IntegrationTypeIcon type="readwise" />}
|
||||
onSettingClick={handleOpenSetting}
|
||||
showSetting={!!token}
|
||||
/>
|
||||
<IntegrationCardContent
|
||||
title={t['com.affine.integration.readwise.name']()}
|
||||
desc={t['com.affine.integration.readwise.desc']()}
|
||||
/>
|
||||
<IntegrationCardFooter>
|
||||
{token ? (
|
||||
<>
|
||||
<ConnectedActions onImport={onImportClick} />
|
||||
{openSetting && (
|
||||
<SettingDialog
|
||||
onClose={handleCloseSetting}
|
||||
onImport={handleImport}
|
||||
/>
|
||||
)}
|
||||
{openImportDialog && (
|
||||
<ImportDialog onClose={() => setOpenImportDialog(false)} />
|
||||
)}
|
||||
</>
|
||||
) : (
|
||||
<ConnectButton onSuccess={handleConnectSuccess} />
|
||||
)}
|
||||
</IntegrationCardFooter>
|
||||
</IntegrationCard>
|
||||
);
|
||||
};
|
||||
@@ -6,36 +6,14 @@ export const dialog = style({
|
||||
maxWidth: 'calc(100vw - 32px)',
|
||||
});
|
||||
|
||||
export const header = style({
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
gap: 8,
|
||||
paddingBottom: 16,
|
||||
borderBottom: '0.5px solid ' + cssVarV2.layer.insideBorder.border,
|
||||
});
|
||||
export const headerIcon = style({
|
||||
width: 40,
|
||||
height: 40,
|
||||
fontSize: 30,
|
||||
borderRadius: 5,
|
||||
});
|
||||
export const headerTitle = style({
|
||||
fontSize: 15,
|
||||
lineHeight: '24px',
|
||||
fontWeight: 500,
|
||||
color: cssVarV2.text.primary,
|
||||
});
|
||||
export const headerCaption = style({
|
||||
fontSize: 12,
|
||||
lineHeight: '20px',
|
||||
fontWeight: 400,
|
||||
export const connectButton = style({
|
||||
width: '100%',
|
||||
color: cssVarV2.text.secondary,
|
||||
});
|
||||
|
||||
export const settings = style({
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
marginTop: 16,
|
||||
gap: 8,
|
||||
alignItems: 'stretch',
|
||||
});
|
||||
@@ -1,4 +1,4 @@
|
||||
import { Button, Modal } from '@affine/component';
|
||||
import { Button } from '@affine/component';
|
||||
import { type TagLike, TagsInlineEditor } from '@affine/core/components/tags';
|
||||
import {
|
||||
IntegrationService,
|
||||
@@ -7,46 +7,80 @@ import {
|
||||
import type { ReadwiseConfig } from '@affine/core/modules/integration/type';
|
||||
import { TagService } from '@affine/core/modules/tag';
|
||||
import { useI18n } from '@affine/i18n';
|
||||
import { PlusIcon } from '@blocksuite/icons/rc';
|
||||
import { LiveData, useLiveData, useService } from '@toeverything/infra';
|
||||
import { useCallback, useMemo } from 'react';
|
||||
import { type ReactNode, useCallback, useMemo, useState } from 'react';
|
||||
|
||||
import { IntegrationCardIcon } from '../card';
|
||||
import {
|
||||
IntegrationSettingHeader,
|
||||
IntegrationSettingItem,
|
||||
IntegrationSettingTextRadioGroup,
|
||||
type IntegrationSettingTextRadioGroupItem,
|
||||
IntegrationSettingToggle,
|
||||
} from '../setting';
|
||||
import * as styles from './setting-dialog.css';
|
||||
import { ReadwiseConnectButton } from './connect';
|
||||
import { ReadwiseDisconnectButton } from './connected';
|
||||
import { ImportDialog } from './import-dialog';
|
||||
import * as styles from './setting-panel.css';
|
||||
import { readwiseTrack } from './track';
|
||||
|
||||
export const SettingDialog = ({
|
||||
onClose,
|
||||
onImport,
|
||||
}: {
|
||||
onClose: () => void;
|
||||
onImport: () => void;
|
||||
}) => {
|
||||
export const ReadwiseSettingPanel = () => {
|
||||
const readwise = useService(IntegrationService).readwise;
|
||||
const settings = useLiveData(readwise.settings$);
|
||||
const token = settings?.token;
|
||||
|
||||
return token ? <ReadwiseConnectedSetting /> : <ReadwiseNotConnectedSetting />;
|
||||
};
|
||||
|
||||
const ReadwiseSettingHeader = ({ action }: { action?: ReactNode }) => {
|
||||
const t = useI18n();
|
||||
|
||||
return (
|
||||
<Modal
|
||||
open
|
||||
onOpenChange={onClose}
|
||||
contentOptions={{ className: styles.dialog }}
|
||||
>
|
||||
<header className={styles.header}>
|
||||
<IntegrationCardIcon className={styles.headerIcon}>
|
||||
<IntegrationTypeIcon type="readwise" />
|
||||
</IntegrationCardIcon>
|
||||
<div>
|
||||
<h1 className={styles.headerTitle}>
|
||||
{t['com.affine.integration.readwise.name']()}
|
||||
</h1>
|
||||
<p className={styles.headerCaption}>
|
||||
{t['com.affine.integration.readwise.setting.caption']()}
|
||||
</p>
|
||||
</div>
|
||||
</header>
|
||||
<IntegrationSettingHeader
|
||||
icon={<IntegrationTypeIcon type="readwise" />}
|
||||
name={t['com.affine.integration.readwise.name']()}
|
||||
desc={t['com.affine.integration.readwise.desc']()}
|
||||
action={action}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
const ReadwiseNotConnectedSetting = () => {
|
||||
const readwise = useService(IntegrationService).readwise;
|
||||
|
||||
const handleConnectSuccess = useCallback(
|
||||
(token: string) => {
|
||||
readwise.connect(token);
|
||||
},
|
||||
[readwise]
|
||||
);
|
||||
|
||||
return (
|
||||
<div>
|
||||
<ReadwiseSettingHeader />
|
||||
<ReadwiseConnectButton
|
||||
onSuccess={handleConnectSuccess}
|
||||
className={styles.connectButton}
|
||||
prefix={<PlusIcon />}
|
||||
size="large"
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
const ReadwiseConnectedSetting = () => {
|
||||
const [openImportDialog, setOpenImportDialog] = useState(false);
|
||||
|
||||
const onImport = useCallback(() => {
|
||||
setOpenImportDialog(true);
|
||||
}, []);
|
||||
|
||||
const closeImportDialog = useCallback(() => {
|
||||
setOpenImportDialog(false);
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<div>
|
||||
<ReadwiseSettingHeader action={<ReadwiseDisconnectButton />} />
|
||||
<ul className={styles.settings}>
|
||||
<TagsSetting />
|
||||
<Divider />
|
||||
@@ -56,7 +90,8 @@ export const SettingDialog = ({
|
||||
<Divider />
|
||||
<StartImport onImport={onImport} />
|
||||
</ul>
|
||||
</Modal>
|
||||
{openImportDialog && <ImportDialog onClose={closeImportDialog} />}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -1,6 +1,37 @@
|
||||
import { cssVarV2 } from '@toeverything/theme/v2';
|
||||
import { style } from '@vanilla-extract/css';
|
||||
|
||||
export const header = style({
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
gap: 8,
|
||||
paddingBottom: 16,
|
||||
borderBottom: '0.5px solid ' + cssVarV2.layer.insideBorder.border,
|
||||
marginBottom: 24,
|
||||
});
|
||||
export const headerContent = style({
|
||||
width: 0,
|
||||
flex: 1,
|
||||
});
|
||||
export const headerIcon = style({
|
||||
width: 40,
|
||||
height: 40,
|
||||
fontSize: 30,
|
||||
borderRadius: 5,
|
||||
});
|
||||
export const headerTitle = style({
|
||||
fontSize: 15,
|
||||
lineHeight: '24px',
|
||||
fontWeight: 500,
|
||||
color: cssVarV2.text.primary,
|
||||
});
|
||||
export const headerCaption = style({
|
||||
fontSize: 12,
|
||||
lineHeight: '20px',
|
||||
fontWeight: 400,
|
||||
color: cssVarV2.text.secondary,
|
||||
});
|
||||
|
||||
export const settingItem = style({
|
||||
width: '100%',
|
||||
display: 'flex',
|
||||
|
||||
@@ -3,8 +3,34 @@ import { DoneIcon } from '@blocksuite/icons/rc';
|
||||
import clsx from 'clsx';
|
||||
import type { HTMLAttributes, ReactNode } from 'react';
|
||||
|
||||
import { IntegrationCardIcon } from './card';
|
||||
import * as styles from './setting.css';
|
||||
|
||||
export const IntegrationSettingHeader = ({
|
||||
icon,
|
||||
name,
|
||||
desc,
|
||||
action,
|
||||
}: {
|
||||
icon: ReactNode;
|
||||
name: string;
|
||||
desc: string;
|
||||
action?: ReactNode;
|
||||
}) => {
|
||||
return (
|
||||
<header className={styles.header}>
|
||||
<IntegrationCardIcon className={styles.headerIcon}>
|
||||
{icon}
|
||||
</IntegrationCardIcon>
|
||||
<div className={styles.headerContent}>
|
||||
<h1 className={styles.headerTitle}>{name}</h1>
|
||||
<p className={styles.headerCaption}>{desc}</p>
|
||||
</div>
|
||||
{action}
|
||||
</header>
|
||||
);
|
||||
};
|
||||
|
||||
// universal
|
||||
export interface IntegrationSettingItemProps
|
||||
extends HTMLAttributes<HTMLDivElement> {
|
||||
|
||||
@@ -269,6 +269,13 @@ export const AFFINE_FLAGS = {
|
||||
configurable: isCanaryBuild,
|
||||
defaultState: isCanaryBuild,
|
||||
},
|
||||
enable_setting_subpage_animation: {
|
||||
category: 'affine',
|
||||
displayName: 'Enable Setting Subpage Animation',
|
||||
description: 'Apply animation for setting subpage open/close',
|
||||
configurable: isCanaryBuild,
|
||||
defaultState: false,
|
||||
},
|
||||
} satisfies { [key in string]: FlagInfo };
|
||||
|
||||
// oxlint-disable-next-line no-redeclare
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { LiveData, useLiveData } from '@toeverything/infra';
|
||||
import { nanoid } from 'nanoid';
|
||||
import {
|
||||
forwardRef,
|
||||
type Ref,
|
||||
@@ -10,9 +11,10 @@ import { createPortal } from 'react-dom';
|
||||
|
||||
export const createIsland = () => {
|
||||
const targetLiveData$ = new LiveData<HTMLDivElement | null>(null);
|
||||
const provided$ = new LiveData<boolean>(false);
|
||||
let mounted = false;
|
||||
let provided = false;
|
||||
return {
|
||||
id: nanoid(),
|
||||
Target: forwardRef(function IslandTarget(
|
||||
{ ...other }: React.HTMLProps<HTMLDivElement>,
|
||||
ref: Ref<HTMLDivElement>
|
||||
@@ -36,16 +38,17 @@ export const createIsland = () => {
|
||||
Provider: ({ children }: React.PropsWithChildren) => {
|
||||
const target = useLiveData(targetLiveData$);
|
||||
useEffect(() => {
|
||||
if (provided === true && BUILD_CONFIG.debug) {
|
||||
if (provided$.value === true && BUILD_CONFIG.debug) {
|
||||
throw new Error('Island should not be provided more than once');
|
||||
}
|
||||
provided = true;
|
||||
provided$.next(true);
|
||||
return () => {
|
||||
provided = false;
|
||||
provided$.next(false);
|
||||
};
|
||||
}, []);
|
||||
return target ? createPortal(children, target) : null;
|
||||
},
|
||||
provided$,
|
||||
};
|
||||
};
|
||||
|
||||
|
||||
Reference in New Issue
Block a user