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

![CleanShot 2025-04-14 at 16.56.30.gif](https://graphite-user-uploaded-assets-prod.s3.amazonaws.com/LakojjjzZNf6ogjOVwKE/c042300d-c442-4708-a07a-54cd9f044abf.gif)
This commit is contained in:
CatsJuice
2025-04-23 07:57:22 +00:00
parent 7e48dcc467
commit af69154f1c
18 changed files with 659 additions and 252 deletions

View File

@@ -13,6 +13,7 @@ import type {
WORKSPACE_DIALOG_SCHEMA, WORKSPACE_DIALOG_SCHEMA,
} from '@affine/core/modules/dialogs/constant'; } from '@affine/core/modules/dialogs/constant';
import { GlobalContextService } from '@affine/core/modules/global-context'; import { GlobalContextService } from '@affine/core/modules/global-context';
import { createIsland, type Island } from '@affine/core/utils/island';
import { ServerDeploymentType } from '@affine/graphql'; import { ServerDeploymentType } from '@affine/graphql';
import { Trans } from '@affine/i18n'; import { Trans } from '@affine/i18n';
import { ContactWithUsIcon } from '@blocksuite/icons/rc'; import { ContactWithUsIcon } from '@blocksuite/icons/rc';
@@ -23,6 +24,7 @@ import {
useCallback, useCallback,
useEffect, useEffect,
useLayoutEffect, useLayoutEffect,
useMemo,
useRef, useRef,
useState, useState,
} from 'react'; } from 'react';
@@ -34,6 +36,11 @@ import { IssueFeedbackModal } from './issue-feedback-modal';
import { SettingSidebar } from './setting-sidebar'; import { SettingSidebar } from './setting-sidebar';
import { StarAFFiNEModal } from './star-affine-modal'; import { StarAFFiNEModal } from './star-affine-modal';
import * as style from './style.css'; import * as style from './style.css';
import {
SubPageContext,
type SubPageContextType,
SubPageTarget,
} from './sub-page';
import type { SettingState } from './types'; import type { SettingState } from './types';
import { WorkspaceSetting } from './workspace-setting'; import { WorkspaceSetting } from './workspace-setting';
@@ -59,6 +66,7 @@ const SettingModalInner = ({
onCloseSetting, onCloseSetting,
scrollAnchor: initialScrollAnchor, scrollAnchor: initialScrollAnchor,
}: SettingProps) => { }: SettingProps) => {
const [subPageIslands, setSubPageIslands] = useState<Island[]>([]);
const [settingState, setSettingState] = useState<SettingState>({ const [settingState, setSettingState] = useState<SettingState>({
activeTab: initialActiveTab, activeTab: initialActiveTab,
scrollAnchor: initialScrollAnchor, scrollAnchor: initialScrollAnchor,
@@ -143,6 +151,24 @@ const SettingModalInner = ({
setOpenStarAFFiNEModal(true); setOpenStarAFFiNEModal(true);
}, [setOpenStarAFFiNEModal]); }, [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(() => { useEffect(() => {
if ( if (
isSelfhosted && isSelfhosted &&
@@ -171,64 +197,69 @@ const SettingModalInner = ({
activeTab={settingState.activeTab} activeTab={settingState.activeTab}
onTabChange={onTabChange} onTabChange={onTabChange}
/> />
<Scrollable.Root> <SubPageContext.Provider value={contextValue}>
<Scrollable.Viewport <Scrollable.Root>
data-testid="setting-modal-content" <Scrollable.Viewport
className={style.wrapper} data-testid="setting-modal-content"
ref={modalContentWrapperRef} className={style.wrapper}
> ref={modalContentWrapperRef}
<div className={style.centerContainer}> data-setting-page
<div ref={modalContentRef} className={style.content}> data-open
<Suspense fallback={<WorkspaceDetailSkeleton />}> >
{settingState.activeTab === 'account' && <div className={style.centerContainer}>
loginStatus === 'authenticated' ? ( <div ref={modalContentRef} className={style.content}>
<AccountSetting onChangeSettingState={setSettingState} /> <Suspense fallback={<WorkspaceDetailSkeleton />}>
) : isWorkspaceSetting(settingState.activeTab) ? ( {settingState.activeTab === 'account' &&
<WorkspaceSetting loginStatus === 'authenticated' ? (
activeTab={settingState.activeTab} <AccountSetting onChangeSettingState={setSettingState} />
onCloseSetting={onCloseSetting} ) : isWorkspaceSetting(settingState.activeTab) ? (
onChangeSettingState={setSettingState} <WorkspaceSetting
/> activeTab={settingState.activeTab}
) : !isWorkspaceSetting(settingState.activeTab) ? ( onCloseSetting={onCloseSetting}
<GeneralSetting onChangeSettingState={setSettingState}
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}
/> />
), ) : !isWorkspaceSetting(settingState.activeTab) ? (
2: ( <GeneralSetting
<span activeTab={settingState.activeTab}
className={style.link} onChangeSettingState={setSettingState}
onClick={handleOpenIssueFeedbackModal}
/> />
), ) : 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> </div>
<StarAFFiNEModal <Scrollable.Scrollbar />
open={openStarAFFiNEModal} </Scrollable.Viewport>
setOpen={setOpenStarAFFiNEModal} <SubPageTarget />
/> </Scrollable.Root>
<IssueFeedbackModal </SubPageContext.Provider>
open={openIssueFeedbackModal}
setOpen={setOpenIssueFeedbackModal}
/>
</div>
<Scrollable.Scrollbar />
</Scrollable.Viewport>
</Scrollable.Root>
</FrameworkScope> </FrameworkScope>
); );
}; };
@@ -254,6 +285,9 @@ export const SettingDialog = ({
}} }}
open open
onOpenChange={() => close()} onOpenChange={() => close()}
closeButtonOptions={{
style: { right: 14, top: 14 },
}}
> >
<Suspense fallback={<CenteredLoading />}> <Suspense fallback={<CenteredLoading />}>
<SettingModalInner <SettingModalInner

View File

@@ -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',
});

View 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;
};

View File

@@ -8,10 +8,11 @@ export const card = style({
padding: '8px 12px 12px 12px', padding: '8px 12px 12px 12px',
borderRadius: 8, borderRadius: 8,
border: '1px solid ' + cssVarV2.layer.insideBorder.border, border: '1px solid ' + cssVarV2.layer.insideBorder.border,
height: 186, height: 150,
display: 'flex', display: 'flex',
flexDirection: 'column', flexDirection: 'column',
background: cssVarV2.layer.background.overlayPanel, background: cssVarV2.layer.background.overlayPanel,
cursor: 'pointer',
}); });
export const cardHeader = style({ export const cardHeader = style({
display: 'flex', display: 'flex',
@@ -48,6 +49,12 @@ export const cardTitle = style({
lineHeight: '22px', lineHeight: '22px',
color: cssVarV2.text.primary, color: cssVarV2.text.primary,
}); });
export const cardStatus = style({
fontSize: 12,
lineHeight: '20px',
fontWeight: 400,
color: cssVarV2.text.secondary,
});
export const cardDesc = style([ export const cardDesc = style([
spaceY, spaceY,
{ {
@@ -69,6 +76,3 @@ export const cardFooter = style({
}, },
}, },
}); });
export const settingIcon = style({
color: cssVarV2.icon.secondary,
});

View File

@@ -1,5 +1,3 @@
import { IconButton, type IconButtonProps } from '@affine/component';
import { SettingsIcon } from '@blocksuite/icons/rc';
import clsx from 'clsx'; import clsx from 'clsx';
import type { HTMLAttributes, ReactNode } from 'react'; import type { HTMLAttributes, ReactNode } from 'react';
@@ -10,16 +8,15 @@ import {
cardFooter, cardFooter,
cardHeader, cardHeader,
cardIcon, cardIcon,
cardStatus,
cardTitle, cardTitle,
settingIcon,
} from './card.css'; } from './card.css';
import { spaceX } from './index.css';
export const IntegrationCard = ({ export const IntegrationCard = ({
className, className,
...props ...props
}: HTMLAttributes<HTMLLIElement>) => { }: HTMLAttributes<HTMLDivElement>) => {
return <li className={clsx(className, card)} {...props} />; return <div className={clsx(className, card)} {...props} />;
}; };
export const IntegrationCardIcon = ({ export const IntegrationCardIcon = ({
@@ -29,52 +26,37 @@ export const IntegrationCardIcon = ({
return <div className={clsx(cardIcon, className)} {...props} />; 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 = ({ export const IntegrationCardHeader = ({
className, className,
icon, icon,
onSettingClick, title,
showSetting = true, status,
...props ...props
}: HTMLAttributes<HTMLHeadElement> & { }: HTMLAttributes<HTMLHeadElement> & {
showSetting?: boolean;
onSettingClick?: () => void;
icon?: ReactNode; icon?: ReactNode;
title?: string;
status?: ReactNode;
}) => { }) => {
return ( return (
<header className={clsx(cardHeader, className)} {...props}> <header className={clsx(cardHeader, className)} {...props}>
<IntegrationCardIcon>{icon}</IntegrationCardIcon> <IntegrationCardIcon>{icon}</IntegrationCardIcon>
<div className={spaceX} /> <div>
{showSetting ? <IntegrationSettingIcon onClick={onSettingClick} /> : null} <div className={cardTitle}>{title}</div>
{status ? <div className={cardStatus}>{status}</div> : null}
</div>
</header> </header>
); );
}; };
export const IntegrationCardContent = ({ export const IntegrationCardContent = ({
className, className,
title,
desc, desc,
...props ...props
}: HTMLAttributes<HTMLDivElement> & { }: HTMLAttributes<HTMLDivElement> & {
title?: string;
desc?: string; desc?: string;
}) => { }) => {
return ( return (
<div className={clsx(cardContent, className)} {...props}> <div className={clsx(cardContent, className)} {...props}>
<div className={cardTitle}>{title}</div>
<div className={cardDesc}>{desc}</div> <div className={cardDesc}>{desc}</div>
</div> </div>
); );

View File

@@ -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 />,
},
];

View File

@@ -10,6 +10,6 @@ export const spaceY = style({
}); });
export const list = style({ export const list = style({
display: 'grid', display: 'grid',
gridTemplateColumns: 'repeat(auto-fill, minmax(250px, 1fr))', gridTemplateColumns: 'repeat(auto-fill, minmax(175px, 1fr))',
gap: '16px', gap: '16px',
}); });

View File

@@ -1,11 +1,19 @@
import { SettingHeader } from '@affine/component/setting-components'; import { SettingHeader } from '@affine/component/setting-components';
import { useI18n } from '@affine/i18n'; 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 { list } from './index.css';
import { ReadwiseIntegration } from './readwise';
export const IntegrationSetting = () => { export const IntegrationSetting = () => {
const t = useI18n(); const t = useI18n();
const [opened, setOpened] = useState<string | null>(null);
return ( return (
<> <>
<SettingHeader <SettingHeader
@@ -19,8 +27,59 @@ export const IntegrationSetting = () => {
} }
/> />
<ul className={list}> <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> </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>
);
};

View File

@@ -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 { useAsyncCallback } from '@affine/core/components/hooks/affine-async-hooks';
import { IntegrationService } from '@affine/core/modules/integration'; import { IntegrationService } from '@affine/core/modules/integration';
import { Trans, useI18n } from '@affine/i18n'; import { Trans, useI18n } from '@affine/i18n';
import { ReadwiseLogoDuotoneIcon } from '@blocksuite/icons/rc'; import { ReadwiseLogoDuotoneIcon } from '@blocksuite/icons/rc';
import { useService } from '@toeverything/infra'; import { useService } from '@toeverything/infra';
import clsx from 'clsx';
import { import {
type FormEvent, type FormEvent,
type MouseEvent,
useCallback, useCallback,
useEffect, useEffect,
useRef, useRef,
@@ -156,11 +164,14 @@ const ConnectDialog = ({
); );
}; };
export const ConnectButton = ({ export const ReadwiseConnectButton = ({
onSuccess, onSuccess,
className,
onClick,
...buttonProps
}: { }: {
onSuccess: (token: string) => void; onSuccess: (token: string) => void;
}) => { } & ButtonProps) => {
const t = useI18n(); const t = useI18n();
const [open, setOpen] = useState(false); const [open, setOpen] = useState(false);
@@ -168,14 +179,22 @@ export const ConnectButton = ({
setOpen(false); setOpen(false);
}, []); }, []);
const handleOpen = useCallback(() => { const handleOpen = useCallback(
setOpen(true); (e: MouseEvent<HTMLButtonElement>) => {
}, []); onClick?.(e);
setOpen(true);
},
[onClick]
);
return ( return (
<> <>
{open && <ConnectDialog onClose={handleClose} onSuccess={onSuccess} />} {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']()} {t['com.affine.integration.readwise.connect']()}
</Button> </Button>
</> </>

View File

@@ -9,7 +9,11 @@ import * as styles from './connected.css';
import { actionButton } from './index.css'; import { actionButton } from './index.css';
import { readwiseTrack } from './track'; import { readwiseTrack } from './track';
export const DisconnectDialog = ({ onClose }: { onClose: () => void }) => { export const ReadwiseDisconnectDialog = ({
onClose,
}: {
onClose: () => void;
}) => {
const t = useI18n(); const t = useI18n();
const readwise = useService(IntegrationService).readwise; 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 t = useI18n();
const [showDisconnectDialog, setShowDisconnectDialog] = useState(false); const [showDisconnectDialog, setShowDisconnectDialog] = useState(false);
return ( return (
<> <>
{showDisconnectDialog && ( {showDisconnectDialog && (
<DisconnectDialog onClose={() => setShowDisconnectDialog(false)} /> <ReadwiseDisconnectDialog
onClose={() => setShowDisconnectDialog(false)}
/>
)} )}
<Button className={actionButton} onClick={onImport}>
{t['com.affine.integration.readwise.import']()}
</Button>
<Button <Button
variant="error" variant="error"
className={actionButton} className={actionButton}

View File

@@ -1,11 +1,7 @@
import { cssVarV2 } from '@toeverything/theme/v2'; import { cssVarV2 } from '@toeverything/theme/v2';
import { style } from '@vanilla-extract/css'; import { style } from '@vanilla-extract/css';
export const actionButton = style({ export const actionButton = style({});
width: 0,
flex: 1,
height: '100%',
});
export const connectDialog = style({ export const connectDialog = style({
width: 480, width: 480,

View File

@@ -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>
);
};

View File

@@ -6,36 +6,14 @@ export const dialog = style({
maxWidth: 'calc(100vw - 32px)', maxWidth: 'calc(100vw - 32px)',
}); });
export const header = style({ export const connectButton = style({
display: 'flex', width: '100%',
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,
color: cssVarV2.text.secondary, color: cssVarV2.text.secondary,
}); });
export const settings = style({ export const settings = style({
display: 'flex', display: 'flex',
flexDirection: 'column', flexDirection: 'column',
marginTop: 16,
gap: 8, gap: 8,
alignItems: 'stretch', alignItems: 'stretch',
}); });

View File

@@ -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 { type TagLike, TagsInlineEditor } from '@affine/core/components/tags';
import { import {
IntegrationService, IntegrationService,
@@ -7,46 +7,80 @@ import {
import type { ReadwiseConfig } from '@affine/core/modules/integration/type'; import type { ReadwiseConfig } from '@affine/core/modules/integration/type';
import { TagService } from '@affine/core/modules/tag'; import { TagService } from '@affine/core/modules/tag';
import { useI18n } from '@affine/i18n'; import { useI18n } from '@affine/i18n';
import { PlusIcon } from '@blocksuite/icons/rc';
import { LiveData, useLiveData, useService } from '@toeverything/infra'; 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 { import {
IntegrationSettingHeader,
IntegrationSettingItem, IntegrationSettingItem,
IntegrationSettingTextRadioGroup, IntegrationSettingTextRadioGroup,
type IntegrationSettingTextRadioGroupItem, type IntegrationSettingTextRadioGroupItem,
IntegrationSettingToggle, IntegrationSettingToggle,
} from '../setting'; } 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'; import { readwiseTrack } from './track';
export const SettingDialog = ({ export const ReadwiseSettingPanel = () => {
onClose, const readwise = useService(IntegrationService).readwise;
onImport, const settings = useLiveData(readwise.settings$);
}: { const token = settings?.token;
onClose: () => void;
onImport: () => void; return token ? <ReadwiseConnectedSetting /> : <ReadwiseNotConnectedSetting />;
}) => { };
const ReadwiseSettingHeader = ({ action }: { action?: ReactNode }) => {
const t = useI18n(); const t = useI18n();
return ( return (
<Modal <IntegrationSettingHeader
open icon={<IntegrationTypeIcon type="readwise" />}
onOpenChange={onClose} name={t['com.affine.integration.readwise.name']()}
contentOptions={{ className: styles.dialog }} desc={t['com.affine.integration.readwise.desc']()}
> action={action}
<header className={styles.header}> />
<IntegrationCardIcon className={styles.headerIcon}> );
<IntegrationTypeIcon type="readwise" /> };
</IntegrationCardIcon>
<div> const ReadwiseNotConnectedSetting = () => {
<h1 className={styles.headerTitle}> const readwise = useService(IntegrationService).readwise;
{t['com.affine.integration.readwise.name']()}
</h1> const handleConnectSuccess = useCallback(
<p className={styles.headerCaption}> (token: string) => {
{t['com.affine.integration.readwise.setting.caption']()} readwise.connect(token);
</p> },
</div> [readwise]
</header> );
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}> <ul className={styles.settings}>
<TagsSetting /> <TagsSetting />
<Divider /> <Divider />
@@ -56,7 +90,8 @@ export const SettingDialog = ({
<Divider /> <Divider />
<StartImport onImport={onImport} /> <StartImport onImport={onImport} />
</ul> </ul>
</Modal> {openImportDialog && <ImportDialog onClose={closeImportDialog} />}
</div>
); );
}; };

View File

@@ -1,6 +1,37 @@
import { cssVarV2 } from '@toeverything/theme/v2'; import { cssVarV2 } from '@toeverything/theme/v2';
import { style } from '@vanilla-extract/css'; 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({ export const settingItem = style({
width: '100%', width: '100%',
display: 'flex', display: 'flex',

View File

@@ -3,8 +3,34 @@ import { DoneIcon } from '@blocksuite/icons/rc';
import clsx from 'clsx'; import clsx from 'clsx';
import type { HTMLAttributes, ReactNode } from 'react'; import type { HTMLAttributes, ReactNode } from 'react';
import { IntegrationCardIcon } from './card';
import * as styles from './setting.css'; 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 // universal
export interface IntegrationSettingItemProps export interface IntegrationSettingItemProps
extends HTMLAttributes<HTMLDivElement> { extends HTMLAttributes<HTMLDivElement> {

View File

@@ -269,6 +269,13 @@ export const AFFINE_FLAGS = {
configurable: isCanaryBuild, configurable: isCanaryBuild,
defaultState: 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 }; } satisfies { [key in string]: FlagInfo };
// oxlint-disable-next-line no-redeclare // oxlint-disable-next-line no-redeclare

View File

@@ -1,4 +1,5 @@
import { LiveData, useLiveData } from '@toeverything/infra'; import { LiveData, useLiveData } from '@toeverything/infra';
import { nanoid } from 'nanoid';
import { import {
forwardRef, forwardRef,
type Ref, type Ref,
@@ -10,9 +11,10 @@ import { createPortal } from 'react-dom';
export const createIsland = () => { export const createIsland = () => {
const targetLiveData$ = new LiveData<HTMLDivElement | null>(null); const targetLiveData$ = new LiveData<HTMLDivElement | null>(null);
const provided$ = new LiveData<boolean>(false);
let mounted = false; let mounted = false;
let provided = false;
return { return {
id: nanoid(),
Target: forwardRef(function IslandTarget( Target: forwardRef(function IslandTarget(
{ ...other }: React.HTMLProps<HTMLDivElement>, { ...other }: React.HTMLProps<HTMLDivElement>,
ref: Ref<HTMLDivElement> ref: Ref<HTMLDivElement>
@@ -36,16 +38,17 @@ export const createIsland = () => {
Provider: ({ children }: React.PropsWithChildren) => { Provider: ({ children }: React.PropsWithChildren) => {
const target = useLiveData(targetLiveData$); const target = useLiveData(targetLiveData$);
useEffect(() => { 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'); throw new Error('Island should not be provided more than once');
} }
provided = true; provided$.next(true);
return () => { return () => {
provided = false; provided$.next(false);
}; };
}, []); }, []);
return target ? createPortal(children, target) : null; return target ? createPortal(children, target) : null;
}, },
provided$,
}; };
}; };