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,
} 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,11 +197,14 @@ const SettingModalInner = ({
activeTab={settingState.activeTab}
onTabChange={onTabChange}
/>
<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}>
@@ -228,7 +257,9 @@ const SettingModalInner = ({
</div>
<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

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

View File

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

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({
display: 'grid',
gridTemplateColumns: 'repeat(auto-fill, minmax(250px, 1fr))',
gridTemplateColumns: 'repeat(auto-fill, minmax(175px, 1fr))',
gap: '16px',
});

View File

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

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 { 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(() => {
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>
</>

View File

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

View File

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

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

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 {
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 (
<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 (
<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>
<ReadwiseSettingHeader />
<ReadwiseConnectButton
onSuccess={handleConnectSuccess}
className={styles.connectButton}
prefix={<PlusIcon />}
size="large"
/>
</div>
</header>
);
};
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>
);
};

View File

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

View File

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

View File

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

View File

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