mirror of
https://github.com/toeverything/AFFiNE.git
synced 2026-02-13 21:05:19 +00:00
feat(mobile): jouranl daily activity and conflict operations (#8779)
close AF-1662
This commit is contained in:
@@ -168,7 +168,7 @@ export const EditorJournalPanel = () => {
|
||||
);
|
||||
};
|
||||
|
||||
const sortPagesByDate = (
|
||||
export const sortPagesByDate = (
|
||||
docs: DocRecord[],
|
||||
field: 'updatedDate' | 'createDate',
|
||||
order: 'asc' | 'desc' = 'desc'
|
||||
|
||||
@@ -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')}`,
|
||||
});
|
||||
@@ -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} />;
|
||||
};
|
||||
@@ -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'),
|
||||
},
|
||||
]);
|
||||
@@ -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}
|
||||
</>
|
||||
);
|
||||
};
|
||||
@@ -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) {
|
||||
|
||||
Reference in New Issue
Block a user