mirror of
https://github.com/toeverything/AFFiNE.git
synced 2026-02-04 08:38:34 +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,
|
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
|
||||||
|
|||||||
@@ -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',
|
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,
|
|
||||||
});
|
|
||||||
|
|||||||
@@ -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>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -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({
|
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',
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -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>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|||||||
@@ -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>
|
||||||
</>
|
</>
|
||||||
|
|||||||
@@ -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}
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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)',
|
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',
|
||||||
});
|
});
|
||||||
@@ -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>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -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',
|
||||||
|
|||||||
@@ -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> {
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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$,
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user