mirror of
https://github.com/toeverything/AFFiNE.git
synced 2026-08-23 04:51:49 +08:00
feat(mobile): jouranl daily activity and conflict operations (#8779)
close AF-1662
This commit is contained in:
@@ -7,6 +7,7 @@ import { useCallback, useContext, useEffect, useState } from 'react';
|
|||||||
import { observeResize } from '../../../utils';
|
import { observeResize } from '../../../utils';
|
||||||
import { Button } from '../../button';
|
import { Button } from '../../button';
|
||||||
import { Modal } from '../../modal';
|
import { Modal } from '../../modal';
|
||||||
|
import { Scrollable } from '../../scrollbar';
|
||||||
import type { MenuProps } from '../menu.types';
|
import type { MenuProps } from '../menu.types';
|
||||||
import type { SubMenuContent } from './context';
|
import type { SubMenuContent } from './context';
|
||||||
import { MobileMenuContext } from './context';
|
import { MobileMenuContext } from './context';
|
||||||
@@ -138,8 +139,12 @@ export const MobileMenu = ({
|
|||||||
>
|
>
|
||||||
{sub.title || t['com.affine.backButton']()}
|
{sub.title || t['com.affine.backButton']()}
|
||||||
</Button>
|
</Button>
|
||||||
|
<Scrollable.Root>
|
||||||
{sub.items}
|
<Scrollable.Viewport className={styles.scrollArea}>
|
||||||
|
{sub.items}
|
||||||
|
</Scrollable.Viewport>
|
||||||
|
<Scrollable.Scrollbar />
|
||||||
|
</Scrollable.Root>
|
||||||
</div>
|
</div>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -3,7 +3,7 @@ import { cssVarV2 } from '@toeverything/theme/v2';
|
|||||||
import { style } from '@vanilla-extract/css';
|
import { style } from '@vanilla-extract/css';
|
||||||
|
|
||||||
import { modalContent } from '../../modal/styles.css';
|
import { modalContent } from '../../modal/styles.css';
|
||||||
import { bgColor } from '../styles.css';
|
import { bgColor, iconColor, labelColor } from '../styles.css';
|
||||||
|
|
||||||
// To override desktop menu style defined in '../styles.css.ts'
|
// To override desktop menu style defined in '../styles.css.ts'
|
||||||
|
|
||||||
@@ -55,6 +55,12 @@ export const mobileMenuItem = style({
|
|||||||
},
|
},
|
||||||
},
|
},
|
||||||
selectors: {
|
selectors: {
|
||||||
|
'&.danger': {
|
||||||
|
vars: {
|
||||||
|
[labelColor]: cssVarV2('button/error'),
|
||||||
|
[iconColor]: cssVarV2('button/error'),
|
||||||
|
},
|
||||||
|
},
|
||||||
'&.danger:hover': {
|
'&.danger:hover': {
|
||||||
vars: { [bgColor]: 'transparent' },
|
vars: { [bgColor]: 'transparent' },
|
||||||
},
|
},
|
||||||
@@ -97,3 +103,7 @@ export const backButton = style({
|
|||||||
paddingLeft: 0,
|
paddingLeft: 0,
|
||||||
marginLeft: 20,
|
marginLeft: 20,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
export const scrollArea = style({
|
||||||
|
maxHeight: '80dvh',
|
||||||
|
});
|
||||||
|
|||||||
@@ -168,7 +168,7 @@ export const EditorJournalPanel = () => {
|
|||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
const sortPagesByDate = (
|
export const sortPagesByDate = (
|
||||||
docs: DocRecord[],
|
docs: DocRecord[],
|
||||||
field: 'updatedDate' | 'createDate',
|
field: 'updatedDate' | 'createDate',
|
||||||
order: 'asc' | 'desc' = 'desc'
|
order: 'asc' | 'desc' = 'desc'
|
||||||
|
|||||||
+19
@@ -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} />;
|
||||||
|
};
|
||||||
+18
@@ -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'),
|
||||||
|
},
|
||||||
|
]);
|
||||||
+135
@@ -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 { DocService, useLiveData, useService } from '@toeverything/infra';
|
||||||
import { useCallback, useEffect, useState } from 'react';
|
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 * as styles from './page-header-more-button.css';
|
||||||
import { DocInfoSheet } from './sheets/doc-info';
|
import { DocInfoSheet } from './sheets/doc-info';
|
||||||
|
|
||||||
@@ -82,6 +84,7 @@ export const PageHeaderMenuButton = () => {
|
|||||||
|
|
||||||
const EditMenu = (
|
const EditMenu = (
|
||||||
<>
|
<>
|
||||||
|
<JournalTodayActivityMenuItem suffix={<MenuSeparator />} />
|
||||||
<MobileMenuItem
|
<MobileMenuItem
|
||||||
prefixIcon={primaryMode === 'page' ? <EdgelessIcon /> : <PageIcon />}
|
prefixIcon={primaryMode === 'page' ? <EdgelessIcon /> : <PageIcon />}
|
||||||
data-testid="editor-option-menu-mode-switch"
|
data-testid="editor-option-menu-mode-switch"
|
||||||
@@ -120,6 +123,7 @@ export const PageHeaderMenuButton = () => {
|
|||||||
<span>{t['com.affine.header.option.view-toc']()}</span>
|
<span>{t['com.affine.header.option.view-toc']()}</span>
|
||||||
</MobileMenuItem>
|
</MobileMenuItem>
|
||||||
</MobileMenu>
|
</MobileMenu>
|
||||||
|
<JournalConflictsMenuItem />
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
if (isInTrash) {
|
if (isInTrash) {
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
{
|
{
|
||||||
"ar": 80,
|
"ar": 79,
|
||||||
"ca": 6,
|
"ca": 6,
|
||||||
"da": 6,
|
"da": 6,
|
||||||
"de": 30,
|
"de": 30,
|
||||||
|
|||||||
@@ -1391,5 +1391,7 @@
|
|||||||
"com.affine.m.selector.remove-warning.confirm": "Do not ask again",
|
"com.affine.m.selector.remove-warning.confirm": "Do not ask again",
|
||||||
"com.affine.m.selector.remove-warning.cancel": "Cancel",
|
"com.affine.m.selector.remove-warning.cancel": "Cancel",
|
||||||
"com.affine.m.selector.remove-warning.where-tag": "tag",
|
"com.affine.m.selector.remove-warning.where-tag": "tag",
|
||||||
"com.affine.m.selector.remove-warning.where-folder": "folder"
|
"com.affine.m.selector.remove-warning.where-folder": "folder",
|
||||||
|
"com.affine.m.selector.journal-menu.today-activity": "Today's activity",
|
||||||
|
"com.affine.m.selector.journal-menu.conflicts": "Duplicate Entries in Today's Journal"
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user