feat(core): add a two-step confirm page to create new journal (#13240)

close AF-2750;

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

* **New Features**
* Introduced a new workspace journals page with date-based navigation,
placeholder UI, and the ability to create daily journals directly from
the page.
* Added a "Today" button for quick navigation to the current day's
journal when viewing other dates.

* **Improvements**
* Enhanced the journal document title display with improved date
formatting and flexible styling.
* Expanded the active state for the journal sidebar button to cover all
journal-related routes.
* Updated journal navigation to open existing entries directly or
navigate to filtered journal listings.

* **Bug Fixes**
* Improved date handling and navigation logic for journal entries to
ensure accurate redirection and creation flows.

* **Style**
* Added new styles for the workspace journals page, including headers,
placeholders, and buttons.

* **Localization**
* Added English translations for journal placeholder text and create
journal prompts.

* **Tests**
* Added confirmation steps in journal creation flows to improve test
reliability.
<!-- end of auto-generated comment: release notes by coderabbit.ai -->
This commit is contained in:
Cats Juice
2025-07-17 10:32:01 +08:00
committed by GitHub
parent 3949714618
commit 46a2ad750f
14 changed files with 304 additions and 30 deletions
@@ -1,11 +0,0 @@
import { useJournalRouteHelper } from '@affine/core/components/hooks/use-journal';
import { useEffect } from 'react';
// this route page acts as a redirector to today's journal
export const Component = () => {
const { openToday } = useJournalRouteHelper();
useEffect(() => {
openToday({ replaceHistory: true });
}, [openToday]);
return null;
};
@@ -9,7 +9,6 @@ import {
useConfirmModal,
} from '@affine/component';
import { Guard } from '@affine/core/components/guard';
import { useJournalRouteHelper } from '@affine/core/components/hooks/use-journal';
import { MoveToTrash } from '@affine/core/components/page-list';
import {
type DocRecord,
@@ -18,7 +17,10 @@ import {
} from '@affine/core/modules/doc';
import { DocDisplayMetaService } from '@affine/core/modules/doc-display-meta';
import { JournalService } from '@affine/core/modules/journal';
import { WorkbenchLink } from '@affine/core/modules/workbench';
import {
WorkbenchLink,
WorkbenchService,
} from '@affine/core/modules/workbench';
import { useI18n } from '@affine/i18n';
import { CalendarXmarkIcon, EditIcon } from '@blocksuite/icons/rc';
import {
@@ -103,13 +105,25 @@ const mobile = environment.isMobile;
export const EditorJournalPanel = () => {
const t = useI18n();
const doc = useServiceOptional(DocService)?.doc;
const workbench = useService(WorkbenchService).workbench;
const journalService = useService(JournalService);
const journalDateStr = useLiveData(
doc ? journalService.journalDate$(doc.id) : null
);
const journalDate = journalDateStr ? dayjs(journalDateStr) : null;
const isJournal = !!journalDate;
const { openJournal } = useJournalRouteHelper();
const openJournal = useCallback(
(date: string) => {
const docs = journalService.journalsByDate$(date).value;
if (docs.length > 0) {
workbench.openDoc(docs[0].id, { at: 'active' });
} else {
workbench.open(`/journals?date=${date}`, { at: 'active' });
}
},
[journalService, workbench]
);
const onDateSelect = useCallback(
(date: string) => {
@@ -0,0 +1,68 @@
import { cssVarV2 } from '@toeverything/theme/v2';
import { style } from '@vanilla-extract/css';
export const header = style({
display: 'flex',
width: '100%',
height: '100%',
alignItems: 'center',
justifyContent: 'center',
position: 'relative',
padding: '0 36px',
});
export const todayButton = style({
position: 'absolute',
right: 0,
});
export const body = style({
width: '100%',
height: '100%',
borderTop: `0.5px solid ${cssVarV2.layer.insideBorder.border}`,
});
export const content = style({
maxWidth: 944,
padding: '0px 50px',
margin: '0 auto',
});
export const docTitleContainer = style({
color: cssVarV2.text.primary,
fontSize: 40,
lineHeight: '50px',
fontWeight: 700,
padding: '38px 0',
});
export const placeholder = style({
height: 200,
width: '100%',
display: 'flex',
flexDirection: 'column',
alignItems: 'center',
justifyContent: 'center',
border: `1px dashed ${cssVarV2.layer.insideBorder.border}`,
borderRadius: 8,
});
export const placeholderIcon = style({
width: 36,
height: 36,
borderRadius: 36,
backgroundColor: cssVarV2.button.emptyIconBackground,
color: cssVarV2.icon.primary,
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
fontSize: 20,
marginBottom: 4,
});
export const placeholderText = style({
fontSize: 14,
lineHeight: '22px',
marginBottom: 16,
color: cssVarV2.text.tertiary,
});
@@ -0,0 +1,141 @@
import {
Button,
WeekDatePicker,
type WeekDatePickerHandle,
} from '@affine/component';
import { BlocksuiteEditorJournalDocTitleUI } from '@affine/core/blocksuite/block-suite-editor/journal-doc-title';
import {
JOURNAL_DATE_FORMAT,
JournalService,
} from '@affine/core/modules/journal';
import {
ViewBody,
ViewHeader,
ViewIcon,
ViewService,
ViewTitle,
WorkbenchService,
} from '@affine/core/modules/workbench';
import { useI18n } from '@affine/i18n';
import { TodayIcon } from '@blocksuite/icons/rc';
import { useLiveData, useService } from '@toeverything/infra';
import dayjs from 'dayjs';
import type { Location } from 'history';
import { useCallback, useLayoutEffect, useRef, useState } from 'react';
import { AllDocSidebarTabs } from '../layouts/all-doc-sidebar-tabs';
import * as styles from './index.css';
function getDateFromUrl(location: Location) {
const searchParams = new URLSearchParams(location.search);
const date = searchParams.get('date')
? dayjs(searchParams.get('date'))
: dayjs();
return date.format(JOURNAL_DATE_FORMAT);
}
const weekStyle = { maxWidth: 800, width: '100%' };
// this route page acts as a redirector to today's journal
export const Component = () => {
const handleRef = useRef<WeekDatePickerHandle>(null);
const t = useI18n();
const journalService = useService(JournalService);
const workbench = useService(WorkbenchService).workbench;
const view = useService(ViewService).view;
const location = useLiveData(view.location$);
const dateString = getDateFromUrl(location);
const todayString = dayjs().format(JOURNAL_DATE_FORMAT);
const isToday = dateString === todayString;
const [redirecting, setRedirecting] = useState(false);
const [ready, setReady] = useState(false);
const createJournal = useCallback(() => {
if (redirecting) return;
setRedirecting(true);
const doc = journalService.ensureJournalByDate(dateString);
workbench.openDoc(doc.id, {
replaceHistory: true,
at: 'active',
});
}, [dateString, journalService, redirecting, workbench]);
const openJournal = useCallback(
(date: string) => {
workbench.open(`/journals?date=${date}`, { at: 'active' });
},
[workbench]
);
useLayoutEffect(() => {
// only handle current route
if (!location.pathname.startsWith('/journals')) return;
// check if the journal is created
const docs = journalService.journalsByDate$(dateString).value;
if (docs.length === 0) {
setReady(true);
return;
}
// if created, redirect to the journal
const journal = docs[0];
workbench.openDoc(journal.id, { replaceHistory: true, at: 'active' });
}, [dateString, journalService, location.pathname, view, workbench]);
if (!ready) return null;
return (
<>
<ViewTitle title="" />
<ViewIcon icon="journal" />
<ViewHeader>
<div className={styles.header}>
<WeekDatePicker
data-testid="journal-week-picker"
handleRef={handleRef}
style={weekStyle}
value={dateString}
onChange={openJournal}
/>
{!isToday ? (
<Button
className={styles.todayButton}
onClick={() => openJournal(todayString)}
>
{t['com.affine.today']()}
</Button>
) : null}
</div>
</ViewHeader>
<ViewBody>
<div className={styles.body}>
<div className={styles.content}>
<BlocksuiteEditorJournalDocTitleUI
date={dateString}
overrideClassName={styles.docTitleContainer}
/>
<div className={styles.placeholder}>
<div className={styles.placeholderIcon}>
<TodayIcon />
</div>
<div className={styles.placeholderText}>
{t['com.affine.journal.placeholder.title']()}
</div>
<Button
variant="primary"
onClick={createJournal}
data-testid="confirm-create-journal-button"
>
{t['com.affine.journal.placeholder.create']()}
</Button>
</div>
</div>
</div>
</ViewBody>
<AllDocSidebarTabs />
</>
);
};
@@ -39,7 +39,7 @@ export const workbenchRoutes = [
},
{
path: '/journals',
lazy: () => import('./pages/journals'),
lazy: () => import('./pages/workspace/journals'),
},
{
path: '/settings',