diff --git a/packages/frontend/core/src/components/page-list/page-content-preview.tsx b/packages/frontend/core/src/components/page-list/page-content-preview.tsx index b2bffb62a..a40ef97e9 100644 --- a/packages/frontend/core/src/components/page-list/page-content-preview.tsx +++ b/packages/frontend/core/src/components/page-list/page-content-preview.tsx @@ -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 ( - + ); }; diff --git a/packages/frontend/core/src/modules/explorer/entities/explore-section.ts b/packages/frontend/core/src/modules/explorer/entities/explore-section.ts index f9fcced5d..2c4e281c3 100644 --- a/packages/frontend/core/src/modules/explorer/entities/explore-section.ts +++ b/packages/frontend/core/src/modules/explorer/entities/explore-section.ts @@ -5,6 +5,7 @@ import { map } from 'rxjs'; import type { CollapsibleSectionName } from '../types'; const DEFAULT_COLLAPSABLE_STATE: Record = { + recent: true, favorites: false, organize: false, collections: true, diff --git a/packages/frontend/core/src/modules/explorer/index.ts b/packages/frontend/core/src/modules/explorer/index.ts index 4bb65965a..d20cea21a 100644 --- a/packages/frontend/core/src/modules/explorer/index.ts +++ b/packages/frontend/core/src/modules/explorer/index.ts @@ -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'; diff --git a/packages/frontend/core/src/modules/explorer/services/explorer.ts b/packages/frontend/core/src/modules/explorer/services/explorer.ts index df5b628fa..238240eb8 100644 --- a/packages/frontend/core/src/modules/explorer/services/explorer.ts +++ b/packages/frontend/core/src/modules/explorer/services/explorer.ts @@ -4,6 +4,7 @@ import { ExplorerSection } from '../entities/explore-section'; import type { CollapsibleSectionName } from '../types'; const allSectionName: Array = [ + 'recent', // mobile only 'favorites', 'organize', 'collections', diff --git a/packages/frontend/core/src/modules/explorer/types.ts b/packages/frontend/core/src/modules/explorer/types.ts index b6169210d..7ecd0ccc2 100644 --- a/packages/frontend/core/src/modules/explorer/types.ts +++ b/packages/frontend/core/src/modules/explorer/types.ts @@ -1,4 +1,5 @@ export type CollapsibleSectionName = + | 'recent' | 'collections' | 'favorites' | 'tags' diff --git a/packages/frontend/core/src/modules/explorer/views/tree/node.css.ts b/packages/frontend/core/src/modules/explorer/views/tree/node.css.ts index 6e83be773..06a7677e9 100644 --- a/packages/frontend/core/src/modules/explorer/views/tree/node.css.ts +++ b/packages/frontend/core/src/modules/explorer/views/tree/node.css.ts @@ -77,7 +77,7 @@ export const postfix = style({ }); export const iconContainer = style({ display: 'flex', - alignContent: 'center', + justifyContent: 'center', alignItems: 'center', width: 20, height: 20, diff --git a/packages/frontend/core/src/modules/workbench/index.ts b/packages/frontend/core/src/modules/workbench/index.ts index 3bc078d05..84478e89b 100644 --- a/packages/frontend/core/src/modules/workbench/index.ts +++ b/packages/frontend/core/src/modules/workbench/index.ts @@ -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'; diff --git a/packages/frontend/core/src/modules/workbench/view/workbench-link.tsx b/packages/frontend/core/src/modules/workbench/view/workbench-link.tsx index 2d8e52173..c00f482db 100644 --- a/packages/frontend/core/src/modules/workbench/view/workbench-link.tsx +++ b/packages/frontend/core/src/modules/workbench/view/workbench-link.tsx @@ -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 - > ->(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) => { - 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 +>; - // eslint suspicious runtime error - // eslint-disable-next-line react/no-danger-with-children - return ( - - ); -}); +export const WorkbenchLink = forwardRef( + 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) => { + 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 ( + + ); + } +); diff --git a/packages/frontend/core/src/pages/index.tsx b/packages/frontend/core/src/pages/index.tsx index 19a255f08..e29423ea7 100644 --- a/packages/frontend/core/src/pages/index.tsx +++ b/packages/frontend/core/src/pages/index.tsx @@ -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(() => { diff --git a/packages/frontend/core/src/pages/workspace/workbench-root.tsx b/packages/frontend/core/src/pages/workspace/workbench-root.tsx index e12e9b2d5..9b2f79ce9 100644 --- a/packages/frontend/core/src/pages/workspace/workbench-root.tsx +++ b/packages/frontend/core/src/pages/workspace/workbench-root.tsx @@ -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'; diff --git a/packages/frontend/core/src/shared/index.ts b/packages/frontend/core/src/shared/index.ts index 0f3592e55..aff2ee626 100644 --- a/packages/frontend/core/src/shared/index.ts +++ b/packages/frontend/core/src/shared/index.ts @@ -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; }; diff --git a/packages/frontend/mobile/package.json b/packages/frontend/mobile/package.json index dfabc27b9..9f6d669aa 100644 --- a/packages/frontend/mobile/package.json +++ b/packages/frontend/mobile/package.json @@ -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" } diff --git a/packages/frontend/mobile/src/app.tsx b/packages/frontend/mobile/src/app.tsx index d80c1b50e..ca5628661 100644 --- a/packages/frontend/mobile/src/app.tsx +++ b/packages/frontend/mobile/src/app.tsx @@ -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'; diff --git a/packages/frontend/mobile/src/components/README.md b/packages/frontend/mobile/src/components/README.md new file mode 100644 index 000000000..2675e8d8a --- /dev/null +++ b/packages/frontend/mobile/src/components/README.md @@ -0,0 +1,3 @@ +# mobile components + +Maintain the smallest possible business components here. diff --git a/packages/frontend/mobile/src/components/app-tabs/home-icon.tsx b/packages/frontend/mobile/src/components/app-tabs/home-icon.tsx new file mode 100644 index 000000000..d2f1239e8 --- /dev/null +++ b/packages/frontend/mobile/src/components/app-tabs/home-icon.tsx @@ -0,0 +1,21 @@ +/** + * TODO(@CatsJuice): replace with `@blocksuite/icons/rc` when ready + */ +export const HomeIcon = () => { + return ( + + + + ); +}; diff --git a/packages/frontend/mobile/src/components/app-tabs/index.tsx b/packages/frontend/mobile/src/components/app-tabs/index.tsx new file mode 100644 index 000000000..9d3077d50 --- /dev/null +++ b/packages/frontend/mobile/src/components/app-tabs/index.tsx @@ -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 ( +
    + {routes.map(route => { + const Link = route.LinkComponent || WorkbenchLink; + return ( + +
  • + +
  • + + ); + })} +
+ ); +}; diff --git a/packages/frontend/mobile/src/components/app-tabs/styles.css.ts b/packages/frontend/mobile/src/components/app-tabs/styles.css.ts new file mode 100644 index 000000000..3bbca2014 --- /dev/null +++ b/packages/frontend/mobile/src/components/app-tabs/styles.css.ts @@ -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'), + }, + }, +}); diff --git a/packages/frontend/mobile/src/components/doc-card/index.tsx b/packages/frontend/mobile/src/components/doc-card/index.tsx new file mode 100644 index 000000000..97e34a20c --- /dev/null +++ b/packages/frontend/mobile/src/components/doc-card/index.tsx @@ -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 { + meta: DocMeta; +} + +export const DocCard = forwardRef( + 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 ( + +
+

+ {meta.title || Untitled} +

+ + } + /> +
+
+ Empty} + /> +
+ +
+ ); + } +); diff --git a/packages/frontend/mobile/src/components/doc-card/styles.css.tsx b/packages/frontend/mobile/src/components/doc-card/styles.css.tsx new file mode 100644 index 000000000..f3dc3a6c4 --- /dev/null +++ b/packages/frontend/mobile/src/components/doc-card/styles.css.tsx @@ -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, +}); diff --git a/packages/frontend/mobile/src/components/doc-card/tag.css.ts b/packages/frontend/mobile/src/components/doc-card/tag.css.ts new file mode 100644 index 000000000..cce1ed092 --- /dev/null +++ b/packages/frontend/mobile/src/components/doc-card/tag.css.ts @@ -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, + }, +}); diff --git a/packages/frontend/mobile/src/components/doc-card/tag.tsx b/packages/frontend/mobile/src/components/doc-card/tag.tsx new file mode 100644 index 000000000..f2f63f434 --- /dev/null +++ b/packages/frontend/mobile/src/components/doc-card/tag.tsx @@ -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 ( +
  • + {name} +
  • + ); +}; + +const GAP = 4; +const MIN_WIDTH = 32; + +const DocCardTagsRenderer = ({ tags, rows }: { tags: Tag[]; rows: number }) => { + const ulRef = useRef(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(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 ( +
      + {tags.map(tag => ( + + ))} + {/* TODO: more icon */} + {/* */} +
    + ); +}; + +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 ; +}; diff --git a/packages/frontend/mobile/src/components/index.ts b/packages/frontend/mobile/src/components/index.ts new file mode 100644 index 000000000..8b4fd75f6 --- /dev/null +++ b/packages/frontend/mobile/src/components/index.ts @@ -0,0 +1,4 @@ +export * from './app-tabs'; +export * from './page-header'; +export * from './search-button'; +export * from './workspace-selector'; diff --git a/packages/frontend/mobile/src/components/page-header/index.tsx b/packages/frontend/mobile/src/components/page-header/index.tsx new file mode 100644 index 000000000..39e12fde8 --- /dev/null +++ b/packages/frontend/mobile/src/components/page-header/index.tsx @@ -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, '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( + function PageHeader( + { + back, + prefix, + suffix, + children, + className, + centerContent = true, + prefixClassName, + prefixStyle, + suffixClassName, + suffixStyle, + ...attrs + }, + ref + ) { + const handleRouteBack = useCallback(() => { + history.back(); + }, []); + + return ( +
    +
    + {back ? ( + } + /> + ) : null} + {prefix} +
    + +
    + {children} +
    + +
    + {suffix} +
    +
    + ); + } +); diff --git a/packages/frontend/mobile/src/components/page-header/styles.css.ts b/packages/frontend/mobile/src/components/page-header/styles.css.ts new file mode 100644 index 000000000..0970150c0 --- /dev/null +++ b/packages/frontend/mobile/src/components/page-header/styles.css.ts @@ -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, +}); diff --git a/packages/frontend/mobile/src/components/search-button/index.tsx b/packages/frontend/mobile/src/components/search-button/index.tsx new file mode 100644 index 000000000..fe584b0a0 --- /dev/null +++ b/packages/frontend/mobile/src/components/search-button/index.tsx @@ -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 ( + +
    + + {t['Quick search']()} +
    +
    + ); +}; diff --git a/packages/frontend/mobile/src/components/search-button/styles.css.ts b/packages/frontend/mobile/src/components/search-button/styles.css.ts new file mode 100644 index 000000000..766150828 --- /dev/null +++ b/packages/frontend/mobile/src/components/search-button/styles.css.ts @@ -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'), +}); diff --git a/packages/frontend/mobile/src/components/workspace-selector/card.css.ts b/packages/frontend/mobile/src/components/workspace-selector/card.css.ts new file mode 100644 index 000000000..f125c5f38 --- /dev/null +++ b/packages/frontend/mobile/src/components/workspace-selector/card.css.ts @@ -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'), +}); diff --git a/packages/frontend/mobile/src/components/workspace-selector/current-card.tsx b/packages/frontend/mobile/src/components/workspace-selector/current-card.tsx new file mode 100644 index 000000000..46aee2fc0 --- /dev/null +++ b/packages/frontend/mobile/src/components/workspace-selector/current-card.tsx @@ -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 {} + +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 ( +
    + +
    + {name} + +
    +
    + ); +}); diff --git a/packages/frontend/mobile/src/components/workspace-selector/index.tsx b/packages/frontend/mobile/src/components/workspace-selector/index.tsx new file mode 100644 index 000000000..364732d22 --- /dev/null +++ b/packages/frontend/mobile/src/components/workspace-selector/index.tsx @@ -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 ( + } + rootOptions={{ open }} + contentOptions={{ + onInteractOutside: close, + onEscapeKeyDown: close, + style: { padding: 0 }, + }} + > + + + ); +}; diff --git a/packages/frontend/mobile/src/components/workspace-selector/menu.css.ts b/packages/frontend/mobile/src/components/workspace-selector/menu.css.ts new file mode 100644 index 000000000..62ea57152 --- /dev/null +++ b/packages/frontend/mobile/src/components/workspace-selector/menu.css.ts @@ -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', +}); diff --git a/packages/frontend/mobile/src/components/workspace-selector/menu.tsx b/packages/frontend/mobile/src/components/workspace-selector/menu.tsx new file mode 100644 index 000000000..900625488 --- /dev/null +++ b/packages/frontend/mobile/src/components/workspace-selector/menu.tsx @@ -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) => { + const info = useWorkspaceInfo(workspace); + const name = info?.name; + const isOwner = info?.isOwner; + + return ( +
  • + +
  • + ); +}; + +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 ( + <> +
    {title}
    +
      + {list.map(ws => ( + toggleWorkspace?.(ws.id)} + /> + ))} +
    + + ); +}; + +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 ( +
    +
    + Workspace + } /> +
    +
    +
    + + +
    +
    + ); +}; diff --git a/packages/frontend/mobile/src/hooks/use-global-events.ts b/packages/frontend/mobile/src/hooks/use-global-events.ts new file mode 100644 index 000000000..0ed3cac1b --- /dev/null +++ b/packages/frontend/mobile/src/hooks/use-global-events.ts @@ -0,0 +1,54 @@ +import { useEffect } from 'react'; + +type Handler = (event: T) => void; + +const _handlesMap = new Map< + keyof WindowEventMap, + Array> +>(); + +function initGlobalEvent(name: T) { + const prev = _handlesMap.get(name); + if (!prev) { + const handlers = [] as Handler[]; + window.addEventListener(name, e => { + handlers.forEach(handler => { + handler(e); + }); + }); + _handlesMap.set(name, handlers as any); + return handlers; + } + return prev; +} + +function addListener( + name: T, + handler: (e: WindowEventMap[T]) => void +) { + initGlobalEvent(name).push(handler); +} + +function removeListener( + name: T, + handler: Handler +) { + const handlers = _handlesMap.get(name) as Handler[]; + const idx = handlers.indexOf(handler); + if (idx !== -1) { + handlers.splice(idx, 1); + } +} + +export const useGlobalEvent = ( + name: T, + handler: (e: WindowEventMap[T]) => void +) => { + useEffect(() => { + addListener(name, handler); + + return () => { + removeListener(name, handler); + }; + }, [handler, name]); +}; diff --git a/packages/frontend/mobile/src/pages/index.tsx b/packages/frontend/mobile/src/pages/index.tsx index 508c37574..5df7ff704 100644 --- a/packages/frontend/mobile/src/pages/index.tsx +++ b/packages/frontend/mobile/src/pages/index.tsx @@ -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 ; + return ; }; diff --git a/packages/frontend/mobile/src/pages/workspace/all.tsx b/packages/frontend/mobile/src/pages/workspace/all.tsx index e2ec20a6b..3097c003b 100644 --- a/packages/frontend/mobile/src/pages/workspace/all.tsx +++ b/packages/frontend/mobile/src/pages/workspace/all.tsx @@ -1,3 +1,10 @@ +import { AppTabs } from '../../components'; + export const Component = () => { - return
    /workspace/:workspaceId/all
    ; + return ( + <> + `workspace/all.tsx`; + + + ); }; diff --git a/packages/frontend/mobile/src/pages/workspace/detail.tsx b/packages/frontend/mobile/src/pages/workspace/detail.tsx index f6c32eba5..937c08166 100644 --- a/packages/frontend/mobile/src/pages/workspace/detail.tsx +++ b/packages/frontend/mobile/src/pages/workspace/detail.tsx @@ -1,3 +1,10 @@ +import { PageHeader } from '../../components/page-header'; + export const Component = () => { - return
    /workspace/:workspaceId/:pageId
    ; + return ( + <> + +
    /workspace/:workspaceId/:pageId
    ; + + ); }; diff --git a/packages/frontend/mobile/src/pages/workspace/home.tsx b/packages/frontend/mobile/src/pages/workspace/home.tsx new file mode 100644 index 000000000..970a70ffd --- /dev/null +++ b/packages/frontend/mobile/src/pages/workspace/home.tsx @@ -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 ( + + + +
    + + {runtimeConfig.enableOrganize && } + + + +
    + +
    + ); +}; diff --git a/packages/frontend/mobile/src/pages/workspace/index.tsx b/packages/frontend/mobile/src/pages/workspace/index.tsx index 831918664..71c74a41d 100644 --- a/packages/frontend/mobile/src/pages/workspace/index.tsx +++ b/packages/frontend/mobile/src/pages/workspace/index.tsx @@ -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 ( + + + + + + ); +}; + +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 ; + return ; }, }; }); diff --git a/packages/frontend/mobile/src/pages/workspace/search.tsx b/packages/frontend/mobile/src/pages/workspace/search.tsx new file mode 100644 index 000000000..2e42427ec --- /dev/null +++ b/packages/frontend/mobile/src/pages/workspace/search.tsx @@ -0,0 +1,10 @@ +import { AppTabs } from '../../components'; + +export const Component = () => { + return ( + <> + Search + + + ); +}; diff --git a/packages/frontend/mobile/src/router.tsx b/packages/frontend/mobile/src/router.tsx index 11f3130d0..02f8a7f0d 100644 --- a/packages/frontend/mobile/src/router.tsx +++ b/packages/frontend/mobile/src/router.tsx @@ -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: , @@ -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', diff --git a/packages/frontend/mobile/src/styles/mobile.css.ts b/packages/frontend/mobile/src/styles/mobile.css.ts new file mode 100644 index 000000000..4d8753890 --- /dev/null +++ b/packages/frontend/mobile/src/styles/mobile.css.ts @@ -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'), +}); diff --git a/packages/frontend/mobile/src/views/README.md b/packages/frontend/mobile/src/views/README.md new file mode 100644 index 000000000..e85ea4a44 --- /dev/null +++ b/packages/frontend/mobile/src/views/README.md @@ -0,0 +1,3 @@ +# mobile views + +Maintain complex views that used for `../pages`, view can contain mobile-components in `../components` diff --git a/packages/frontend/mobile/src/views/home-header/index.tsx b/packages/frontend/mobile/src/views/home-header/index.tsx new file mode 100644 index 000000000..465518a06 --- /dev/null +++ b/packages/frontend/mobile/src/views/home-header/index.tsx @@ -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 ( +
    +
    +
    +
    + +
    +
    + + } + /> + +
    +
    +
    + +
    +
    +
    +
    + ); +}; diff --git a/packages/frontend/mobile/src/views/home-header/styles.css.ts b/packages/frontend/mobile/src/views/home-header/styles.css.ts new file mode 100644 index 000000000..f52c2c3e6 --- /dev/null +++ b/packages/frontend/mobile/src/views/home-header/styles.css.ts @@ -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, + }, + }, +}); diff --git a/packages/frontend/mobile/src/views/index.ts b/packages/frontend/mobile/src/views/index.ts new file mode 100644 index 000000000..09ecfc48d --- /dev/null +++ b/packages/frontend/mobile/src/views/index.ts @@ -0,0 +1,2 @@ +export * from './home-header'; +export * from './recent-docs'; diff --git a/packages/frontend/mobile/src/views/recent-docs/index.tsx b/packages/frontend/mobile/src/views/recent-docs/index.tsx new file mode 100644 index 000000000..6945b38b1 --- /dev/null +++ b/packages/frontend/mobile/src/views/recent-docs/index.tsx @@ -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 ( + +
    +
      + {cardMetas.map((doc, index) => ( +
    • + +
    • + ))} +
    +
    +
    + ); +}; diff --git a/packages/frontend/mobile/src/views/recent-docs/styles.css.ts b/packages/frontend/mobile/src/views/recent-docs/styles.css.ts new file mode 100644 index 000000000..73cc1d08c --- /dev/null +++ b/packages/frontend/mobile/src/views/recent-docs/styles.css.ts @@ -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%', +}); diff --git a/yarn.lock b/yarn.lock index 9122c7e7f..4f8222d02 100644 --- a/yarn.lock +++ b/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: