refactor(core): move mobile components to core (#8258)

This commit is contained in:
EYHN
2024-09-14 05:51:14 +00:00
parent ff15ea1eec
commit 3d80725c1a
192 changed files with 157 additions and 165 deletions

View File

@@ -11,7 +11,7 @@ import { ArrowRightSmallIcon, OpenInNewIcon } from '@blocksuite/icons/rc';
import { useCallback } from 'react';
import { useAppSettingHelper } from '../../../../../components/hooks/affine/use-app-setting-helper';
import { appIconMap, appNames } from '../../../../../pages/open-app';
import { appIconMap, appNames } from '../../../../../desktop/pages/open-app';
import { popupWindow } from '../../../../../utils';
import { relatedLinks } from './config';
import * as styles from './style.css';

View File

@@ -19,10 +19,10 @@ import { useAsyncCallback } from '@affine/core/components/hooks/affine-async-hoo
import { useDocMetaHelper } from '@affine/core/components/hooks/use-block-suite-page-meta';
import { Export, MoveToTrash } from '@affine/core/components/page-list';
import { IsFavoriteIcon } from '@affine/core/components/pure/icons';
import { useDetailPageHeaderResponsive } from '@affine/core/desktop/pages/workspace/detail-page/use-header-responsive';
import { EditorService } from '@affine/core/modules/editor';
import { WorkbenchService } from '@affine/core/modules/workbench';
import { ViewService } from '@affine/core/modules/workbench/services/view';
import { useDetailPageHeaderResponsive } from '@affine/core/pages/workspace/detail-page/use-header-responsive';
import { WorkspaceFlavour } from '@affine/env/workspace';
import { useI18n } from '@affine/i18n';
import { track } from '@affine/track';

View File

@@ -18,10 +18,8 @@ import {
} from '@blocksuite/icons/rc';
import {
useLiveData,
useService,
type WorkspaceMetadata,
type WorkspaceProfileInfo,
WorkspaceService,
} from '@toeverything/infra';
import { cssVar } from '@toeverything/theme';
import clsx from 'clsx';
@@ -173,8 +171,7 @@ const WorkspaceSyncInfo = ({
workspaceProfile: WorkspaceProfileInfo;
}) => {
const syncStatus = useSyncEngineSyncProgress(workspaceMetadata);
const currentWorkspace = useService(WorkspaceService).workspace;
const isCloud = currentWorkspace.flavour === WorkspaceFlavour.AFFINE_CLOUD;
const isCloud = workspaceMetadata.flavour === WorkspaceFlavour.AFFINE_CLOUD;
const { paused, pause } = usePauseAnimation();
// to make sure that animation will play first time

View File

@@ -8,12 +8,12 @@ import { useLiveData, useService } from '@toeverything/infra';
import type { ReactElement } from 'react';
import { useCallback, useEffect, useState } from 'react';
import { SignOutModal } from '../components/affine/sign-out-modal';
import { SignOutModal } from '../../components/affine/sign-out-modal';
import {
RouteLogic,
useNavigateHelper,
} from '../components/hooks/use-navigate-helper';
import { AuthService } from '../modules/cloud';
} from '../../components/hooks/use-navigate-helper';
import { AuthService } from '../../modules/cloud';
import { SignIn } from './auth/sign-in';
export const PageNotFound = ({

View File

@@ -0,0 +1,5 @@
import { AIUpgradeSuccess } from '../../components/affine/subscription-landing';
export const Component = () => {
return <AIUpgradeSuccess />;
};

View File

@@ -23,12 +23,12 @@ import type { LoaderFunction } from 'react-router-dom';
import { redirect, useParams, useSearchParams } from 'react-router-dom';
import { z } from 'zod';
import { useMutation } from '../../components/hooks/use-mutation';
import { useMutation } from '../../../components/hooks/use-mutation';
import {
RouteLogic,
useNavigateHelper,
} from '../../components/hooks/use-navigate-helper';
import { AuthService, ServerConfigService } from '../../modules/cloud';
} from '../../../components/hooks/use-navigate-helper';
import { AuthService, ServerConfigService } from '../../../modules/cloud';
const authTypeSchema = z.enum([
'onboarding',

View File

@@ -8,7 +8,7 @@ import {
useNavigate,
} from 'react-router-dom';
import { AuthService } from '../../modules/cloud';
import { AuthService } from '../../../modules/cloud';
import { supportedClient } from './common';
interface LoaderData {

View File

@@ -8,7 +8,7 @@ import {
useNavigate,
} from 'react-router-dom';
import { AuthService } from '../../modules/cloud';
import { AuthService } from '../../../modules/cloud';
import { supportedClient } from './common';
interface LoaderData {

View File

@@ -6,11 +6,11 @@ import { useEffect } from 'react';
// eslint-disable-next-line @typescript-eslint/no-restricted-imports
import { useNavigate, useSearchParams } from 'react-router-dom';
import { AuthPanel } from '../../components/affine/auth';
import { AuthPanel } from '../../../components/affine/auth';
import {
RouteLogic,
useNavigateHelper,
} from '../../components/hooks/use-navigate-helper';
} from '../../../components/hooks/use-navigate-helper';
export const SignIn = () => {
const session = useService(AuthService).session;

View File

@@ -6,7 +6,7 @@ import { useCallback } from 'react';
import {
RouteLogic,
useNavigateHelper,
} from '../components/hooks/use-navigate-helper';
} from '../../components/hooks/use-navigate-helper';
export const Component = () => {
const t = useI18n();

View File

@@ -3,8 +3,8 @@ import { useService } from '@toeverything/infra';
import { useEffect } from 'react';
import { useSearchParams } from 'react-router-dom';
import { useNavigateHelper } from '../components/hooks/use-navigate-helper';
import { ImportTemplateDialogService } from '../modules/import-template';
import { useNavigateHelper } from '../../components/hooks/use-navigate-helper';
import { ImportTemplateDialogService } from '../../modules/import-template';
export const Component = () => {
const importTemplateDialogService = useService(ImportTemplateDialogService);

View File

@@ -17,11 +17,11 @@ import { type LoaderFunction, useSearchParams } from 'react-router-dom';
import {
buildShowcaseWorkspace,
createFirstAppData,
} from '../bootstrap/first-app-data';
import { AppFallback } from '../components/affine/app-container';
import { useNavigateHelper } from '../components/hooks/use-navigate-helper';
import { WorkspaceNavigator } from '../components/workspace-selector';
import { AuthService } from '../modules/cloud';
} from '../../bootstrap/first-app-data';
import { AppFallback } from '../../components/affine/app-container';
import { useNavigateHelper } from '../../components/hooks/use-navigate-helper';
import { WorkspaceNavigator } from '../../components/workspace-selector';
import { AuthService } from '../../modules/cloud';
export const loader: LoaderFunction = async () => {
return null;

View File

@@ -11,12 +11,12 @@ import { useCallback, useEffect } from 'react';
import type { LoaderFunction } from 'react-router-dom';
import { redirect, useLoaderData } from 'react-router-dom';
import { authAtom } from '../components/atoms';
import { authAtom } from '../../components/atoms';
import {
RouteLogic,
useNavigateHelper,
} from '../components/hooks/use-navigate-helper';
import { AuthService } from '../modules/cloud';
} from '../../components/hooks/use-navigate-helper';
import { AuthService } from '../../modules/cloud';
export const loader: LoaderFunction = async args => {
const inviteId = args.params.inviteId || '';

View File

@@ -3,15 +3,15 @@ import { assertExists } from '@blocksuite/global/utils';
import { useCallback } from 'react';
import { redirect } from 'react-router-dom';
import { Onboarding } from '../components/affine/onboarding/onboarding';
import { Onboarding } from '../../components/affine/onboarding/onboarding';
import {
appConfigStorage,
useAppConfigStorage,
} from '../components/hooks/use-app-config-storage';
} from '../../components/hooks/use-app-config-storage';
import {
RouteLogic,
useNavigateHelper,
} from '../components/hooks/use-navigate-helper';
} from '../../components/hooks/use-navigate-helper';
export const loader = () => {
if (!BUILD_CONFIG.isElectron && !appConfigStorage.get('onBoarding')) {

View File

@@ -1,6 +1,6 @@
import { Outlet } from 'react-router-dom';
import { AllWorkspaceModals } from '../components/providers/modal-provider';
import { AllWorkspaceModals } from '../../components/providers/modal-provider';
export const RootWrapper = () => {
return (

View File

@@ -7,12 +7,12 @@ import { useEffect, useMemo, useState } from 'react';
import { useSearchParams } from 'react-router-dom';
import { EMPTY, mergeMap, switchMap } from 'rxjs';
import { generateSubscriptionCallbackLink } from '../components/hooks/affine/use-subscription-notify';
import { generateSubscriptionCallbackLink } from '../../components/hooks/affine/use-subscription-notify';
import {
RouteLogic,
useNavigateHelper,
} from '../components/hooks/use-navigate-helper';
import { AuthService, SubscriptionService } from '../modules/cloud';
} from '../../components/hooks/use-navigate-helper';
import { AuthService, SubscriptionService } from '../../modules/cloud';
import { container } from './subscribe.css';
export const Component = () => {

View File

@@ -1,4 +1,4 @@
import { ThemeEditor } from '../modules/theme-editor';
import { ThemeEditor } from '../../modules/theme-editor';
export const Component = () => {
return <ThemeEditor />;

View File

@@ -0,0 +1,5 @@
import { CloudUpgradeSuccess } from '../../components/affine/subscription-landing';
export const Component = () => {
return <CloudUpgradeSuccess />;
};

View File

@@ -15,8 +15,8 @@ import { useLiveData, useService, WorkspaceService } from '@toeverything/infra';
import { nanoid } from 'nanoid';
import { useCallback, useMemo, useState } from 'react';
import { CollectionService } from '../../../modules/collection';
import { ViewBody, ViewHeader } from '../../../modules/workbench';
import { CollectionService } from '../../../../modules/collection';
import { ViewBody, ViewHeader } from '../../../../modules/workbench';
import { EmptyCollectionList } from '../page-list-empty';
import { AllCollectionHeader } from './header';
import * as styles from './index.css';

View File

@@ -3,12 +3,12 @@ import type { Collection, Filter } from '@affine/env/filter';
import { useService, WorkspaceService } from '@toeverything/infra';
import { useCallback } from 'react';
import { filterContainerStyle } from '../../../components/filter-container.css';
import { useNavigateHelper } from '../../../components/hooks/use-navigate-helper';
import { filterContainerStyle } from '../../../../components/filter-container.css';
import { useNavigateHelper } from '../../../../components/hooks/use-navigate-helper';
import {
FilterList,
SaveAsCollectionButton,
} from '../../../components/page-list';
} from '../../../../components/page-list';
export const FilterContainer = ({
filters,

View File

@@ -19,7 +19,7 @@ import {
ViewHeader,
ViewIcon,
ViewTitle,
} from '../../../modules/workbench';
} from '../../../../modules/workbench';
import { EmptyPageList } from '../page-list-empty';
import * as styles from './all-page.css';
import { FilterContainer } from './all-page-filter';

View File

@@ -14,7 +14,7 @@ import {
ViewHeader,
ViewIcon,
ViewTitle,
} from '../../../modules/workbench';
} from '../../../../modules/workbench';
import { EmptyTagList } from '../page-list-empty';
import * as styles from './all-tag.css';
import { AllTagHeader } from './header';

View File

@@ -19,14 +19,14 @@ import {
import { useCallback, useEffect, useState } from 'react';
import { useParams } from 'react-router-dom';
import { useNavigateHelper } from '../../../components/hooks/use-navigate-helper';
import { useNavigateHelper } from '../../../../components/hooks/use-navigate-helper';
import {
useIsActiveView,
ViewBody,
ViewHeader,
ViewIcon,
ViewTitle,
} from '../../../modules/workbench';
} from '../../../../modules/workbench';
import { CollectionDetailHeader } from './header';
export const CollectionDetail = ({

View File

@@ -22,10 +22,10 @@ import { useLiveData, useService, type Workspace } from '@toeverything/infra';
import { useAtom, useAtomValue } from 'jotai';
import { forwardRef, useCallback, useEffect, useRef, useState } from 'react';
import { SharePageButton } from '../../../components/affine/share-page-modal';
import { appSidebarFloatingAtom } from '../../../components/app-sidebar';
import { BlocksuiteHeaderTitle } from '../../../components/blocksuite/block-suite-header/title/index';
import { HeaderDivider } from '../../../components/pure/header';
import { SharePageButton } from '../../../../components/affine/share-page-modal';
import { appSidebarFloatingAtom } from '../../../../components/app-sidebar';
import { BlocksuiteHeaderTitle } from '../../../../components/blocksuite/block-suite-header/title/index';
import { HeaderDivider } from '../../../../components/pure/header';
import * as styles from './detail-page-header.css';
import { useDetailPageHeaderResponsive } from './use-header-responsive';

View File

@@ -27,22 +27,22 @@ import clsx from 'clsx';
import { memo, useCallback, useEffect, useRef } from 'react';
import { useParams } from 'react-router-dom';
import { AffineErrorBoundary } from '../../../components/affine/affine-error-boundary';
import { GlobalPageHistoryModal } from '../../../components/affine/page-history-modal';
import { useRegisterBlocksuiteEditorCommands } from '../../../components/hooks/affine/use-register-blocksuite-editor-commands';
import { useActiveBlocksuiteEditor } from '../../../components/hooks/use-block-suite-editor';
import { usePageDocumentTitle } from '../../../components/hooks/use-global-state';
import { useNavigateHelper } from '../../../components/hooks/use-navigate-helper';
import { PageDetailEditor } from '../../../components/page-detail-editor';
import { TrashPageFooter } from '../../../components/pure/trash-page-footer';
import { TopTip } from '../../../components/top-tip';
import { AffineErrorBoundary } from '../../../../components/affine/affine-error-boundary';
import { GlobalPageHistoryModal } from '../../../../components/affine/page-history-modal';
import { useRegisterBlocksuiteEditorCommands } from '../../../../components/hooks/affine/use-register-blocksuite-editor-commands';
import { useActiveBlocksuiteEditor } from '../../../../components/hooks/use-block-suite-editor';
import { usePageDocumentTitle } from '../../../../components/hooks/use-global-state';
import { useNavigateHelper } from '../../../../components/hooks/use-navigate-helper';
import { PageDetailEditor } from '../../../../components/page-detail-editor';
import { TrashPageFooter } from '../../../../components/pure/trash-page-footer';
import { TopTip } from '../../../../components/top-tip';
import {
useIsActiveView,
ViewBody,
ViewHeader,
ViewSidebarTab,
WorkbenchService,
} from '../../../modules/workbench';
} from '../../../../modules/workbench';
import { PageNotFound } from '../../404';
import * as styles from './detail-page.css';
import { DetailPageHeader } from './detail-page-header';

View File

@@ -1,6 +1,6 @@
import { AffineOtherPageLayout } from '@affine/component/affine-other-page-layout';
import { AppFallback } from '@affine/core/components/affine/app-container';
import { workspaceRoutes } from '@affine/core/workspace-router';
import { workbenchRoutes } from '@affine/core/desktop/workbench-router';
import { ZipTransformer } from '@blocksuite/blocks';
import type { Workspace, WorkspaceMetadata } from '@toeverything/infra';
import {
@@ -14,9 +14,9 @@ import type { ReactElement } from 'react';
import { useEffect, useLayoutEffect, useMemo, useState } from 'react';
import { matchPath, useLocation, useParams } from 'react-router-dom';
import { AffineErrorBoundary } from '../../components/affine/affine-error-boundary';
import { WorkspaceLayout } from '../../components/layouts/workspace-layout';
import { WorkbenchRoot } from '../../modules/workbench';
import { AffineErrorBoundary } from '../../../components/affine/affine-error-boundary';
import { WorkspaceLayout } from '../../../components/layouts/workspace-layout';
import { WorkbenchRoot } from '../../../modules/workbench';
import { PageNotFound } from '../404';
import { SharePage } from './share/share-page';
@@ -54,7 +54,7 @@ export const Component = (): ReactElement => {
match.params.docId &&
match.params.workspaceId &&
// // TODO(eyhn): need a better way to check if it's a docId
workspaceRoutes.find(route =>
workbenchRoutes.find(route =>
matchPath(route.path, '/' + match.params.docId)
)?.path === '/:pageId'
) {

View File

@@ -20,7 +20,7 @@ import {
ViewHeader,
ViewIcon,
ViewTitle,
} from '../../modules/workbench';
} from '../../../modules/workbench';
import { EmptyPageList } from './page-list-empty';
import * as styles from './trash-page.css';

View File

@@ -8,7 +8,7 @@ import {
useNavigate,
} from 'react-router-dom';
import { NavigateContext } from './components/hooks/use-navigate-helper';
import { NavigateContext } from '../components/hooks/use-navigate-helper';
import { RootWrapper } from './pages/root';
export function RootRouter() {

View File

@@ -1,6 +1,6 @@
import type { RouteObject } from 'react-router-dom';
export const workspaceRoutes = [
export const workbenchRoutes = [
{
path: '/all',
lazy: () => import('./pages/workspace/all-page/all-page'),

View File

@@ -0,0 +1,3 @@
# mobile components
Maintain the smallest possible business components here.

View File

@@ -0,0 +1,70 @@
import { SafeArea } from '@affine/component';
import {
WorkbenchLink,
WorkbenchService,
} from '@affine/core/modules/workbench';
import { AllDocsIcon, MobileHomeIcon, SearchIcon } from '@blocksuite/icons/rc';
import { useLiveData, useService } from '@toeverything/infra';
import type { Location } from 'react-router-dom';
import * as styles from './styles.css';
interface Route {
to: string;
Icon: React.FC;
LinkComponent?: React.FC;
isActive?: (location: Location) => boolean;
}
const routes: Route[] = [
{
to: '/home',
Icon: MobileHomeIcon,
},
{
to: '/all',
Icon: AllDocsIcon,
isActive: location =>
location.pathname === '/all' ||
location.pathname.startsWith('/collection') ||
location.pathname.startsWith('/tag'),
},
{
to: '/search',
Icon: SearchIcon,
},
];
export const AppTabs = () => {
const workbench = useService(WorkbenchService).workbench;
const location = useLiveData(workbench.location$);
return (
<SafeArea bottom className={styles.appTabs} bottomOffset={2}>
<ul className={styles.appTabsInner} id="app-tabs" role="tablist">
{routes.map(route => {
const Link = route.LinkComponent || WorkbenchLink;
const isActive = route.isActive
? route.isActive(location)
: location.pathname === route.to;
return (
<Link
data-active={isActive}
to={route.to}
key={route.to}
className={styles.tabItem}
role="tab"
aria-label={route.to.slice(1)}
replaceHistory
>
<li style={{ lineHeight: 0 }}>
<route.Icon />
</li>
</Link>
);
})}
</ul>
</SafeArea>
);
};

View File

@@ -0,0 +1,42 @@
import { cssVarV2 } from '@toeverything/theme/v2';
import { style } from '@vanilla-extract/css';
import { globalVars } from '../../styles/mobile.css';
export const appTabs = style({
backgroundColor: cssVarV2('layer/background/secondary'),
borderTop: `1px solid ${cssVarV2('layer/insideBorder/border')}`,
width: '100dvw',
position: 'fixed',
bottom: -2,
zIndex: 1,
});
export const appTabsInner = style({
display: 'flex',
justifyContent: 'space-between',
alignItems: 'center',
gap: 15.5,
height: `calc(${globalVars.appTabHeight} + 2px)`,
padding: 16,
});
export const tabItem = style({
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
width: 0,
flex: 1,
height: 36,
padding: 3,
fontSize: 30,
color: cssVarV2('icon/primary'),
lineHeight: 0,
selectors: {
'&[data-active="true"]': {
color: cssVarV2('button/primary'),
},
},
});

View File

@@ -0,0 +1,71 @@
import { IconButton } from '@affine/component';
import { useCatchEventCallback } from '@affine/core/components/hooks/use-catch-event-hook';
import { PagePreview } from '@affine/core/components/page-list/page-content-preview';
import { IsFavoriteIcon } from '@affine/core/components/pure/icons';
import { CompatibleFavoriteItemsAdapter } from '@affine/core/modules/properties';
import {
WorkbenchLink,
type WorkbenchLinkProps,
} from '@affine/core/modules/workbench';
import type { DocMeta } from '@blocksuite/store';
import { useLiveData, useService, WorkspaceService } from '@toeverything/infra';
import clsx from 'clsx';
import { forwardRef, type ReactNode } from 'react';
import * as styles from './styles.css';
import { DocCardTags } from './tag';
export interface DocCardProps extends Omit<WorkbenchLinkProps, 'to'> {
meta: {
id: DocMeta['id'];
title?: ReactNode;
} & { [key: string]: any };
showTags?: boolean;
}
export const DocCard = forwardRef<HTMLAnchorElement, DocCardProps>(
function DocCard({ showTags = true, meta, className, ...attrs }, ref) {
const favAdapter = useService(CompatibleFavoriteItemsAdapter);
const workspace = useService(WorkspaceService).workspace;
const favorited = useLiveData(favAdapter.isFavorite$(meta.id, 'doc'));
const toggleFavorite = useCatchEventCallback(
(e: React.MouseEvent) => {
e.preventDefault();
favAdapter.toggle(meta.id, 'doc');
},
[favAdapter, meta.id]
);
return (
<WorkbenchLink
to={`/${meta.id}`}
ref={ref}
className={clsx(styles.card, className)}
data-testid="doc-card"
{...attrs}
>
<header className={styles.head} data-testid="doc-card-header">
<h3 className={styles.title}>
{meta.title || <span className={styles.untitled}>Untitled</span>}
</h3>
<IconButton
aria-label="favorite"
icon={
<IsFavoriteIcon onClick={toggleFavorite} favorite={favorited} />
}
/>
</header>
<main className={styles.content}>
<PagePreview
docCollection={workspace.docCollection}
pageId={meta.id}
emptyFallback={<div className={styles.contentEmpty}>Empty</div>}
/>
</main>
{showTags ? <DocCardTags docId={meta.id} rows={2} /> : null}
</WorkbenchLink>
);
}
);

View File

@@ -0,0 +1,53 @@
import { cssVarV2 } from '@toeverything/theme/v2';
import { style } from '@vanilla-extract/css';
export const card = style({
padding: 16,
borderRadius: 12,
border: `0.5px solid ${cssVarV2('layer/insideBorder/border')}`,
boxShadow: '0px 2px 3px rgba(0,0,0,0.05)',
background: cssVarV2('layer/background/primary'),
display: 'flex',
flexDirection: 'column',
gap: 8,
color: 'unset',
':visited': { color: 'unset' },
':hover': { color: 'unset' },
':active': { color: 'unset' },
});
export const head = style({
display: 'flex',
alignItems: 'center',
justifyContent: 'space-between',
gap: 8,
});
export const title = style({
width: 0,
flex: 1,
fontSize: 17,
lineHeight: '22px',
fontWeight: 600,
letterSpacing: -0.43,
whiteSpace: 'nowrap',
textOverflow: 'ellipsis',
overflow: 'hidden',
});
export const untitled = style({
opacity: 0.4,
});
export const content = style({
fontSize: 13,
lineHeight: '18px',
fontWeight: 400,
letterSpacing: -0.08,
flex: 1,
overflow: 'hidden',
});
export const contentEmpty = style({
opacity: 0.3,
});

View File

@@ -0,0 +1,44 @@
import { cssVarV2 } from '@toeverything/theme/v2';
import { createVar, style } from '@vanilla-extract/css';
export const tagColorVar = createVar();
export const tags = style({
width: '100%',
display: 'flex',
flexWrap: 'wrap',
alignItems: 'center',
position: 'relative',
gap: 4,
});
export const tag = style({
padding: '0px 8px',
borderRadius: 10,
alignItems: 'center',
border: `1px solid ${cssVarV2('layer/insideBorder/blackBorder')}`,
maxWidth: '100%',
fontSize: 12,
lineHeight: '20px',
fontWeight: 400,
whiteSpace: 'nowrap',
overflow: 'hidden',
textOverflow: 'ellipsis',
':before': {
content: "''",
display: 'inline-block',
width: 8,
height: 8,
borderRadius: 4,
backgroundColor: tagColorVar,
marginRight: 4,
},
});
export const more = style({
fontSize: 16,
color: cssVarV2('icon/primary'),
});

View File

@@ -0,0 +1,42 @@
import type { Tag } from '@affine/core/modules/tag';
import { TagService } from '@affine/core/modules/tag';
import { MoreHorizontalIcon } from '@blocksuite/icons/rc';
import { useLiveData, useService } from '@toeverything/infra';
import { assignInlineVars } from '@vanilla-extract/dynamic';
import * as styles from './tag.css';
const DocCardTag = ({ tag }: { tag: Tag }) => {
const name = useLiveData(tag.value$);
const color = useLiveData(tag.color$);
return (
<li
data-name={name}
data-color={color}
className={styles.tag}
style={assignInlineVars({ [styles.tagColorVar]: color })}
>
{name}
</li>
);
};
const DocCardTagsRenderer = ({ tags }: { tags: Tag[] }) => {
return (
<ul className={styles.tags}>
{tags.slice(0, 2).map(tag => (
<DocCardTag key={tag.id} tag={tag} />
))}
{tags.length > 2 ? <MoreHorizontalIcon className={styles.more} /> : null}
</ul>
);
};
export const DocCardTags = ({ docId }: { docId: string; rows?: number }) => {
const tagService = useService(TagService);
const tags = useLiveData(tagService.tagList.tagsByPageId$(docId));
if (!tags.length) return null;
return <DocCardTagsRenderer tags={tags} />;
};

View File

@@ -0,0 +1,7 @@
export * from './app-tabs';
export * from './doc-card';
export * from './page-header';
export * from './search-input';
export * from './search-result';
export * from './user-plan-tag';
export * from './workspace-selector';

View File

@@ -0,0 +1,105 @@
import { IconButton, SafeArea } from '@affine/component';
import { ArrowLeftSmallIcon } from '@blocksuite/icons/rc';
import clsx from 'clsx';
import {
forwardRef,
type HtmlHTMLAttributes,
type ReactNode,
useCallback,
} from 'react';
import * as styles from './styles.css';
export interface PageHeaderProps
extends Omit<HtmlHTMLAttributes<HTMLHeadElement>, 'prefix'> {
/**
* whether to show back button
*/
back?: boolean;
/**
* Override back button action
*/
backAction?: () => void;
/**
* prefix content, shown after back button(if exists)
*/
prefix?: ReactNode;
/**
* suffix content
*/
suffix?: ReactNode;
/**
* Weather to center the content
* @default true
*/
centerContent?: boolean;
prefixClassName?: string;
prefixStyle?: React.CSSProperties;
suffixClassName?: string;
suffixStyle?: React.CSSProperties;
}
export const PageHeader = forwardRef<HTMLDivElement, PageHeaderProps>(
function PageHeader(
{
back,
backAction,
prefix,
suffix,
children,
className,
centerContent = true,
prefixClassName,
prefixStyle,
suffixClassName,
suffixStyle,
...attrs
},
ref
) {
const handleRouteBack = useCallback(() => {
backAction ? backAction() : history.back();
}, [backAction]);
return (
<SafeArea
top
ref={ref}
className={clsx(styles.root, className)}
data-testid="mobile-page-header"
{...attrs}
>
<header className={styles.inner}>
<section
className={clsx(styles.prefix, prefixClassName)}
style={prefixStyle}
>
{back ? (
<IconButton
size={24}
style={{ padding: 10 }}
onClick={handleRouteBack}
icon={<ArrowLeftSmallIcon />}
/>
) : null}
{prefix}
</section>
<section className={clsx(styles.content, { center: centerContent })}>
{children}
</section>
<section
className={clsx(styles.suffix, suffixClassName)}
style={suffixStyle}
>
{suffix}
</section>
</header>
</SafeArea>
);
}
);

View File

@@ -0,0 +1,49 @@
import { cssVarV2 } from '@toeverything/theme/v2';
import { style } from '@vanilla-extract/css';
export const root = style({
width: '100%',
position: 'sticky',
top: 0,
zIndex: 1,
backgroundColor: cssVarV2('layer/background/secondary'),
});
export const inner = style({
minHeight: 44,
padding: '0 6px',
display: 'flex',
alignItems: 'center',
justifyContent: 'space-between',
});
export const content = style({
selectors: {
'&.center': {
position: 'absolute',
left: '50%',
transform: 'translateX(-50%)',
width: 'fit-content',
maxWidth: 'calc(100% - 12px - 88px - 16px)',
display: 'flex',
justifyContent: 'center',
pointerEvents: 'none',
},
'&:not(.center)': {
width: 0,
flex: 1,
},
},
});
export const spacer = style({
width: 0,
flex: 1,
});
export const prefix = style({
display: 'flex',
alignItems: 'center',
gap: 0,
});
export const suffix = style({
display: 'flex',
alignItems: 'center',
gap: 6,
});

View File

@@ -0,0 +1,110 @@
import { useAutoFocus } from '@affine/component';
import { getSvgPath } from '@blocksuite/global/utils';
import { SearchIcon } from '@blocksuite/icons/rc';
import clsx from 'clsx';
import { debounce } from 'lodash-es';
import {
type FormEventHandler,
forwardRef,
type HTMLProps,
useCallback,
useEffect,
useMemo,
useRef,
useState,
} from 'react';
import * as styles from './style.css';
export interface SearchInputProps
extends Omit<HTMLProps<HTMLInputElement>, 'onInput'> {
value?: string;
height?: number;
cornerRadius?: number;
cornerSmoothing?: number;
debounce?: number;
onInput?: (value: string) => void;
}
export const SearchInput = forwardRef<HTMLInputElement, SearchInputProps>(
function SearchInput(
{
className,
style,
placeholder = 'Search',
value = '',
height = 44,
cornerRadius = 10,
cornerSmoothing = 0.6,
autoFocus,
debounce: debounceDuration,
onInput,
onClick,
...attrs
},
upstreamRef
) {
const focusRef = useAutoFocus<HTMLInputElement>(autoFocus);
const containerRef = useRef<HTMLDivElement>(null);
const [width, setWidth] = useState(window.innerWidth);
const [inputValue, setInputValue] = useState(value);
const clipPath = useMemo(
() => getSvgPath({ width, height, cornerRadius, cornerSmoothing }),
[cornerRadius, cornerSmoothing, height, width]
);
useEffect(() => {
setWidth(containerRef.current?.offsetWidth ?? 0);
}, []);
const emitValue = useMemo(() => {
const cb = (value: string) => onInput?.(value);
return debounceDuration ? debounce(cb, debounceDuration) : cb;
}, [debounceDuration, onInput]);
const handleInput: FormEventHandler<HTMLInputElement> = useCallback(
e => {
const value = e.currentTarget.value;
setInputValue(value);
emitValue(value);
},
[emitValue]
);
const inputRef = (el: HTMLInputElement | null) => {
focusRef.current = el;
if (upstreamRef) {
if (typeof upstreamRef === 'function') {
upstreamRef(el);
} else {
upstreamRef.current = el;
}
}
};
return (
<div
onClick={onClick}
ref={containerRef}
className={clsx(styles.wrapper, className)}
style={{ ...style, height, clipPath: `path('${clipPath}')` }}
>
<div className={styles.prefixIcon}>
<SearchIcon width="20" height="20" />
</div>
<input
ref={inputRef}
{...attrs}
value={inputValue}
onInput={handleInput}
className={styles.input}
/>
{!inputValue ? (
<div className={styles.placeholder}>{placeholder}</div>
) : null}
</div>
);
}
);

View File

@@ -0,0 +1,51 @@
import { cssVarV2 } from '@toeverything/theme/v2';
import { generateIdentifier, style } from '@vanilla-extract/css';
export const searchVTName = generateIdentifier('mobile-search-input');
export const searchVTScope = generateIdentifier('mobile-search');
export const wrapper = style({
position: 'relative',
backgroundColor: cssVarV2('layer/background/primary'),
selectors: {
[`[data-${searchVTScope}] &`]: {
viewTransitionName: searchVTName,
},
},
});
export const prefixIcon = style({
position: 'absolute',
width: 36,
height: '100%',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
color: cssVarV2('icon/primary'),
pointerEvents: 'none',
});
export const input = style({
padding: '11px 8px 11px 36px',
width: '100%',
height: '100%',
outline: 'none',
border: 'none',
fontWeight: 400,
fontSize: 17,
lineHeight: '22px',
letterSpacing: -0.43,
});
export const placeholder = style([
input,
{
position: 'absolute',
left: 0,
top: 0,
pointerEvents: 'none',
color: cssVarV2('text/secondary'),
},
]);

View File

@@ -0,0 +1,2 @@
export * from './search-res-label';
export * from './universal-item';

View File

@@ -0,0 +1,15 @@
import type { QuickSearchItem } from '@affine/core/modules/quicksearch';
import { HighlightText } from '@affine/core/modules/quicksearch/views/highlight-text';
import { isI18nString, useI18n } from '@affine/i18n';
export interface SearchResLabelProps {
item: QuickSearchItem;
}
export const SearchResLabel = ({ item }: SearchResLabelProps) => {
const i18n = useI18n();
const text = !isI18nString(item.label)
? i18n.t(item.label.title)
: i18n.t(item.label);
return <HighlightText text={text} start="<b>" end="</b>" />;
};

View File

@@ -0,0 +1,43 @@
import { cssVarV2 } from '@toeverything/theme/v2';
import { style } from '@vanilla-extract/css';
export const item = style({
display: 'flex',
alignItems: 'center',
gap: 12,
borderBottom: `0.5px solid ${cssVarV2('layer/insideBorder/border')}`,
height: 44,
color: 'unset',
':visited': { color: 'unset' },
':hover': { color: 'unset' },
':active': { color: 'unset' },
':focus': { color: 'unset' },
});
export const iconWrapper = style({
width: 32,
height: 32,
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
flexShrink: 0,
fontSize: 24,
color: cssVarV2('icon/primary'),
});
export const content = style({
width: 0,
flex: 1,
fontSize: 17,
lineHeight: '22px',
fontWeight: 400,
letterSpacing: -0.43,
color: cssVarV2('text/primary'),
textOverflow: 'ellipsis',
whiteSpace: 'nowrap',
overflow: 'hidden',
});
export const suffixIcon = style({
color: cssVarV2('icon/secondary'),
});

View File

@@ -0,0 +1,32 @@
import type { QuickSearchItem } from '@affine/core/modules/quicksearch';
import { WorkbenchLink } from '@affine/core/modules/workbench';
import { ArrowRightSmallIcon } from '@blocksuite/icons/rc';
import { SearchResLabel } from './search-res-label';
import * as styles from './universal-item.css';
export interface UniversalSearchResultItemProps {
id: string;
item: QuickSearchItem;
category: 'tag' | 'collection';
}
export const UniversalSearchResultItem = ({
id,
item,
category,
}: UniversalSearchResultItemProps) => {
return (
<WorkbenchLink to={`/${category}/${id}`} className={styles.item}>
<div className={styles.iconWrapper}>
{item.icon &&
(typeof item.icon === 'function' ? <item.icon /> : item.icon)}
</div>
<div className={styles.content}>
<SearchResLabel item={item} />
</div>
<ArrowRightSmallIcon fontSize="16px" className={styles.suffixIcon} />
</WorkbenchLink>
);
};

View File

@@ -0,0 +1,52 @@
import {
ServerConfigService,
SubscriptionService,
} from '@affine/core/modules/cloud';
import { SubscriptionPlan } from '@affine/graphql';
import { useLiveData, useServices } from '@toeverything/infra';
import clsx from 'clsx';
import { forwardRef, type HTMLProps, useEffect } from 'react';
import { tag } from './style.css';
export const UserPlanTag = forwardRef<
HTMLDivElement,
HTMLProps<HTMLDivElement>
>(function UserPlanTag({ className, ...attrs }, ref) {
const { serverConfigService, subscriptionService } = useServices({
ServerConfigService,
SubscriptionService,
});
const hasPayment = useLiveData(
serverConfigService.serverConfig.features$.map(r => r?.payment)
);
const plan = useLiveData(
subscriptionService.subscription.pro$.map(subscription =>
subscription !== null ? subscription?.plan : null
)
);
const isBeliever = useLiveData(subscriptionService.subscription.isBeliever$);
const isLoading = plan === null;
useEffect(() => {
// revalidate subscription to get the latest status
subscriptionService.subscription.revalidate();
}, [subscriptionService]);
if (!hasPayment) return null;
if (isLoading) return null;
const planLabel = isBeliever ? 'Believer' : (plan ?? SubscriptionPlan.Free);
return (
<div
ref={ref}
className={clsx(tag, className)}
data-is-believer={isBeliever}
{...attrs}
>
{planLabel}
</div>
);
});

View File

@@ -0,0 +1,22 @@
import { cssVar } from '@toeverything/theme';
import { style } from '@vanilla-extract/css';
export const tag = style({
display: 'flex',
fontSize: cssVar('fontXs'),
height: 20,
fontWeight: 500,
cursor: 'pointer',
color: cssVar('pureWhite'),
backgroundColor: cssVar('brandColor'),
padding: '0 4px',
borderRadius: 4,
justifyContent: 'center',
alignItems: 'center',
selectors: {
'&[data-is-believer="true"]': {
backgroundColor: '#374151',
},
},
});

View File

@@ -0,0 +1,24 @@
import { cssVarV2 } from '@toeverything/theme/v2';
import { style } from '@vanilla-extract/css';
export const card = style({
display: 'flex',
alignItems: 'center',
gap: 10,
});
export const label = style({
display: 'flex',
gap: 4,
fontSize: 17,
fontWeight: 600,
lineHeight: '22px',
color: cssVarV2('text/primary'),
letterSpacing: -0.43,
});
export const dropdownIcon = style({
fontSize: 24,
color: cssVarV2('icon/primary'),
});

View File

@@ -0,0 +1,44 @@
import { WorkspaceAvatar } from '@affine/component/workspace-avatar';
import { useWorkspaceInfo } from '@affine/core/components/hooks/use-workspace-info';
import { UNTITLED_WORKSPACE_NAME } from '@affine/env/constant';
import { ArrowDownSmallIcon } from '@blocksuite/icons/rc';
import { useService, WorkspaceService } from '@toeverything/infra';
import clsx from 'clsx';
import { forwardRef, type HTMLAttributes } from 'react';
import { card, dropdownIcon, label } from './card.css';
export interface CurrentWorkspaceCardProps
extends HTMLAttributes<HTMLDivElement> {}
export const CurrentWorkspaceCard = forwardRef<
HTMLDivElement,
CurrentWorkspaceCardProps
>(function CurrentWorkspaceCard({ onClick, className, ...attrs }, ref) {
const currentWorkspace = useService(WorkspaceService).workspace;
const info = useWorkspaceInfo(currentWorkspace.meta);
const name = info?.name ?? UNTITLED_WORKSPACE_NAME;
return (
<div
ref={ref}
onClick={onClick}
className={clsx(card, className)}
{...attrs}
>
<WorkspaceAvatar
key={currentWorkspace.id}
meta={currentWorkspace.meta}
rounded={3}
data-testid="workspace-avatar"
size={40}
name={name}
colorfulFallback
/>
<div className={label}>
{name}
<ArrowDownSmallIcon className={dropdownIcon} />
</div>
</div>
);
});

View File

@@ -0,0 +1,39 @@
import { MobileMenu } from '@affine/component';
import { track } from '@affine/track';
import { useService, WorkspacesService } from '@toeverything/infra';
import { useCallback, useEffect, useState } from 'react';
import { CurrentWorkspaceCard } from './current-card';
import { SelectorMenu } from './menu';
export const WorkspaceSelector = () => {
const [open, setOpen] = useState(false);
const workspaceManager = useService(WorkspacesService);
const openMenu = useCallback(() => {
track.$.navigationPanel.workspaceList.open();
setOpen(true);
}, []);
const close = useCallback(() => {
setOpen(false);
}, []);
// revalidate workspace list when open workspace list
useEffect(() => {
if (open) workspaceManager.list.revalidate();
}, [workspaceManager, open]);
return (
<MobileMenu
items={<SelectorMenu onClose={close} />}
rootOptions={{ open }}
contentOptions={{
onInteractOutside: close,
onEscapeKeyDown: close,
style: { padding: 0 },
}}
>
<CurrentWorkspaceCard onClick={openMenu} />
</MobileMenu>
);
};

View File

@@ -0,0 +1,76 @@
import { cssVar } from '@toeverything/theme';
import { cssVarV2 } from '@toeverything/theme/v2';
import { style } from '@vanilla-extract/css';
export const root = style({
maxHeight: 'calc(100dvh - 100px)',
display: 'flex',
flexDirection: 'column',
});
export const divider = style({
height: 16,
display: 'flex',
alignItems: 'center',
position: 'relative',
':before': {
content: '""',
width: '100%',
height: 0.5,
background: cssVar('dividerColor'),
},
});
export const head = style({
display: 'flex',
alignItems: 'center',
justifyContent: 'space-between',
gap: 4,
padding: '10px 16px',
fontSize: 17,
fontWeight: 600,
lineHeight: '22px',
letterSpacing: -0.43,
color: cssVarV2('text/primary'),
});
export const body = style({
overflowY: 'auto',
flexShrink: 0,
flex: 1,
});
export const wsList = style({});
export const wsListTitle = style({
padding: '6px 16px',
fontSize: 13,
lineHeight: '18px',
letterSpacing: -0.08,
color: cssVar('textSecondaryColor'),
});
export const wsItem = style({
padding: '4px 12px',
});
export const wsCard = style({
display: 'flex',
alignItems: 'center',
border: 'none',
background: 'none',
width: '100%',
padding: 8,
borderRadius: 8,
gap: 8,
':active': {
background: cssVarV2('layer/background/hoverOverlay'),
},
});
export const wsName = style({
width: 0,
flex: 1,
textOverflow: 'ellipsis',
whiteSpace: 'nowrap',
overflow: 'hidden',
fontSize: 17,
lineHeight: '22px',
letterSpacing: -0.43,
textAlign: 'left',
});

View File

@@ -0,0 +1,128 @@
import { IconButton } from '@affine/component';
import { WorkspaceAvatar } from '@affine/component/workspace-avatar';
import { useNavigateHelper } from '@affine/core/components/hooks/use-navigate-helper';
import { useWorkspaceInfo } from '@affine/core/components/hooks/use-workspace-info';
import { WorkspaceFlavour } from '@affine/env/workspace';
import { CloseIcon, CollaborationIcon } from '@blocksuite/icons/rc';
import {
useLiveData,
useService,
type WorkspaceMetadata,
WorkspaceService,
WorkspacesService,
} from '@toeverything/infra';
import clsx from 'clsx';
import { type HTMLAttributes, useCallback, useMemo } from 'react';
import * as styles from './menu.css';
const filterByFlavour = (
workspaces: WorkspaceMetadata[],
flavour: WorkspaceFlavour
) => workspaces.filter(ws => flavour === ws.flavour);
const WorkspaceItem = ({
workspace,
className,
...attrs
}: { workspace: WorkspaceMetadata } & HTMLAttributes<HTMLButtonElement>) => {
const info = useWorkspaceInfo(workspace);
const name = info?.name;
const isOwner = info?.isOwner;
return (
<li className={styles.wsItem}>
<button className={clsx(styles.wsCard, className)} {...attrs}>
<WorkspaceAvatar
key={workspace.id}
meta={workspace}
rounded={6}
data-testid="workspace-avatar"
size={32}
name={name}
colorfulFallback
/>
<div className={styles.wsName}>{name}</div>
{!isOwner ? <CollaborationIcon fontSize={24} /> : null}
</button>
</li>
);
};
const WorkspaceList = ({
list,
title,
onClose,
}: {
title: string;
list: WorkspaceMetadata[];
onClose?: () => void;
}) => {
const currentWorkspace = useService(WorkspaceService).workspace;
const { jumpToPage } = useNavigateHelper();
const toggleWorkspace = useCallback(
(id: string) => {
if (id !== currentWorkspace.id) {
jumpToPage(id, 'all');
}
onClose?.();
},
[currentWorkspace.id, jumpToPage, onClose]
);
if (!list.length) return null;
return (
<>
<section className={styles.wsListTitle}>{title}</section>
<ul className={styles.wsList}>
{list.map(ws => (
<WorkspaceItem
key={ws.id}
workspace={ws}
onClick={() => toggleWorkspace?.(ws.id)}
/>
))}
</ul>
</>
);
};
export const SelectorMenu = ({ onClose }: { onClose?: () => void }) => {
const workspacesService = useService(WorkspacesService);
const workspaces = useLiveData(workspacesService.list.workspaces$);
const cloudWorkspaces = useMemo(
() => filterByFlavour(workspaces, WorkspaceFlavour.AFFINE_CLOUD),
[workspaces]
);
const localWorkspaces = useMemo(
() => filterByFlavour(workspaces, WorkspaceFlavour.LOCAL),
[workspaces]
);
return (
<div className={styles.root}>
<header className={styles.head}>
Workspace
<IconButton onClick={onClose} size="24" icon={<CloseIcon />} />
</header>
<div className={styles.divider} />
<main className={styles.body}>
<WorkspaceList
onClose={onClose}
title="Cloud Sync"
list={cloudWorkspaces}
/>
<div className={styles.divider} />
<WorkspaceList
onClose={onClose}
title="Local Storage"
list={localWorkspaces}
/>
</main>
</div>
);
};

View File

@@ -0,0 +1,54 @@
import { useEffect } from 'react';
type Handler<T extends Event> = (event: T) => void;
const _handlesMap = new Map<
keyof WindowEventMap,
Array<Handler<WindowEventMap[keyof WindowEventMap]>>
>();
function initGlobalEvent<T extends keyof WindowEventMap>(name: T) {
const prev = _handlesMap.get(name);
if (!prev) {
const handlers = [] as Handler<WindowEventMap[T]>[];
window.addEventListener(name, e => {
handlers.forEach(handler => {
handler(e);
});
});
_handlesMap.set(name, handlers as any);
return handlers;
}
return prev;
}
function addListener<T extends keyof WindowEventMap>(
name: T,
handler: (e: WindowEventMap[T]) => void
) {
initGlobalEvent(name).push(handler);
}
function removeListener<T extends keyof WindowEventMap>(
name: T,
handler: Handler<WindowEventMap[T]>
) {
const handlers = _handlesMap.get(name) as Handler<WindowEventMap[T]>[];
const idx = handlers.indexOf(handler);
if (idx !== -1) {
handlers.splice(idx, 1);
}
}
export const useGlobalEvent = <T extends keyof WindowEventMap>(
name: T,
handler: (e: WindowEventMap[T]) => void
) => {
useEffect(() => {
addListener(name, handler);
return () => {
removeListener(name, handler);
};
}, [handler, name]);
};

View File

@@ -0,0 +1,7 @@
import type { Framework } from '@toeverything/infra';
import { configureMobileSearchModule } from './search';
export function configureMobileModules(framework: Framework) {
configureMobileSearchModule(framework);
}

View File

@@ -0,0 +1,9 @@
import { type Framework, WorkspaceScope } from '@toeverything/infra';
import { MobileSearchService } from './service/search';
export { MobileSearchService };
export function configureMobileSearchModule(framework: Framework) {
framework.scope(WorkspaceScope).service(MobileSearchService);
}

View File

@@ -0,0 +1,18 @@
import {
CollectionsQuickSearchSession,
DocsQuickSearchSession,
RecentDocsQuickSearchSession,
TagsQuickSearchSession,
} from '@affine/core/modules/quicksearch';
import { Service } from '@toeverything/infra';
export class MobileSearchService extends Service {
readonly recentDocs = this.framework.createEntity(
RecentDocsQuickSearchSession
);
readonly collections = this.framework.createEntity(
CollectionsQuickSearchSession
);
readonly docs = this.framework.createEntity(DocsQuickSearchSession);
readonly tags = this.framework.createEntity(TagsQuickSearchSession);
}

View File

@@ -0,0 +1,3 @@
export const Component = () => {
return <div>/404</div>;
};

View File

@@ -0,0 +1,3 @@
export const Component = () => {
return <div>/auth/*</div>;
};

View File

@@ -0,0 +1,8 @@
import { Component as IndexComponent } from '@affine/core/desktop/pages/index';
// Default route fallback for mobile
export const Component = () => {
// TODO: replace with a mobile version
return <IndexComponent defaultIndexRoute={'home'} />;
};

Some files were not shown because too many files have changed in this diff Show More