mirror of
https://github.com/toeverything/AFFiNE.git
synced 2026-08-02 09:59:55 +08:00
feat(core): calendar integration storage (#11788)
close AF-2501, AF-2504
This commit is contained in:
+49
@@ -0,0 +1,49 @@
|
||||
import { cssVarV2 } from '@toeverything/theme/v2';
|
||||
import { style } from '@vanilla-extract/css';
|
||||
|
||||
export const list = style({
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
gap: 24,
|
||||
});
|
||||
|
||||
export const newButton = style({
|
||||
color: cssVarV2.text.secondary,
|
||||
});
|
||||
|
||||
export const newDialog = style({
|
||||
maxWidth: 480,
|
||||
});
|
||||
|
||||
export const newDialogHeader = style({
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
gap: 8,
|
||||
});
|
||||
|
||||
export const newDialogTitle = style({
|
||||
fontSize: 15,
|
||||
lineHeight: '24px',
|
||||
fontWeight: 500,
|
||||
color: cssVarV2.text.primary,
|
||||
});
|
||||
|
||||
export const newDialogContent = style({
|
||||
marginTop: 16,
|
||||
marginBottom: 20,
|
||||
});
|
||||
|
||||
export const newDialogLabel = style({
|
||||
fontSize: 12,
|
||||
lineHeight: '20px',
|
||||
fontWeight: 500,
|
||||
color: cssVarV2.text.primary,
|
||||
marginBottom: 4,
|
||||
});
|
||||
|
||||
export const newDialogFooter = style({
|
||||
display: 'flex',
|
||||
justifyContent: 'flex-end',
|
||||
alignItems: 'center',
|
||||
gap: 20,
|
||||
});
|
||||
+123
@@ -0,0 +1,123 @@
|
||||
import { Button, Input, Modal, notify } from '@affine/component';
|
||||
import { IntegrationService } from '@affine/core/modules/integration';
|
||||
import { useI18n } from '@affine/i18n';
|
||||
import { PlusIcon, TodayIcon } from '@blocksuite/icons/rc';
|
||||
import { useLiveData, useService } from '@toeverything/infra';
|
||||
import { useCallback, useState } from 'react';
|
||||
|
||||
import { IntegrationCardIcon } from '../card';
|
||||
import { IntegrationSettingHeader } from '../setting';
|
||||
import * as styles from './setting-panel.css';
|
||||
import { SubscriptionSetting } from './subscription-setting';
|
||||
|
||||
export const CalendarSettingPanel = () => {
|
||||
const t = useI18n();
|
||||
const calendar = useService(IntegrationService).calendar;
|
||||
const subscriptions = useLiveData(calendar.subscriptions$);
|
||||
return (
|
||||
<>
|
||||
<IntegrationSettingHeader
|
||||
icon={<TodayIcon />}
|
||||
name={t['com.affine.integration.calendar.name']()}
|
||||
desc={t['com.affine.integration.calendar.desc']()}
|
||||
divider={false}
|
||||
/>
|
||||
<div className={styles.list}>
|
||||
{subscriptions.map(subscription => (
|
||||
<SubscriptionSetting
|
||||
key={subscription.url}
|
||||
subscription={subscription}
|
||||
/>
|
||||
))}
|
||||
<AddSubscription />
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
const AddSubscription = () => {
|
||||
const t = useI18n();
|
||||
const [open, setOpen] = useState(false);
|
||||
const [url, setUrl] = useState('');
|
||||
const [verifying, setVerifying] = useState(false);
|
||||
const calendar = useService(IntegrationService).calendar;
|
||||
|
||||
const handleOpen = useCallback(() => {
|
||||
setOpen(true);
|
||||
}, []);
|
||||
const handleClose = useCallback(() => {
|
||||
setOpen(false);
|
||||
setUrl('');
|
||||
}, []);
|
||||
|
||||
const handleInputChange = useCallback((value: string) => {
|
||||
setUrl(value);
|
||||
}, []);
|
||||
|
||||
const handleAddSub = useCallback(() => {
|
||||
setVerifying(true);
|
||||
calendar
|
||||
.createSubscription(url)
|
||||
.then(() => {
|
||||
setOpen(false);
|
||||
setUrl('');
|
||||
})
|
||||
.catch(() => {
|
||||
notify.error({
|
||||
title: t['com.affine.integration.calendar.new-error'](),
|
||||
});
|
||||
})
|
||||
.finally(() => {
|
||||
setVerifying(false);
|
||||
});
|
||||
}, [calendar, t, url]);
|
||||
|
||||
return (
|
||||
<>
|
||||
<Button
|
||||
prefix={<PlusIcon />}
|
||||
size="large"
|
||||
onClick={handleOpen}
|
||||
className={styles.newButton}
|
||||
>
|
||||
{t['com.affine.integration.calendar.new-subscription']()}
|
||||
</Button>
|
||||
<Modal
|
||||
open={open}
|
||||
onOpenChange={setOpen}
|
||||
persistent
|
||||
withoutCloseButton
|
||||
contentOptions={{ className: styles.newDialog }}
|
||||
>
|
||||
<header className={styles.newDialogHeader}>
|
||||
<IntegrationCardIcon>
|
||||
<TodayIcon />
|
||||
</IntegrationCardIcon>
|
||||
<div className={styles.newDialogTitle}>
|
||||
{t['com.affine.integration.calendar.new-title']()}
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<div className={styles.newDialogContent}>
|
||||
<div className={styles.newDialogLabel}>
|
||||
{t['com.affine.integration.calendar.new-url-label']()}
|
||||
</div>
|
||||
<Input
|
||||
type="text"
|
||||
value={url}
|
||||
onChange={handleInputChange}
|
||||
placeholder="https://example.com/calendar.ics"
|
||||
onEnter={handleAddSub}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<footer className={styles.newDialogFooter}>
|
||||
<Button onClick={handleClose}>{t['Cancel']()}</Button>
|
||||
<Button variant="primary" onClick={handleAddSub} loading={verifying}>
|
||||
{t['com.affine.integration.calendar.new-subscription']()}
|
||||
</Button>
|
||||
</footer>
|
||||
</Modal>
|
||||
</>
|
||||
);
|
||||
};
|
||||
+84
@@ -0,0 +1,84 @@
|
||||
import { cssVarV2 } from '@toeverything/theme/v2';
|
||||
import { style } from '@vanilla-extract/css';
|
||||
|
||||
export const card = style({
|
||||
padding: '8px 16px 12px 16px',
|
||||
borderRadius: 8,
|
||||
background: cssVarV2.layer.background.primary,
|
||||
border: `1px solid ${cssVarV2.layer.insideBorder.border}`,
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
gap: 4,
|
||||
});
|
||||
export const divider = style({
|
||||
height: 8,
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
':before': {
|
||||
content: '',
|
||||
width: '100%',
|
||||
height: 0,
|
||||
borderTop: `0.5px solid ${cssVarV2.tab.divider.divider}`,
|
||||
},
|
||||
});
|
||||
export const header = style({
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
gap: 8,
|
||||
});
|
||||
export const colorPickerTrigger = style({
|
||||
width: 24,
|
||||
height: 24,
|
||||
borderRadius: 4,
|
||||
cursor: 'pointer',
|
||||
background: cssVarV2.layer.background.overlayPanel,
|
||||
border: `1px solid ${cssVarV2.layer.insideBorder.border}`,
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
':before': {
|
||||
content: '',
|
||||
width: 11,
|
||||
height: 11,
|
||||
borderRadius: 11,
|
||||
backgroundColor: 'currentColor',
|
||||
},
|
||||
});
|
||||
export const colorPicker = style({
|
||||
display: 'flex',
|
||||
gap: 4,
|
||||
alignItems: 'center',
|
||||
});
|
||||
export const colorPickerItem = style({
|
||||
width: 20,
|
||||
height: 20,
|
||||
borderRadius: 10,
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
cursor: 'pointer',
|
||||
selectors: {
|
||||
'&[data-active="true"]': {
|
||||
boxShadow: `0 0 0 1px ${cssVarV2.button.primary}`,
|
||||
},
|
||||
'&:before': {
|
||||
content: '',
|
||||
width: 16,
|
||||
height: 16,
|
||||
borderRadius: 8,
|
||||
background: 'currentColor',
|
||||
},
|
||||
},
|
||||
});
|
||||
export const name = style({
|
||||
fontSize: 14,
|
||||
fontWeight: 500,
|
||||
lineHeight: '22px',
|
||||
color: cssVarV2.text.primary,
|
||||
width: 0,
|
||||
flexGrow: 1,
|
||||
textOverflow: 'ellipsis',
|
||||
overflow: 'hidden',
|
||||
whiteSpace: 'nowrap',
|
||||
});
|
||||
+87
@@ -0,0 +1,87 @@
|
||||
import { Button, Menu } from '@affine/component';
|
||||
import {
|
||||
type CalendarSubscription,
|
||||
IntegrationService,
|
||||
} from '@affine/core/modules/integration';
|
||||
import { useI18n } from '@affine/i18n';
|
||||
import { useLiveData, useService } from '@toeverything/infra';
|
||||
import { useCallback, useMemo, useState } from 'react';
|
||||
|
||||
import * as styles from './subscription-setting.css';
|
||||
|
||||
export const SubscriptionSetting = ({
|
||||
subscription,
|
||||
}: {
|
||||
subscription: CalendarSubscription;
|
||||
}) => {
|
||||
const t = useI18n();
|
||||
const [menuOpen, setMenuOpen] = useState(false);
|
||||
const calendar = useService(IntegrationService).calendar;
|
||||
const config = useLiveData(subscription.config$);
|
||||
const name = useLiveData(subscription.name$);
|
||||
|
||||
const handleColorChange = useCallback(
|
||||
(color: string) => {
|
||||
calendar.updateSubscription(subscription.url, { color });
|
||||
setMenuOpen(false);
|
||||
},
|
||||
[calendar, subscription.url]
|
||||
);
|
||||
|
||||
const handleUnsubscribe = useCallback(() => {
|
||||
calendar.deleteSubscription(subscription.url);
|
||||
}, [calendar, subscription.url]);
|
||||
|
||||
if (!config) return null;
|
||||
|
||||
return (
|
||||
<div className={styles.card}>
|
||||
<div className={styles.header}>
|
||||
<Menu
|
||||
rootOptions={{ open: menuOpen, onOpenChange: setMenuOpen }}
|
||||
contentOptions={{ alignOffset: -6 }}
|
||||
items={
|
||||
<ColorPicker
|
||||
activeColor={config.color}
|
||||
onChange={handleColorChange}
|
||||
/>
|
||||
}
|
||||
>
|
||||
<div
|
||||
className={styles.colorPickerTrigger}
|
||||
style={{ color: config.color }}
|
||||
/>
|
||||
</Menu>
|
||||
<div className={styles.name}>{name || t['Untitled']()}</div>
|
||||
<Button variant="error" onClick={handleUnsubscribe}>
|
||||
{t['com.affine.integration.calendar.unsubscribe']()}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
const ColorPicker = ({
|
||||
activeColor,
|
||||
onChange,
|
||||
}: {
|
||||
onChange: (color: string) => void;
|
||||
activeColor: string;
|
||||
}) => {
|
||||
const calendar = useService(IntegrationService).calendar;
|
||||
const colors = useMemo(() => calendar.colors, [calendar]);
|
||||
|
||||
return (
|
||||
<ul className={styles.colorPicker}>
|
||||
{colors.map(color => (
|
||||
<li
|
||||
key={color}
|
||||
onClick={() => onChange(color)}
|
||||
data-active={color === activeColor}
|
||||
className={styles.colorPickerItem}
|
||||
style={{ color }}
|
||||
/>
|
||||
))}
|
||||
</ul>
|
||||
);
|
||||
};
|
||||
+41
-5
@@ -1,23 +1,59 @@
|
||||
import type { FeatureFlagService } from '@affine/core/modules/feature-flag';
|
||||
import { IntegrationTypeIcon } from '@affine/core/modules/integration';
|
||||
import type { I18nString } from '@affine/i18n';
|
||||
import { TodayIcon } from '@blocksuite/icons/rc';
|
||||
import { LiveData } from '@toeverything/infra';
|
||||
import type { ReactNode } from 'react';
|
||||
|
||||
import { CalendarSettingPanel } from './calendar/setting-panel';
|
||||
import { ReadwiseSettingPanel } from './readwise/setting-panel';
|
||||
|
||||
export type IntegrationCard = {
|
||||
interface IntegrationCard {
|
||||
id: string;
|
||||
name: I18nString;
|
||||
desc: I18nString;
|
||||
icon: ReactNode;
|
||||
setting: ReactNode;
|
||||
};
|
||||
}
|
||||
|
||||
export const INTEGRATION_LIST: IntegrationCard[] = [
|
||||
const INTEGRATION_LIST = [
|
||||
{
|
||||
id: 'readwise',
|
||||
id: 'readwise' as const,
|
||||
name: 'com.affine.integration.readwise.name',
|
||||
desc: 'com.affine.integration.readwise.desc',
|
||||
icon: <IntegrationTypeIcon type="readwise" />,
|
||||
setting: <ReadwiseSettingPanel />,
|
||||
},
|
||||
];
|
||||
BUILD_CONFIG.isElectron && {
|
||||
id: 'calendar' as const,
|
||||
name: 'com.affine.integration.calendar.name',
|
||||
desc: 'com.affine.integration.calendar.desc',
|
||||
icon: <TodayIcon />,
|
||||
setting: <CalendarSettingPanel />,
|
||||
},
|
||||
] satisfies (IntegrationCard | false)[];
|
||||
|
||||
type IntegrationId = Exclude<
|
||||
Extract<(typeof INTEGRATION_LIST)[number], {}>,
|
||||
false
|
||||
>['id'];
|
||||
|
||||
export type IntegrationItem = Exclude<IntegrationCard, 'id'> & {
|
||||
id: IntegrationId;
|
||||
};
|
||||
|
||||
export function getAllowedIntegrationList$(
|
||||
featureFlagService: FeatureFlagService
|
||||
) {
|
||||
return LiveData.computed(get => {
|
||||
return INTEGRATION_LIST.filter(item => {
|
||||
if (!item) return false;
|
||||
|
||||
if (item.id === 'calendar') {
|
||||
return get(featureFlagService.flags.enable_calendar_integration.$);
|
||||
}
|
||||
|
||||
return true;
|
||||
}) as IntegrationItem[];
|
||||
});
|
||||
}
|
||||
|
||||
+14
-3
@@ -1,6 +1,8 @@
|
||||
import { SettingHeader } from '@affine/component/setting-components';
|
||||
import { FeatureFlagService } from '@affine/core/modules/feature-flag';
|
||||
import { useI18n } from '@affine/i18n';
|
||||
import { type ReactNode, useState } from 'react';
|
||||
import { useLiveData, useService } from '@toeverything/infra';
|
||||
import { type ReactNode, useMemo, useState } from 'react';
|
||||
|
||||
import { SubPageProvider, useSubPageIsland } from '../../sub-page';
|
||||
import {
|
||||
@@ -8,12 +10,21 @@ import {
|
||||
IntegrationCardContent,
|
||||
IntegrationCardHeader,
|
||||
} from './card';
|
||||
import { INTEGRATION_LIST } from './constants';
|
||||
import { getAllowedIntegrationList$ } from './constants';
|
||||
import { list } from './index.css';
|
||||
|
||||
export const IntegrationSetting = () => {
|
||||
const t = useI18n();
|
||||
const [opened, setOpened] = useState<string | null>(null);
|
||||
const featureFlagService = useService(FeatureFlagService);
|
||||
|
||||
const integrationList = useLiveData(
|
||||
useMemo(
|
||||
() => getAllowedIntegrationList$(featureFlagService),
|
||||
[featureFlagService]
|
||||
)
|
||||
);
|
||||
|
||||
return (
|
||||
<>
|
||||
<SettingHeader
|
||||
@@ -27,7 +38,7 @@ export const IntegrationSetting = () => {
|
||||
}
|
||||
/>
|
||||
<ul className={list}>
|
||||
{INTEGRATION_LIST.map(item => {
|
||||
{integrationList.map(item => {
|
||||
const title =
|
||||
typeof item.name === 'string'
|
||||
? t[item.name]()
|
||||
|
||||
+6
-2
@@ -5,9 +5,13 @@ export const header = style({
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
gap: 8,
|
||||
paddingBottom: 16,
|
||||
borderBottom: '0.5px solid ' + cssVarV2.layer.insideBorder.border,
|
||||
marginBottom: 24,
|
||||
selectors: {
|
||||
'&[data-divider="true"]': {
|
||||
paddingBottom: 16,
|
||||
borderBottom: '0.5px solid ' + cssVarV2.layer.insideBorder.border,
|
||||
},
|
||||
},
|
||||
});
|
||||
export const headerContent = style({
|
||||
width: 0,
|
||||
|
||||
+3
-1
@@ -11,14 +11,16 @@ export const IntegrationSettingHeader = ({
|
||||
name,
|
||||
desc,
|
||||
action,
|
||||
divider = true,
|
||||
}: {
|
||||
icon: ReactNode;
|
||||
name: string;
|
||||
desc: string;
|
||||
action?: ReactNode;
|
||||
divider?: boolean;
|
||||
}) => {
|
||||
return (
|
||||
<header className={styles.header}>
|
||||
<header className={styles.header} data-divider={divider}>
|
||||
<IntegrationCardIcon className={styles.headerIcon}>
|
||||
{icon}
|
||||
</IntegrationCardIcon>
|
||||
|
||||
Reference in New Issue
Block a user