mirror of
https://github.com/toeverything/AFFiNE.git
synced 2026-02-24 09:52:49 +08:00
refactor(core): move mobile components to core (#8258)
This commit is contained in:
@@ -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';
|
||||
|
||||
@@ -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';
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 = ({
|
||||
@@ -0,0 +1,5 @@
|
||||
import { AIUpgradeSuccess } from '../../components/affine/subscription-landing';
|
||||
|
||||
export const Component = () => {
|
||||
return <AIUpgradeSuccess />;
|
||||
};
|
||||
@@ -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',
|
||||
@@ -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 {
|
||||
@@ -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 {
|
||||
@@ -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;
|
||||
@@ -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();
|
||||
@@ -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);
|
||||
@@ -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;
|
||||
@@ -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 || '';
|
||||
@@ -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')) {
|
||||
@@ -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 (
|
||||
@@ -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 = () => {
|
||||
@@ -1,4 +1,4 @@
|
||||
import { ThemeEditor } from '../modules/theme-editor';
|
||||
import { ThemeEditor } from '../../modules/theme-editor';
|
||||
|
||||
export const Component = () => {
|
||||
return <ThemeEditor />;
|
||||
@@ -0,0 +1,5 @@
|
||||
import { CloudUpgradeSuccess } from '../../components/affine/subscription-landing';
|
||||
|
||||
export const Component = () => {
|
||||
return <CloudUpgradeSuccess />;
|
||||
};
|
||||
@@ -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';
|
||||
@@ -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,
|
||||
@@ -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';
|
||||
@@ -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';
|
||||
@@ -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 = ({
|
||||
@@ -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';
|
||||
|
||||
@@ -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';
|
||||
@@ -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'
|
||||
) {
|
||||
@@ -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';
|
||||
|
||||
@@ -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() {
|
||||
@@ -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'),
|
||||
3
packages/frontend/core/src/mobile/components/README.md
Normal file
3
packages/frontend/core/src/mobile/components/README.md
Normal file
@@ -0,0 +1,3 @@
|
||||
# mobile components
|
||||
|
||||
Maintain the smallest possible business components here.
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
@@ -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'),
|
||||
},
|
||||
},
|
||||
});
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
);
|
||||
@@ -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,
|
||||
});
|
||||
@@ -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'),
|
||||
});
|
||||
@@ -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} />;
|
||||
};
|
||||
7
packages/frontend/core/src/mobile/components/index.ts
Normal file
7
packages/frontend/core/src/mobile/components/index.ts
Normal 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';
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
);
|
||||
@@ -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,
|
||||
});
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
);
|
||||
@@ -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'),
|
||||
},
|
||||
]);
|
||||
@@ -0,0 +1,2 @@
|
||||
export * from './search-res-label';
|
||||
export * from './universal-item';
|
||||
@@ -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>" />;
|
||||
};
|
||||
@@ -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'),
|
||||
});
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
@@ -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>
|
||||
);
|
||||
});
|
||||
@@ -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',
|
||||
},
|
||||
},
|
||||
});
|
||||
@@ -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'),
|
||||
});
|
||||
@@ -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>
|
||||
);
|
||||
});
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
@@ -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',
|
||||
});
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
54
packages/frontend/core/src/mobile/hooks/use-global-events.ts
Normal file
54
packages/frontend/core/src/mobile/hooks/use-global-events.ts
Normal 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]);
|
||||
};
|
||||
7
packages/frontend/core/src/mobile/modules/index.ts
Normal file
7
packages/frontend/core/src/mobile/modules/index.ts
Normal file
@@ -0,0 +1,7 @@
|
||||
import type { Framework } from '@toeverything/infra';
|
||||
|
||||
import { configureMobileSearchModule } from './search';
|
||||
|
||||
export function configureMobileModules(framework: Framework) {
|
||||
configureMobileSearchModule(framework);
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
3
packages/frontend/core/src/mobile/pages/404.tsx
Normal file
3
packages/frontend/core/src/mobile/pages/404.tsx
Normal file
@@ -0,0 +1,3 @@
|
||||
export const Component = () => {
|
||||
return <div>/404</div>;
|
||||
};
|
||||
3
packages/frontend/core/src/mobile/pages/auth.tsx
Normal file
3
packages/frontend/core/src/mobile/pages/auth.tsx
Normal file
@@ -0,0 +1,3 @@
|
||||
export const Component = () => {
|
||||
return <div>/auth/*</div>;
|
||||
};
|
||||
8
packages/frontend/core/src/mobile/pages/index.tsx
Normal file
8
packages/frontend/core/src/mobile/pages/index.tsx
Normal 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
Reference in New Issue
Block a user