mirror of
https://github.com/toeverything/AFFiNE.git
synced 2026-07-22 12:36:24 +08:00
feat(mobile): mobile index page UI (#7959)
This commit is contained in:
@@ -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>
|
||||
);
|
||||
};
|
||||
@@ -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'),
|
||||
},
|
||||
},
|
||||
});
|
||||
@@ -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,
|
||||
});
|
||||
@@ -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,
|
||||
},
|
||||
});
|
||||
@@ -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} />;
|
||||
};
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
Reference in New Issue
Block a user