mirror of
https://github.com/toeverything/AFFiNE.git
synced 2026-02-15 05:37:32 +00:00
feat: basic caldav support (#14372)
fix #13531 #### PR Dependency Tree * **PR #14372** 👈 This tree was auto-generated by [Charcoal](https://github.com/danerwilliams/charcoal) <!-- This is an auto-generated comment: release notes by coderabbit.ai --> ## Summary by CodeRabbit * **New Features** * CalDAV calendar integration: link and sync CalDAV-compatible calendars (discovery, listing, event sync). * New UI flow and dialog to link CalDAV accounts with provider selection, credentials, and display name. * **API / Config** * Server exposes CalDAV provider presets in config and new GraphQL mutation to link CalDAV accounts. * New calendar config section for CalDAV with validation and defaults. * **Tests** * Comprehensive CalDAV integration test suite added. * **Chores** * Removed analytics tokens from build configuration and reduced Cloud E2E test shards. <!-- end of auto-generated comment: release notes by coderabbit.ai -->
This commit is contained in:
@@ -288,6 +288,10 @@
|
||||
"type": "Object",
|
||||
"desc": "Google Calendar integration config",
|
||||
"link": "https://developers.google.com/calendar/api/guides/push"
|
||||
},
|
||||
"caldav": {
|
||||
"type": "Object",
|
||||
"desc": "CalDAV integration config"
|
||||
}
|
||||
},
|
||||
"captcha": {
|
||||
|
||||
@@ -12,6 +12,15 @@ import useSWR from 'swr';
|
||||
import useSWRImmutable from 'swr/immutable';
|
||||
import useSWRInfinite from 'swr/infinite';
|
||||
|
||||
export type UseQueryConfig<Query extends GraphQLQuery = GraphQLQuery> = Omit<
|
||||
SWRConfiguration<
|
||||
QueryResponse<Query>,
|
||||
GraphQLError,
|
||||
(options: QueryOptions<Query>) => Promise<QueryResponse<Query>>
|
||||
>,
|
||||
'fetcher'
|
||||
>;
|
||||
|
||||
/**
|
||||
* A `useSWR` wrapper for sending graphql queries
|
||||
*
|
||||
@@ -32,14 +41,7 @@ import useSWRInfinite from 'swr/infinite';
|
||||
*/
|
||||
type useQueryFn = <Query extends GraphQLQuery>(
|
||||
options?: QueryOptions<Query>,
|
||||
config?: Omit<
|
||||
SWRConfiguration<
|
||||
QueryResponse<Query>,
|
||||
GraphQLError,
|
||||
(options: QueryOptions<Query>) => Promise<QueryResponse<Query>>
|
||||
>,
|
||||
'fetcher'
|
||||
>
|
||||
config?: UseQueryConfig<Query>
|
||||
) => SWRResponse<
|
||||
QueryResponse<Query>,
|
||||
GraphQLError,
|
||||
|
||||
@@ -130,3 +130,53 @@ export const empty = style({
|
||||
color: cssVarV2.text.secondary,
|
||||
padding: '12px 0',
|
||||
});
|
||||
|
||||
export const caldavDialog = style({
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
gap: 20,
|
||||
});
|
||||
|
||||
export const caldavField = style({
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
gap: 10,
|
||||
});
|
||||
|
||||
export const caldavLabel = style({
|
||||
fontSize: 12,
|
||||
lineHeight: '18px',
|
||||
color: cssVarV2.text.secondary,
|
||||
});
|
||||
|
||||
export const caldavProviderButton = style({
|
||||
width: '100%',
|
||||
justifyContent: 'space-between',
|
||||
});
|
||||
|
||||
export const caldavHint = style({
|
||||
display: 'flex',
|
||||
gap: 6,
|
||||
flexWrap: 'wrap',
|
||||
fontSize: 12,
|
||||
lineHeight: '18px',
|
||||
color: cssVarV2.text.secondary,
|
||||
});
|
||||
|
||||
export const caldavLink = style({
|
||||
color: cssVarV2.text.primary,
|
||||
textDecoration: 'underline',
|
||||
});
|
||||
|
||||
export const caldavError = style({
|
||||
fontSize: 12,
|
||||
lineHeight: '18px',
|
||||
color: cssVarV2.status.error,
|
||||
});
|
||||
|
||||
export const caldavFooter = style({
|
||||
display: 'flex',
|
||||
justifyContent: 'flex-end',
|
||||
gap: 12,
|
||||
marginTop: 12,
|
||||
});
|
||||
|
||||
@@ -1,12 +1,27 @@
|
||||
import { Button, Loading, Menu, MenuItem, notify } from '@affine/component';
|
||||
import {
|
||||
Button,
|
||||
Input,
|
||||
Loading,
|
||||
Menu,
|
||||
MenuItem,
|
||||
Modal,
|
||||
notify,
|
||||
} from '@affine/component';
|
||||
import {
|
||||
useQuery,
|
||||
type UseQueryConfig,
|
||||
} from '@affine/core/components/hooks/use-query';
|
||||
import { GraphQLService } from '@affine/core/modules/cloud';
|
||||
import { UrlService } from '@affine/core/modules/url';
|
||||
import { UserFriendlyError } from '@affine/error';
|
||||
import {
|
||||
type CalendarAccountsQuery,
|
||||
calendarAccountsQuery,
|
||||
type CalendarProvidersQuery,
|
||||
calendarProvidersQuery,
|
||||
CalendarProviderType,
|
||||
type GraphQLQuery,
|
||||
linkCalDavAccountMutation,
|
||||
linkCalendarAccountMutation,
|
||||
unlinkCalendarAccountMutation,
|
||||
} from '@affine/graphql';
|
||||
@@ -14,6 +29,7 @@ import { useI18n } from '@affine/i18n';
|
||||
import { GoogleIcon, LinkIcon, TodayIcon } from '@blocksuite/icons/rc';
|
||||
import { useService } from '@toeverything/infra';
|
||||
import {
|
||||
type FormEvent,
|
||||
type ReactNode,
|
||||
useCallback,
|
||||
useEffect,
|
||||
@@ -28,6 +44,10 @@ type CalendarAccount = NonNullable<
|
||||
CalendarAccountsQuery['currentUser']
|
||||
>['calendarAccounts'][number];
|
||||
|
||||
type CalendarCalDAVProvider = NonNullable<
|
||||
CalendarProvidersQuery['serverConfig']
|
||||
>['calendarCalDAVProviders'][number];
|
||||
|
||||
const providerMeta = {
|
||||
[CalendarProviderType.Google]: {
|
||||
label: 'Google Calendar',
|
||||
@@ -41,68 +61,328 @@ const providerMeta = {
|
||||
Record<CalendarProviderType, { label: string; icon: ReactNode }>
|
||||
>;
|
||||
|
||||
const CalDAVLinkDialog = ({
|
||||
open,
|
||||
providers,
|
||||
onClose,
|
||||
onLinked,
|
||||
}: {
|
||||
open: boolean;
|
||||
providers: CalendarCalDAVProvider[];
|
||||
onClose: () => void;
|
||||
onLinked: () => void;
|
||||
}) => {
|
||||
const t = useI18n();
|
||||
const gqlService = useService(GraphQLService);
|
||||
const [providerId, setProviderId] = useState<string | null>(null);
|
||||
const [username, setUsername] = useState('');
|
||||
const [password, setPassword] = useState('');
|
||||
const [displayName, setDisplayName] = useState('');
|
||||
const [submitting, setSubmitting] = useState(false);
|
||||
const [errors, setErrors] = useState<{
|
||||
provider?: string;
|
||||
username?: string;
|
||||
password?: string;
|
||||
}>({});
|
||||
|
||||
const selectedProvider = useMemo(() => {
|
||||
if (providerId) {
|
||||
const match = providers.find(provider => provider.id === providerId);
|
||||
if (match) {
|
||||
return match;
|
||||
}
|
||||
}
|
||||
return providers[0] ?? null;
|
||||
}, [providerId, providers]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!open) return;
|
||||
setProviderId(providers[0]?.id ?? null);
|
||||
setUsername('');
|
||||
setPassword('');
|
||||
setDisplayName('');
|
||||
setErrors({});
|
||||
}, [open, providers]);
|
||||
|
||||
const handleProviderSelect = useCallback(
|
||||
(provider: CalendarCalDAVProvider) => {
|
||||
setProviderId(provider.id);
|
||||
setErrors(prev => ({ ...prev, provider: undefined }));
|
||||
},
|
||||
[]
|
||||
);
|
||||
|
||||
const handleUsernameInput = useCallback(
|
||||
(event: FormEvent<HTMLInputElement>) => {
|
||||
setUsername(event.currentTarget.value);
|
||||
setErrors(prev => ({ ...prev, username: undefined }));
|
||||
},
|
||||
[]
|
||||
);
|
||||
|
||||
const handlePasswordInput = useCallback(
|
||||
(event: FormEvent<HTMLInputElement>) => {
|
||||
setPassword(event.currentTarget.value);
|
||||
setErrors(prev => ({ ...prev, password: undefined }));
|
||||
},
|
||||
[]
|
||||
);
|
||||
|
||||
const handleDisplayNameInput = useCallback(
|
||||
(event: FormEvent<HTMLInputElement>) => {
|
||||
setDisplayName(event.currentTarget.value);
|
||||
},
|
||||
[]
|
||||
);
|
||||
|
||||
const handleSubmit = useCallback(async () => {
|
||||
const nextErrors: {
|
||||
provider?: string;
|
||||
username?: string;
|
||||
password?: string;
|
||||
} = {};
|
||||
if (!selectedProvider) {
|
||||
nextErrors.provider =
|
||||
t['com.affine.integration.calendar.caldav.field.provider.error']();
|
||||
}
|
||||
if (!username.trim()) {
|
||||
nextErrors.username =
|
||||
t['com.affine.integration.calendar.caldav.field.username.error']();
|
||||
}
|
||||
if (!password) {
|
||||
nextErrors.password =
|
||||
t['com.affine.integration.calendar.caldav.field.password.error']();
|
||||
}
|
||||
if (Object.keys(nextErrors).length) {
|
||||
setErrors(nextErrors);
|
||||
return;
|
||||
}
|
||||
|
||||
setSubmitting(true);
|
||||
try {
|
||||
await gqlService.gql({
|
||||
query: linkCalDavAccountMutation,
|
||||
variables: {
|
||||
input: {
|
||||
providerPresetId: selectedProvider!.id,
|
||||
username: username.trim(),
|
||||
password,
|
||||
displayName: displayName.trim() || null,
|
||||
},
|
||||
},
|
||||
});
|
||||
onLinked();
|
||||
onClose();
|
||||
} catch (error) {
|
||||
const message =
|
||||
error instanceof UserFriendlyError ? error.message : String(error);
|
||||
notify.error({
|
||||
title: t['com.affine.integration.calendar.caldav.link.failed'](),
|
||||
message: message || undefined,
|
||||
});
|
||||
} finally {
|
||||
setSubmitting(false);
|
||||
}
|
||||
}, [
|
||||
displayName,
|
||||
gqlService,
|
||||
onClose,
|
||||
onLinked,
|
||||
password,
|
||||
selectedProvider,
|
||||
t,
|
||||
username,
|
||||
]);
|
||||
|
||||
return (
|
||||
<Modal
|
||||
open={open}
|
||||
width={480}
|
||||
title={t['com.affine.integration.calendar.caldav.link.title']()}
|
||||
onOpenChange={nextOpen => {
|
||||
if (!nextOpen) onClose();
|
||||
}}
|
||||
contentOptions={{ className: styles.caldavDialog }}
|
||||
>
|
||||
<div className={styles.caldavField}>
|
||||
<div className={styles.caldavLabel}>
|
||||
{t['com.affine.integration.calendar.caldav.field.provider']()}
|
||||
</div>
|
||||
<Menu
|
||||
items={providers.map(provider => (
|
||||
<MenuItem
|
||||
key={provider.id}
|
||||
onSelect={() => handleProviderSelect(provider)}
|
||||
>
|
||||
{provider.label}
|
||||
</MenuItem>
|
||||
))}
|
||||
contentOptions={{ align: 'start' }}
|
||||
>
|
||||
<Button
|
||||
className={styles.caldavProviderButton}
|
||||
disabled={!providers.length}
|
||||
>
|
||||
{selectedProvider?.label ??
|
||||
t[
|
||||
'com.affine.integration.calendar.caldav.field.provider.placeholder'
|
||||
]()}
|
||||
</Button>
|
||||
</Menu>
|
||||
{errors.provider ? (
|
||||
<div className={styles.caldavError}>{errors.provider}</div>
|
||||
) : null}
|
||||
{selectedProvider?.requiresAppPassword ? (
|
||||
<div className={styles.caldavHint}>
|
||||
{t['com.affine.integration.calendar.caldav.hint.app-password']()}
|
||||
{selectedProvider.docsUrl ? (
|
||||
<a
|
||||
className={styles.caldavLink}
|
||||
href={selectedProvider.docsUrl}
|
||||
target="_blank"
|
||||
rel="noreferrer noopener"
|
||||
>
|
||||
{t['com.affine.integration.calendar.caldav.hint.learn-more']()}
|
||||
</a>
|
||||
) : null}
|
||||
</div>
|
||||
) : selectedProvider?.docsUrl ? (
|
||||
<div className={styles.caldavHint}>
|
||||
<a
|
||||
className={styles.caldavLink}
|
||||
href={selectedProvider.docsUrl}
|
||||
target="_blank"
|
||||
rel="noreferrer noopener"
|
||||
>
|
||||
{t['com.affine.integration.calendar.caldav.hint.guide']()}
|
||||
</a>
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
<div className={styles.caldavField}>
|
||||
<div className={styles.caldavLabel}>
|
||||
{t['com.affine.integration.calendar.caldav.field.username']()}
|
||||
</div>
|
||||
<Input
|
||||
value={username}
|
||||
onInput={handleUsernameInput}
|
||||
placeholder={t[
|
||||
'com.affine.integration.calendar.caldav.field.username.placeholder'
|
||||
]()}
|
||||
status={errors.username ? 'error' : 'default'}
|
||||
disabled={submitting}
|
||||
/>
|
||||
{errors.username ? (
|
||||
<div className={styles.caldavError}>{errors.username}</div>
|
||||
) : null}
|
||||
</div>
|
||||
<div className={styles.caldavField}>
|
||||
<div className={styles.caldavLabel}>
|
||||
{t['com.affine.integration.calendar.caldav.field.password']()}
|
||||
</div>
|
||||
<Input
|
||||
value={password}
|
||||
onInput={handlePasswordInput}
|
||||
placeholder={t[
|
||||
'com.affine.integration.calendar.caldav.field.password.placeholder'
|
||||
]()}
|
||||
type="password"
|
||||
status={errors.password ? 'error' : 'default'}
|
||||
disabled={submitting}
|
||||
/>
|
||||
{errors.password ? (
|
||||
<div className={styles.caldavError}>{errors.password}</div>
|
||||
) : null}
|
||||
</div>
|
||||
<div className={styles.caldavField}>
|
||||
<div className={styles.caldavLabel}>
|
||||
{t['com.affine.integration.calendar.caldav.field.displayName']()}
|
||||
</div>
|
||||
<Input
|
||||
value={displayName}
|
||||
onInput={handleDisplayNameInput}
|
||||
placeholder={t[
|
||||
'com.affine.integration.calendar.caldav.field.displayName.placeholder'
|
||||
]()}
|
||||
disabled={submitting}
|
||||
/>
|
||||
</div>
|
||||
<div className={styles.caldavFooter}>
|
||||
<Button disabled={submitting} onClick={onClose}>
|
||||
{t['Cancel']()}
|
||||
</Button>
|
||||
<Button
|
||||
variant="primary"
|
||||
loading={submitting}
|
||||
disabled={submitting || !providers.length}
|
||||
onClick={() => void handleSubmit()}
|
||||
>
|
||||
{t['com.affine.integration.calendar.account.link']()}
|
||||
</Button>
|
||||
</div>
|
||||
</Modal>
|
||||
);
|
||||
};
|
||||
|
||||
export const IntegrationsPanel = () => {
|
||||
const t = useI18n();
|
||||
const gqlService = useService(GraphQLService);
|
||||
const urlService = useService(UrlService);
|
||||
const [accounts, setAccounts] = useState<CalendarAccount[]>([]);
|
||||
const [providers, setProviders] = useState<CalendarProviderType[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [linking, setLinking] = useState(false);
|
||||
const [unlinkingAccountId, setUnlinkingAccountId] = useState<string | null>(
|
||||
null
|
||||
);
|
||||
const [openedExternalWindow, setOpenedExternalWindow] = useState(false);
|
||||
|
||||
const revalidate = useCallback(
|
||||
async (signal?: AbortSignal) => {
|
||||
setLoading(true);
|
||||
try {
|
||||
const [accountsData, providersData] = await Promise.all([
|
||||
gqlService.gql({
|
||||
query: calendarAccountsQuery,
|
||||
context: { signal },
|
||||
}),
|
||||
gqlService.gql({
|
||||
query: calendarProvidersQuery,
|
||||
context: { signal },
|
||||
}),
|
||||
]);
|
||||
setAccounts(accountsData.currentUser?.calendarAccounts ?? []);
|
||||
setProviders(providersData.serverConfig.calendarProviders ?? []);
|
||||
} catch (error) {
|
||||
if (
|
||||
signal?.aborted ||
|
||||
(error instanceof UserFriendlyError && error.is('REQUEST_ABORTED'))
|
||||
) {
|
||||
return;
|
||||
}
|
||||
|
||||
notify.error({
|
||||
title: 'Failed to load calendar accounts',
|
||||
message: String(error) || undefined,
|
||||
});
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
},
|
||||
[gqlService]
|
||||
const [caldavDialogOpen, setCaldavDialogOpen] = useState(false);
|
||||
const makeConfig: <Query extends GraphQLQuery>(
|
||||
title: string
|
||||
) => UseQueryConfig<Query> = useCallback(
|
||||
title => ({
|
||||
suspense: false,
|
||||
revalidateOnFocus: openedExternalWindow,
|
||||
onError: error => {
|
||||
notify.error({ title, message: String(error) || undefined });
|
||||
},
|
||||
}),
|
||||
[openedExternalWindow]
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
const controller = new AbortController();
|
||||
revalidate(controller.signal).catch(() => undefined);
|
||||
return () => controller.abort();
|
||||
}, [revalidate]);
|
||||
const {
|
||||
data: accountsData,
|
||||
isLoading: accountsLoading,
|
||||
mutate: mutateAccounts,
|
||||
} = useQuery(
|
||||
{ query: calendarAccountsQuery },
|
||||
useMemo(
|
||||
() =>
|
||||
makeConfig(t['com.affine.integration.calendar.account.load-error']()),
|
||||
[makeConfig, t]
|
||||
)
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
if (!openedExternalWindow) return;
|
||||
const handleFocus = () => {
|
||||
revalidate().catch(() => undefined);
|
||||
};
|
||||
window.addEventListener('focus', handleFocus);
|
||||
return () => window.removeEventListener('focus', handleFocus);
|
||||
}, [openedExternalWindow, revalidate]);
|
||||
const {
|
||||
data: providersData,
|
||||
isLoading: providersLoading,
|
||||
mutate: mutateProviders,
|
||||
} = useQuery(
|
||||
{ query: calendarProvidersQuery },
|
||||
|
||||
useMemo(
|
||||
() =>
|
||||
makeConfig(t['com.affine.integration.calendar.provider.load-error']()),
|
||||
[makeConfig, t]
|
||||
)
|
||||
);
|
||||
|
||||
const accounts: CalendarAccount[] =
|
||||
accountsData?.currentUser?.calendarAccounts ?? [];
|
||||
const providers = useMemo(
|
||||
() => providersData?.serverConfig.calendarProviders ?? [],
|
||||
[providersData]
|
||||
);
|
||||
const caldavProviders =
|
||||
providersData?.serverConfig.calendarCalDAVProviders ?? [];
|
||||
const loading = accountsLoading || providersLoading;
|
||||
|
||||
const providerOptions = useMemo(() => {
|
||||
return providers.map(provider => {
|
||||
@@ -117,6 +397,11 @@ export const IntegrationsPanel = () => {
|
||||
|
||||
const handleLink = useCallback(
|
||||
async (provider: CalendarProviderType) => {
|
||||
if (provider === CalendarProviderType.CalDAV) {
|
||||
setCaldavDialogOpen(true);
|
||||
return;
|
||||
}
|
||||
|
||||
setLinking(true);
|
||||
try {
|
||||
const data = await gqlService.gql({
|
||||
@@ -131,12 +416,14 @@ export const IntegrationsPanel = () => {
|
||||
urlService.openExternal(data.linkCalendarAccount);
|
||||
setOpenedExternalWindow(true);
|
||||
} catch (error) {
|
||||
notify.error({ title: 'Failed to start calendar authorization' });
|
||||
notify.error({
|
||||
title: t['com.affine.integration.calendar.auth.start-error'](),
|
||||
});
|
||||
} finally {
|
||||
setLinking(false);
|
||||
}
|
||||
},
|
||||
[gqlService, urlService]
|
||||
[gqlService, t, urlService]
|
||||
);
|
||||
|
||||
const handleUnlink = useCallback(
|
||||
@@ -149,104 +436,147 @@ export const IntegrationsPanel = () => {
|
||||
accountId,
|
||||
},
|
||||
});
|
||||
setAccounts(prev => prev.filter(account => account.id !== accountId));
|
||||
await mutateAccounts(
|
||||
current => {
|
||||
if (!current?.currentUser) {
|
||||
return current;
|
||||
}
|
||||
return {
|
||||
...current,
|
||||
currentUser: {
|
||||
...current.currentUser,
|
||||
calendarAccounts: current.currentUser.calendarAccounts.filter(
|
||||
account => account.id !== accountId
|
||||
),
|
||||
},
|
||||
};
|
||||
},
|
||||
{
|
||||
revalidate: false,
|
||||
}
|
||||
);
|
||||
} catch (error) {
|
||||
notify.error({ title: 'Failed to unlink calendar account' });
|
||||
notify.error({
|
||||
title: t['com.affine.integration.calendar.account.unlink-error'](),
|
||||
});
|
||||
} finally {
|
||||
setUnlinkingAccountId(null);
|
||||
}
|
||||
},
|
||||
[gqlService]
|
||||
[gqlService, mutateAccounts, t]
|
||||
);
|
||||
|
||||
return (
|
||||
<CollapsibleWrapper
|
||||
title={t['com.affine.integration.integrations']()}
|
||||
caption={t['com.affine.integration.setting.description']()}
|
||||
>
|
||||
<div className={styles.panel}>
|
||||
<div className={styles.panelHeader}>
|
||||
<div className={styles.panelTitle}>
|
||||
<TodayIcon />
|
||||
<span>{t['com.affine.integration.calendar.name']()}</span>
|
||||
</div>
|
||||
{providerOptions.length ? (
|
||||
<Menu
|
||||
items={providerOptions.map(option => (
|
||||
<MenuItem
|
||||
key={option.provider}
|
||||
prefixIcon={option.icon}
|
||||
onSelect={() => void handleLink(option.provider)}
|
||||
>
|
||||
{option.label}
|
||||
</MenuItem>
|
||||
))}
|
||||
contentOptions={{ align: 'end' }}
|
||||
>
|
||||
<Button variant="primary" loading={linking}>
|
||||
Link
|
||||
<>
|
||||
<CalDAVLinkDialog
|
||||
open={caldavDialogOpen}
|
||||
providers={caldavProviders}
|
||||
onClose={() => setCaldavDialogOpen(false)}
|
||||
onLinked={() => {
|
||||
void Promise.all([mutateAccounts(), mutateProviders()]).catch(
|
||||
() => undefined
|
||||
);
|
||||
}}
|
||||
/>
|
||||
<CollapsibleWrapper
|
||||
title={t['com.affine.integration.integrations']()}
|
||||
caption={t['com.affine.integration.setting.description']()}
|
||||
>
|
||||
<div className={styles.panel}>
|
||||
<div className={styles.panelHeader}>
|
||||
<div className={styles.panelTitle}>
|
||||
<TodayIcon />
|
||||
<span>{t['com.affine.integration.calendar.name']()}</span>
|
||||
</div>
|
||||
{providerOptions.length ? (
|
||||
<Menu
|
||||
items={providerOptions.map(option => (
|
||||
<MenuItem
|
||||
key={option.provider}
|
||||
prefixIcon={option.icon}
|
||||
onSelect={() => void handleLink(option.provider)}
|
||||
>
|
||||
{option.label}
|
||||
</MenuItem>
|
||||
))}
|
||||
contentOptions={{ align: 'end' }}
|
||||
>
|
||||
<Button variant="primary" loading={linking}>
|
||||
{t['com.affine.integration.calendar.account.link']()}
|
||||
</Button>
|
||||
</Menu>
|
||||
) : (
|
||||
<Button variant="primary" disabled>
|
||||
{t['com.affine.integration.calendar.account.link']()}
|
||||
</Button>
|
||||
</Menu>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{loading ? (
|
||||
<div className={styles.loading}>
|
||||
<Loading size={20} />
|
||||
</div>
|
||||
) : accounts.length ? (
|
||||
<div className={styles.accountList}>
|
||||
{accounts.map(account => {
|
||||
const meta = providerMeta[account.provider];
|
||||
const title =
|
||||
account.displayName ?? account.email ?? account.id;
|
||||
const subtitle = account.displayName ? account.email : null;
|
||||
const showStatus =
|
||||
account.status !== 'active' || Boolean(account.lastError);
|
||||
const statusMessage = account.lastError
|
||||
? t['com.affine.integration.calendar.account.status.failed']({
|
||||
error: account.lastError,
|
||||
})
|
||||
: t[
|
||||
'com.affine.integration.calendar.account.status.failed-reconnect'
|
||||
]();
|
||||
|
||||
return (
|
||||
<div key={account.id} className={styles.accountRow}>
|
||||
<div className={styles.accountInfo}>
|
||||
<div className={styles.accountIcon}>
|
||||
{meta?.icon ?? <LinkIcon />}
|
||||
</div>
|
||||
<div className={styles.accountDetails}>
|
||||
<div className={styles.accountName}>{title}</div>
|
||||
<div className={styles.accountMeta}>
|
||||
{subtitle ? <span>{subtitle}</span> : null}
|
||||
<span>
|
||||
{t['com.affine.integration.calendar.account.count'](
|
||||
{ count: String(account.calendarsCount) }
|
||||
)}
|
||||
</span>
|
||||
</div>
|
||||
{showStatus ? (
|
||||
<div className={styles.accountStatus}>
|
||||
<span className={styles.statusDot} />
|
||||
{statusMessage}
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
<div className={styles.accountActions}>
|
||||
<Button
|
||||
variant="error"
|
||||
disabled={unlinkingAccountId === account.id}
|
||||
onClick={() => void handleUnlink(account.id)}
|
||||
>
|
||||
{t['com.affine.integration.calendar.account.unlink']()}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
) : (
|
||||
<Button variant="primary" disabled>
|
||||
Link
|
||||
</Button>
|
||||
<div className={styles.empty}>
|
||||
{t['com.affine.integration.calendar.account.linked-empty']()}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{loading ? (
|
||||
<div className={styles.loading}>
|
||||
<Loading size={20} />
|
||||
</div>
|
||||
) : accounts.length ? (
|
||||
<div className={styles.accountList}>
|
||||
{accounts.map(account => {
|
||||
const meta = providerMeta[account.provider];
|
||||
const title = account.displayName ?? account.email ?? account.id;
|
||||
const subtitle = account.displayName ? account.email : null;
|
||||
const showStatus =
|
||||
account.status !== 'active' || Boolean(account.lastError);
|
||||
|
||||
return (
|
||||
<div key={account.id} className={styles.accountRow}>
|
||||
<div className={styles.accountInfo}>
|
||||
<div className={styles.accountIcon}>
|
||||
{meta?.icon ?? <LinkIcon />}
|
||||
</div>
|
||||
<div className={styles.accountDetails}>
|
||||
<div className={styles.accountName}>{title}</div>
|
||||
<div className={styles.accountMeta}>
|
||||
{subtitle ? <span>{subtitle}</span> : null}
|
||||
<span>
|
||||
{account.calendarsCount} calendar
|
||||
{account.calendarsCount === 1 ? '' : 's'}
|
||||
</span>
|
||||
</div>
|
||||
{showStatus ? (
|
||||
<div className={styles.accountStatus}>
|
||||
<span className={styles.statusDot} />
|
||||
Authorization failed. Please reconnect your account.
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
<div className={styles.accountActions}>
|
||||
<Button
|
||||
variant="error"
|
||||
disabled={unlinkingAccountId === account.id}
|
||||
onClick={() => void handleUnlink(account.id)}
|
||||
>
|
||||
Unlink
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
) : (
|
||||
<div className={styles.empty}>No calendar accounts linked yet.</div>
|
||||
)}
|
||||
</div>
|
||||
</CollapsibleWrapper>
|
||||
</CollapsibleWrapper>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -1,27 +1,27 @@
|
||||
{
|
||||
"ar": 98,
|
||||
"ca": 99,
|
||||
"ar": 97,
|
||||
"ca": 98,
|
||||
"da": 4,
|
||||
"de": 99,
|
||||
"el-GR": 98,
|
||||
"de": 98,
|
||||
"el-GR": 97,
|
||||
"en": 100,
|
||||
"es-AR": 98,
|
||||
"es-CL": 99,
|
||||
"es": 98,
|
||||
"fa": 98,
|
||||
"fr": 99,
|
||||
"es-AR": 97,
|
||||
"es-CL": 98,
|
||||
"es": 97,
|
||||
"fa": 97,
|
||||
"fr": 98,
|
||||
"hi": 2,
|
||||
"it-IT": 99,
|
||||
"it-IT": 98,
|
||||
"it": 1,
|
||||
"ja": 98,
|
||||
"ko": 99,
|
||||
"ja": 97,
|
||||
"ko": 98,
|
||||
"nb-NO": 48,
|
||||
"pl": 100,
|
||||
"pt-BR": 98,
|
||||
"ru": 99,
|
||||
"sv-SE": 98,
|
||||
"uk": 98,
|
||||
"pl": 98,
|
||||
"pt-BR": 97,
|
||||
"ru": 98,
|
||||
"sv-SE": 97,
|
||||
"uk": 97,
|
||||
"ur": 2,
|
||||
"zh-Hans": 100,
|
||||
"zh-Hant": 98
|
||||
"zh-Hans": 99,
|
||||
"zh-Hant": 97
|
||||
}
|
||||
|
||||
@@ -8281,6 +8281,114 @@ export function useAFFiNEI18N(): {
|
||||
* `All day`
|
||||
*/
|
||||
["com.affine.integration.calendar.all-day"](): string;
|
||||
/**
|
||||
* `Failed to load calendar accounts`
|
||||
*/
|
||||
["com.affine.integration.calendar.account.load-error"](): string;
|
||||
/**
|
||||
* `Failed to load calendar providers`
|
||||
*/
|
||||
["com.affine.integration.calendar.provider.load-error"](): string;
|
||||
/**
|
||||
* `Failed to start calendar authorization`
|
||||
*/
|
||||
["com.affine.integration.calendar.auth.start-error"](): string;
|
||||
/**
|
||||
* `Failed to unlink calendar account`
|
||||
*/
|
||||
["com.affine.integration.calendar.account.unlink-error"](): string;
|
||||
/**
|
||||
* `Unlink`
|
||||
*/
|
||||
["com.affine.integration.calendar.account.unlink"](): string;
|
||||
/**
|
||||
* `Link`
|
||||
*/
|
||||
["com.affine.integration.calendar.account.link"](): string;
|
||||
/**
|
||||
* `No calendar accounts linked yet.`
|
||||
*/
|
||||
["com.affine.integration.calendar.account.linked-empty"](): string;
|
||||
/**
|
||||
* `Authorization failed: {{error}}`
|
||||
*/
|
||||
["com.affine.integration.calendar.account.status.failed"](options: {
|
||||
readonly error: string;
|
||||
}): string;
|
||||
/**
|
||||
* `Authorization failed. Please reconnect your account.`
|
||||
*/
|
||||
["com.affine.integration.calendar.account.status.failed-reconnect"](): string;
|
||||
/**
|
||||
* `{{count}} calendar`
|
||||
*/
|
||||
["com.affine.integration.calendar.account.count"](options: {
|
||||
readonly count: string;
|
||||
}): string;
|
||||
/**
|
||||
* `Link CalDAV account`
|
||||
*/
|
||||
["com.affine.integration.calendar.caldav.link.title"](): string;
|
||||
/**
|
||||
* `Failed to link CalDAV account`
|
||||
*/
|
||||
["com.affine.integration.calendar.caldav.link.failed"](): string;
|
||||
/**
|
||||
* `Provider`
|
||||
*/
|
||||
["com.affine.integration.calendar.caldav.field.provider"](): string;
|
||||
/**
|
||||
* `Select provider`
|
||||
*/
|
||||
["com.affine.integration.calendar.caldav.field.provider.placeholder"](): string;
|
||||
/**
|
||||
* `Please select a provider.`
|
||||
*/
|
||||
["com.affine.integration.calendar.caldav.field.provider.error"](): string;
|
||||
/**
|
||||
* `Username`
|
||||
*/
|
||||
["com.affine.integration.calendar.caldav.field.username"](): string;
|
||||
/**
|
||||
* `email@example.com`
|
||||
*/
|
||||
["com.affine.integration.calendar.caldav.field.username.placeholder"](): string;
|
||||
/**
|
||||
* `Username is required.`
|
||||
*/
|
||||
["com.affine.integration.calendar.caldav.field.username.error"](): string;
|
||||
/**
|
||||
* `Password`
|
||||
*/
|
||||
["com.affine.integration.calendar.caldav.field.password"](): string;
|
||||
/**
|
||||
* `Password or app-specific password`
|
||||
*/
|
||||
["com.affine.integration.calendar.caldav.field.password.placeholder"](): string;
|
||||
/**
|
||||
* `Password is required.`
|
||||
*/
|
||||
["com.affine.integration.calendar.caldav.field.password.error"](): string;
|
||||
/**
|
||||
* `Display name (optional)`
|
||||
*/
|
||||
["com.affine.integration.calendar.caldav.field.displayName"](): string;
|
||||
/**
|
||||
* `My CalDAV`
|
||||
*/
|
||||
["com.affine.integration.calendar.caldav.field.displayName.placeholder"](): string;
|
||||
/**
|
||||
* `App-specific password required.`
|
||||
*/
|
||||
["com.affine.integration.calendar.caldav.hint.app-password"](): string;
|
||||
/**
|
||||
* `Learn more`
|
||||
*/
|
||||
["com.affine.integration.calendar.caldav.hint.learn-more"](): string;
|
||||
/**
|
||||
* `Provider setup guide`
|
||||
*/
|
||||
["com.affine.integration.calendar.caldav.hint.guide"](): string;
|
||||
/**
|
||||
* `New doc`
|
||||
*/
|
||||
|
||||
@@ -2078,6 +2078,32 @@
|
||||
"com.affine.integration.calendar.new-url-label": "Calendar URL",
|
||||
"com.affine.integration.calendar.save-error": "An error occurred while saving the calendar settings",
|
||||
"com.affine.integration.calendar.all-day": "All day",
|
||||
"com.affine.integration.calendar.account.load-error": "Failed to load calendar accounts",
|
||||
"com.affine.integration.calendar.provider.load-error": "Failed to load calendar providers",
|
||||
"com.affine.integration.calendar.auth.start-error": "Failed to start calendar authorization",
|
||||
"com.affine.integration.calendar.account.unlink-error": "Failed to unlink calendar account",
|
||||
"com.affine.integration.calendar.account.unlink": "Unlink",
|
||||
"com.affine.integration.calendar.account.link": "Link",
|
||||
"com.affine.integration.calendar.account.linked-empty": "No calendar accounts linked yet.",
|
||||
"com.affine.integration.calendar.account.status.failed": "Authorization failed: {{error}}",
|
||||
"com.affine.integration.calendar.account.status.failed-reconnect": "Authorization failed. Please reconnect your account.",
|
||||
"com.affine.integration.calendar.account.count": "{{count}} calendar",
|
||||
"com.affine.integration.calendar.caldav.link.title": "Link CalDAV account",
|
||||
"com.affine.integration.calendar.caldav.link.failed": "Failed to link CalDAV account",
|
||||
"com.affine.integration.calendar.caldav.field.provider": "Provider",
|
||||
"com.affine.integration.calendar.caldav.field.provider.placeholder": "Select provider",
|
||||
"com.affine.integration.calendar.caldav.field.provider.error": "Please select a provider.",
|
||||
"com.affine.integration.calendar.caldav.field.username": "Username",
|
||||
"com.affine.integration.calendar.caldav.field.username.placeholder": "email@example.com",
|
||||
"com.affine.integration.calendar.caldav.field.username.error": "Username is required.",
|
||||
"com.affine.integration.calendar.caldav.field.password": "Password",
|
||||
"com.affine.integration.calendar.caldav.field.password.placeholder": "Password or app-specific password",
|
||||
"com.affine.integration.calendar.caldav.field.password.error": "Password is required.",
|
||||
"com.affine.integration.calendar.caldav.field.displayName": "Display name (optional)",
|
||||
"com.affine.integration.calendar.caldav.field.displayName.placeholder": "My CalDAV",
|
||||
"com.affine.integration.calendar.caldav.hint.app-password": "App-specific password required.",
|
||||
"com.affine.integration.calendar.caldav.hint.learn-more": "Learn more",
|
||||
"com.affine.integration.calendar.caldav.hint.guide": "Provider setup guide",
|
||||
"com.affine.integration.calendar.new-doc": "New doc",
|
||||
"com.affine.integration.calendar.show-events": "Show calendar events",
|
||||
"com.affine.integration.calendar.show-events-desc": "Enabling this setting allows you to connect your calendar events to your Journal in AFFiNE",
|
||||
|
||||
Reference in New Issue
Block a user