feat(core): add daily count for journal sidebar (#5559)
This commit is contained in:
@@ -1,5 +1,154 @@
|
|||||||
import { style } from '@vanilla-extract/css';
|
import { style } from '@vanilla-extract/css';
|
||||||
|
|
||||||
|
const interactive = style({
|
||||||
|
position: 'relative',
|
||||||
|
|
||||||
|
selectors: {
|
||||||
|
'&:hover': {
|
||||||
|
backgroundColor: 'var(--affine-hover-color)',
|
||||||
|
},
|
||||||
|
'&::before': {
|
||||||
|
content: '""',
|
||||||
|
position: 'absolute',
|
||||||
|
inset: 0,
|
||||||
|
opacity: 0,
|
||||||
|
borderRadius: 'inherit',
|
||||||
|
boxShadow: '0 0 0 3px var(--affine-primary-color)',
|
||||||
|
pointerEvents: 'none',
|
||||||
|
},
|
||||||
|
'&::after': {
|
||||||
|
content: '""',
|
||||||
|
position: 'absolute',
|
||||||
|
inset: 0,
|
||||||
|
borderRadius: 'inherit',
|
||||||
|
boxShadow: '0 0 0 0px var(--affine-primary-color)',
|
||||||
|
pointerEvents: 'none',
|
||||||
|
},
|
||||||
|
'&:focus-visible::before': {
|
||||||
|
opacity: 0.2,
|
||||||
|
},
|
||||||
|
'&:focus-visible::after': {
|
||||||
|
boxShadow: '0 0 0 1px var(--affine-primary-color)',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
export const calendar = style({
|
export const calendar = style({
|
||||||
padding: '16px',
|
padding: '16px',
|
||||||
});
|
});
|
||||||
|
|
||||||
|
export const journalPanel = style({
|
||||||
|
width: '100%',
|
||||||
|
height: '100%',
|
||||||
|
display: 'flex',
|
||||||
|
flexDirection: 'column',
|
||||||
|
alignItems: 'stretch',
|
||||||
|
});
|
||||||
|
|
||||||
|
export const dailyCount = style({
|
||||||
|
height: 0,
|
||||||
|
flexGrow: 1,
|
||||||
|
display: 'flex',
|
||||||
|
flexDirection: 'column',
|
||||||
|
gap: 8,
|
||||||
|
});
|
||||||
|
|
||||||
|
export const dailyCountHeader = style({
|
||||||
|
display: 'flex',
|
||||||
|
alignItems: 'center',
|
||||||
|
justifyContent: 'space-between',
|
||||||
|
padding: '0 16px',
|
||||||
|
gap: 8,
|
||||||
|
});
|
||||||
|
|
||||||
|
export const dailyCountNav = style([
|
||||||
|
interactive,
|
||||||
|
{
|
||||||
|
height: 28,
|
||||||
|
width: 0,
|
||||||
|
flex: 1,
|
||||||
|
fontWeight: 500,
|
||||||
|
fontSize: 14,
|
||||||
|
padding: '4px 8px',
|
||||||
|
whiteSpace: 'nowrap',
|
||||||
|
borderRadius: 8,
|
||||||
|
display: 'flex',
|
||||||
|
alignItems: 'center',
|
||||||
|
justifyContent: 'center',
|
||||||
|
color: 'var(--affine-text-secondary-color)',
|
||||||
|
transition: 'all .3s',
|
||||||
|
|
||||||
|
selectors: {
|
||||||
|
'&[aria-selected="true"]': {
|
||||||
|
backgroundColor: 'var(--affine-background-tertiary-color)',
|
||||||
|
color: 'var(--affine-text-primary-color)',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
|
||||||
|
export const dailyCountContainer = style({
|
||||||
|
height: 0,
|
||||||
|
flexGrow: 1,
|
||||||
|
display: 'flex',
|
||||||
|
width: `calc(var(--item-count) * 100%)`,
|
||||||
|
transition: 'transform .15s ease',
|
||||||
|
transform:
|
||||||
|
'translateX(calc(var(--active-index) * 100% / var(--item-count) * -1))',
|
||||||
|
});
|
||||||
|
export const dailyCountItem = style({
|
||||||
|
width: 'calc(100% / var(--item-count))',
|
||||||
|
height: '100%',
|
||||||
|
});
|
||||||
|
export const dailyCountContent = style({
|
||||||
|
padding: '8px 16px',
|
||||||
|
display: 'flex',
|
||||||
|
flexDirection: 'column',
|
||||||
|
gap: 4,
|
||||||
|
});
|
||||||
|
export const dailyCountEmpty = style({
|
||||||
|
width: '100%',
|
||||||
|
height: '100%',
|
||||||
|
maxHeight: 220,
|
||||||
|
display: 'flex',
|
||||||
|
flexDirection: 'column',
|
||||||
|
alignItems: 'center',
|
||||||
|
justifyContent: 'center',
|
||||||
|
|
||||||
|
lineHeight: '24px',
|
||||||
|
fontSize: 15,
|
||||||
|
color: 'var(--affine-text-secondary-color)',
|
||||||
|
textAlign: 'center',
|
||||||
|
padding: '0 70px',
|
||||||
|
fontWeight: 400,
|
||||||
|
});
|
||||||
|
|
||||||
|
// page item
|
||||||
|
export const pageItem = style([
|
||||||
|
interactive,
|
||||||
|
{
|
||||||
|
width: '100%',
|
||||||
|
display: 'flex',
|
||||||
|
alignItems: 'center',
|
||||||
|
borderRadius: 4,
|
||||||
|
padding: '0 4px',
|
||||||
|
gap: 8,
|
||||||
|
height: 30,
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
export const pageItemIcon = style({
|
||||||
|
width: 20,
|
||||||
|
height: 20,
|
||||||
|
color: 'var(--affine-icon-color)',
|
||||||
|
});
|
||||||
|
export const pageItemLabel = style({
|
||||||
|
width: 0,
|
||||||
|
flex: 1,
|
||||||
|
overflow: 'hidden',
|
||||||
|
textOverflow: 'ellipsis',
|
||||||
|
whiteSpace: 'nowrap',
|
||||||
|
fontWeight: 500,
|
||||||
|
fontSize: 'var(--affine-font-size-sm)',
|
||||||
|
color: 'var(--affine-text-primary-color)',
|
||||||
|
textAlign: 'left',
|
||||||
|
});
|
||||||
|
|||||||
@@ -1,16 +1,67 @@
|
|||||||
import { AFFiNEDatePicker } from '@affine/component';
|
import { AFFiNEDatePicker, Scrollable } from '@affine/component';
|
||||||
import {
|
import {
|
||||||
useJournalHelper,
|
useJournalHelper,
|
||||||
useJournalInfoHelper,
|
useJournalInfoHelper,
|
||||||
} from '@affine/core/hooks/use-journal';
|
} from '@affine/core/hooks/use-journal';
|
||||||
import { TodayIcon } from '@blocksuite/icons';
|
import { useNavigateHelper } from '@affine/core/hooks/use-navigate-helper';
|
||||||
|
import { useAFFiNEI18N } from '@affine/i18n/hooks';
|
||||||
|
import { EdgelessIcon, PageIcon, TodayIcon } from '@blocksuite/icons';
|
||||||
|
import type { Page } from '@blocksuite/store';
|
||||||
|
import { assignInlineVars } from '@vanilla-extract/dynamic';
|
||||||
|
import clsx from 'clsx';
|
||||||
import dayjs from 'dayjs';
|
import dayjs from 'dayjs';
|
||||||
import { useCallback, useEffect, useState } from 'react';
|
import type { HTMLAttributes, ReactNode } from 'react';
|
||||||
|
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
|
||||||
|
|
||||||
import type { EditorExtension, EditorExtensionProps } from '..';
|
import type { EditorExtension, EditorExtensionProps } from '..';
|
||||||
import * as styles from './journal.css';
|
import * as styles from './journal.css';
|
||||||
|
|
||||||
const EditorJournalPanel = ({ workspace, page }: EditorExtensionProps) => {
|
/**
|
||||||
|
* @internal
|
||||||
|
*/
|
||||||
|
const CountDisplay = ({
|
||||||
|
count,
|
||||||
|
max = 99,
|
||||||
|
...attrs
|
||||||
|
}: { count: number; max?: number } & HTMLAttributes<HTMLSpanElement>) => {
|
||||||
|
return <span {...attrs}>{count > max ? `${max}+` : count}</span>;
|
||||||
|
};
|
||||||
|
interface PageItemProps extends HTMLAttributes<HTMLButtonElement> {
|
||||||
|
page: Page;
|
||||||
|
right?: ReactNode;
|
||||||
|
}
|
||||||
|
const PageItem = ({ page, right, className, ...attrs }: PageItemProps) => {
|
||||||
|
const { isJournal } = useJournalInfoHelper(page.meta);
|
||||||
|
|
||||||
|
const icon = isJournal ? (
|
||||||
|
<TodayIcon width={20} height={20} />
|
||||||
|
) : page.meta.mode === 'edgeless' ? (
|
||||||
|
<EdgelessIcon width={20} height={20} />
|
||||||
|
) : (
|
||||||
|
<PageIcon width={20} height={20} />
|
||||||
|
);
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
aria-label={page.meta.title}
|
||||||
|
className={clsx(className, styles.pageItem)}
|
||||||
|
{...attrs}
|
||||||
|
>
|
||||||
|
<div className={styles.pageItemIcon}>{icon}</div>
|
||||||
|
<span className={styles.pageItemLabel}>{page.meta.title}</span>
|
||||||
|
{right}
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
type NavItemName = 'createdToday' | 'updatedToday';
|
||||||
|
interface NavItem {
|
||||||
|
name: NavItemName;
|
||||||
|
label: string;
|
||||||
|
count: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
const EditorJournalPanel = (props: EditorExtensionProps) => {
|
||||||
|
const { workspace, page } = props;
|
||||||
const { journalDate } = useJournalInfoHelper(page?.meta);
|
const { journalDate } = useJournalInfoHelper(page?.meta);
|
||||||
const { openJournal } = useJournalHelper(workspace);
|
const { openJournal } = useJournalHelper(workspace);
|
||||||
const [date, setDate] = useState(dayjs().format('YYYY-MM-DD'));
|
const [date, setDate] = useState(dayjs().format('YYYY-MM-DD'));
|
||||||
@@ -28,12 +79,151 @@ const EditorJournalPanel = ({ workspace, page }: EditorExtensionProps) => {
|
|||||||
);
|
);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
|
<div className={styles.journalPanel}>
|
||||||
<AFFiNEDatePicker
|
<AFFiNEDatePicker
|
||||||
inline
|
inline
|
||||||
value={date}
|
value={date}
|
||||||
onSelect={onDateSelect}
|
onSelect={onDateSelect}
|
||||||
calendarClassName={styles.calendar}
|
calendarClassName={styles.calendar}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
|
<JournalDailyCountBlock {...props} />
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
const sortPagesByDate = (
|
||||||
|
pages: Page[],
|
||||||
|
field: 'updatedDate' | 'createDate',
|
||||||
|
order: 'asc' | 'desc' = 'desc'
|
||||||
|
) => {
|
||||||
|
return [...pages].sort((a, b) => {
|
||||||
|
return (
|
||||||
|
(order === 'asc' ? 1 : -1) *
|
||||||
|
dayjs(b.meta[field]).diff(dayjs(a.meta[field]))
|
||||||
|
);
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
const DailyCountEmptyFallback = ({ name }: { name: NavItemName }) => {
|
||||||
|
const t = useAFFiNEI18N();
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className={styles.dailyCountEmpty}>
|
||||||
|
{name === 'createdToday'
|
||||||
|
? t['com.affine.journal.daily-count-created-empty-tips']()
|
||||||
|
: t['com.affine.journal.daily-count-updated-empty-tips']()}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
const JournalDailyCountBlock = ({ workspace, page }: EditorExtensionProps) => {
|
||||||
|
const nodeRef = useRef<HTMLDivElement>(null);
|
||||||
|
const t = useAFFiNEI18N();
|
||||||
|
const { journalDate } = useJournalInfoHelper(page?.meta);
|
||||||
|
const [activeItem, setActiveItem] = useState<NavItemName>('createdToday');
|
||||||
|
|
||||||
|
const navigateHelper = useNavigateHelper();
|
||||||
|
|
||||||
|
const getTodaysPages = useCallback(
|
||||||
|
(field: 'createDate' | 'updatedDate') => {
|
||||||
|
const pages: Page[] = [];
|
||||||
|
Array.from(workspace.pages.values()).forEach(page => {
|
||||||
|
if (page.meta.trash) return;
|
||||||
|
if (
|
||||||
|
page.meta[field] &&
|
||||||
|
dayjs(page.meta[field]).isSame(journalDate, 'day')
|
||||||
|
) {
|
||||||
|
pages.push(page);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
return sortPagesByDate(pages, field);
|
||||||
|
},
|
||||||
|
[journalDate, workspace.pages]
|
||||||
|
);
|
||||||
|
|
||||||
|
const createdToday = useMemo(
|
||||||
|
() => getTodaysPages('createDate'),
|
||||||
|
[getTodaysPages]
|
||||||
|
);
|
||||||
|
const updatedToday = useMemo(
|
||||||
|
() => getTodaysPages('updatedDate'),
|
||||||
|
[getTodaysPages]
|
||||||
|
);
|
||||||
|
|
||||||
|
const headerItems = useMemo<NavItem[]>(
|
||||||
|
() => [
|
||||||
|
{
|
||||||
|
name: 'createdToday',
|
||||||
|
label: t['com.affine.journal.created-today'](),
|
||||||
|
count: createdToday.length,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'updatedToday',
|
||||||
|
label: t['com.affine.journal.updated-today'](),
|
||||||
|
count: updatedToday.length,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
[createdToday.length, t, updatedToday.length]
|
||||||
|
);
|
||||||
|
|
||||||
|
const activeIndex = headerItems.findIndex(({ name }) => name === activeItem);
|
||||||
|
|
||||||
|
const vars = assignInlineVars({
|
||||||
|
'--active-index': String(activeIndex),
|
||||||
|
'--item-count': String(headerItems.length),
|
||||||
|
});
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className={styles.dailyCount} style={vars}>
|
||||||
|
<header className={styles.dailyCountHeader}>
|
||||||
|
{headerItems.map(({ label, count, name }, index) => {
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
onClick={() => setActiveItem(name)}
|
||||||
|
aria-selected={activeItem === name}
|
||||||
|
className={styles.dailyCountNav}
|
||||||
|
key={index}
|
||||||
|
>
|
||||||
|
{label}
|
||||||
|
|
||||||
|
<CountDisplay count={count} />
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<main className={styles.dailyCountContainer} data-active={activeItem}>
|
||||||
|
{headerItems.map(({ name }) => {
|
||||||
|
const renderList =
|
||||||
|
name === 'createdToday' ? createdToday : updatedToday;
|
||||||
|
if (renderList.length === 0)
|
||||||
|
return (
|
||||||
|
<div key={name} className={styles.dailyCountItem}>
|
||||||
|
<DailyCountEmptyFallback name={name} />
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
return (
|
||||||
|
<Scrollable.Root key={name} className={styles.dailyCountItem}>
|
||||||
|
<Scrollable.Scrollbar />
|
||||||
|
<Scrollable.Viewport>
|
||||||
|
<div className={styles.dailyCountContent} ref={nodeRef}>
|
||||||
|
{renderList.map((page, index) => (
|
||||||
|
<PageItem
|
||||||
|
onClick={() =>
|
||||||
|
navigateHelper.openPage(workspace.id, page.id)
|
||||||
|
}
|
||||||
|
tabIndex={name === activeItem ? 0 : -1}
|
||||||
|
key={index}
|
||||||
|
page={page}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</Scrollable.Viewport>
|
||||||
|
</Scrollable.Root>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</main>
|
||||||
|
</div>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -1043,5 +1043,9 @@
|
|||||||
"com.affine.calendar.weekdays.wed": "Wed",
|
"com.affine.calendar.weekdays.wed": "Wed",
|
||||||
"com.affine.calendar.weekdays.thu": "Thu",
|
"com.affine.calendar.weekdays.thu": "Thu",
|
||||||
"com.affine.calendar.weekdays.fri": "Fri",
|
"com.affine.calendar.weekdays.fri": "Fri",
|
||||||
"com.affine.calendar.weekdays.sat": "Sat"
|
"com.affine.calendar.weekdays.sat": "Sat",
|
||||||
|
"com.affine.journal.created-today": "Created today",
|
||||||
|
"com.affine.journal.updated-today": "Updated today",
|
||||||
|
"com.affine.journal.daily-count-created-empty-tips": "You haven't created anything yet",
|
||||||
|
"com.affine.journal.daily-count-updated-empty-tips": "You haven't updated anything yet"
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user