feat(core): events list in journal calendar (#11873)

close AF-2505

<!-- This is an auto-generated comment: release notes by coderabbit.ai -->
## Summary by CodeRabbit

- **New Features**
  - Introduced calendar event integration in the journal detail page, allowing users to view and interact with calendar events for a selected date.
  - Added the ability to create new linked documents from calendar events directly within the journal interface.
- **Improvements**
  - Enhanced calendar integration with support for all-day and timed events, improved event grouping by date, and real-time updates.
  - Added new English localization entries for calendar event labels.
- **Bug Fixes**
  - Improved URL handling for calendar subscriptions, ensuring better compatibility and error feedback.
- **Style**
  - Added new styles for calendar event components to ensure a consistent and user-friendly appearance.
<!-- end of auto-generated comment: release notes by coderabbit.ai -->
This commit is contained in:
CatsJuice
2025-04-25 02:37:51 +00:00
parent 4b7fddc32c
commit f5ac0aee97
11 changed files with 361 additions and 18 deletions
@@ -0,0 +1,71 @@
import { cssVarV2 } from '@toeverything/theme/v2';
import { createVar, style } from '@vanilla-extract/css';
export const primaryColor = createVar('calendar-event-primary');
export const list = style({
display: 'flex',
flexDirection: 'column',
gap: 2,
padding: '0px 16px 10px 16px',
});
export const event = style({
display: 'flex',
gap: 8,
alignItems: 'center',
padding: '5px 4px',
borderRadius: 4,
cursor: 'pointer',
selectors: {
'&:hover': {
backgroundColor: cssVarV2.layer.background.hoverOverlay,
},
},
});
export const eventIcon = style({
width: 20,
height: 20,
color: primaryColor,
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
fontSize: 20,
});
export const eventTitle = style({
width: 0,
flex: 1,
overflow: 'hidden',
textOverflow: 'ellipsis',
whiteSpace: 'nowrap',
fontSize: 14,
fontWeight: 400,
lineHeight: '22px',
color: cssVarV2.text.primary,
});
export const eventCaption = style({
fontSize: 12,
lineHeight: '20px',
color: cssVarV2.text.secondary,
});
export const eventTime = style({
display: 'flex',
selectors: {
[`${event}:hover &`]: {
display: 'none',
},
},
});
export const eventNewDoc = style({
display: 'none',
gap: 4,
selectors: {
[`${event}:hover &`]: {
display: 'flex',
},
},
});
@@ -0,0 +1,140 @@
import { Loading, toast } from '@affine/component';
import { usePageHelper } from '@affine/core/blocksuite/block-suite-page-list/utils';
import { useAsyncCallback } from '@affine/core/components/hooks/affine-async-hooks';
import { DocsService } from '@affine/core/modules/doc';
import {
type CalendarEvent,
IntegrationService,
} from '@affine/core/modules/integration';
import { JournalService } from '@affine/core/modules/journal';
import { GuardService } from '@affine/core/modules/permissions';
import { WorkspaceService } from '@affine/core/modules/workspace';
import { useI18n } from '@affine/i18n';
import { FullDayIcon, PeriodIcon, PlusIcon } from '@blocksuite/icons/rc';
import { useLiveData, useService } from '@toeverything/infra';
import { cssVarV2 } from '@toeverything/theme/v2';
import { assignInlineVars } from '@vanilla-extract/dynamic';
import type { Dayjs } from 'dayjs';
import type ICAL from 'ical.js';
import { useEffect, useMemo, useState } from 'react';
import * as styles from './calendar-events.css';
const pad = (val?: number) => (val ?? 0).toString().padStart(2, '0');
function formatTime(start?: ICAL.Time, end?: ICAL.Time) {
const from = `${pad(start?.hour)}:${pad(start?.minute)}`;
const to = `${pad(end?.hour)}:${pad(end?.minute)}`;
return from === to ? from : `${from} - ${to}`;
}
export const CalendarEvents = ({ date }: { date: Dayjs }) => {
const calendar = useService(IntegrationService).calendar;
const events = useLiveData(
useMemo(() => calendar.eventsByDate$(date), [calendar, date])
);
useEffect(() => {
const update = () => {
calendar.subscriptions$.value.forEach(sub => sub.update());
};
update();
const interval = setInterval(update, 5 * 60 * 1000);
return () => clearInterval(interval);
}, [calendar]);
return (
<ul className={styles.list}>
{events.map(event => (
<CalendarEventRenderer key={event.id} event={event} />
))}
</ul>
);
};
const CalendarEventRenderer = ({ event }: { event: CalendarEvent }) => {
const t = useI18n();
const { url, title, startAt, endAt, allDay, date } = event;
const [loading, setLoading] = useState(false);
const calendar = useService(IntegrationService).calendar;
const docsService = useService(DocsService);
const guardService = useService(GuardService);
const journalService = useService(JournalService);
const workspaceService = useService(WorkspaceService);
const { createPage } = usePageHelper(
workspaceService.workspace.docCollection
);
const subscription = useLiveData(
useMemo(() => calendar.subscription$(url), [calendar, url])
);
const config = useLiveData(
useMemo(() => subscription?.config$, [subscription?.config$])
);
const color = config?.color || cssVarV2.button.primary;
const handleClick = useAsyncCallback(async () => {
if (!date || loading) return;
const docs = journalService.journalsByDate$(
date.format('YYYY-MM-DD')
).value;
if (docs.length === 0) return;
setLoading(true);
try {
for (const doc of docs) {
const canEdit = await guardService.can('Doc_Update', doc.id);
if (!canEdit) {
toast(t['com.affine.no-permission']());
continue;
}
const newDoc = createPage();
await docsService.changeDocTitle(newDoc.id, title);
await docsService.addLinkedDoc(doc.id, newDoc.id);
}
} finally {
setLoading(false);
}
}, [
createPage,
date,
docsService,
guardService,
journalService,
loading,
t,
title,
]);
return (
<li
style={assignInlineVars({
[styles.primaryColor]: color,
})}
className={styles.event}
data-all-day={allDay}
onClick={handleClick}
>
<div className={styles.eventIcon}>
{allDay ? <FullDayIcon /> : <PeriodIcon />}
</div>
<div className={styles.eventTitle}>{title}</div>
{loading ? (
<Loading />
) : (
<div className={styles.eventCaption}>
<span className={styles.eventTime}>
{allDay
? t['com.affine.integration.calendar.all-day']()
: formatTime(startAt, endAt)}
</span>
<span className={styles.eventNewDoc}>
<PlusIcon style={{ fontSize: 18 }} />
{t['com.affine.integration.calendar.new-doc']()}
</span>
</div>
)}
</li>
);
};
@@ -32,6 +32,7 @@ import dayjs from 'dayjs';
import type { HTMLAttributes, PropsWithChildren, ReactNode } from 'react';
import { useCallback, useMemo, useRef, useState } from 'react';
import { CalendarEvents } from './calendar-events';
import * as styles from './journal.css';
import { JournalTemplateOnboarding } from './template-onboarding';
import { JournalTemplateSetting } from './template-setting';
@@ -166,6 +167,7 @@ export const EditorJournalPanel = () => {
{journalDate ? (
<>
<JournalConflictBlock date={journalDate} />
<CalendarEvents date={journalDate} />
<JournalDailyCountBlock date={journalDate} />
</>
) : (