mirror of
https://github.com/toeverything/AFFiNE.git
synced 2025-01-02 14:53:31 +03:00
feat(mobile): mobile index page UI (#7959)
This commit is contained in:
parent
f37051dc87
commit
db76780bc9
@ -1,34 +1,32 @@
|
||||
import type { DocCollection } from '@blocksuite/store';
|
||||
import { useAtomValue } from 'jotai';
|
||||
import { Suspense } from 'react';
|
||||
import { type ReactNode, Suspense } from 'react';
|
||||
|
||||
import { useBlockSuitePagePreview } from './use-block-suite-page-preview';
|
||||
import { useDocCollectionPage } from './use-block-suite-workspace-page';
|
||||
|
||||
interface PagePreviewInnerProps {
|
||||
interface PagePreviewProps {
|
||||
docCollection: DocCollection;
|
||||
pageId: string;
|
||||
emptyFallback?: ReactNode;
|
||||
}
|
||||
|
||||
const PagePreviewInner = ({
|
||||
docCollection: workspace,
|
||||
pageId,
|
||||
}: PagePreviewInnerProps) => {
|
||||
emptyFallback,
|
||||
}: PagePreviewProps) => {
|
||||
const page = useDocCollectionPage(workspace, pageId);
|
||||
const previewAtom = useBlockSuitePagePreview(page);
|
||||
const preview = useAtomValue(previewAtom);
|
||||
return preview ? preview : null;
|
||||
const res = preview ? preview : null;
|
||||
return res || emptyFallback;
|
||||
};
|
||||
|
||||
interface PagePreviewProps {
|
||||
docCollection: DocCollection;
|
||||
pageId: string;
|
||||
}
|
||||
|
||||
export const PagePreview = ({ docCollection, pageId }: PagePreviewProps) => {
|
||||
export const PagePreview = (props: PagePreviewProps) => {
|
||||
return (
|
||||
<Suspense>
|
||||
<PagePreviewInner docCollection={docCollection} pageId={pageId} />
|
||||
<PagePreviewInner {...props} />
|
||||
</Suspense>
|
||||
);
|
||||
};
|
||||
|
@ -5,6 +5,7 @@ import { map } from 'rxjs';
|
||||
import type { CollapsibleSectionName } from '../types';
|
||||
|
||||
const DEFAULT_COLLAPSABLE_STATE: Record<CollapsibleSectionName, boolean> = {
|
||||
recent: true,
|
||||
favorites: false,
|
||||
organize: false,
|
||||
collections: true,
|
||||
|
@ -8,6 +8,7 @@ import { ExplorerSection } from './entities/explore-section';
|
||||
import { ExplorerService } from './services/explorer';
|
||||
export { ExplorerService } from './services/explorer';
|
||||
export type { CollapsibleSectionName } from './types';
|
||||
export { CollapsibleSection } from './views/layouts/collapsible-section';
|
||||
export { ExplorerMobileContext } from './views/mobile.context';
|
||||
export { ExplorerCollections } from './views/sections/collections';
|
||||
export { ExplorerFavorites } from './views/sections/favorites';
|
||||
|
@ -4,6 +4,7 @@ import { ExplorerSection } from '../entities/explore-section';
|
||||
import type { CollapsibleSectionName } from '../types';
|
||||
|
||||
const allSectionName: Array<CollapsibleSectionName> = [
|
||||
'recent', // mobile only
|
||||
'favorites',
|
||||
'organize',
|
||||
'collections',
|
||||
|
@ -1,4 +1,5 @@
|
||||
export type CollapsibleSectionName =
|
||||
| 'recent'
|
||||
| 'collections'
|
||||
| 'favorites'
|
||||
| 'tags'
|
||||
|
@ -77,7 +77,7 @@ export const postfix = style({
|
||||
});
|
||||
export const iconContainer = style({
|
||||
display: 'flex',
|
||||
alignContent: 'center',
|
||||
justifyContent: 'center',
|
||||
alignItems: 'center',
|
||||
width: 20,
|
||||
height: 20,
|
||||
|
@ -1,9 +1,11 @@
|
||||
export { Workbench } from './entities/workbench';
|
||||
export { ViewScope } from './scopes/view';
|
||||
export { WorkbenchService } from './services/workbench';
|
||||
export { useBindWorkbenchToBrowserRouter } from './view/browser-adapter';
|
||||
export { useIsActiveView } from './view/use-is-active-view';
|
||||
export { ViewBody, ViewHeader, ViewSidebarTab } from './view/view-islands';
|
||||
export { ViewIcon, ViewTitle } from './view/view-meta';
|
||||
export type { WorkbenchLinkProps } from './view/workbench-link';
|
||||
export { WorkbenchLink } from './view/workbench-link';
|
||||
export { WorkbenchRoot } from './view/workbench-root';
|
||||
|
||||
|
@ -10,56 +10,57 @@ import { forwardRef, type MouseEvent } from 'react';
|
||||
|
||||
import { WorkbenchService } from '../services/workbench';
|
||||
|
||||
export const WorkbenchLink = forwardRef<
|
||||
HTMLAnchorElement,
|
||||
React.PropsWithChildren<
|
||||
{
|
||||
to: To;
|
||||
onClick?: (e: MouseEvent) => void;
|
||||
} & React.HTMLProps<HTMLAnchorElement>
|
||||
>
|
||||
>(function WorkbenchLink({ to, onClick, ...other }, ref) {
|
||||
const { featureFlagService, workbenchService } = useServices({
|
||||
FeatureFlagService,
|
||||
WorkbenchService,
|
||||
});
|
||||
const enableMultiView = useLiveData(
|
||||
featureFlagService.flags.enable_multi_view.$
|
||||
);
|
||||
const workbench = workbenchService.workbench;
|
||||
const basename = useLiveData(workbench.basename$);
|
||||
const link =
|
||||
basename +
|
||||
(typeof to === 'string' ? to : `${to.pathname}${to.search}${to.hash}`);
|
||||
const handleClick = useCatchEventCallback(
|
||||
async (event: React.MouseEvent<HTMLAnchorElement>) => {
|
||||
onClick?.(event);
|
||||
if (event.defaultPrevented) {
|
||||
return;
|
||||
}
|
||||
const at = (() => {
|
||||
if (isNewTabTrigger(event)) {
|
||||
return event.altKey && enableMultiView && environment.isDesktop
|
||||
? 'tail'
|
||||
: 'new-tab';
|
||||
}
|
||||
return 'active';
|
||||
})();
|
||||
workbench.open(to, { at });
|
||||
event.preventDefault();
|
||||
},
|
||||
[enableMultiView, onClick, to, workbench]
|
||||
);
|
||||
export type WorkbenchLinkProps = React.PropsWithChildren<
|
||||
{
|
||||
to: To;
|
||||
onClick?: (e: MouseEvent) => void;
|
||||
} & React.HTMLProps<HTMLAnchorElement>
|
||||
>;
|
||||
|
||||
// eslint suspicious runtime error
|
||||
// eslint-disable-next-line react/no-danger-with-children
|
||||
return (
|
||||
<a
|
||||
{...other}
|
||||
ref={ref}
|
||||
href={link}
|
||||
onClick={handleClick}
|
||||
onAuxClick={handleClick}
|
||||
/>
|
||||
);
|
||||
});
|
||||
export const WorkbenchLink = forwardRef<HTMLAnchorElement, WorkbenchLinkProps>(
|
||||
function WorkbenchLink({ to, onClick, ...other }, ref) {
|
||||
const { featureFlagService, workbenchService } = useServices({
|
||||
FeatureFlagService,
|
||||
WorkbenchService,
|
||||
});
|
||||
const enableMultiView = useLiveData(
|
||||
featureFlagService.flags.enable_multi_view.$
|
||||
);
|
||||
const workbench = workbenchService.workbench;
|
||||
const basename = useLiveData(workbench.basename$);
|
||||
const link =
|
||||
basename +
|
||||
(typeof to === 'string' ? to : `${to.pathname}${to.search}${to.hash}`);
|
||||
const handleClick = useCatchEventCallback(
|
||||
async (event: React.MouseEvent<HTMLAnchorElement>) => {
|
||||
onClick?.(event);
|
||||
if (event.defaultPrevented) {
|
||||
return;
|
||||
}
|
||||
const at = (() => {
|
||||
if (isNewTabTrigger(event)) {
|
||||
return event.altKey && enableMultiView && environment.isDesktop
|
||||
? 'tail'
|
||||
: 'new-tab';
|
||||
}
|
||||
return 'active';
|
||||
})();
|
||||
workbench.open(to, { at });
|
||||
event.preventDefault();
|
||||
},
|
||||
[enableMultiView, onClick, to, workbench]
|
||||
);
|
||||
|
||||
// eslint suspicious runtime error
|
||||
// eslint-disable-next-line react/no-danger-with-children
|
||||
return (
|
||||
<a
|
||||
{...other}
|
||||
ref={ref}
|
||||
href={link}
|
||||
onClick={handleClick}
|
||||
onAuxClick={handleClick}
|
||||
/>
|
||||
);
|
||||
}
|
||||
);
|
||||
|
@ -28,7 +28,11 @@ export const loader: LoaderFunction = async () => {
|
||||
return null;
|
||||
};
|
||||
|
||||
export const Component = () => {
|
||||
export const Component = ({
|
||||
defaultIndexRoute = WorkspaceSubPath.ALL,
|
||||
}: {
|
||||
defaultIndexRoute?: WorkspaceSubPath;
|
||||
}) => {
|
||||
// navigating and creating may be slow, to avoid flickering, we show workspace fallback
|
||||
const [navigating, setNavigating] = useState(true);
|
||||
const [creating, setCreating] = useState(false);
|
||||
@ -59,11 +63,11 @@ export const Component = () => {
|
||||
if (defaultDocId) {
|
||||
jumpToPage(meta.id, defaultDocId);
|
||||
} else {
|
||||
openPage(meta.id, WorkspaceSubPath.ALL);
|
||||
openPage(meta.id, defaultIndexRoute);
|
||||
}
|
||||
})
|
||||
.catch(err => console.error('Failed to create cloud workspace', err));
|
||||
}, [jumpToPage, openPage, workspacesService]);
|
||||
}, [defaultIndexRoute, jumpToPage, openPage, workspacesService]);
|
||||
|
||||
useLayoutEffect(() => {
|
||||
if (!navigating) {
|
||||
@ -86,7 +90,7 @@ export const Component = () => {
|
||||
const openWorkspace =
|
||||
list.find(w => w.flavour === WorkspaceFlavour.AFFINE_CLOUD) ??
|
||||
list[0];
|
||||
openPage(openWorkspace.id, WorkspaceSubPath.ALL);
|
||||
openPage(openWorkspace.id, defaultIndexRoute);
|
||||
} else {
|
||||
return;
|
||||
}
|
||||
@ -99,7 +103,7 @@ export const Component = () => {
|
||||
const lastId = localStorage.getItem('last_workspace_id');
|
||||
|
||||
const openWorkspace = list.find(w => w.id === lastId) ?? list[0];
|
||||
openPage(openWorkspace.id, WorkspaceSubPath.ALL);
|
||||
openPage(openWorkspace.id, defaultIndexRoute);
|
||||
}
|
||||
}, [
|
||||
createCloudWorkspace,
|
||||
@ -109,6 +113,7 @@ export const Component = () => {
|
||||
listIsLoading,
|
||||
loggedIn,
|
||||
navigating,
|
||||
defaultIndexRoute,
|
||||
]);
|
||||
|
||||
useEffect(() => {
|
||||
|
@ -1,5 +1,7 @@
|
||||
import { WorkbenchService } from '@affine/core/modules/workbench';
|
||||
import { useBindWorkbenchToBrowserRouter } from '@affine/core/modules/workbench/view/browser-adapter';
|
||||
import {
|
||||
useBindWorkbenchToBrowserRouter,
|
||||
WorkbenchService,
|
||||
} from '@affine/core/modules/workbench';
|
||||
import { ViewRoot } from '@affine/core/modules/workbench/view/view-root';
|
||||
import { useLiveData, useService } from '@toeverything/infra';
|
||||
import { useEffect } from 'react';
|
||||
|
@ -7,12 +7,14 @@ export enum WorkspaceSubPath {
|
||||
ALL = 'all',
|
||||
TRASH = 'trash',
|
||||
SHARED = 'shared',
|
||||
HOME = 'home',
|
||||
}
|
||||
|
||||
export const WorkspaceSubPathName = {
|
||||
[WorkspaceSubPath.ALL]: 'All Pages',
|
||||
[WorkspaceSubPath.TRASH]: 'Trash',
|
||||
[WorkspaceSubPath.SHARED]: 'Shared',
|
||||
[WorkspaceSubPath.HOME]: 'Home',
|
||||
} satisfies {
|
||||
[Path in WorkspaceSubPath]: string;
|
||||
};
|
||||
@ -21,6 +23,7 @@ export const pathGenerator = {
|
||||
all: workspaceId => `/workspace/${workspaceId}/all`,
|
||||
trash: workspaceId => `/workspace/${workspaceId}/trash`,
|
||||
shared: workspaceId => `/workspace/${workspaceId}/shared`,
|
||||
home: workspaceId => `/workspace/${workspaceId}/home`,
|
||||
} satisfies {
|
||||
[Path in WorkspaceSubPath]: (workspaceId: string) => string;
|
||||
};
|
||||
|
@ -12,16 +12,22 @@
|
||||
"@affine/component": "workspace:*",
|
||||
"@affine/core": "workspace:*",
|
||||
"@affine/env": "workspace:*",
|
||||
"@blocksuite/icons": "^2.1.64",
|
||||
"@sentry/react": "^8.0.0",
|
||||
"@toeverything/theme": "^1.0.7",
|
||||
"clsx": "^2.1.1",
|
||||
"core-js": "^3.36.1",
|
||||
"intl-segmenter-polyfill-rs": "^0.1.7",
|
||||
"react": "^18.2.0",
|
||||
"react-dom": "^18.2.0"
|
||||
"react-dom": "^18.2.0",
|
||||
"react-router-dom": "^6.26.1"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@affine/cli": "workspace:*",
|
||||
"@types/react": "^18.2.75",
|
||||
"@types/react-dom": "^18.2.24",
|
||||
"@vanilla-extract/css": "^1.15.5",
|
||||
"@vanilla-extract/dynamic": "^2.1.2",
|
||||
"cross-env": "^7.0.3",
|
||||
"typescript": "^5.4.5"
|
||||
}
|
||||
|
@ -1,5 +1,6 @@
|
||||
import '@affine/component/theme/global.css';
|
||||
import '@affine/component/theme/theme.css';
|
||||
import './styles/mobile.css';
|
||||
|
||||
import { AffineContext } from '@affine/component/context';
|
||||
import { AppFallback } from '@affine/core/components/affine/app-container';
|
||||
|
3
packages/frontend/mobile/src/components/README.md
Normal file
3
packages/frontend/mobile/src/components/README.md
Normal file
@ -0,0 +1,3 @@
|
||||
# mobile components
|
||||
|
||||
Maintain the smallest possible business components here.
|
@ -0,0 +1,21 @@
|
||||
/**
|
||||
* TODO(@CatsJuice): replace with `@blocksuite/icons/rc` when ready
|
||||
*/
|
||||
export const HomeIcon = () => {
|
||||
return (
|
||||
<svg
|
||||
width="1em"
|
||||
height="1em"
|
||||
viewBox="0 0 31 30"
|
||||
fill="none"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
>
|
||||
<path
|
||||
fillRule="evenodd"
|
||||
clipRule="evenodd"
|
||||
d="M24.5957 24.375V13.0977C24.5957 12.9048 24.5067 12.7228 24.3544 12.6044L15.6044 5.7988C15.3787 5.62326 15.0627 5.62326 14.837 5.7988L6.08699 12.6044C5.93475 12.7228 5.8457 12.9048 5.8457 13.0977V24.375C5.8457 24.7202 6.12553 25 6.4707 25H23.9707C24.3159 25 24.5957 24.7202 24.5957 24.375ZM4.93585 11.1243C4.32689 11.598 3.9707 12.3262 3.9707 13.0977V24.375C3.9707 25.7557 5.08999 26.875 6.4707 26.875H23.9707C25.3514 26.875 26.4707 25.7557 26.4707 24.375V13.0977C26.4707 12.3262 26.1145 11.598 25.5056 11.1243L16.7556 4.31876C15.8528 3.61661 14.5886 3.6166 13.6859 4.31876L4.93585 11.1243Z"
|
||||
fill="currentColor"
|
||||
/>
|
||||
</svg>
|
||||
);
|
||||
};
|
55
packages/frontend/mobile/src/components/app-tabs/index.tsx
Normal file
55
packages/frontend/mobile/src/components/app-tabs/index.tsx
Normal file
@ -0,0 +1,55 @@
|
||||
import {
|
||||
WorkbenchLink,
|
||||
WorkbenchService,
|
||||
} from '@affine/core/modules/workbench';
|
||||
import { AllDocsIcon, SearchIcon } from '@blocksuite/icons/rc';
|
||||
import { useLiveData, useService } from '@toeverything/infra';
|
||||
|
||||
import { HomeIcon } from './home-icon';
|
||||
import * as styles from './styles.css';
|
||||
|
||||
interface Route {
|
||||
to: string;
|
||||
Icon: React.FC;
|
||||
LinkComponent?: React.FC;
|
||||
}
|
||||
|
||||
const routes: Route[] = [
|
||||
{
|
||||
to: '/home',
|
||||
Icon: HomeIcon,
|
||||
},
|
||||
{
|
||||
to: '/all',
|
||||
Icon: AllDocsIcon,
|
||||
},
|
||||
{
|
||||
to: '/search',
|
||||
Icon: SearchIcon,
|
||||
},
|
||||
];
|
||||
|
||||
export const AppTabs = () => {
|
||||
const workbench = useService(WorkbenchService).workbench;
|
||||
const location = useLiveData(workbench.location$);
|
||||
|
||||
return (
|
||||
<ul className={styles.appTabs} id="app-tabs">
|
||||
{routes.map(route => {
|
||||
const Link = route.LinkComponent || WorkbenchLink;
|
||||
return (
|
||||
<Link
|
||||
data-active={location.pathname === route.to}
|
||||
to={route.to}
|
||||
key={route.to}
|
||||
className={styles.tabItem}
|
||||
>
|
||||
<li>
|
||||
<route.Icon />
|
||||
</li>
|
||||
</Link>
|
||||
);
|
||||
})}
|
||||
</ul>
|
||||
);
|
||||
};
|
@ -0,0 +1,38 @@
|
||||
import { cssVarV2 } from '@toeverything/theme/v2';
|
||||
import { style } from '@vanilla-extract/css';
|
||||
|
||||
import { globalVars } from '../../styles/mobile.css';
|
||||
|
||||
export const appTabs = style({
|
||||
display: 'flex',
|
||||
justifyContent: 'space-between',
|
||||
alignItems: 'center',
|
||||
|
||||
backgroundColor: cssVarV2('layer/background/secondary'),
|
||||
borderTop: `1px solid ${cssVarV2('layer/insideBorder/border')}`,
|
||||
|
||||
width: '100vw',
|
||||
height: globalVars.appTabHeight,
|
||||
padding: 16,
|
||||
gap: 15.5,
|
||||
|
||||
position: 'fixed',
|
||||
bottom: 0,
|
||||
});
|
||||
export const tabItem = style({
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
width: 0,
|
||||
flex: 1,
|
||||
height: 36,
|
||||
padding: 3,
|
||||
fontSize: 30,
|
||||
color: cssVarV2('icon/primary'),
|
||||
|
||||
selectors: {
|
||||
'&[data-active="true"]': {
|
||||
color: cssVarV2('button/primary'),
|
||||
},
|
||||
},
|
||||
});
|
61
packages/frontend/mobile/src/components/doc-card/index.tsx
Normal file
61
packages/frontend/mobile/src/components/doc-card/index.tsx
Normal file
@ -0,0 +1,61 @@
|
||||
import { IconButton } from '@affine/component';
|
||||
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, useCallback } from 'react';
|
||||
|
||||
import * as styles from './styles.css';
|
||||
import { DocCardTags } from './tag';
|
||||
|
||||
export interface DocCardProps extends Omit<WorkbenchLinkProps, 'to'> {
|
||||
meta: DocMeta;
|
||||
}
|
||||
|
||||
export const DocCard = forwardRef<HTMLAnchorElement, DocCardProps>(
|
||||
function DocCard({ meta, className, ...attrs }, ref) {
|
||||
const favAdapter = useService(CompatibleFavoriteItemsAdapter);
|
||||
const workspace = useService(WorkspaceService).workspace;
|
||||
|
||||
const favorited = useLiveData(favAdapter.isFavorite$(meta.id, 'doc'));
|
||||
|
||||
const toggleFavorite = useCallback(
|
||||
() => favAdapter.toggle(meta.id, 'doc'),
|
||||
[favAdapter, meta.id]
|
||||
);
|
||||
|
||||
return (
|
||||
<WorkbenchLink
|
||||
to={`/${meta.id}`}
|
||||
ref={ref}
|
||||
className={clsx(styles.card, className)}
|
||||
{...attrs}
|
||||
>
|
||||
<header className={styles.head}>
|
||||
<h3 className={styles.title}>
|
||||
{meta.title || <span className={styles.untitled}>Untitled</span>}
|
||||
</h3>
|
||||
<IconButton
|
||||
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>
|
||||
<DocCardTags docId={meta.id} rows={2} />
|
||||
</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,
|
||||
});
|
45
packages/frontend/mobile/src/components/doc-card/tag.css.ts
Normal file
45
packages/frontend/mobile/src/components/doc-card/tag.css.ts
Normal file
@ -0,0 +1,45 @@
|
||||
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: 'flex-start',
|
||||
position: 'relative',
|
||||
|
||||
transition: 'height 0.23s',
|
||||
overflow: 'hidden',
|
||||
});
|
||||
|
||||
export const tag = style({
|
||||
visibility: 'hidden',
|
||||
position: 'absolute',
|
||||
// transition: 'all 0.23s',
|
||||
|
||||
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,
|
||||
},
|
||||
});
|
143
packages/frontend/mobile/src/components/doc-card/tag.tsx
Normal file
143
packages/frontend/mobile/src/components/doc-card/tag.tsx
Normal file
@ -0,0 +1,143 @@
|
||||
import { observeResize } from '@affine/component';
|
||||
import type { Tag } from '@affine/core/modules/tag';
|
||||
import { TagService } from '@affine/core/modules/tag';
|
||||
import { useLiveData, useService } from '@toeverything/infra';
|
||||
import { assignInlineVars } from '@vanilla-extract/dynamic';
|
||||
import { useCallback, useEffect, useRef } from 'react';
|
||||
|
||||
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 GAP = 4;
|
||||
const MIN_WIDTH = 32;
|
||||
|
||||
const DocCardTagsRenderer = ({ tags, rows }: { tags: Tag[]; rows: number }) => {
|
||||
const ulRef = useRef<HTMLUListElement>(null);
|
||||
|
||||
// A strategy to layout tags
|
||||
const layoutTags = useCallback(
|
||||
(entry: ResizeObserverEntry) => {
|
||||
const availableWidth = entry.contentRect.width;
|
||||
const lis = Array.from(ulRef.current?.querySelectorAll('li') ?? []);
|
||||
|
||||
const tagGrid: Array<{
|
||||
x: number;
|
||||
y: number;
|
||||
w: number;
|
||||
el: HTMLLIElement;
|
||||
}> = [];
|
||||
|
||||
for (let i = 0; i < rows; i++) {
|
||||
let width = 0;
|
||||
let restSpace = availableWidth - width;
|
||||
while (restSpace >= MIN_WIDTH) {
|
||||
const li = lis.shift();
|
||||
if (!li) break;
|
||||
const liWidth = li.scrollWidth + 2; // 2 is for border
|
||||
let liDisplayWidth = Math.min(liWidth, restSpace);
|
||||
|
||||
restSpace = restSpace - liDisplayWidth - GAP;
|
||||
if (restSpace < MIN_WIDTH) {
|
||||
liDisplayWidth += restSpace;
|
||||
}
|
||||
|
||||
tagGrid.push({
|
||||
x: width,
|
||||
y: i * (22 + GAP),
|
||||
w: liDisplayWidth,
|
||||
el: li,
|
||||
});
|
||||
|
||||
width += liDisplayWidth + GAP;
|
||||
}
|
||||
}
|
||||
|
||||
const lastItem = tagGrid[tagGrid.length - 1];
|
||||
|
||||
tagGrid.forEach(({ el, x, y, w }) => {
|
||||
Object.assign(el.style, {
|
||||
width: `${w}px`,
|
||||
transform: `translate(${x}px, ${y}px)`,
|
||||
visibility: 'visible',
|
||||
});
|
||||
});
|
||||
|
||||
// hide rest
|
||||
lis.forEach(li =>
|
||||
Object.assign(li.style, {
|
||||
visibility: 'hidden',
|
||||
width: '0px',
|
||||
transform: `translate(0px, ${lastItem.y + 22 + GAP}px)`,
|
||||
})
|
||||
);
|
||||
|
||||
// update ul height
|
||||
// to avoid trigger resize immediately
|
||||
setTimeout(() => {
|
||||
if (ulRef.current) {
|
||||
ulRef.current.style.height = `${lastItem.y + 22}px`;
|
||||
}
|
||||
});
|
||||
},
|
||||
[rows]
|
||||
);
|
||||
|
||||
const prevEntryRef = useRef<ResizeObserverEntry | null>(null);
|
||||
useEffect(() => {
|
||||
tags; // make sure tags is in deps
|
||||
const ul = ulRef.current;
|
||||
if (!ul) return;
|
||||
|
||||
const dispose = observeResize(ul, entry => {
|
||||
if (entry.contentRect.width === prevEntryRef.current?.contentRect.width) {
|
||||
return;
|
||||
}
|
||||
|
||||
layoutTags(entry);
|
||||
prevEntryRef.current = entry;
|
||||
});
|
||||
return () => {
|
||||
dispose();
|
||||
prevEntryRef.current = null;
|
||||
};
|
||||
}, [layoutTags, tags]);
|
||||
|
||||
return (
|
||||
<ul className={styles.tags} ref={ulRef} style={{ gap: GAP }}>
|
||||
{tags.map(tag => (
|
||||
<DocCardTag key={tag.id} tag={tag} />
|
||||
))}
|
||||
{/* TODO: more icon */}
|
||||
{/* <MoreHorizontalIcon /> */}
|
||||
</ul>
|
||||
);
|
||||
};
|
||||
|
||||
export const DocCardTags = ({
|
||||
docId,
|
||||
rows = 2,
|
||||
}: {
|
||||
docId: string;
|
||||
rows?: number;
|
||||
}) => {
|
||||
const tagService = useService(TagService);
|
||||
const tags = useLiveData(tagService.tagList.tagsByPageId$(docId));
|
||||
|
||||
if (!tags.length) return null;
|
||||
return <DocCardTagsRenderer tags={tags} rows={rows} />;
|
||||
};
|
4
packages/frontend/mobile/src/components/index.ts
Normal file
4
packages/frontend/mobile/src/components/index.ts
Normal file
@ -0,0 +1,4 @@
|
||||
export * from './app-tabs';
|
||||
export * from './page-header';
|
||||
export * from './search-button';
|
||||
export * from './workspace-selector';
|
@ -0,0 +1,92 @@
|
||||
import { IconButton } 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;
|
||||
|
||||
/**
|
||||
* 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<HTMLHeadElement, PageHeaderProps>(
|
||||
function PageHeader(
|
||||
{
|
||||
back,
|
||||
prefix,
|
||||
suffix,
|
||||
children,
|
||||
className,
|
||||
centerContent = true,
|
||||
prefixClassName,
|
||||
prefixStyle,
|
||||
suffixClassName,
|
||||
suffixStyle,
|
||||
...attrs
|
||||
},
|
||||
ref
|
||||
) {
|
||||
const handleRouteBack = useCallback(() => {
|
||||
history.back();
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<header ref={ref} className={clsx(styles.root, className)} {...attrs}>
|
||||
<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>
|
||||
);
|
||||
}
|
||||
);
|
@ -0,0 +1,38 @@
|
||||
import { style } from '@vanilla-extract/css';
|
||||
|
||||
export const root = style({
|
||||
width: '100%',
|
||||
minHeight: 44,
|
||||
padding: '0 6px',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'space-between',
|
||||
position: 'relative',
|
||||
});
|
||||
export const content = style({
|
||||
selectors: {
|
||||
'&.center': {
|
||||
position: 'absolute',
|
||||
left: '50%',
|
||||
transform: 'translateX(-50%)',
|
||||
},
|
||||
'&: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: 18,
|
||||
});
|
@ -0,0 +1,17 @@
|
||||
import { WorkbenchLink } from '@affine/core/modules/workbench';
|
||||
import { useI18n } from '@affine/i18n';
|
||||
import { SearchIcon } from '@blocksuite/icons/rc';
|
||||
|
||||
import * as styles from './styles.css';
|
||||
|
||||
export const SearchButton = () => {
|
||||
const t = useI18n();
|
||||
return (
|
||||
<WorkbenchLink to="/search">
|
||||
<div className={styles.search}>
|
||||
<SearchIcon className={styles.icon} />
|
||||
{t['Quick search']()}
|
||||
</div>
|
||||
</WorkbenchLink>
|
||||
);
|
||||
};
|
@ -0,0 +1,25 @@
|
||||
import { cssVarV2 } from '@toeverything/theme/v2';
|
||||
import { style } from '@vanilla-extract/css';
|
||||
|
||||
export const search = style({
|
||||
width: '100%',
|
||||
height: 44,
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
gap: 8,
|
||||
padding: '7px 8px',
|
||||
background: cssVarV2('layer/background/primary'),
|
||||
borderRadius: 10,
|
||||
|
||||
color: cssVarV2('text/secondary'),
|
||||
fontSize: 17,
|
||||
fontWeight: 400,
|
||||
lineHeight: '22px',
|
||||
letterSpacing: -0.43,
|
||||
});
|
||||
|
||||
export const icon = style({
|
||||
width: 20,
|
||||
height: 20,
|
||||
color: cssVarV2('icon/primary'),
|
||||
});
|
@ -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/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/core/mixpanel';
|
||||
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(100vh - 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/hooks/use-navigate-helper';
|
||||
import { useWorkspaceInfo } from '@affine/core/hooks/use-workspace-info';
|
||||
import { WorkspaceSubPath } from '@affine/core/shared';
|
||||
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 { jumpToSubPath } = useNavigateHelper();
|
||||
const toggleWorkspace = useCallback(
|
||||
(id: string) => {
|
||||
if (id !== currentWorkspace.id) {
|
||||
jumpToSubPath(id, WorkspaceSubPath.ALL);
|
||||
}
|
||||
onClose?.();
|
||||
},
|
||||
[currentWorkspace.id, jumpToSubPath, 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}
|
||||
/>
|
||||
<WorkspaceList
|
||||
onClose={onClose}
|
||||
title="Local Storage"
|
||||
list={localWorkspaces}
|
||||
/>
|
||||
</main>
|
||||
</div>
|
||||
);
|
||||
};
|
54
packages/frontend/mobile/src/hooks/use-global-events.ts
Normal file
54
packages/frontend/mobile/src/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]);
|
||||
};
|
@ -1,8 +1,9 @@
|
||||
import { Component as IndexComponent } from '@affine/core/pages/index';
|
||||
import { WorkspaceSubPath } from '@affine/core/shared';
|
||||
|
||||
// Default route fallback for mobile
|
||||
|
||||
export const Component = () => {
|
||||
// TODO: replace with a mobile version
|
||||
return <IndexComponent />;
|
||||
return <IndexComponent defaultIndexRoute={WorkspaceSubPath.HOME} />;
|
||||
};
|
||||
|
@ -1,3 +1,10 @@
|
||||
import { AppTabs } from '../../components';
|
||||
|
||||
export const Component = () => {
|
||||
return <div>/workspace/:workspaceId/all</div>;
|
||||
return (
|
||||
<>
|
||||
`workspace/all.tsx`;
|
||||
<AppTabs />
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
@ -1,3 +1,10 @@
|
||||
import { PageHeader } from '../../components/page-header';
|
||||
|
||||
export const Component = () => {
|
||||
return <div>/workspace/:workspaceId/:pageId</div>;
|
||||
return (
|
||||
<>
|
||||
<PageHeader back />
|
||||
<div>/workspace/:workspaceId/:pageId</div>;
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
35
packages/frontend/mobile/src/pages/workspace/home.tsx
Normal file
35
packages/frontend/mobile/src/pages/workspace/home.tsx
Normal file
@ -0,0 +1,35 @@
|
||||
import {
|
||||
ExplorerCollections,
|
||||
ExplorerFavorites,
|
||||
ExplorerMigrationFavorites,
|
||||
ExplorerMobileContext,
|
||||
ExplorerOrganize,
|
||||
} from '@affine/core/modules/explorer';
|
||||
import { ExplorerTags } from '@affine/core/modules/explorer/views/sections/tags';
|
||||
|
||||
import { AppTabs } from '../../components';
|
||||
import { HomeHeader, RecentDocs } from '../../views';
|
||||
|
||||
export const Component = () => {
|
||||
return (
|
||||
<ExplorerMobileContext.Provider value={true}>
|
||||
<HomeHeader />
|
||||
<RecentDocs />
|
||||
<div
|
||||
style={{
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
gap: 32,
|
||||
padding: '0 8px 32px 8px',
|
||||
}}
|
||||
>
|
||||
<ExplorerFavorites />
|
||||
{runtimeConfig.enableOrganize && <ExplorerOrganize />}
|
||||
<ExplorerMigrationFavorites />
|
||||
<ExplorerCollections />
|
||||
<ExplorerTags />
|
||||
</div>
|
||||
<AppTabs />
|
||||
</ExplorerMobileContext.Provider>
|
||||
);
|
||||
};
|
@ -1,5 +1,5 @@
|
||||
import { AffineErrorBoundary } from '@affine/core/components/affine/affine-error-boundary';
|
||||
import { AppFallback } from '@affine/core/components/affine/app-container';
|
||||
import { RouteContainer } from '@affine/core/modules/workbench/view/route-container';
|
||||
import { PageNotFound } from '@affine/core/pages/404';
|
||||
import { MobileWorkbenchRoot } from '@affine/core/pages/workspace/workbench-root';
|
||||
import {
|
||||
@ -7,13 +7,44 @@ import {
|
||||
useServices,
|
||||
WorkspacesService,
|
||||
} from '@toeverything/infra';
|
||||
import { lazy as reactLazy, useEffect, useMemo, useState } from 'react';
|
||||
import { matchPath, useLocation, useParams } from 'react-router-dom';
|
||||
import {
|
||||
lazy as reactLazy,
|
||||
Suspense,
|
||||
useEffect,
|
||||
useMemo,
|
||||
useState,
|
||||
} from 'react';
|
||||
import {
|
||||
matchPath,
|
||||
type RouteObject,
|
||||
useLocation,
|
||||
useParams,
|
||||
} from 'react-router-dom';
|
||||
|
||||
import { viewRoutes } from '../../router';
|
||||
import { WorkspaceLayout } from './layout';
|
||||
|
||||
const warpedRoutes = viewRoutes.map(({ path, lazy }) => {
|
||||
type Route = { Component: React.ComponentType };
|
||||
/**
|
||||
* Source: core/src/modules/workbench/view/route-container.tsx
|
||||
**/
|
||||
const MobileRouteContainer = ({ route }: { route: Route }) => {
|
||||
return (
|
||||
<AffineErrorBoundary>
|
||||
<Suspense>
|
||||
<route.Component />
|
||||
</Suspense>
|
||||
</AffineErrorBoundary>
|
||||
);
|
||||
};
|
||||
|
||||
const warpedRoutes = viewRoutes.map((originalRoute: RouteObject) => {
|
||||
if (originalRoute.Component || !originalRoute.lazy) {
|
||||
return originalRoute;
|
||||
}
|
||||
|
||||
const { path, lazy } = originalRoute;
|
||||
|
||||
const Component = reactLazy(() =>
|
||||
lazy().then(m => ({
|
||||
default: m.Component as React.ComponentType,
|
||||
@ -26,7 +57,7 @@ const warpedRoutes = viewRoutes.map(({ path, lazy }) => {
|
||||
return {
|
||||
path,
|
||||
Component: () => {
|
||||
return <RouteContainer route={route} />;
|
||||
return <MobileRouteContainer route={route} />;
|
||||
},
|
||||
};
|
||||
});
|
||||
|
10
packages/frontend/mobile/src/pages/workspace/search.tsx
Normal file
10
packages/frontend/mobile/src/pages/workspace/search.tsx
Normal file
@ -0,0 +1,10 @@
|
||||
import { AppTabs } from '../../components';
|
||||
|
||||
export const Component = () => {
|
||||
return (
|
||||
<>
|
||||
Search
|
||||
<AppTabs />
|
||||
</>
|
||||
);
|
||||
};
|
@ -6,6 +6,10 @@ import {
|
||||
redirect,
|
||||
} from 'react-router-dom';
|
||||
|
||||
import { Component as All } from './pages/workspace/all';
|
||||
import { Component as Home } from './pages/workspace/home';
|
||||
import { Component as Search } from './pages/workspace/search';
|
||||
|
||||
export const topLevelRoutes = [
|
||||
{
|
||||
element: <RootRouter />,
|
||||
@ -49,9 +53,17 @@ export const topLevelRoutes = [
|
||||
] satisfies [RouteObject, ...RouteObject[]];
|
||||
|
||||
export const viewRoutes = [
|
||||
{
|
||||
path: '/home',
|
||||
Component: Home,
|
||||
},
|
||||
{
|
||||
path: '/search',
|
||||
Component: Search,
|
||||
},
|
||||
{
|
||||
path: '/all',
|
||||
lazy: () => import('./pages/workspace/all'),
|
||||
Component: All,
|
||||
},
|
||||
{
|
||||
path: '/collection',
|
||||
|
23
packages/frontend/mobile/src/styles/mobile.css.ts
Normal file
23
packages/frontend/mobile/src/styles/mobile.css.ts
Normal file
@ -0,0 +1,23 @@
|
||||
import { cssVarV2 } from '@toeverything/theme/v2';
|
||||
import { createVar, globalStyle } from '@vanilla-extract/css';
|
||||
|
||||
export const globalVars = {
|
||||
appTabHeight: createVar('appTabHeight'),
|
||||
};
|
||||
|
||||
globalStyle(':root', {
|
||||
vars: {
|
||||
[globalVars.appTabHeight]: '68px',
|
||||
},
|
||||
});
|
||||
|
||||
globalStyle('body', {
|
||||
height: 'auto',
|
||||
});
|
||||
globalStyle('body:has(#app-tabs)', {
|
||||
paddingBottom: globalVars.appTabHeight,
|
||||
});
|
||||
globalStyle('html', {
|
||||
overflowY: 'auto',
|
||||
background: cssVarV2('layer/background/secondary'),
|
||||
});
|
3
packages/frontend/mobile/src/views/README.md
Normal file
3
packages/frontend/mobile/src/views/README.md
Normal file
@ -0,0 +1,3 @@
|
||||
# mobile views
|
||||
|
||||
Maintain complex views that used for `../pages`, view can contain mobile-components in `../components`
|
51
packages/frontend/mobile/src/views/home-header/index.tsx
Normal file
51
packages/frontend/mobile/src/views/home-header/index.tsx
Normal file
@ -0,0 +1,51 @@
|
||||
import { IconButton } from '@affine/component';
|
||||
import { SettingsIcon } from '@blocksuite/icons/rc';
|
||||
import clsx from 'clsx';
|
||||
import { useCallback, useState } from 'react';
|
||||
import { Link } from 'react-router-dom';
|
||||
|
||||
import { SearchButton, WorkspaceSelector } from '../../components';
|
||||
import { useGlobalEvent } from '../../hooks/use-global-events';
|
||||
import * as styles from './styles.css';
|
||||
|
||||
/**
|
||||
* Contains `Setting`, `Workspace Selector`, `Search`
|
||||
* When scrolled:
|
||||
* - combine Setting and Workspace Selector
|
||||
* - hide Search
|
||||
*/
|
||||
export const HomeHeader = () => {
|
||||
const [dense, setDense] = useState(false);
|
||||
|
||||
useGlobalEvent(
|
||||
'scroll',
|
||||
useCallback(() => {
|
||||
setDense(window.scrollY > 114);
|
||||
}, [])
|
||||
);
|
||||
|
||||
return (
|
||||
<div className={clsx(styles.root, { dense })}>
|
||||
<div className={styles.float}>
|
||||
<div className={styles.headerAndWsSelector}>
|
||||
<div className={styles.wsSelectorWrapper}>
|
||||
<WorkspaceSelector />
|
||||
</div>
|
||||
<div className={styles.settingWrapper}>
|
||||
<Link to="/settings">
|
||||
<IconButton
|
||||
size="24"
|
||||
style={{ padding: 10 }}
|
||||
icon={<SettingsIcon />}
|
||||
/>
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
<div className={styles.searchWrapper}>
|
||||
<SearchButton />
|
||||
</div>
|
||||
</div>
|
||||
<div className={styles.space} />
|
||||
</div>
|
||||
);
|
||||
};
|
83
packages/frontend/mobile/src/views/home-header/styles.css.ts
Normal file
83
packages/frontend/mobile/src/views/home-header/styles.css.ts
Normal file
@ -0,0 +1,83 @@
|
||||
import { cssVarV2 } from '@toeverything/theme/v2';
|
||||
import { createVar, style } from '@vanilla-extract/css';
|
||||
|
||||
const headerHeight = createVar('headerHeight');
|
||||
const wsSelectorHeight = createVar('wsSelectorHeight');
|
||||
const searchHeight = createVar('searchHeight');
|
||||
|
||||
const searchPadding = [10, 16, 15, 16];
|
||||
|
||||
export const root = style({
|
||||
vars: {
|
||||
[headerHeight]: '44px',
|
||||
[wsSelectorHeight]: '48px',
|
||||
[searchHeight]: '44px',
|
||||
},
|
||||
width: '100vw',
|
||||
});
|
||||
export const float = style({
|
||||
// why not 'sticky'?
|
||||
// when height change, will affect scroll behavior, causing shaking
|
||||
position: 'fixed',
|
||||
top: 0,
|
||||
width: '100%',
|
||||
background: cssVarV2('layer/background/secondary'),
|
||||
paddingTop: 12,
|
||||
zIndex: 1,
|
||||
});
|
||||
export const space = style({
|
||||
height: `calc(${headerHeight} + ${wsSelectorHeight} + ${searchHeight} + ${searchPadding[0] + searchPadding[2]}px + 12px)`,
|
||||
});
|
||||
|
||||
export const headerAndWsSelector = style({
|
||||
display: 'flex',
|
||||
gap: 10,
|
||||
alignItems: 'end',
|
||||
transition: 'height 0.2s',
|
||||
height: `calc(${headerHeight} + ${wsSelectorHeight})`,
|
||||
|
||||
selectors: {
|
||||
[`${root}.dense &`]: {
|
||||
height: wsSelectorHeight,
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
export const wsSelectorWrapper = style({
|
||||
width: 0,
|
||||
flex: 1,
|
||||
height: wsSelectorHeight,
|
||||
padding: '0 10px 0 16px',
|
||||
});
|
||||
|
||||
export const settingWrapper = style({
|
||||
width: '44px',
|
||||
height: headerHeight,
|
||||
transition: 'height 0.2s',
|
||||
|
||||
display: 'flex',
|
||||
justifyContent: 'center',
|
||||
alignItems: 'center',
|
||||
alignSelf: 'start',
|
||||
|
||||
selectors: {
|
||||
[`${root}.dense &`]: {
|
||||
height: wsSelectorHeight,
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
export const searchWrapper = style({
|
||||
padding: searchPadding.map(v => `${v}px`).join(' '),
|
||||
width: '100%',
|
||||
height: 44 + searchPadding[0] + searchPadding[2],
|
||||
transition: 'all 0.2s',
|
||||
overflow: 'hidden',
|
||||
selectors: {
|
||||
[`${root}.dense &`]: {
|
||||
height: 0,
|
||||
paddingTop: 0,
|
||||
paddingBottom: 0,
|
||||
},
|
||||
},
|
||||
});
|
2
packages/frontend/mobile/src/views/index.ts
Normal file
2
packages/frontend/mobile/src/views/index.ts
Normal file
@ -0,0 +1,2 @@
|
||||
export * from './home-header';
|
||||
export * from './recent-docs';
|
36
packages/frontend/mobile/src/views/recent-docs/index.tsx
Normal file
36
packages/frontend/mobile/src/views/recent-docs/index.tsx
Normal file
@ -0,0 +1,36 @@
|
||||
import { useBlockSuiteDocMeta } from '@affine/core/hooks/use-block-suite-page-meta';
|
||||
import { CollapsibleSection } from '@affine/core/modules/explorer';
|
||||
import { useService, WorkspaceService } from '@toeverything/infra';
|
||||
import { useMemo } from 'react';
|
||||
|
||||
import { DocCard } from '../../components/doc-card';
|
||||
import * as styles from './styles.css';
|
||||
|
||||
export const RecentDocs = ({ max = 5 }: { max?: number }) => {
|
||||
const workspace = useService(WorkspaceService).workspace;
|
||||
const allPageMetas = useBlockSuiteDocMeta(workspace.docCollection);
|
||||
|
||||
const cardMetas = useMemo(() => {
|
||||
return [...allPageMetas]
|
||||
.sort((a, b) => (b.updatedDate ?? 0) - (a.updatedDate ?? 0))
|
||||
.slice(0, max);
|
||||
}, [allPageMetas, max]);
|
||||
|
||||
return (
|
||||
<CollapsibleSection
|
||||
name="recent"
|
||||
title="Recent"
|
||||
headerClassName={styles.header}
|
||||
>
|
||||
<div className={styles.scroll}>
|
||||
<ul className={styles.list}>
|
||||
{cardMetas.map((doc, index) => (
|
||||
<li key={index} className={styles.cardWrapper}>
|
||||
<DocCard meta={doc} />
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
</CollapsibleSection>
|
||||
);
|
||||
};
|
31
packages/frontend/mobile/src/views/recent-docs/styles.css.ts
Normal file
31
packages/frontend/mobile/src/views/recent-docs/styles.css.ts
Normal file
@ -0,0 +1,31 @@
|
||||
import { globalStyle, style } from '@vanilla-extract/css';
|
||||
|
||||
export const scroll = style({
|
||||
width: '100%',
|
||||
paddingTop: 8,
|
||||
paddingBottom: 32,
|
||||
overflowX: 'auto',
|
||||
});
|
||||
|
||||
export const list = style({
|
||||
paddingLeft: 16,
|
||||
paddingRight: 16,
|
||||
display: 'flex',
|
||||
gap: 10,
|
||||
width: 'fit-content',
|
||||
});
|
||||
|
||||
export const cardWrapper = style({
|
||||
width: 172,
|
||||
height: 210,
|
||||
flexShrink: 0,
|
||||
});
|
||||
|
||||
export const header = style({
|
||||
margin: '0 8px',
|
||||
});
|
||||
|
||||
globalStyle(`${cardWrapper} > *`, {
|
||||
width: '100%',
|
||||
height: '100%',
|
||||
});
|
12
yarn.lock
12
yarn.lock
@ -660,14 +660,20 @@ __metadata:
|
||||
"@affine/component": "workspace:*"
|
||||
"@affine/core": "workspace:*"
|
||||
"@affine/env": "workspace:*"
|
||||
"@blocksuite/icons": "npm:^2.1.64"
|
||||
"@sentry/react": "npm:^8.0.0"
|
||||
"@toeverything/theme": "npm:^1.0.7"
|
||||
"@types/react": "npm:^18.2.75"
|
||||
"@types/react-dom": "npm:^18.2.24"
|
||||
"@vanilla-extract/css": "npm:^1.15.5"
|
||||
"@vanilla-extract/dynamic": "npm:^2.1.2"
|
||||
clsx: "npm:^2.1.1"
|
||||
core-js: "npm:^3.36.1"
|
||||
cross-env: "npm:^7.0.3"
|
||||
intl-segmenter-polyfill-rs: "npm:^0.1.7"
|
||||
react: "npm:^18.2.0"
|
||||
react-dom: "npm:^18.2.0"
|
||||
react-router-dom: "npm:^6.26.1"
|
||||
typescript: "npm:^5.4.5"
|
||||
languageName: unknown
|
||||
linkType: soft
|
||||
@ -3619,7 +3625,7 @@ __metadata:
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"@blocksuite/icons@npm:2.1.64, @blocksuite/icons@npm:^2.1.62":
|
||||
"@blocksuite/icons@npm:2.1.64, @blocksuite/icons@npm:^2.1.62, @blocksuite/icons@npm:^2.1.64":
|
||||
version: 2.1.64
|
||||
resolution: "@blocksuite/icons@npm:2.1.64"
|
||||
peerDependencies:
|
||||
@ -15317,7 +15323,7 @@ __metadata:
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"@vanilla-extract/dynamic@npm:^2.1.0":
|
||||
"@vanilla-extract/dynamic@npm:^2.1.0, @vanilla-extract/dynamic@npm:^2.1.2":
|
||||
version: 2.1.2
|
||||
resolution: "@vanilla-extract/dynamic@npm:2.1.2"
|
||||
dependencies:
|
||||
@ -30816,7 +30822,7 @@ __metadata:
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"react-router-dom@npm:^6.22.3, react-router-dom@npm:^6.23.1":
|
||||
"react-router-dom@npm:^6.22.3, react-router-dom@npm:^6.23.1, react-router-dom@npm:^6.26.1":
|
||||
version: 6.26.1
|
||||
resolution: "react-router-dom@npm:6.26.1"
|
||||
dependencies:
|
||||
|
Loading…
Reference in New Issue
Block a user