refactor: new project struct (#8199)

packages/frontend/web -> packages/frontend/apps/web
packages/frontend/mobile -> packages/frontend/apps/mobile
packages/frontend/electron -> packages/frontend/apps/electron
This commit is contained in:
EYHN
2024-09-12 07:42:57 +00:00
parent 7c4eab6cd3
commit cc5a6e6d40
291 changed files with 139 additions and 134 deletions

View File

@@ -0,0 +1,92 @@
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';
import { configureCommonModules } from '@affine/core/modules';
import { configureLocalStorageStateStorageImpls } from '@affine/core/modules/storage';
import { configureBrowserWorkbenchModule } from '@affine/core/modules/workbench';
import {
configureBrowserWorkspaceFlavours,
configureIndexedDBWorkspaceEngineStorageProvider,
} from '@affine/core/modules/workspace-engine';
import {
performanceLogger,
performanceRenderLogger,
} from '@affine/core/shared';
import { Telemetry } from '@affine/core/telemetry';
import { createI18n, setUpLanguage } from '@affine/i18n';
import {
Framework,
FrameworkRoot,
getCurrentStore,
LifecycleService,
} from '@toeverything/infra';
import { Suspense } from 'react';
import { RouterProvider } from 'react-router-dom';
import { configureMobileModules } from './modules';
import { router } from './router';
if (environment.isElectron && environment.isDebug) {
document.body.innerHTML = `<h1 style="color:red;font-size:5rem;text-align:center;">Don't run web entry in electron.</h1>`;
throw new Error('Wrong distribution');
}
const future = {
v7_startTransition: true,
} as const;
const performanceI18nLogger = performanceLogger.namespace('i18n');
async function loadLanguage() {
performanceI18nLogger.info('start');
const i18n = createI18n();
document.documentElement.lang = i18n.language;
performanceI18nLogger.info('set up');
await setUpLanguage(i18n);
performanceI18nLogger.info('done');
}
let languageLoadingPromise: Promise<void> | null = null;
const framework = new Framework();
configureCommonModules(framework);
configureBrowserWorkbenchModule(framework);
configureLocalStorageStateStorageImpls(framework);
configureBrowserWorkspaceFlavours(framework);
configureIndexedDBWorkspaceEngineStorageProvider(framework);
configureMobileModules(framework);
const frameworkProvider = framework.provider();
// setup application lifecycle events, and emit application start event
window.addEventListener('focus', () => {
frameworkProvider.get(LifecycleService).applicationFocus();
});
frameworkProvider.get(LifecycleService).applicationStart();
export function App() {
performanceRenderLogger.debug('App');
if (!languageLoadingPromise) {
languageLoadingPromise = loadLanguage().catch(console.error);
}
return (
<Suspense>
<FrameworkRoot framework={frameworkProvider}>
<AffineContext store={getCurrentStore()}>
<Telemetry />
<RouterProvider
fallbackElement={<AppFallback />}
router={router}
future={future}
/>
</AffineContext>
</FrameworkRoot>
</Suspense>
);
}

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -0,0 +1,39 @@
import { MobileMenu } from '@affine/component';
import { track } from '@affine/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>
);
};

View File

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

View File

@@ -0,0 +1,129 @@
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}
/>
<div className={styles.divider} />
<WorkspaceList
onClose={onClose}
title="Local Storage"
list={localWorkspaces}
/>
</main>
</div>
);
};

View File

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

View File

@@ -0,0 +1,67 @@
import './polyfill/dispose';
import './polyfill/intl-segmenter';
import './polyfill/promise-with-resolvers';
import './polyfill/request-idle-callback';
import '@affine/core/bootstrap/preload';
import { performanceLogger } from '@affine/core/shared';
import {
init,
reactRouterV6BrowserTracingIntegration,
setTags,
} from '@sentry/react';
import { StrictMode, useEffect } from 'react';
import { createRoot } from 'react-dom/client';
import {
createRoutesFromChildren,
matchRoutes,
useLocation,
useNavigationType,
} from 'react-router-dom';
import { App } from './app';
const performanceMainLogger = performanceLogger.namespace('main');
function main() {
performanceMainLogger.info('setup start');
if (window.SENTRY_RELEASE || environment.isDebug) {
// https://docs.sentry.io/platforms/javascript/guides/react/#configure
init({
dsn: process.env.SENTRY_DSN,
environment: process.env.BUILD_TYPE ?? 'development',
integrations: [
reactRouterV6BrowserTracingIntegration({
useEffect,
useLocation,
useNavigationType,
createRoutesFromChildren,
matchRoutes,
}),
],
});
setTags({
appVersion: runtimeConfig.appVersion,
editorVersion: runtimeConfig.editorVersion,
});
}
performanceMainLogger.info('setup done');
mountApp();
}
function mountApp() {
performanceMainLogger.info('import app');
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
const root = document.getElementById('app')!;
performanceMainLogger.info('render app');
createRoot(root).render(
<StrictMode>
<App />
</StrictMode>
);
}
try {
main();
} catch (err) {
console.error('Failed to bootstrap app', err);
}

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -0,0 +1,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 defaultIndexRoute={WorkspaceSubPath.HOME} />;
};

View File

@@ -0,0 +1,38 @@
import {
RouteLogic,
useNavigateHelper,
} from '@affine/core/hooks/use-navigate-helper';
import { AuthService } from '@affine/core/modules/cloud';
import { useLiveData, useService } from '@toeverything/infra';
import { useEffect } from 'react';
// eslint-disable-next-line @typescript-eslint/no-restricted-imports
import { useNavigate, useSearchParams } from 'react-router-dom';
import { MobileSignIn } from '../views/sign-in/mobile-sign-in';
export const Component = () => {
const session = useService(AuthService).session;
const status = useLiveData(session.status$);
const isRevalidating = useLiveData(session.isRevalidating$);
const navigate = useNavigate();
const { jumpToIndex } = useNavigateHelper();
const [searchParams] = useSearchParams();
const isLoggedIn = status === 'authenticated' && !isRevalidating;
useEffect(() => {
if (isLoggedIn) {
const redirectUri = searchParams.get('redirect_uri');
if (redirectUri) {
navigate(redirectUri, {
replace: true,
});
} else {
jumpToIndex(RouteLogic.REPLACE, {
search: searchParams.toString(),
});
}
}
}, [jumpToIndex, navigate, isLoggedIn, searchParams]);
return <MobileSignIn onSkip={() => navigate('/')} />;
};

View File

@@ -0,0 +1,18 @@
import { SafeArea, useThemeColorV2 } from '@affine/component';
import { AppTabs } from '../../components';
import { AllDocList, AllDocsHeader, AllDocsMenu } from '../../views';
export const Component = () => {
useThemeColorV2('layer/background/secondary');
return (
<>
<AllDocsHeader operations={<AllDocsMenu />} />
<SafeArea bottom>
<AllDocList />
</SafeArea>
<AppTabs />
</>
);
};

View File

@@ -0,0 +1,78 @@
import { notify, useThemeColorV2 } from '@affine/component';
import { useNavigateHelper } from '@affine/core/hooks/use-navigate-helper';
import { CollectionService } from '@affine/core/modules/collection';
import { WorkspaceSubPath } from '@affine/core/shared';
import {
GlobalContextService,
useLiveData,
useServices,
WorkspaceService,
} from '@toeverything/infra';
import { useCallback, useEffect } from 'react';
import { useParams } from 'react-router-dom';
import { AppTabs } from '../../../components';
import { CollectionDetail } from '../../../views';
export const Component = () => {
useThemeColorV2('layer/background/secondary');
const { collectionService, globalContextService, workspaceService } =
useServices({
WorkspaceService,
CollectionService,
GlobalContextService,
});
const globalContext = globalContextService.globalContext;
const collections = useLiveData(collectionService.collections$);
const params = useParams();
const navigate = useNavigateHelper();
const workspace = workspaceService.workspace;
const collection = collections.find(v => v.id === params.collectionId);
useEffect(() => {
if (collection) {
globalContext.collectionId.set(collection.id);
globalContext.isCollection.set(true);
return () => {
globalContext.collectionId.set(null);
globalContext.isCollection.set(false);
};
}
return;
}, [collection, globalContext]);
const notifyCollectionDeleted = useCallback(() => {
navigate.jumpToSubPath(workspace.id, WorkspaceSubPath.HOME);
const collection = collectionService.collectionsTrash$.value.find(
v => v.collection.id === params.collectionId
);
let text = 'Collection does not exist';
if (collection) {
if (collection.userId) {
text = `${collection.collection.name} has been deleted by ${collection.userName}`;
} else {
text = `${collection.collection.name} has been deleted`;
}
}
return notify.error({ title: text });
}, [collectionService, navigate, params.collectionId, workspace.id]);
useEffect(() => {
if (!collection) {
notifyCollectionDeleted();
}
}, [collection, notifyCollectionDeleted]);
if (!collection) {
return null;
}
return (
<>
<CollectionDetail collection={collection} />
<AppTabs />
</>
);
};

View File

@@ -0,0 +1,15 @@
import { useThemeColorV2 } from '@affine/component';
import { AppTabs } from '../../../components';
import { AllDocsHeader, CollectionList } from '../../../views';
export const Component = () => {
useThemeColorV2('layer/background/secondary');
return (
<>
<AllDocsHeader />
<AppTabs />
<CollectionList />
</>
);
};

View File

@@ -0,0 +1,43 @@
import { IconButton, MobileMenu } from '@affine/component';
import { useJournalInfoHelper } from '@affine/core/hooks/use-journal';
import { EditorJournalPanel } from '@affine/core/pages/workspace/detail-page/tabs/journal';
import { TodayIcon, TomorrowIcon, YesterdayIcon } from '@blocksuite/icons/rc';
import { useService, WorkspaceService } from '@toeverything/infra';
export const JournalIconButton = ({
docId,
className,
}: {
docId: string;
className?: string;
}) => {
const workspace = useService(WorkspaceService).workspace;
const { journalDate, isJournal } = useJournalInfoHelper(
workspace.docCollection,
docId
);
const Icon = journalDate
? journalDate.isBefore(new Date(), 'day')
? YesterdayIcon
: journalDate.isAfter(new Date(), 'day')
? TomorrowIcon
: TodayIcon
: TodayIcon;
if (!isJournal) {
return null;
}
return (
<MobileMenu
items={<EditorJournalPanel />}
contentOptions={{
align: 'center',
}}
>
<IconButton className={className} size={24}>
<Icon />
</IconButton>
</MobileMenu>
);
};

View File

@@ -0,0 +1,95 @@
import { cssVar } from '@toeverything/theme';
import { cssVarV2 } from '@toeverything/theme/v2';
import { globalStyle, style } from '@vanilla-extract/css';
export const root = style({
background: cssVarV2('layer/background/primary'),
minHeight: '100dvh',
display: 'flex',
flexDirection: 'column',
});
export const header = style({
background: cssVarV2('layer/background/primary'),
position: 'sticky',
top: 0,
zIndex: 1,
});
export const mainContainer = style({
containerType: 'inline-size',
display: 'flex',
flexDirection: 'column',
flex: 1,
overflow: 'hidden',
borderTop: `0.5px solid transparent`,
transition: 'border-color 0.2s',
selectors: {
'&[data-dynamic-top-border="false"]': {
borderColor: cssVar('borderColor'),
},
'&[data-has-scroll-top="true"]': {
borderColor: cssVar('borderColor'),
},
},
});
export const editorContainer = style({
position: 'relative',
display: 'flex',
flexDirection: 'column',
flex: 1,
zIndex: 0,
});
// brings styles of .affine-page-viewport from blocksuite
export const affineDocViewport = style({
display: 'flex',
flexDirection: 'column',
containerName: 'viewport',
containerType: 'inline-size',
background: cssVarV2('layer/background/primary'),
selectors: {
'&[data-mode="edgeless"]': {
position: 'absolute',
top: 0,
left: 0,
right: 0,
bottom: 0,
},
},
});
export const scrollbar = style({
marginRight: '4px',
});
globalStyle('.doc-title-container', {
fontSize: cssVar('fontH1'),
'@container': {
[`viewport (width <= 640px)`]: {
padding: '10px 16px',
lineHeight: '38px',
},
},
});
globalStyle('[data-peek-view-wrapper] .doc-title-container', {
fontSize: cssVar('fontH6'),
});
globalStyle('.affine-page-root-block-container', {
'@container': {
[`viewport (width <= 640px)`]: {
paddingLeft: 16,
paddingRight: 16,
},
},
});
export const journalIconButton = style({
position: 'absolute',
zIndex: 1,
top: 16,
right: 12,
display: 'flex',
});

View File

@@ -0,0 +1,246 @@
import { useThemeColorV2 } from '@affine/component';
import { PageDetailSkeleton } from '@affine/component/page-detail-skeleton';
import { AffineErrorBoundary } from '@affine/core/components/affine/affine-error-boundary';
import { PageDetailEditor } from '@affine/core/components/page-detail-editor';
import { useRegisterBlocksuiteEditorCommands } from '@affine/core/hooks/affine/use-register-blocksuite-editor-commands';
import { useActiveBlocksuiteEditor } from '@affine/core/hooks/use-block-suite-editor';
import { useDocMetaHelper } from '@affine/core/hooks/use-block-suite-page-meta';
import { usePageDocumentTitle } from '@affine/core/hooks/use-global-state';
import { useNavigateHelper } from '@affine/core/hooks/use-navigate-helper';
import { EditorService } from '@affine/core/modules/editor';
import { WorkbenchService } from '@affine/core/modules/workbench';
import { ViewService } from '@affine/core/modules/workbench/services/view';
import { DetailPageWrapper } from '@affine/core/pages/workspace/detail-page/detail-page-wrapper';
import type { PageRootService } from '@blocksuite/blocks';
import {
BookmarkBlockService,
customImageProxyMiddleware,
EmbedGithubBlockService,
EmbedLoomBlockService,
EmbedYoutubeBlockService,
ImageBlockService,
} from '@blocksuite/blocks';
import { DisposableGroup } from '@blocksuite/global/utils';
import { type AffineEditorContainer } from '@blocksuite/presets';
import {
DocService,
FrameworkScope,
GlobalContextService,
useLiveData,
useServices,
WorkspaceService,
} from '@toeverything/infra';
import clsx from 'clsx';
import { useCallback, useEffect } from 'react';
import { useParams } from 'react-router-dom';
import { PageHeader } from '../../../components';
import { JournalIconButton } from './journal-icon-button';
import * as styles from './mobile-detail-page.css';
import { PageHeaderMenuButton } from './page-header-more-button';
import { PageHeaderShareButton } from './page-header-share-button';
const DetailPageImpl = () => {
const { editorService, docService, workspaceService, globalContextService } =
useServices({
WorkbenchService,
ViewService,
EditorService,
DocService,
WorkspaceService,
GlobalContextService,
});
const editor = editorService.editor;
const workspace = workspaceService.workspace;
const docCollection = workspace.docCollection;
const globalContext = globalContextService.globalContext;
const doc = docService.doc;
const mode = useLiveData(editor.mode$);
const isInTrash = useLiveData(doc.meta$.map(meta => meta.trash));
const { openPage, jumpToPageBlock, jumpToTag } = useNavigateHelper();
const editorContainer = useLiveData(editor.editorContainer$);
const { setDocReadonly } = useDocMetaHelper(workspace.docCollection);
// TODO(@eyhn): remove jotai here
const [_, setActiveBlockSuiteEditor] = useActiveBlocksuiteEditor();
useEffect(() => {
setActiveBlockSuiteEditor(editorContainer);
}, [editorContainer, setActiveBlockSuiteEditor]);
useEffect(() => {
globalContext.docId.set(doc.id);
globalContext.isDoc.set(true);
return () => {
globalContext.docId.set(null);
globalContext.isDoc.set(false);
};
}, [doc, globalContext]);
useEffect(() => {
globalContext.docMode.set(mode);
return () => {
globalContext.docMode.set(null);
};
}, [doc, globalContext, mode]);
useEffect(() => {
setDocReadonly(doc.id, true);
}, [doc.id, setDocReadonly]);
useEffect(() => {
globalContext.isTrashDoc.set(!!isInTrash);
return () => {
globalContext.isTrashDoc.set(null);
};
}, [globalContext, isInTrash]);
useRegisterBlocksuiteEditorCommands(editor);
const title = useLiveData(doc.title$);
usePageDocumentTitle(title);
const onLoad = useCallback(
(editorContainer: AffineEditorContainer) => {
// blocksuite editor host
const editorHost = editorContainer.host;
// provide image proxy endpoint to blocksuite
editorHost?.std.clipboard.use(
customImageProxyMiddleware(runtimeConfig.imageProxyUrl)
);
ImageBlockService.setImageProxyURL(runtimeConfig.imageProxyUrl);
// provide link preview endpoint to blocksuite
BookmarkBlockService.setLinkPreviewEndpoint(runtimeConfig.linkPreviewUrl);
EmbedGithubBlockService.setLinkPreviewEndpoint(
runtimeConfig.linkPreviewUrl
);
EmbedYoutubeBlockService.setLinkPreviewEndpoint(
runtimeConfig.linkPreviewUrl
);
EmbedLoomBlockService.setLinkPreviewEndpoint(
runtimeConfig.linkPreviewUrl
);
// provide page mode and updated date to blocksuite
const pageService =
editorHost?.std.getService<PageRootService>('affine:page');
const disposable = new DisposableGroup();
if (pageService) {
disposable.add(
pageService.slots.docLinkClicked.on(({ pageId, params }) => {
if (params) {
const { mode, blockIds, elementIds } = params;
return jumpToPageBlock(
docCollection.id,
pageId,
mode,
blockIds,
elementIds
);
}
return openPage(docCollection.id, pageId);
})
);
disposable.add(
pageService.slots.tagClicked.on(({ tagId }) => {
jumpToTag(workspace.id, tagId);
})
);
}
editor.setEditorContainer(editorContainer);
return () => {
disposable.dispose();
};
},
[
editor,
jumpToPageBlock,
docCollection.id,
openPage,
jumpToTag,
workspace.id,
]
);
return (
<FrameworkScope scope={editor.scope}>
<div className={styles.mainContainer}>
<div
data-mode={mode}
className={clsx(
'affine-page-viewport',
styles.affineDocViewport,
styles.editorContainer
)}
>
{/* Add a key to force rerender when page changed, to avoid error boundary persisting. */}
<AffineErrorBoundary key={doc.id}>
{mode === 'page' && (
<JournalIconButton
docId={doc.id}
className={styles.journalIconButton}
/>
)}
<PageDetailEditor onLoad={onLoad} />
</AffineErrorBoundary>
</div>
</div>
</FrameworkScope>
);
};
const skeleton = (
<>
<PageHeader back className={styles.header} />
<PageDetailSkeleton />
</>
);
const notFound = (
<>
<PageHeader back className={styles.header} />
Page Not Found (TODO)
</>
);
export const Component = () => {
useThemeColorV2('layer/background/primary');
const params = useParams();
const pageId = params.pageId;
if (!pageId) {
return null;
}
return (
<div className={styles.root}>
<DetailPageWrapper
skeleton={skeleton}
notFound={notFound}
pageId={pageId}
>
<PageHeader
back
className={styles.header}
suffix={
<>
<PageHeaderShareButton />
<PageHeaderMenuButton />
</>
}
/>
<DetailPageImpl />
</DetailPageWrapper>
</div>
);
};

View File

@@ -0,0 +1,16 @@
import { cssVar } from '@toeverything/theme';
import { style } from '@vanilla-extract/css';
export const iconButton = style({
selectors: {
'&[data-state=open]': {
backgroundColor: cssVar('hoverColor'),
},
},
padding: '10px',
});
export const outlinePanel = style({
maxHeight: '60vh',
overflow: 'auto',
});

View File

@@ -0,0 +1,141 @@
import { IconButton, toast } from '@affine/component';
import {
MenuSeparator,
MobileMenu,
MobileMenuItem,
} from '@affine/component/ui/menu';
import { useFavorite } from '@affine/core/components/blocksuite/block-suite-header/favorite';
import { IsFavoriteIcon } from '@affine/core/components/pure/icons';
import { track } from '@affine/core/mixpanel';
import { EditorService } from '@affine/core/modules/editor';
import { ViewService } from '@affine/core/modules/workbench/services/view';
import { EditorOutlinePanel } from '@affine/core/pages/workspace/detail-page/tabs/outline';
import { preventDefault } from '@affine/core/utils';
import { useI18n } from '@affine/i18n';
import {
EdgelessIcon,
InformationIcon,
MoreHorizontalIcon,
PageIcon,
TocIcon,
} from '@blocksuite/icons/rc';
import { DocService, useLiveData, useService } from '@toeverything/infra';
import { useCallback, useEffect, useState } from 'react';
import * as styles from './page-header-more-button.css';
import { DocInfoSheet } from './sheets/doc-info';
export const PageHeaderMenuButton = () => {
const t = useI18n();
const docId = useService(DocService).doc.id;
const editorService = useService(EditorService);
const editorContainer = useLiveData(editorService.editor.editorContainer$);
const [open, setOpen] = useState(false);
const location = useLiveData(useService(ViewService).view.location$);
const isInTrash = useLiveData(
editorService.editor.doc.meta$.map(meta => meta.trash)
);
const currentMode = useLiveData(editorService.editor.mode$);
const { favorite, toggleFavorite } = useFavorite(docId);
const handleSwitchMode = useCallback(() => {
editorService.editor.toggleMode();
track.$.header.docOptions.switchPageMode({
mode: currentMode === 'page' ? 'edgeless' : 'page',
});
toast(
currentMode === 'page'
? t['com.affine.toastMessage.edgelessMode']()
: t['com.affine.toastMessage.pageMode']()
);
}, [currentMode, editorService, t]);
const handleMenuOpenChange = useCallback((open: boolean) => {
if (open) {
track.$.header.docOptions.open();
}
setOpen(open);
}, []);
useEffect(() => {
// when the location is changed, close the menu
handleMenuOpenChange(false);
}, [handleMenuOpenChange, location.pathname]);
const handleToggleFavorite = useCallback(() => {
track.$.header.docOptions.toggleFavorite();
toggleFavorite();
}, [toggleFavorite]);
const EditMenu = (
<>
<MobileMenuItem
prefixIcon={currentMode === 'page' ? <EdgelessIcon /> : <PageIcon />}
data-testid="editor-option-menu-mode-switch"
onSelect={handleSwitchMode}
>
{t['Convert to ']()}
{currentMode === 'page'
? t['com.affine.pageMode.edgeless']()
: t['com.affine.pageMode.page']()}
</MobileMenuItem>
<MobileMenuItem
data-testid="editor-option-menu-favorite"
onSelect={handleToggleFavorite}
prefixIcon={<IsFavoriteIcon favorite={favorite} />}
>
{favorite
? t['com.affine.favoritePageOperation.remove']()
: t['com.affine.favoritePageOperation.add']()}
</MobileMenuItem>
<MenuSeparator />
<MobileMenu items={<DocInfoSheet docId={docId} />}>
<MobileMenuItem
prefixIcon={<InformationIcon />}
onClick={preventDefault}
>
<span>{t['com.affine.page-properties.page-info.view']()}</span>
</MobileMenuItem>
</MobileMenu>
<MobileMenu
items={
<div className={styles.outlinePanel}>
<EditorOutlinePanel editor={editorContainer} />
</div>
}
>
<MobileMenuItem prefixIcon={<TocIcon />} onClick={preventDefault}>
<span>{t['com.affine.header.option.view-toc']()}</span>
</MobileMenuItem>
</MobileMenu>
</>
);
if (isInTrash) {
return null;
}
return (
<MobileMenu
items={EditMenu}
contentOptions={{
align: 'center',
}}
rootOptions={{
open,
onOpenChange: handleMenuOpenChange,
}}
>
<IconButton
size={24}
data-testid="detail-page-header-more-button"
className={styles.iconButton}
>
<MoreHorizontalIcon />
</IconButton>
</MobileMenu>
);
};

View File

@@ -0,0 +1,5 @@
import { style } from '@vanilla-extract/css';
export const content = style({
padding: '0 20px',
});

View File

@@ -0,0 +1,42 @@
import { IconButton, MobileMenu } from '@affine/component';
import { SharePage } from '@affine/core/components/affine/share-page-modal/share-menu/share-page';
import { useEnableCloud } from '@affine/core/hooks/affine/use-enable-cloud';
import { WorkspaceFlavour } from '@affine/env/workspace';
import { ShareiOsIcon } from '@blocksuite/icons/rc';
import { DocService, useServices, WorkspaceService } from '@toeverything/infra';
import * as styles from './page-header-share-button.css';
export const PageHeaderShareButton = () => {
const { workspaceService, docService } = useServices({
WorkspaceService,
DocService,
});
const workspace = workspaceService.workspace;
const doc = docService.doc.blockSuiteDoc;
const confirmEnableCloud = useEnableCloud();
if (workspace.meta.flavour === WorkspaceFlavour.LOCAL) {
return null;
}
return (
<MobileMenu
items={
<div className={styles.content}>
<SharePage
workspaceMetadata={workspace.meta}
currentPage={doc}
onEnableAffineCloud={() =>
confirmEnableCloud(workspace, {
openPageId: doc.id,
})
}
/>
</div>
}
>
<IconButton size={24} style={{ padding: 10 }} icon={<ShareiOsIcon />} />
</MobileMenu>
);
};

View File

@@ -0,0 +1,51 @@
import { rowHPadding } from '@affine/core/components/affine/page-properties/styles.css';
import { style } from '@vanilla-extract/css';
export const viewport = style({
vars: {
[rowHPadding]: '0px',
},
});
export const item = style({
display: 'flex',
justifyContent: 'space-between',
alignItems: 'center',
gap: 4,
height: 34,
padding: '0 20px',
fontSize: 17,
lineHeight: '22px',
fontWeight: 400,
letterSpacing: -0.43,
});
export const linksRow = style({
padding: '0 16px',
});
export const timeRow = style({
padding: '0 16px',
});
export const tagsRow = style({
padding: '0 16px',
});
export const properties = style({
padding: '0 16px',
});
export const scrollBar = style({
width: 6,
transform: 'translateX(-4px)',
});
export const rowNameContainer = style({
display: 'flex',
flexDirection: 'row',
gap: 6,
padding: 6,
width: '160px',
});

View File

@@ -0,0 +1,100 @@
import { Divider, Scrollable } from '@affine/component';
import {
PagePropertyRow,
SortableProperties,
usePagePropertiesManager,
} from '@affine/core/components/affine/page-properties';
import { managerContext } from '@affine/core/components/affine/page-properties/common';
import { LinksRow } from '@affine/core/components/affine/page-properties/info-modal/links-row';
import { TagsRow } from '@affine/core/components/affine/page-properties/info-modal/tags-row';
import { TimeRow } from '@affine/core/components/affine/page-properties/info-modal/time-row';
import { DocsSearchService } from '@affine/core/modules/docs-search';
import { useI18n } from '@affine/i18n';
import { LiveData, useLiveData, useService } from '@toeverything/infra';
import { Suspense, useMemo } from 'react';
import * as styles from './doc-info.css';
export const DocInfoSheet = ({ docId }: { docId: string }) => {
const manager = usePagePropertiesManager(docId);
const docsSearchService = useService(DocsSearchService);
const t = useI18n();
const links = useLiveData(
useMemo(
() => LiveData.from(docsSearchService.watchRefsFrom(docId), null),
[docId, docsSearchService]
)
);
const backlinks = useLiveData(
useMemo(
() => LiveData.from(docsSearchService.watchRefsTo(docId), null),
[docId, docsSearchService]
)
);
return (
<Scrollable.Root>
<Scrollable.Viewport
className={styles.viewport}
data-testid="doc-info-menu"
>
<managerContext.Provider value={manager}>
<Suspense>
<TimeRow docId={docId} className={styles.timeRow} />
<Divider size="thinner" />
{backlinks && backlinks.length > 0 ? (
<>
<LinksRow
className={styles.linksRow}
references={backlinks}
label={t['com.affine.page-properties.backlinks']()}
/>
<Divider size="thinner" />
</>
) : null}
{links && links.length > 0 ? (
<>
<LinksRow
className={styles.linksRow}
references={links}
label={t['com.affine.page-properties.outgoing-links']()}
/>
<Divider size="thinner" />
</>
) : null}
<div className={styles.properties}>
<TagsRow docId={docId} readonly={manager.readonly} />
<SortableProperties>
{properties =>
properties.length ? (
<>
{properties
.filter(
property =>
manager.isPropertyRequired(property.id) ||
(property.visibility !== 'hide' &&
!(
property.visibility === 'hide-if-empty' &&
!property.value
))
)
.map(property => (
<PagePropertyRow
key={property.id}
property={property}
rowNameClassName={styles.rowNameContainer}
/>
))}
</>
) : null
}
</SortableProperties>
</div>
</Suspense>
</managerContext.Provider>
</Scrollable.Viewport>
<Scrollable.Scrollbar className={styles.scrollBar} />
</Scrollable.Root>
);
};

View File

@@ -0,0 +1,40 @@
import { SafeArea, useThemeColorV2 } from '@affine/component';
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 = () => {
useThemeColorV2('layer/background/secondary');
return (
<ExplorerMobileContext.Provider value={true}>
<HomeHeader />
<RecentDocs />
<SafeArea bottom>
<div
style={{
display: 'flex',
flexDirection: 'column',
gap: 32,
padding: '0 8px 32px 8px',
}}
>
<ExplorerFavorites />
{runtimeConfig.enableOrganize && <ExplorerOrganize />}
<ExplorerMigrationFavorites />
<ExplorerCollections />
<ExplorerTags />
</div>
</SafeArea>
<AppTabs />
</ExplorerMobileContext.Provider>
);
};

View File

@@ -0,0 +1,143 @@
import { AffineErrorBoundary } from '@affine/core/components/affine/affine-error-boundary';
import { AppFallback } from '@affine/core/components/affine/app-container';
import { PageNotFound } from '@affine/core/pages/404';
import { MobileWorkbenchRoot } from '@affine/core/pages/workspace/workbench-root';
import {
useLiveData,
useServices,
WorkspacesService,
} from '@toeverything/infra';
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';
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,
}))
);
const route = {
Component,
};
return {
path,
Component: () => {
return <MobileRouteContainer route={route} />;
},
};
});
export const Component = () => {
const { workspacesService } = useServices({
WorkspacesService,
});
const params = useParams();
const location = useLocation();
// todo(pengx17): dedupe the code with core
// check if we are in detail doc route, if so, maybe render share page
const detailDocRoute = useMemo(() => {
const match = matchPath(
'/workspace/:workspaceId/:docId',
location.pathname
);
if (
match &&
match.params.docId &&
match.params.workspaceId &&
// TODO(eyhn): need a better way to check if it's a docId
viewRoutes.find(route => matchPath(route.path, '/' + match.params.docId))
?.path === '/:pageId'
) {
return {
docId: match.params.docId,
workspaceId: match.params.workspaceId,
};
} else {
return null;
}
}, [location.pathname]);
const [workspaceNotFound, setWorkspaceNotFound] = useState(false);
const listLoading = useLiveData(workspacesService.list.isRevalidating$);
const workspaces = useLiveData(workspacesService.list.workspaces$);
const meta = useMemo(() => {
return workspaces.find(({ id }) => id === params.workspaceId);
}, [workspaces, params.workspaceId]);
// if listLoading is false, we can show 404 page, otherwise we should show loading page.
useEffect(() => {
if (listLoading === false && meta === undefined) {
setWorkspaceNotFound(true);
}
if (meta) {
setWorkspaceNotFound(false);
}
}, [listLoading, meta, workspacesService]);
// if workspace is not found, we should revalidate in interval
useEffect(() => {
if (listLoading === false && meta === undefined) {
const timer = setInterval(
() => workspacesService.list.revalidate(),
5000
);
return () => clearInterval(timer);
}
return;
}, [listLoading, meta, workspaceNotFound, workspacesService]);
if (workspaceNotFound) {
if (
detailDocRoute /* */ &&
environment.isDesktopEdition /* only browser has share page */
) {
return <div>TODO: share page</div>;
}
return <PageNotFound noPermission />;
}
if (!meta) {
return <AppFallback key="workspaceLoading" />;
}
return (
<WorkspaceLayout meta={meta}>
<MobileWorkbenchRoot routes={warpedRoutes} />
</WorkspaceLayout>
);
};

View File

@@ -0,0 +1,104 @@
import { AffineErrorBoundary } from '@affine/core/components/affine/affine-error-boundary';
import { AppFallback } from '@affine/core/components/affine/app-container';
import { WorkspaceLayoutProviders } from '@affine/core/layouts/workspace-layout';
import { SWRConfigProvider } from '@affine/core/providers/swr-config-provider';
import type { Workspace, WorkspaceMetadata } from '@toeverything/infra';
import {
FrameworkScope,
GlobalContextService,
useLiveData,
useServices,
WorkspacesService,
} from '@toeverything/infra';
import {
type PropsWithChildren,
useEffect,
useLayoutEffect,
useState,
} from 'react';
import { MobileCurrentWorkspaceModals } from '../../provider/model-provider';
// TODO(@forehalo): reuse the global context with [core/electron]
declare global {
/**
* @internal debug only
*/
// eslint-disable-next-line no-var
var currentWorkspace: Workspace | undefined;
// eslint-disable-next-line no-var
var exportWorkspaceSnapshot: (docs?: string[]) => Promise<void>;
// eslint-disable-next-line no-var
var importWorkspaceSnapshot: () => Promise<void>;
interface WindowEventMap {
'affine:workspace:change': CustomEvent<{ id: string }>;
}
}
export const WorkspaceLayout = ({
meta,
children,
}: PropsWithChildren<{ meta: WorkspaceMetadata }>) => {
// todo: reduce code duplication with packages\frontend\core\src\pages\workspace\index.tsx
const { workspacesService, globalContextService } = useServices({
WorkspacesService,
GlobalContextService,
});
const [workspace, setWorkspace] = useState<Workspace | null>(null);
useLayoutEffect(() => {
const ref = workspacesService.open({ metadata: meta });
setWorkspace(ref.workspace);
return () => {
ref.dispose();
};
}, [meta, workspacesService]);
useEffect(() => {
if (workspace) {
// for debug purpose
window.currentWorkspace = workspace ?? undefined;
window.dispatchEvent(
new CustomEvent('affine:workspace:change', {
detail: {
id: workspace.id,
},
})
);
localStorage.setItem('last_workspace_id', workspace.id);
globalContextService.globalContext.workspaceId.set(workspace.id);
return () => {
window.currentWorkspace = undefined;
globalContextService.globalContext.workspaceId.set(null);
};
}
return;
}, [globalContextService, workspace]);
const isRootDocReady =
useLiveData(workspace?.engine.rootDocState$.map(v => v.ready)) ?? false;
if (!workspace) {
return null; // skip this, workspace will be set in layout effect
}
if (!isRootDocReady) {
return (
<FrameworkScope scope={workspace.scope}>
<AppFallback />
</FrameworkScope>
);
}
return (
<FrameworkScope scope={workspace.scope}>
<AffineErrorBoundary height="100dvh">
<SWRConfigProvider>
<MobileCurrentWorkspaceModals />
<WorkspaceLayoutProviders>{children}</WorkspaceLayoutProviders>
</SWRConfigProvider>
</AffineErrorBoundary>
</FrameworkScope>
);
};

View File

@@ -0,0 +1,153 @@
import { SafeArea, useThemeColorV2 } from '@affine/component';
import { CollectionService } from '@affine/core/modules/collection';
import {
type QuickSearchItem,
QuickSearchTagIcon,
} from '@affine/core/modules/quicksearch';
import { TagService } from '@affine/core/modules/tag';
import { ViewLayersIcon } from '@blocksuite/icons/rc';
import {
LiveData,
useLiveData,
useService,
useServices,
} from '@toeverything/infra';
import { useCallback, useMemo } from 'react';
import { AppTabs, SearchInput, SearchResLabel } from '../../components';
import { MobileSearchService } from '../../modules/search';
import { SearchResults } from '../../views/search/search-results';
import * as styles from '../../views/search/style.css';
const searchInput$ = new LiveData('');
const RecentList = () => {
const { mobileSearchService, collectionService, tagService } = useServices({
MobileSearchService,
CollectionService,
TagService,
});
const recentDocsList = useLiveData(mobileSearchService.recentDocs.items$);
const collections = useLiveData(collectionService.collections$);
const tags = useLiveData(
LiveData.computed(get =>
get(tagService.tagList.tags$).map(tag => ({
id: tag.id,
title: get(tag.value$),
color: get(tag.color$),
}))
)
);
const docs = useMemo(
() =>
recentDocsList.map(item => ({
id: item.payload.docId,
icon: item.icon,
title: <SearchResLabel item={item} />,
})),
[recentDocsList]
);
const collectionList = useMemo(() => {
return collections.slice(0, 3).map(item => {
return {
id: 'collection:' + item.id,
source: 'collection',
label: { title: item.name },
icon: <ViewLayersIcon />,
payload: { collectionId: item.id },
} satisfies QuickSearchItem<'collection', { collectionId: string }>;
});
}, [collections]);
const tagList = useMemo(() => {
return tags
.reverse()
.slice(0, 3)
.map(item => {
return {
id: 'tag:' + item.id,
source: 'tag',
label: { title: item.title },
icon: <QuickSearchTagIcon color={item.color} />,
payload: { tagId: item.id },
} satisfies QuickSearchItem<'tag', { tagId: string }>;
});
}, [tags]);
return (
<SearchResults
title="Recent"
docs={docs}
collections={collectionList}
tags={tagList}
/>
);
};
const WithQueryList = () => {
const searchService = useService(MobileSearchService);
const collectionList = useLiveData(searchService.collections.items$);
const docList = useLiveData(searchService.docs.items$);
const tagList = useLiveData(searchService.tags.items$);
const docs = useMemo(
() =>
docList.map(item => ({
id: item.payload.docId,
icon: item.icon,
title: <SearchResLabel item={item} />,
})),
[docList]
);
return (
<SearchResults
title="Search result"
docs={docs}
collections={collectionList}
tags={tagList}
/>
);
};
export const Component = () => {
useThemeColorV2('layer/background/secondary');
const searchInput = useLiveData(searchInput$);
const searchService = useService(MobileSearchService);
const onSearch = useCallback(
(v: string) => {
searchInput$.next(v);
searchService.recentDocs.query(v);
searchService.collections.query(v);
searchService.docs.query(v);
searchService.tags.query(v);
},
[
searchService.collections,
searchService.docs,
searchService.recentDocs,
searchService.tags,
]
);
return (
<>
<SafeArea top>
<div className={styles.searchHeader} data-testid="search-header">
<SearchInput
debounce={300}
autoFocus={!searchInput}
value={searchInput}
onInput={onSearch}
placeholder="Search Docs, Collections"
/>
</div>
</SafeArea>
{searchInput ? <WithQueryList /> : <RecentList />}
<AppTabs />
</>
);
};

View File

@@ -0,0 +1,42 @@
import { useThemeColorV2 } from '@affine/component';
import { TagService } from '@affine/core/modules/tag';
import { PageNotFound } from '@affine/core/pages/404';
import {
GlobalContextService,
useLiveData,
useService,
} from '@toeverything/infra';
import { useEffect } from 'react';
import { useParams } from 'react-router-dom';
import { TagDetail } from '../../../views';
export const Component = () => {
useThemeColorV2('layer/background/secondary');
const params = useParams();
const tagId = params.tagId;
const globalContext = useService(GlobalContextService).globalContext;
const tagList = useService(TagService).tagList;
const currentTag = useLiveData(tagList.tagByTagId$(tagId));
useEffect(() => {
if (currentTag) {
globalContext.tagId.set(currentTag.id);
globalContext.isTag.set(true);
return () => {
globalContext.tagId.set(null);
globalContext.isTag.set(false);
};
}
return;
}, [currentTag, globalContext]);
if (!currentTag) {
return <PageNotFound />;
}
return <TagDetail tag={currentTag} />;
};

View File

@@ -0,0 +1,15 @@
import { useThemeColorV2 } from '@affine/component';
import { AppTabs } from '../../../components';
import { AllDocsHeader, TagList } from '../../../views';
export const Component = () => {
useThemeColorV2('layer/background/secondary');
return (
<>
<AllDocsHeader />
<AppTabs />
<TagList />
</>
);
};

View File

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

View File

@@ -0,0 +1,2 @@
import 'core-js/modules/esnext.symbol.async-dispose';
import 'core-js/modules/esnext.symbol.dispose';

View File

@@ -0,0 +1,11 @@
if (Intl.Segmenter === undefined) {
await import('intl-segmenter-polyfill-rs').then(({ Segmenter }) => {
Object.defineProperty(Intl, 'Segmenter', {
value: Segmenter,
configurable: true,
writable: true,
});
});
}
export {};

View File

@@ -0,0 +1 @@
import 'core-js/features/promise/with-resolvers';

View File

@@ -0,0 +1,19 @@
window.requestIdleCallback =
window.requestIdleCallback ||
function (cb) {
const start = Date.now();
return setTimeout(function () {
cb({
didTimeout: false,
timeRemaining: function () {
return Math.max(0, 50 - (Date.now() - start));
},
});
}, 1);
};
window.cancelIdleCallback =
window.cancelIdleCallback ||
function (id) {
clearTimeout(id);
};

View File

@@ -0,0 +1,76 @@
import { NotificationCenter } from '@affine/component';
import { AiLoginRequiredModal } from '@affine/core/components/affine/auth/ai-login-required';
import { HistoryTipsModal } from '@affine/core/components/affine/history-tips-modal';
import { IssueFeedbackModal } from '@affine/core/components/affine/issue-feedback-modal';
import {
CloudQuotaModal,
LocalQuotaModal,
} from '@affine/core/components/affine/quota-reached-modal';
import { StarAFFiNEModal } from '@affine/core/components/affine/star-affine-modal';
import { MoveToTrash } from '@affine/core/components/page-list';
import { useTrashModalHelper } from '@affine/core/hooks/affine/use-trash-modal-helper';
import { CreateWorkspaceDialogProvider } from '@affine/core/modules/create-workspace';
import { PeekViewManagerModal } from '@affine/core/modules/peek-view';
import { SignOutConfirmModal } from '@affine/core/providers/modal-provider';
import { WorkspaceFlavour } from '@affine/env/workspace';
import { useService, WorkspaceService } from '@toeverything/infra';
import { useCallback } from 'react';
import { MobileSettingModal } from '../views';
import { MobileSignInModal } from '../views/sign-in/modal';
export function MobileCurrentWorkspaceModals() {
const currentWorkspace = useService(WorkspaceService).workspace;
const { trashModal, setTrashModal, handleOnConfirm } = useTrashModalHelper(
currentWorkspace.docCollection
);
const deletePageTitles = trashModal.pageTitles;
const trashConfirmOpen = trashModal.open;
const onTrashConfirmOpenChange = useCallback(
(open: boolean) => {
setTrashModal({
...trashModal,
open,
});
},
[trashModal, setTrashModal]
);
return (
<>
<StarAFFiNEModal />
<IssueFeedbackModal />
{currentWorkspace ? <MobileSettingModal /> : null}
{currentWorkspace?.flavour === WorkspaceFlavour.LOCAL && (
<>
<LocalQuotaModal />
<HistoryTipsModal />
</>
)}
{currentWorkspace?.flavour === WorkspaceFlavour.AFFINE_CLOUD && (
<CloudQuotaModal />
)}
<AiLoginRequiredModal />
<PeekViewManagerModal />
<MoveToTrash.ConfirmModal
open={trashConfirmOpen}
onConfirm={handleOnConfirm}
onOpenChange={onTrashConfirmOpenChange}
titles={deletePageTitles}
/>
</>
);
}
// I don't like the name, but let's keep it for now
export const AllWorkspaceModals = () => {
return (
<>
<NotificationCenter />
<CreateWorkspaceDialogProvider />
<MobileSignInModal />
<SignOutConfirmModal />
</>
);
};

View File

@@ -0,0 +1,159 @@
import { NavigateContext } from '@affine/core/hooks/use-navigate-helper';
import { wrapCreateBrowserRouter } from '@sentry/react';
import { useEffect, useState } from 'react';
import type { RouteObject } from 'react-router-dom';
import {
createBrowserRouter as reactRouterCreateBrowserRouter,
Outlet,
redirect,
// eslint-disable-next-line @typescript-eslint/no-restricted-imports
useNavigate,
} from 'react-router-dom';
import { Component as All } from './pages/workspace/all';
import { Component as Collection } from './pages/workspace/collection';
import { Component as CollectionDetail } from './pages/workspace/collection/detail';
import { Component as Home } from './pages/workspace/home';
import { Component as Search } from './pages/workspace/search';
import { Component as Tag } from './pages/workspace/tag';
import { Component as TagDetail } from './pages/workspace/tag/detail';
import { AllWorkspaceModals } from './provider/model-provider';
function RootRouter() {
const navigate = useNavigate();
const [ready, setReady] = useState(false);
useEffect(() => {
// a hack to make sure router is ready
setReady(true);
}, []);
return (
ready && (
<NavigateContext.Provider value={navigate}>
<AllWorkspaceModals />
<Outlet />
</NavigateContext.Provider>
)
);
}
export const topLevelRoutes = [
{
element: <RootRouter />,
children: [
{
path: '/',
lazy: () => import('./pages/index'),
},
{
path: '/workspace/:workspaceId/*',
lazy: () => import('./pages/workspace/index'),
},
{
path: '/share/:workspaceId/:pageId',
loader: ({ params }) => {
return redirect(`/workspace/${params.workspaceId}/${params.pageId}`);
},
},
{
path: '/404',
lazy: () => import('./pages/404'),
},
{
path: '/auth/:authType',
lazy: () => import('./pages/auth'),
},
{
path: '/sign-in',
lazy: () => import('./pages/sign-in'),
},
{
path: '/magic-link',
lazy: () =>
import(
/* webpackChunkName: "auth" */ '@affine/core/pages/auth/magic-link'
),
},
{
path: '/oauth/login',
lazy: () =>
import(
/* webpackChunkName: "auth" */ '@affine/core/pages/auth/oauth-login'
),
},
{
path: '/oauth/callback',
lazy: () =>
import(
/* webpackChunkName: "auth" */ '@affine/core/pages/auth/oauth-callback'
),
},
{
path: '/redirect-proxy',
lazy: () => import('@affine/core/pages/redirect'),
},
{
path: '*',
lazy: () => import('./pages/404'),
},
],
},
] satisfies [RouteObject, ...RouteObject[]];
export const viewRoutes = [
{
path: '/home',
Component: Home,
},
{
path: '/search',
Component: Search,
},
{
path: '/all',
Component: All,
},
{
path: '/collection',
// lazy: () => import('./pages/workspace/collection/index'),
Component: Collection,
},
{
path: '/collection/:collectionId',
// lazy: () => import('./pages/workspace/collection/detail'),
Component: CollectionDetail,
},
{
path: '/tag',
// lazy: () => import('./pages/workspace/tag/index'),
Component: Tag,
},
{
path: '/tag/:tagId',
// lazy: () => import('./pages/workspace/tag/detail'),
Component: TagDetail,
},
{
path: '/trash',
lazy: () => import('./pages/workspace/trash'),
},
{
path: '/:pageId',
lazy: () => import('./pages/workspace/detail/mobile-detail-page'),
},
{
path: '*',
lazy: () => import('./pages/404'),
},
] satisfies [RouteObject, ...RouteObject[]];
const createBrowserRouter = wrapCreateBrowserRouter(
reactRouterCreateBrowserRouter
);
export const router = (
window.SENTRY_RELEASE ? createBrowserRouter : reactRouterCreateBrowserRouter
)(topLevelRoutes, {
future: {
v7_normalizeFormMethod: true,
},
});

View File

@@ -0,0 +1,31 @@
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',
minHeight: '100dvh',
});
globalStyle('body:has(#app-tabs)', {
paddingBottom: globalVars.appTabHeight,
});
globalStyle('html', {
overflowY: 'auto',
background: cssVarV2('layer/background/secondary'),
});
globalStyle('a:focus', {
outline: 'none',
});
globalStyle('button:focus', {
outline: 'none',
});

View File

@@ -0,0 +1,3 @@
# mobile views
Maintain complex views that used for `../pages`, view can contain mobile-components in `../components`

View File

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

View File

@@ -0,0 +1,54 @@
import { IconButton, MobileMenu } from '@affine/component';
import { EmptyCollectionDetail } from '@affine/core/components/affine/empty';
import { isEmptyCollection } from '@affine/core/pages/workspace/collection';
import type { Collection } from '@affine/env/filter';
import { MoreHorizontalIcon, ViewLayersIcon } from '@blocksuite/icons/rc';
import { PageHeader } from '../../../components';
import { AllDocList } from '../doc/list';
import { AllDocsMenu } from '../doc/menu';
import * as styles from './detail.css';
export const DetailHeader = ({ collection }: { collection: Collection }) => {
return (
<PageHeader
back
suffix={
<MobileMenu items={<AllDocsMenu />}>
<IconButton
size="24"
style={{ padding: 10 }}
icon={<MoreHorizontalIcon />}
/>
</MobileMenu>
}
>
<div className={styles.headerContent}>
<ViewLayersIcon className={styles.headerIcon} />
{collection.name}
</div>
</PageHeader>
);
};
export const CollectionDetail = ({
collection,
}: {
collection: Collection;
}) => {
if (isEmptyCollection(collection)) {
return (
<>
<DetailHeader collection={collection} />
<EmptyCollectionDetail collection={collection} absoluteCenter />
</>
);
}
return (
<>
<DetailHeader collection={collection} />
<AllDocList collection={collection} />
</>
);
};

View File

@@ -0,0 +1,12 @@
import type { Collection } from '@affine/env/filter';
import { DetailHeader } from './detail';
export const EmptyCollection = ({ collection }: { collection: Collection }) => {
return (
<>
<DetailHeader collection={collection} />
Empty
</>
);
};

View File

@@ -0,0 +1,3 @@
export { CollectionDetail } from './detail';
export * from './empty';
export * from './list';

View File

@@ -0,0 +1,38 @@
import { IconButton } from '@affine/component';
import type { CollectionMeta } from '@affine/core/components/page-list';
import { IsFavoriteIcon } from '@affine/core/components/pure/icons';
import { CompatibleFavoriteItemsAdapter } from '@affine/core/modules/properties';
import { WorkbenchLink } from '@affine/core/modules/workbench';
import { ViewLayersIcon } from '@blocksuite/icons/rc';
import { useLiveData, useService } from '@toeverything/infra';
import { type MouseEvent, useCallback } from 'react';
import { item, name, prefixIcon, suffixIcon } from './styles.css';
export const CollectionListItem = ({ meta }: { meta: CollectionMeta }) => {
const favAdapter = useService(CompatibleFavoriteItemsAdapter);
const isFavorite = useLiveData(favAdapter.isFavorite$(meta.id, 'collection'));
const toggle = useCallback(
(e: MouseEvent) => {
e.preventDefault();
e.stopPropagation();
favAdapter.toggle(meta.id, 'collection');
},
[favAdapter, meta.id]
);
return (
<WorkbenchLink to={`/collection/${meta.id}`} className={item}>
<ViewLayersIcon className={prefixIcon} />
<span className={name}>{meta.title}</span>
<IconButton
className={suffixIcon}
onClick={toggle}
icon={<IsFavoriteIcon favorite={isFavorite} />}
size="24"
/>
</WorkbenchLink>
);
};

View File

@@ -0,0 +1,34 @@
import { EmptyCollections } from '@affine/core/components/affine/empty';
import type { CollectionMeta } from '@affine/core/components/page-list';
import { CollectionService } from '@affine/core/modules/collection';
import { useLiveData, useService } from '@toeverything/infra';
import { useMemo } from 'react';
import { CollectionListItem } from './item';
import { list } from './styles.css';
export const CollectionList = () => {
const collectionService = useService(CollectionService);
const collections = useLiveData(collectionService.collections$);
const collectionMetas = useMemo(
() =>
collections.map(
collection =>
({ ...collection, title: collection.name }) satisfies CollectionMeta
),
[collections]
);
if (!collectionMetas.length) {
return <EmptyCollections absoluteCenter />;
}
return (
<ul className={list}>
{collectionMetas.map(meta => (
<CollectionListItem key={meta.id} meta={meta} />
))}
</ul>
);
};

View File

@@ -0,0 +1,45 @@
import { cssVarV2 } from '@toeverything/theme/v2';
import { style } from '@vanilla-extract/css';
export const list = style({});
export const item = style({
display: 'flex',
alignItems: 'center',
gap: 8,
padding: 16,
color: 'unset',
':visited': { color: 'unset' },
':hover': { color: 'unset' },
':active': { color: 'unset' },
':focus': { color: 'unset' },
selectors: {
'&:not(:last-child)': {
borderBottom: `0.5px solid ${cssVarV2('layer/insideBorder/border')}`,
},
},
});
export const icon = style({
width: 24,
height: 24,
color: cssVarV2('icon/primary'),
flexShrink: 0,
});
export const prefixIcon = style([icon]);
export const name = style({
width: 0,
flex: 1,
overflow: 'hidden',
textOverflow: 'ellipsis',
whiteSpace: 'nowrap',
color: cssVarV2('text/primary'),
fontSize: 17,
fontWeight: 600,
lineHeight: '24px',
letterSpacing: -0.43,
});
export const suffixIcon = style([icon]);

View File

@@ -0,0 +1,3 @@
export * from './list';
export * from './masonry';
export * from './menu';

View File

@@ -0,0 +1,32 @@
import { cssVarV2 } from '@toeverything/theme/v2';
import { globalStyle, style } from '@vanilla-extract/css';
export const groupTitle = style({
display: 'flex',
alignItems: 'center',
padding: '0px 16px',
width: '100%',
});
// to override style defined in `core`
globalStyle(`${groupTitle} > div`, {
marginRight: -4,
});
globalStyle(`${groupTitle} div[data-testid^='group-label']`, {
fontSize: `20px !important`,
color: `${cssVarV2('text/primary')} !important`,
lineHeight: '25px !important',
});
export const groupTitleIcon = style({
color: cssVarV2('icon/tertiary'),
transition: 'transform 0.2s',
selectors: {
'[data-state="closed"] &': {
transform: `rotate(-90deg)`,
},
},
});
export const groups = style({
display: 'flex',
flexDirection: 'column',
gap: 32,
});

View File

@@ -0,0 +1,93 @@
import { EmptyDocs } from '@affine/core/components/affine/empty';
import {
type ItemGroupDefinition,
type ItemGroupProps,
useAllDocDisplayProperties,
useFilteredPageMetas,
usePageItemGroupDefinitions,
} from '@affine/core/components/page-list';
import { itemsToItemGroups } from '@affine/core/components/page-list/items-to-item-group';
import { useBlockSuiteDocMeta } from '@affine/core/hooks/use-block-suite-page-meta';
import type { Tag } from '@affine/core/modules/tag';
import type { Collection, Filter } from '@affine/env/filter';
import { ToggleExpandIcon } from '@blocksuite/icons/rc';
import type { DocMeta } from '@blocksuite/store';
import * as Collapsible from '@radix-ui/react-collapsible';
import { useLiveData, useService, WorkspaceService } from '@toeverything/infra';
import { useMemo } from 'react';
import * as styles from './list.css';
import { MasonryDocs } from './masonry';
const DocGroup = ({ group }: { group: ItemGroupProps<DocMeta> }) => {
const [properties] = useAllDocDisplayProperties();
const showTags = properties.displayProperties.tags;
if (group.id === 'all') {
return <MasonryDocs items={group.items} showTags={showTags} />;
}
return (
<Collapsible.Root defaultOpen>
<Collapsible.Trigger className={styles.groupTitle}>
{group.label}
<ToggleExpandIcon className={styles.groupTitleIcon} />
</Collapsible.Trigger>
<Collapsible.Content>
<MasonryDocs items={group.items} showTags={showTags} />
</Collapsible.Content>
</Collapsible.Root>
);
};
export interface AllDocListProps {
collection?: Collection;
tag?: Tag;
filters?: Filter[];
trash?: boolean;
}
export const AllDocList = ({
trash,
collection,
tag,
filters = [],
}: AllDocListProps) => {
const workspace = useService(WorkspaceService).workspace;
const allPageMetas = useBlockSuiteDocMeta(workspace.docCollection);
const tagPageIds = useLiveData(tag?.pageIds$);
const filteredPageMetas = useFilteredPageMetas(allPageMetas, {
trash,
filters,
collection,
});
const finalPageMetas = useMemo(() => {
if (tag) {
const pageIdsSet = new Set(tagPageIds);
return filteredPageMetas.filter(page => pageIdsSet.has(page.id));
}
return filteredPageMetas;
}, [filteredPageMetas, tag, tagPageIds]);
const groupDefs =
usePageItemGroupDefinitions() as ItemGroupDefinition<DocMeta>[];
const groups = useMemo(() => {
return itemsToItemGroups(finalPageMetas ?? [], groupDefs);
}, [finalPageMetas, groupDefs]);
if (!groups.length) {
return <EmptyDocs absoluteCenter tagId={tag?.id} />;
}
return (
<div className={styles.groups}>
{groups.map(group => (
<DocGroup key={group.id} group={group} />
))}
</div>
);
};

View File

@@ -0,0 +1,28 @@
import { style } from '@vanilla-extract/css';
export const invisibleWrapper = style({
position: 'absolute',
padding: 'inherit',
width: '100%',
height: 0,
overflow: 'hidden',
visibility: 'hidden',
pointerEvents: 'none',
});
export const invisibleList = style({
width: `calc(50% - 17px / 2)`,
});
export const stacks = style({
position: 'relative',
width: '100%',
display: 'flex',
gap: 17,
padding: 16,
});
export const stack = style({
width: 0,
flex: 1,
display: 'flex',
flexDirection: 'column',
gap: 10,
});

View File

@@ -0,0 +1,37 @@
import type { DocMeta } from '@blocksuite/store';
import { useMemo } from 'react';
import { DocCard } from '../../../components';
import * as styles from './masonry.css';
// TODO(@CatsJuice): Large amount docs performance
export const MasonryDocs = ({
items,
showTags,
}: {
items: DocMeta[];
showTags?: boolean;
}) => {
// card preview is loaded lazily, it's meaningless to calculate height
const stacks = useMemo(() => {
return items.reduce(
(acc, item, i) => {
acc[i % 2].push(item);
return acc;
},
[[], []] as [DocMeta[], DocMeta[]]
);
}, [items]);
return (
<div className={styles.stacks}>
{stacks.map((stack, i) => (
<ul key={i} className={styles.stack}>
{stack.map(item => (
<DocCard showTags={showTags} key={item.id} meta={item} />
))}
</ul>
))}
</div>
);
};

View File

@@ -0,0 +1,67 @@
import { cssVarV2 } from '@toeverything/theme/v2';
import { style } from '@vanilla-extract/css';
export const root = style({
display: 'flex',
flexDirection: 'column',
gap: 10,
});
export const head = style({
padding: '10px 20px',
fontSize: 17,
fontWeight: 600,
lineHeight: '22px',
letterSpacing: -0.43,
color: cssVarV2('text/primary'),
});
export const item = style({
display: 'flex',
justifyContent: 'space-between',
alignItems: 'center',
gap: 4,
height: 34,
padding: '0 20px',
fontSize: 17,
lineHeight: '22px',
fontWeight: 400,
letterSpacing: -0.43,
});
export const itemSuffix = style({
display: 'flex',
gap: 8,
alignItems: 'center',
});
export const itemSuffixText = style({
color: cssVarV2('text/secondary'),
});
export const itemSuffixIcon = style({
color: cssVarV2('icon/primary'),
fontSize: 20,
});
export const divider = style({
width: '100%',
height: 16,
display: 'flex',
alignItems: 'center',
':before': {
content: "''",
height: 0.5,
width: '100%',
backgroundColor: cssVarV2('layer/insideBorder/border'),
},
});
export const propertiesList = style({
padding: '4px 20px',
display: 'flex',
gap: 8,
});
export const propertyButton = style({
opacity: 0.4,
selectors: {
'&[data-selected="true"]': {
opacity: 1,
},
},
});

View File

@@ -0,0 +1,73 @@
import { Button, MobileMenu, MobileMenuItem } from '@affine/component';
import {
getGroupOptions,
useAllDocDisplayProperties,
} from '@affine/core/components/page-list';
import { useI18n } from '@affine/i18n';
import { ArrowRightSmallIcon } from '@blocksuite/icons/rc';
import { useMemo } from 'react';
import * as styles from './menu.css';
export const AllDocsMenu = () => {
const t = useI18n();
const [properties, setProperties] = useAllDocDisplayProperties();
const groupOptions = useMemo(() => getGroupOptions(t), [t]);
const activeGroup = useMemo(
() => groupOptions.find(g => g.value === properties.groupBy),
[groupOptions, properties.groupBy]
);
return (
<div className={styles.root}>
<header className={styles.head}>Display Settings</header>
<MobileMenu
items={
<>
<div className={styles.divider} />
{groupOptions.map(group => (
<MobileMenuItem
onSelect={() => setProperties('groupBy', group.value)}
selected={properties.groupBy === group.value}
key={group.value}
>
{group.label}
</MobileMenuItem>
))}
</>
}
>
<div className={styles.item}>
<span>{t['com.affine.page.display.grouping']()}</span>
<div className={styles.itemSuffix}>
<span className={styles.itemSuffixText}>{activeGroup?.label}</span>
<ArrowRightSmallIcon className={styles.itemSuffixIcon} />
</div>
</div>
</MobileMenu>
<div className={styles.divider} />
<div className={styles.item}>
{t['com.affine.page.display.display-properties']()}
</div>
<div className={styles.propertiesList}>
<Button
size="large"
className={styles.propertyButton}
data-selected={properties.displayProperties.tags}
onClick={() =>
setProperties('displayProperties', {
...properties.displayProperties,
tags: !properties.displayProperties.tags,
})
}
>
Tag
</Button>
</div>
</div>
);
};

View File

@@ -0,0 +1,31 @@
import { IconButton, MobileMenu, SafeArea } from '@affine/component';
import { MoreHorizontalIcon } from '@blocksuite/icons/rc';
import { header, headerContent, headerSpace } from './style.css';
import { AllDocsTabs } from './tabs';
export interface AllDocsHeaderProps {
operations?: React.ReactNode;
}
export const AllDocsHeader = ({ operations }: AllDocsHeaderProps) => {
return (
<>
<SafeArea top className={header}>
<header className={headerContent}>
<AllDocsTabs />
<div>
{operations ? (
<MobileMenu items={operations}>
<IconButton icon={<MoreHorizontalIcon />} />
</MobileMenu>
) : null}
</div>
</header>
</SafeArea>
<SafeArea top>
<div className={headerSpace} />
</SafeArea>
</>
);
};

View File

@@ -0,0 +1,4 @@
export * from './collection';
export * from './doc/';
export * from './header';
export * from './tag';

View File

@@ -0,0 +1,43 @@
import { cssVarV2 } from '@toeverything/theme/v2';
import { style } from '@vanilla-extract/css';
const basicHeader = style({
width: '100%',
height: 56,
});
export const header = style({
width: '100%',
position: 'fixed',
top: 0,
backgroundColor: cssVarV2('layer/background/secondary'),
zIndex: 1,
});
export const headerSpace = style([basicHeader]);
export const headerContent = style([
basicHeader,
{
display: 'flex',
alignItems: 'center',
justifyContent: 'space-between',
gap: 16,
padding: `0px 16px`,
},
]);
export const tabs = style({
height: 56,
gap: 16,
display: 'flex',
alignItems: 'center',
});
export const tab = style({
fontSize: 20,
fontWeight: 600,
lineHeight: '28px',
color: cssVarV2('text/tertiary'),
selectors: {
'&[data-active="true"]': {
color: cssVarV2('text/primary'),
},
},
});

View File

@@ -0,0 +1,50 @@
import {
WorkbenchLink,
WorkbenchService,
} from '@affine/core/modules/workbench';
import { useLiveData, useService } from '@toeverything/infra';
import * as styles from './style.css';
interface Tab {
to: string;
label: string;
}
const tabs: Tab[] = [
{
to: '/all',
label: 'Docs',
},
{
to: '/collection',
label: 'Collections',
},
{
to: '/tag',
label: 'Tags',
},
];
export const AllDocsTabs = () => {
const workbench = useService(WorkbenchService).workbench;
const location = useLiveData(workbench.location$);
return (
<ul className={styles.tabs}>
{tabs.map(tab => {
return (
<WorkbenchLink
data-active={location.pathname === tab.to}
replaceHistory
className={styles.tab}
key={tab.to}
to={tab.to}
>
{tab.label}
</WorkbenchLink>
);
})}
</ul>
);
};

View File

@@ -0,0 +1,32 @@
import { IconButton, MobileMenu } from '@affine/component';
import type { Tag } from '@affine/core/modules/tag';
import { MoreHorizontalIcon } from '@blocksuite/icons/rc';
import { useLiveData } from '@toeverything/infra';
import { PageHeader } from '../../../components';
import { AllDocsMenu } from '../doc';
import * as styles from './detail.css';
export const TagDetailHeader = ({ tag }: { tag: Tag }) => {
const name = useLiveData(tag.value$);
const color = useLiveData(tag.color$);
return (
<PageHeader
back
suffix={
<MobileMenu items={<AllDocsMenu />}>
<IconButton
size="24"
style={{ padding: 10 }}
icon={<MoreHorizontalIcon />}
/>
</MobileMenu>
}
>
<div className={styles.headerContent}>
<div className={styles.headerIcon} style={{ color }} />
{name}
</div>
</PageHeader>
);
};

View File

@@ -0,0 +1,29 @@
import { cssVarV2 } from '@toeverything/theme/v2';
import { style } from '@vanilla-extract/css';
export const headerContent = style({
fontSize: 17,
fontWeight: 600,
lineHeight: '22px',
letterSpacing: -0.43,
color: cssVarV2('text/primary'),
whiteSpace: 'nowrap',
textOverflow: 'ellipsis',
overflow: 'hidden',
});
export const headerIcon = style({
width: 24,
height: 24,
marginRight: 8,
display: 'inline-flex',
alignItems: 'center',
justifyContent: 'center',
':before': {
content: '""',
width: 12,
height: 12,
borderRadius: 6,
backgroundColor: 'currentColor',
},
});

View File

@@ -0,0 +1,15 @@
import type { Tag } from '@affine/core/modules/tag';
import { AppTabs } from '../../../components';
import { AllDocList } from '../doc';
import { TagDetailHeader } from './detail-header';
export const TagDetail = ({ tag }: { tag: Tag }) => {
return (
<>
<TagDetailHeader tag={tag} />
<AllDocList tag={tag} />
<AppTabs />
</>
);
};

View File

@@ -0,0 +1,12 @@
import type { Tag } from '@affine/core/modules/tag';
import { TagDetailHeader } from './detail-header';
export const TagEmpty = ({ tag }: { tag: Tag }) => {
return (
<>
<TagDetailHeader tag={tag} />
Empty
</>
);
};

View File

@@ -0,0 +1,3 @@
export { TagDetail } from './detail';
export * from './item';
export * from './list';

View File

@@ -0,0 +1,38 @@
import { IconButton } from '@affine/component';
import { IsFavoriteIcon } from '@affine/core/components/pure/icons';
import { CompatibleFavoriteItemsAdapter } from '@affine/core/modules/properties';
import type { Tag } from '@affine/core/modules/tag';
import { WorkbenchLink } from '@affine/core/modules/workbench';
import { useLiveData, useService } from '@toeverything/infra';
import { type MouseEvent, useCallback } from 'react';
import { content, item, prefixIcon, suffixIcon } from './styles.css';
export const TagItem = ({ tag }: { tag: Tag }) => {
const favAdapter = useService(CompatibleFavoriteItemsAdapter);
const isFavorite = useLiveData(favAdapter.isFavorite$(tag.id, 'tag'));
const color = useLiveData(tag.color$);
const name = useLiveData(tag.value$);
const toggle = useCallback(
(e: MouseEvent) => {
e.preventDefault();
e.stopPropagation();
favAdapter.toggle(tag.id, 'tag');
},
[favAdapter, tag.id]
);
return (
<WorkbenchLink to={`/tag/${tag.id}`} className={item}>
<div className={prefixIcon} style={{ color }} />
<span className={content}>{name}</span>
<IconButton
className={suffixIcon}
onClick={toggle}
icon={<IsFavoriteIcon favorite={isFavorite} />}
size="24"
/>
</WorkbenchLink>
);
};

View File

@@ -0,0 +1,23 @@
import { EmptyTags } from '@affine/core/components/affine/empty';
import { TagService } from '@affine/core/modules/tag';
import { useLiveData, useService } from '@toeverything/infra';
import { TagItem } from './item';
import { list } from './styles.css';
export const TagList = () => {
const tagList = useService(TagService).tagList;
const tags = useLiveData(tagList.tags$);
if (!tags.length) {
return <EmptyTags absoluteCenter />;
}
return (
<ul className={list}>
{tags.map(tag => (
<TagItem key={tag.id} tag={tag} />
))}
</ul>
);
};

View File

@@ -0,0 +1,54 @@
import { cssVarV2 } from '@toeverything/theme/v2';
import { style } from '@vanilla-extract/css';
export const list = style({});
export const item = style({
display: 'flex',
alignItems: 'center',
gap: 8,
padding: 16,
':visited': { color: 'unset' },
':hover': { color: 'unset' },
':active': { color: 'unset' },
':focus': { color: 'unset' },
selectors: {
'&:not(:last-child)': {
borderBottom: `0.5px solid ${cssVarV2('layer/insideBorder/border')}`,
},
},
});
export const content = style({
width: 0,
flex: 1,
overflow: 'hidden',
textOverflow: 'ellipsis',
whiteSpace: 'nowrap',
color: cssVarV2('text/primary'),
fontSize: 17,
fontWeight: 600,
lineHeight: '24px',
letterSpacing: -0.43,
});
export const prefixIcon = style({
width: 24,
height: 24,
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
':before': {
content: '""',
width: 12,
height: 12,
borderRadius: 6,
backgroundColor: 'currentColor',
},
});
export const suffixIcon = style({
padding: 0,
fontSize: 24,
});

View File

@@ -0,0 +1,73 @@
import {
IconButton,
SafeArea,
startScopedViewTransition,
} from '@affine/component';
import { openSettingModalAtom } from '@affine/core/atoms';
import { WorkbenchService } from '@affine/core/modules/workbench';
import { useI18n } from '@affine/i18n';
import { SettingsIcon } from '@blocksuite/icons/rc';
import { useService } from '@toeverything/infra';
import clsx from 'clsx';
import { useSetAtom } from 'jotai';
import { useCallback, useState } from 'react';
import { SearchInput, WorkspaceSelector } from '../../components';
import { searchVTScope } from '../../components/search-input/style.css';
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 t = useI18n();
const workbench = useService(WorkbenchService).workbench;
const openSetting = useSetAtom(openSettingModalAtom);
const [dense, setDense] = useState(false);
useGlobalEvent(
'scroll',
useCallback(() => {
setDense(window.scrollY > 114);
}, [])
);
const navSearch = useCallback(() => {
startScopedViewTransition(searchVTScope, () => {
workbench.open('/search');
});
}, [workbench]);
return (
<div className={clsx(styles.root, { dense })}>
<SafeArea top className={styles.float}>
<div className={styles.headerAndWsSelector}>
<div className={styles.wsSelectorWrapper}>
<WorkspaceSelector />
</div>
<div className={styles.settingWrapper}>
<IconButton
onClick={() => {
openSetting({ open: true, activeTab: 'appearance' });
}}
size="24"
style={{ padding: 10 }}
icon={<SettingsIcon />}
/>
</div>
</div>
<div className={styles.searchWrapper}>
<SearchInput placeholder={t['Quick search']()} onClick={navSearch} />
</div>
</SafeArea>
<SafeArea top>
<div className={styles.space} />
</SafeArea>
</div>
);
};

View File

@@ -0,0 +1,84 @@
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'),
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',
display: 'flex',
alignItems: 'center',
});
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,
},
},
});

View File

@@ -0,0 +1,4 @@
export * from './all-docs';
export * from './home-header';
export * from './recent-docs';
export * from './settings';

View File

@@ -0,0 +1,43 @@
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]
.filter(meta => !meta.trash)
.sort((a, b) => (b.updatedDate ?? 0) - (a.updatedDate ?? 0))
.slice(0, max);
}, [allPageMetas, max]);
if (!cardMetas.length) {
return null;
}
return (
<CollapsibleSection
name="recent"
title="Recent"
headerClassName={styles.header}
className={styles.recentSection}
testId="recent-docs"
>
<div className={styles.scroll} data-testid="recent-docs-list">
<ul className={styles.list}>
{cardMetas.map((doc, index) => (
<li key={index} className={styles.cardWrapper}>
<DocCard meta={doc} />
</li>
))}
</ul>
</div>
</CollapsibleSection>
);
};

View File

@@ -0,0 +1,39 @@
import { globalStyle, style } from '@vanilla-extract/css';
export const recentSection = style({
paddingBottom: 32,
selectors: {
'&[data-state="open"]': {
paddingBottom: 0,
},
},
});
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%',
});

View File

@@ -0,0 +1,87 @@
import { useI18n } from '@affine/i18n';
import { DocCard, type DocCardProps } from '../../components';
import {
UniversalSearchResultItem,
type UniversalSearchResultItemProps,
} from '../../components/search-result/universal-item';
import * as styles from './style.css';
export interface SearchResultsProps {
title: string;
docs?: DocCardProps['meta'][];
collections?: UniversalSearchResultItemProps['item'][];
tags?: UniversalSearchResultItemProps['item'][];
}
const Empty = () => {
const t = useI18n();
return (
<div className={styles.empty}>{t['com.affine.mobile.search.empty']()}</div>
);
};
export const SearchResults = ({
title,
docs,
collections,
tags,
}: SearchResultsProps) => {
return (
<>
<div className={styles.resTitle}>{title}</div>
{!docs?.length && !collections?.length && !tags?.length ? (
<Empty />
) : null}
{/* Doc Res */}
{docs?.length ? (
<div className={styles.resBlock} data-scroll>
<div className={styles.resBlockTitle}>Docs</div>
<div className={styles.resBlockScrollContent}>
<div className={styles.scrollDocsContent}>
{docs.map(doc => (
<DocCard meta={doc} key={doc.id} className={styles.docCard} />
))}
</div>
</div>
</div>
) : null}
{/* Collection Res */}
{collections?.length ? (
<div className={styles.resBlock}>
<div className={styles.resBlockTitle}>Collections</div>
<div className={styles.resBlockListContent}>
{collections.map(collection => (
<UniversalSearchResultItem
category="collection"
id={collection.payload.collectionId}
key={collection.id}
item={collection}
/>
))}
</div>
</div>
) : null}
{/* Tag Res */}
{tags?.length ? (
<div className={styles.resBlock}>
<div className={styles.resBlockTitle}>Tags</div>
<div className={styles.resBlockListContent}>
{tags.map(tag => (
<UniversalSearchResultItem
category="tag"
id={tag.payload.tagId}
key={tag.id}
item={tag}
/>
))}
</div>
</div>
) : null}
</>
);
};

View File

@@ -0,0 +1,74 @@
import { cssVarV2 } from '@toeverything/theme/v2';
import { style } from '@vanilla-extract/css';
export const searchHeader = style({
padding: 16,
});
export const resTitle = style({
padding: '6px 16px',
marginBottom: 8,
height: 30,
fontSize: 13,
lineHeight: '18px',
fontWeight: 400,
letterSpacing: -0.08,
color: cssVarV2('text/secondary'),
});
export const resBlock = style({
paddingBottom: 32,
selectors: {
'&[data-scroll]': {
paddingBottom: 0,
},
},
});
export const resBlockTitle = style({
padding: '0 16px',
fontSize: 20,
lineHeight: '25px',
fontWeight: 400,
letterSpacing: -0.45,
color: cssVarV2('text/primary'),
});
const resBlockContent = style({
padding: '12px 0px',
});
export const resBlockListContent = style([
resBlockContent,
{
paddingLeft: 16,
paddingRight: 16,
},
]);
export const resBlockScrollContent = style([
resBlockContent,
{
width: '100%',
overflowX: 'auto',
paddingBottom: 32,
},
]);
export const scrollDocsContent = style({
display: 'flex',
gap: 12,
padding: '0 16px',
width: 'fit-content',
});
export const docCard = style({
width: 170,
height: 210,
flexShrink: 0,
});
export const empty = style({
padding: '0 16px',
fontSize: 20,
fontWeight: 400,
lineHeight: '25px',
letterSpacing: -0.45,
color: cssVarV2('text/primary'),
});

View File

@@ -0,0 +1,22 @@
import { useI18n } from '@affine/i18n';
import { SettingGroup } from '../group';
import { RowLayout } from '../row.layout';
const { appVersion, editorVersion } = runtimeConfig;
export const AboutGroup = () => {
const t = useI18n();
return (
<SettingGroup title={t['com.affine.mobile.setting.about.title']()}>
<RowLayout label={t['com.affine.mobile.setting.about.appVersion']()}>
{appVersion}
</RowLayout>
<RowLayout label={t['com.affine.mobile.setting.about.editorVersion']()}>
{editorVersion}
</RowLayout>
</SettingGroup>
);
};

View File

@@ -0,0 +1,37 @@
import { getBaseFontStyleOptions } from '@affine/core/components/affine/setting-modal/general-setting/editor/general';
import {
EditorSettingService,
type FontFamily,
} from '@affine/core/modules/editor-settting';
import { useI18n } from '@affine/i18n';
import { useLiveData, useService } from '@toeverything/infra';
import { useCallback, useMemo } from 'react';
import { SettingDropdownSelect } from '../dropdown-select';
import { RowLayout } from '../row.layout';
export const FontStyleSetting = () => {
const t = useI18n();
const editorSetting = useService(EditorSettingService).editorSetting;
const fontFamily = useLiveData(
editorSetting.settings$.selector(s => s.fontFamily)
);
const options = useMemo(() => getBaseFontStyleOptions(t), [t]);
const handleEdit = useCallback(
(v: FontFamily) => {
editorSetting.set('fontFamily', v);
},
[editorSetting]
);
return (
<RowLayout label={t['com.affine.mobile.setting.appearance.font']()}>
<SettingDropdownSelect<FontFamily>
options={options}
value={fontFamily}
onChange={handleEdit}
/>
</RowLayout>
);
};

View File

@@ -0,0 +1,17 @@
import { useI18n } from '@affine/i18n';
import { SettingGroup } from '../group';
import { FontStyleSetting } from './font';
import { LanguageSetting } from './language';
import { ThemeSetting } from './theme';
export const AppearanceGroup = () => {
const t = useI18n();
return (
<SettingGroup title={t['com.affine.mobile.setting.appearance.title']()}>
<ThemeSetting />
<FontStyleSetting />
<LanguageSetting />
</SettingGroup>
);
};

View File

@@ -0,0 +1,39 @@
import { useLanguageHelper } from '@affine/core/hooks/affine/use-language-helper';
import { useI18n } from '@affine/i18n';
import { useMemo } from 'react';
import { SettingDropdownSelect } from '../dropdown-select';
import { RowLayout } from '../row.layout';
export const LanguageSetting = () => {
const t = useI18n();
const { currentLanguage, languagesList, onLanguageChange } =
useLanguageHelper();
const languageOptions = useMemo(
() =>
languagesList.map(language => ({
label: language.originalName,
value: language.tag,
})),
[languagesList]
);
return (
<RowLayout label={t['com.affine.mobile.setting.appearance.language']()}>
<SettingDropdownSelect
options={languageOptions}
value={currentLanguage?.tag}
onChange={onLanguageChange}
menuOptions={{
contentOptions: {
style: {
maxHeight: '60dvh',
overflowY: 'auto',
},
},
}}
/>
</RowLayout>
);
};

View File

@@ -0,0 +1,24 @@
import { getThemeOptions } from '@affine/core/components/affine/setting-modal/general-setting/appearance';
import { useI18n } from '@affine/i18n';
import { useTheme } from 'next-themes';
import { useMemo } from 'react';
import { SettingDropdownSelect } from '../dropdown-select';
import { RowLayout } from '../row.layout';
export const ThemeSetting = () => {
const t = useI18n();
const options = useMemo(() => getThemeOptions(t), [t]);
const { setTheme, theme } = useTheme();
return (
<RowLayout label={t['com.affine.mobile.setting.appearance.theme']()}>
<SettingDropdownSelect
options={options}
value={theme}
onChange={setTheme}
/>
</RowLayout>
);
};

View File

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

View File

@@ -0,0 +1,74 @@
import { type MenuProps, MobileMenu, MobileMenuItem } from '@affine/component';
import { ArrowDownSmallIcon } from '@blocksuite/icons/rc';
import clsx from 'clsx';
import {
type CSSProperties,
type HTMLAttributes,
type ReactNode,
useMemo,
} from 'react';
import * as styles from './dropdown-select.css';
interface DropdownItem<V extends string> {
label?: ReactNode;
value: V;
testId?: string;
style?: CSSProperties;
[key: string]: any;
}
export interface SettingDropdownSelectProps<
V extends string,
E extends boolean | undefined,
> extends Omit<HTMLAttributes<HTMLDivElement>, 'onChange'> {
options?: Array<DropdownItem<V>>;
value?: V;
onChange?: (
v: E extends true ? DropdownItem<V>['value'] : DropdownItem<V>
) => void;
emitValue?: E;
menuOptions?: Omit<MenuProps, 'items' | 'children'>;
}
export const SettingDropdownSelect = <
V extends string = string,
E extends boolean | undefined = true,
>({
options = [],
value,
emitValue = true,
onChange,
className,
menuOptions,
...attrs
}: SettingDropdownSelectProps<V, E>) => {
const selectedItem = useMemo(
() => options.find(opt => opt.value === value),
[options, value]
);
return (
<MobileMenu
items={options.map(opt => (
<MobileMenuItem
divide
key={opt.value}
selected={value === opt.value}
data-testid={opt.testId}
onSelect={() =>
emitValue ? onChange?.(opt.value as any) : onChange?.(opt as any)
}
style={opt.style}
>
{opt.label}
</MobileMenuItem>
))}
{...menuOptions}
>
<div className={clsx(styles.root, className)} {...attrs}>
<span className={styles.label}>{selectedItem?.label ?? ''}</span>
<ArrowDownSmallIcon className={styles.icon} />
</div>
</MobileMenu>
);
};

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