feat(mobile): jouranl daily activity and conflict operations (#8779)

close AF-1662
This commit is contained in:
CatsJuice
2024-11-11 09:31:31 +00:00
parent 50a04f6443
commit c4e65c754e
10 changed files with 353 additions and 6 deletions

View File

@@ -168,7 +168,7 @@ export const EditorJournalPanel = () => {
);
};
const sortPagesByDate = (
export const sortPagesByDate = (
docs: DocRecord[],
field: 'updatedDate' | 'createDate',
order: 'asc' | 'desc' = 'desc'

View File

@@ -0,0 +1,19 @@
import { cssVar } from '@toeverything/theme';
import { cssVarV2 } from '@toeverything/theme/v2';
import { style } from '@vanilla-extract/css';
export const docItem = style({
display: 'flex',
alignItems: 'center',
gap: 8,
});
export const duplicateTag = style({
borderRadius: 4,
padding: '0 8px',
fontSize: cssVar('fontXs'),
lineHeight: '20px',
color: cssVarV2('toast/iconState/error'),
background: cssVarV2('layer/background/error'),
border: `1px solid ${cssVarV2('database/border')}`,
});

View File

@@ -0,0 +1,154 @@
import {
IconButton,
MobileMenu,
MobileMenuItem,
MobileMenuSub,
useConfirmModal,
} from '@affine/component';
import { MoveToTrash } from '@affine/core/components/page-list';
import { DocDisplayMetaService } from '@affine/core/modules/doc-display-meta';
import { JournalService } from '@affine/core/modules/journal';
import { WorkbenchLink } from '@affine/core/modules/workbench';
import { useI18n } from '@affine/i18n';
import { CalendarXmarkIcon, EditIcon, TodayIcon } from '@blocksuite/icons/rc';
import type { DocRecord } from '@toeverything/infra';
import {
DocService,
DocsService,
useLiveData,
useService,
} from '@toeverything/infra';
import { type MouseEvent, useCallback, useMemo } from 'react';
import * as styles from './journal-conflicts.css';
const ResolveConflictOperations = ({ docRecord }: { docRecord: DocRecord }) => {
const t = useI18n();
const journalService = useService(JournalService);
const { openConfirmModal } = useConfirmModal();
const handleOpenTrashModal = useCallback(
(docRecord: DocRecord) => {
openConfirmModal({
title: t['com.affine.moveToTrash.confirmModal.title'](),
description: t['com.affine.moveToTrash.confirmModal.description']({
title: docRecord.title$.value || t['Untitled'](),
}),
cancelText: t['com.affine.confirmModal.button.cancel'](),
confirmText: t.Delete(),
onConfirm: () => {
docRecord.moveToTrash();
},
});
},
[openConfirmModal, t]
);
const handleRemoveJournalMark = useCallback(
(docId: string) => {
journalService.removeJournalDate(docId);
},
[journalService]
);
return (
<>
<MobileMenuItem
prefixIcon={<CalendarXmarkIcon />}
onClick={() => {
handleRemoveJournalMark(docRecord.id);
}}
data-testid="journal-conflict-remove-mark"
>
{t['com.affine.page-properties.property.journal-remove']()}
</MobileMenuItem>
<MoveToTrash onSelect={() => handleOpenTrashModal(docRecord)} />
</>
);
};
const preventNav = (e: MouseEvent<HTMLButtonElement>) => {
e.stopPropagation();
e.preventDefault();
};
const DocItem = ({ docRecord }: { docRecord: DocRecord }) => {
const docId = docRecord.id;
const i18n = useI18n();
const docDisplayMetaService = useService(DocDisplayMetaService);
const Icon = useLiveData(
docDisplayMetaService.icon$(docId, { compareDate: new Date() })
);
const titleMeta = useLiveData(docDisplayMetaService.title$(docId));
const title = i18n.t(titleMeta);
return (
<WorkbenchLink aria-label={title} to={`/${docId}`}>
<MobileMenuItem
prefixIcon={<Icon />}
suffix={
<MobileMenu
items={<ResolveConflictOperations docRecord={docRecord} />}
>
<IconButton onClick={preventNav} icon={<EditIcon />} />
</MobileMenu>
}
>
<div className={styles.docItem}>
{title}
<div className={styles.duplicateTag}>
{i18n['com.affine.page-properties.property.journal-duplicated']()}
</div>
</div>
</MobileMenuItem>
</WorkbenchLink>
);
};
const ConflictList = ({ docRecords }: { docRecords: DocRecord[] }) => {
return docRecords.map(docRecord => (
<DocItem key={docRecord.id} docRecord={docRecord} />
));
};
const ConflictListMenuItem = ({ docRecords }: { docRecords: DocRecord[] }) => {
const t = useI18n();
return (
<MobileMenuSub
triggerOptions={{
prefixIcon: <TodayIcon />,
type: 'danger',
}}
items={<ConflictList docRecords={docRecords} />}
>
{t['com.affine.m.selector.journal-menu.conflicts']()}
</MobileMenuSub>
);
};
const JournalConflictsChecker = ({ date }: { date: string }) => {
const docRecordList = useService(DocsService).list;
const journalService = useService(JournalService);
const docs = useLiveData(
useMemo(() => journalService.journalsByDate$(date), [journalService, date])
);
const docRecords = useLiveData(
docRecordList.docs$.map(records =>
records.filter(v => {
return docs.some(doc => doc.id === v.id);
})
)
);
if (docRecords.length <= 1) return null;
return <ConflictListMenuItem docRecords={docRecords} />;
};
export const JournalConflictsMenuItem = () => {
const journalService = useService(JournalService);
const docId = useService(DocService).doc.id;
const journalDate = useLiveData(journalService.journalDate$(docId));
if (!journalDate) return null;
return <JournalConflictsChecker date={journalDate} />;
};

View File

@@ -0,0 +1,18 @@
import { bodyEmphasized, bodyRegular } from '@toeverything/theme/typography';
import { cssVarV2 } from '@toeverything/theme/v2';
import { style } from '@vanilla-extract/css';
export const title = style([
bodyEmphasized,
{
padding: '11px 20px',
},
]);
export const empty = style([
bodyRegular,
{
padding: '11px 20px',
color: cssVarV2('text/placeholder'),
},
]);

View File

@@ -0,0 +1,135 @@
import { MenuItem, MenuSeparator, MobileMenuSub } from '@affine/component';
import { sortPagesByDate } from '@affine/core/desktop/pages/workspace/detail-page/tabs/journal';
import { DocDisplayMetaService } from '@affine/core/modules/doc-display-meta';
import { JournalService } from '@affine/core/modules/journal';
import { WorkbenchLink } from '@affine/core/modules/workbench';
import { useI18n } from '@affine/i18n';
import { HistoryIcon } from '@blocksuite/icons/rc';
import type { DocRecord } from '@toeverything/infra';
import {
DocService,
DocsService,
useLiveData,
useService,
} from '@toeverything/infra';
import dayjs from 'dayjs';
import { type ReactNode, useCallback, useMemo } from 'react';
import * as styles from './journal-today-activity.css';
interface JournalTodayActivityMenuItemProps {
prefix?: ReactNode;
suffix?: ReactNode;
}
type Category = 'created' | 'updated';
const DocItem = ({ docId }: { docId: string }) => {
const i18n = useI18n();
const docDisplayMetaService = useService(DocDisplayMetaService);
const Icon = useLiveData(
docDisplayMetaService.icon$(docId, { compareDate: new Date() })
);
const titleMeta = useLiveData(docDisplayMetaService.title$(docId));
const title = i18n.t(titleMeta);
return (
<WorkbenchLink aria-label={title} to={`/${docId}`}>
<MenuItem prefixIcon={<Icon />}>{title}</MenuItem>
</WorkbenchLink>
);
};
const ActivityBlock = ({
name,
list,
}: {
name: Category;
list: DocRecord[];
}) => {
const t = useI18n();
const title =
name === 'created'
? t['com.affine.journal.created-today']()
: t['com.affine.journal.updated-today']();
return (
<>
<div className={styles.title}>{title}</div>
{list.length > 0 ? (
list.map(doc => {
return <DocItem docId={doc.id} key={doc.id} />;
})
) : (
<div className={styles.empty}>
{name === 'created'
? t['com.affine.journal.daily-count-created-empty-tips']()
: t['com.affine.journal.daily-count-updated-empty-tips']()}
</div>
)}
</>
);
};
const TodaysActivity = ({ date }: { date: string }) => {
const docRecords = useLiveData(useService(DocsService).list.docs$);
const getTodaysPages = useCallback(
(field: 'createDate' | 'updatedDate') => {
return sortPagesByDate(
docRecords.filter(docRecord => {
const meta = docRecord.meta$.value;
if (meta.trash) return false;
return meta[field] && dayjs(meta[field]).isSame(date, 'day');
}),
field
);
},
[date, docRecords]
);
const createdToday = useMemo(
() => getTodaysPages('createDate'),
[getTodaysPages]
);
const updatedToday = useMemo(
() => getTodaysPages('updatedDate'),
[getTodaysPages]
);
return (
<>
<ActivityBlock name="created" list={createdToday} />
<MenuSeparator />
<ActivityBlock name="updated" list={updatedToday} />
</>
);
};
export const JournalTodayActivityMenuItem = ({
prefix,
suffix,
}: JournalTodayActivityMenuItemProps) => {
const docService = useService(DocService);
const journalService = useService(JournalService);
const docId = docService.doc.id;
const journalDate = useLiveData(journalService.journalDate$(docId));
const t = useI18n();
if (!journalDate) return null;
return (
<>
{prefix}
<MobileMenuSub
triggerOptions={{
prefixIcon: <HistoryIcon />,
}}
items={<TodaysActivity date={journalDate} />}
title={t['com.affine.m.selector.journal-menu.today-activity']()}
>
{t['com.affine.m.selector.journal-menu.today-activity']()}
</MobileMenuSub>
{suffix}
</>
);
};

View File

@@ -22,6 +22,8 @@ import {
import { DocService, useLiveData, useService } from '@toeverything/infra';
import { useCallback, useEffect, useState } from 'react';
import { JournalConflictsMenuItem } from './menu/journal-conflicts';
import { JournalTodayActivityMenuItem } from './menu/journal-today-activity';
import * as styles from './page-header-more-button.css';
import { DocInfoSheet } from './sheets/doc-info';
@@ -82,6 +84,7 @@ export const PageHeaderMenuButton = () => {
const EditMenu = (
<>
<JournalTodayActivityMenuItem suffix={<MenuSeparator />} />
<MobileMenuItem
prefixIcon={primaryMode === 'page' ? <EdgelessIcon /> : <PageIcon />}
data-testid="editor-option-menu-mode-switch"
@@ -120,6 +123,7 @@ export const PageHeaderMenuButton = () => {
<span>{t['com.affine.header.option.view-toc']()}</span>
</MobileMenuItem>
</MobileMenu>
<JournalConflictsMenuItem />
</>
);
if (isInTrash) {