mirror of
https://github.com/toeverything/AFFiNE.git
synced 2026-02-13 21:05:19 +00:00
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:
92
packages/frontend/apps/mobile/src/app.tsx
Normal file
92
packages/frontend/apps/mobile/src/app.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
3
packages/frontend/apps/mobile/src/components/README.md
Normal file
3
packages/frontend/apps/mobile/src/components/README.md
Normal file
@@ -0,0 +1,3 @@
|
||||
# mobile components
|
||||
|
||||
Maintain the smallest possible business components here.
|
||||
@@ -0,0 +1,70 @@
|
||||
import { SafeArea } from '@affine/component';
|
||||
import {
|
||||
WorkbenchLink,
|
||||
WorkbenchService,
|
||||
} from '@affine/core/modules/workbench';
|
||||
import { AllDocsIcon, MobileHomeIcon, SearchIcon } from '@blocksuite/icons/rc';
|
||||
import { useLiveData, useService } from '@toeverything/infra';
|
||||
import type { Location } from 'react-router-dom';
|
||||
|
||||
import * as styles from './styles.css';
|
||||
|
||||
interface Route {
|
||||
to: string;
|
||||
Icon: React.FC;
|
||||
LinkComponent?: React.FC;
|
||||
isActive?: (location: Location) => boolean;
|
||||
}
|
||||
|
||||
const routes: Route[] = [
|
||||
{
|
||||
to: '/home',
|
||||
Icon: MobileHomeIcon,
|
||||
},
|
||||
{
|
||||
to: '/all',
|
||||
Icon: AllDocsIcon,
|
||||
isActive: location =>
|
||||
location.pathname === '/all' ||
|
||||
location.pathname.startsWith('/collection') ||
|
||||
location.pathname.startsWith('/tag'),
|
||||
},
|
||||
{
|
||||
to: '/search',
|
||||
Icon: SearchIcon,
|
||||
},
|
||||
];
|
||||
|
||||
export const AppTabs = () => {
|
||||
const workbench = useService(WorkbenchService).workbench;
|
||||
const location = useLiveData(workbench.location$);
|
||||
|
||||
return (
|
||||
<SafeArea bottom className={styles.appTabs} bottomOffset={2}>
|
||||
<ul className={styles.appTabsInner} id="app-tabs" role="tablist">
|
||||
{routes.map(route => {
|
||||
const Link = route.LinkComponent || WorkbenchLink;
|
||||
|
||||
const isActive = route.isActive
|
||||
? route.isActive(location)
|
||||
: location.pathname === route.to;
|
||||
return (
|
||||
<Link
|
||||
data-active={isActive}
|
||||
to={route.to}
|
||||
key={route.to}
|
||||
className={styles.tabItem}
|
||||
role="tab"
|
||||
aria-label={route.to.slice(1)}
|
||||
replaceHistory
|
||||
>
|
||||
<li style={{ lineHeight: 0 }}>
|
||||
<route.Icon />
|
||||
</li>
|
||||
</Link>
|
||||
);
|
||||
})}
|
||||
</ul>
|
||||
</SafeArea>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,42 @@
|
||||
import { cssVarV2 } from '@toeverything/theme/v2';
|
||||
import { style } from '@vanilla-extract/css';
|
||||
|
||||
import { globalVars } from '../../styles/mobile.css';
|
||||
|
||||
export const appTabs = style({
|
||||
backgroundColor: cssVarV2('layer/background/secondary'),
|
||||
borderTop: `1px solid ${cssVarV2('layer/insideBorder/border')}`,
|
||||
|
||||
width: '100dvw',
|
||||
|
||||
position: 'fixed',
|
||||
bottom: -2,
|
||||
zIndex: 1,
|
||||
});
|
||||
export const appTabsInner = style({
|
||||
display: 'flex',
|
||||
justifyContent: 'space-between',
|
||||
alignItems: 'center',
|
||||
gap: 15.5,
|
||||
|
||||
height: `calc(${globalVars.appTabHeight} + 2px)`,
|
||||
padding: 16,
|
||||
});
|
||||
export const tabItem = style({
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
width: 0,
|
||||
flex: 1,
|
||||
height: 36,
|
||||
padding: 3,
|
||||
fontSize: 30,
|
||||
color: cssVarV2('icon/primary'),
|
||||
lineHeight: 0,
|
||||
|
||||
selectors: {
|
||||
'&[data-active="true"]': {
|
||||
color: cssVarV2('button/primary'),
|
||||
},
|
||||
},
|
||||
});
|
||||
@@ -0,0 +1,71 @@
|
||||
import { IconButton } from '@affine/component';
|
||||
import { 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>
|
||||
);
|
||||
}
|
||||
);
|
||||
@@ -0,0 +1,53 @@
|
||||
import { cssVarV2 } from '@toeverything/theme/v2';
|
||||
import { style } from '@vanilla-extract/css';
|
||||
|
||||
export const card = style({
|
||||
padding: 16,
|
||||
borderRadius: 12,
|
||||
border: `0.5px solid ${cssVarV2('layer/insideBorder/border')}`,
|
||||
boxShadow: '0px 2px 3px rgba(0,0,0,0.05)',
|
||||
background: cssVarV2('layer/background/primary'),
|
||||
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
gap: 8,
|
||||
|
||||
color: 'unset',
|
||||
':visited': { color: 'unset' },
|
||||
':hover': { color: 'unset' },
|
||||
':active': { color: 'unset' },
|
||||
});
|
||||
export const head = style({
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'space-between',
|
||||
gap: 8,
|
||||
});
|
||||
export const title = style({
|
||||
width: 0,
|
||||
flex: 1,
|
||||
fontSize: 17,
|
||||
lineHeight: '22px',
|
||||
fontWeight: 600,
|
||||
letterSpacing: -0.43,
|
||||
|
||||
whiteSpace: 'nowrap',
|
||||
textOverflow: 'ellipsis',
|
||||
overflow: 'hidden',
|
||||
});
|
||||
export const untitled = style({
|
||||
opacity: 0.4,
|
||||
});
|
||||
export const content = style({
|
||||
fontSize: 13,
|
||||
lineHeight: '18px',
|
||||
fontWeight: 400,
|
||||
letterSpacing: -0.08,
|
||||
flex: 1,
|
||||
|
||||
overflow: 'hidden',
|
||||
});
|
||||
|
||||
export const contentEmpty = style({
|
||||
opacity: 0.3,
|
||||
});
|
||||
@@ -0,0 +1,44 @@
|
||||
import { cssVarV2 } from '@toeverything/theme/v2';
|
||||
import { createVar, style } from '@vanilla-extract/css';
|
||||
|
||||
export const tagColorVar = createVar();
|
||||
|
||||
export const tags = style({
|
||||
width: '100%',
|
||||
display: 'flex',
|
||||
flexWrap: 'wrap',
|
||||
alignItems: 'center',
|
||||
position: 'relative',
|
||||
gap: 4,
|
||||
});
|
||||
|
||||
export const tag = style({
|
||||
padding: '0px 8px',
|
||||
borderRadius: 10,
|
||||
alignItems: 'center',
|
||||
border: `1px solid ${cssVarV2('layer/insideBorder/blackBorder')}`,
|
||||
maxWidth: '100%',
|
||||
|
||||
fontSize: 12,
|
||||
lineHeight: '20px',
|
||||
fontWeight: 400,
|
||||
|
||||
whiteSpace: 'nowrap',
|
||||
overflow: 'hidden',
|
||||
textOverflow: 'ellipsis',
|
||||
|
||||
':before': {
|
||||
content: "''",
|
||||
display: 'inline-block',
|
||||
width: 8,
|
||||
height: 8,
|
||||
borderRadius: 4,
|
||||
backgroundColor: tagColorVar,
|
||||
marginRight: 4,
|
||||
},
|
||||
});
|
||||
|
||||
export const more = style({
|
||||
fontSize: 16,
|
||||
color: cssVarV2('icon/primary'),
|
||||
});
|
||||
@@ -0,0 +1,42 @@
|
||||
import type { Tag } from '@affine/core/modules/tag';
|
||||
import { TagService } from '@affine/core/modules/tag';
|
||||
import { MoreHorizontalIcon } from '@blocksuite/icons/rc';
|
||||
import { useLiveData, useService } from '@toeverything/infra';
|
||||
import { assignInlineVars } from '@vanilla-extract/dynamic';
|
||||
|
||||
import * as styles from './tag.css';
|
||||
|
||||
const DocCardTag = ({ tag }: { tag: Tag }) => {
|
||||
const name = useLiveData(tag.value$);
|
||||
const color = useLiveData(tag.color$);
|
||||
|
||||
return (
|
||||
<li
|
||||
data-name={name}
|
||||
data-color={color}
|
||||
className={styles.tag}
|
||||
style={assignInlineVars({ [styles.tagColorVar]: color })}
|
||||
>
|
||||
{name}
|
||||
</li>
|
||||
);
|
||||
};
|
||||
|
||||
const DocCardTagsRenderer = ({ tags }: { tags: Tag[] }) => {
|
||||
return (
|
||||
<ul className={styles.tags}>
|
||||
{tags.slice(0, 2).map(tag => (
|
||||
<DocCardTag key={tag.id} tag={tag} />
|
||||
))}
|
||||
{tags.length > 2 ? <MoreHorizontalIcon className={styles.more} /> : null}
|
||||
</ul>
|
||||
);
|
||||
};
|
||||
|
||||
export const DocCardTags = ({ docId }: { docId: string; rows?: number }) => {
|
||||
const tagService = useService(TagService);
|
||||
const tags = useLiveData(tagService.tagList.tagsByPageId$(docId));
|
||||
|
||||
if (!tags.length) return null;
|
||||
return <DocCardTagsRenderer tags={tags} />;
|
||||
};
|
||||
7
packages/frontend/apps/mobile/src/components/index.ts
Normal file
7
packages/frontend/apps/mobile/src/components/index.ts
Normal file
@@ -0,0 +1,7 @@
|
||||
export * from './app-tabs';
|
||||
export * from './doc-card';
|
||||
export * from './page-header';
|
||||
export * from './search-input';
|
||||
export * from './search-result';
|
||||
export * from './user-plan-tag';
|
||||
export * from './workspace-selector';
|
||||
@@ -0,0 +1,105 @@
|
||||
import { IconButton, SafeArea } from '@affine/component';
|
||||
import { ArrowLeftSmallIcon } from '@blocksuite/icons/rc';
|
||||
import clsx from 'clsx';
|
||||
import {
|
||||
forwardRef,
|
||||
type HtmlHTMLAttributes,
|
||||
type ReactNode,
|
||||
useCallback,
|
||||
} from 'react';
|
||||
|
||||
import * as styles from './styles.css';
|
||||
|
||||
export interface PageHeaderProps
|
||||
extends Omit<HtmlHTMLAttributes<HTMLHeadElement>, 'prefix'> {
|
||||
/**
|
||||
* whether to show back button
|
||||
*/
|
||||
back?: boolean;
|
||||
/**
|
||||
* Override back button action
|
||||
*/
|
||||
backAction?: () => void;
|
||||
|
||||
/**
|
||||
* prefix content, shown after back button(if exists)
|
||||
*/
|
||||
prefix?: ReactNode;
|
||||
|
||||
/**
|
||||
* suffix content
|
||||
*/
|
||||
suffix?: ReactNode;
|
||||
|
||||
/**
|
||||
* Weather to center the content
|
||||
* @default true
|
||||
*/
|
||||
centerContent?: boolean;
|
||||
|
||||
prefixClassName?: string;
|
||||
prefixStyle?: React.CSSProperties;
|
||||
suffixClassName?: string;
|
||||
suffixStyle?: React.CSSProperties;
|
||||
}
|
||||
export const PageHeader = forwardRef<HTMLDivElement, PageHeaderProps>(
|
||||
function PageHeader(
|
||||
{
|
||||
back,
|
||||
backAction,
|
||||
prefix,
|
||||
suffix,
|
||||
children,
|
||||
className,
|
||||
centerContent = true,
|
||||
prefixClassName,
|
||||
prefixStyle,
|
||||
suffixClassName,
|
||||
suffixStyle,
|
||||
...attrs
|
||||
},
|
||||
ref
|
||||
) {
|
||||
const handleRouteBack = useCallback(() => {
|
||||
backAction ? backAction() : history.back();
|
||||
}, [backAction]);
|
||||
|
||||
return (
|
||||
<SafeArea
|
||||
top
|
||||
ref={ref}
|
||||
className={clsx(styles.root, className)}
|
||||
data-testid="mobile-page-header"
|
||||
{...attrs}
|
||||
>
|
||||
<header className={styles.inner}>
|
||||
<section
|
||||
className={clsx(styles.prefix, prefixClassName)}
|
||||
style={prefixStyle}
|
||||
>
|
||||
{back ? (
|
||||
<IconButton
|
||||
size={24}
|
||||
style={{ padding: 10 }}
|
||||
onClick={handleRouteBack}
|
||||
icon={<ArrowLeftSmallIcon />}
|
||||
/>
|
||||
) : null}
|
||||
{prefix}
|
||||
</section>
|
||||
|
||||
<section className={clsx(styles.content, { center: centerContent })}>
|
||||
{children}
|
||||
</section>
|
||||
|
||||
<section
|
||||
className={clsx(styles.suffix, suffixClassName)}
|
||||
style={suffixStyle}
|
||||
>
|
||||
{suffix}
|
||||
</section>
|
||||
</header>
|
||||
</SafeArea>
|
||||
);
|
||||
}
|
||||
);
|
||||
@@ -0,0 +1,49 @@
|
||||
import { cssVarV2 } from '@toeverything/theme/v2';
|
||||
import { style } from '@vanilla-extract/css';
|
||||
|
||||
export const root = style({
|
||||
width: '100%',
|
||||
position: 'sticky',
|
||||
top: 0,
|
||||
zIndex: 1,
|
||||
backgroundColor: cssVarV2('layer/background/secondary'),
|
||||
});
|
||||
export const inner = style({
|
||||
minHeight: 44,
|
||||
padding: '0 6px',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'space-between',
|
||||
});
|
||||
export const content = style({
|
||||
selectors: {
|
||||
'&.center': {
|
||||
position: 'absolute',
|
||||
left: '50%',
|
||||
transform: 'translateX(-50%)',
|
||||
width: 'fit-content',
|
||||
maxWidth: 'calc(100% - 12px - 88px - 16px)',
|
||||
display: 'flex',
|
||||
justifyContent: 'center',
|
||||
pointerEvents: 'none',
|
||||
},
|
||||
'&:not(.center)': {
|
||||
width: 0,
|
||||
flex: 1,
|
||||
},
|
||||
},
|
||||
});
|
||||
export const spacer = style({
|
||||
width: 0,
|
||||
flex: 1,
|
||||
});
|
||||
export const prefix = style({
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
gap: 0,
|
||||
});
|
||||
export const suffix = style({
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
gap: 6,
|
||||
});
|
||||
@@ -0,0 +1,110 @@
|
||||
import { useAutoFocus } from '@affine/component';
|
||||
import { 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>
|
||||
);
|
||||
}
|
||||
);
|
||||
@@ -0,0 +1,51 @@
|
||||
import { cssVarV2 } from '@toeverything/theme/v2';
|
||||
import { generateIdentifier, style } from '@vanilla-extract/css';
|
||||
|
||||
export const searchVTName = generateIdentifier('mobile-search-input');
|
||||
export const searchVTScope = generateIdentifier('mobile-search');
|
||||
|
||||
export const wrapper = style({
|
||||
position: 'relative',
|
||||
backgroundColor: cssVarV2('layer/background/primary'),
|
||||
|
||||
selectors: {
|
||||
[`[data-${searchVTScope}] &`]: {
|
||||
viewTransitionName: searchVTName,
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
export const prefixIcon = style({
|
||||
position: 'absolute',
|
||||
width: 36,
|
||||
height: '100%',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
color: cssVarV2('icon/primary'),
|
||||
pointerEvents: 'none',
|
||||
});
|
||||
|
||||
export const input = style({
|
||||
padding: '11px 8px 11px 36px',
|
||||
width: '100%',
|
||||
height: '100%',
|
||||
outline: 'none',
|
||||
border: 'none',
|
||||
|
||||
fontWeight: 400,
|
||||
fontSize: 17,
|
||||
lineHeight: '22px',
|
||||
letterSpacing: -0.43,
|
||||
});
|
||||
|
||||
export const placeholder = style([
|
||||
input,
|
||||
{
|
||||
position: 'absolute',
|
||||
left: 0,
|
||||
top: 0,
|
||||
pointerEvents: 'none',
|
||||
color: cssVarV2('text/secondary'),
|
||||
},
|
||||
]);
|
||||
@@ -0,0 +1,2 @@
|
||||
export * from './search-res-label';
|
||||
export * from './universal-item';
|
||||
@@ -0,0 +1,15 @@
|
||||
import type { QuickSearchItem } from '@affine/core/modules/quicksearch';
|
||||
import { HighlightText } from '@affine/core/modules/quicksearch/views/highlight-text';
|
||||
import { isI18nString, useI18n } from '@affine/i18n';
|
||||
|
||||
export interface SearchResLabelProps {
|
||||
item: QuickSearchItem;
|
||||
}
|
||||
export const SearchResLabel = ({ item }: SearchResLabelProps) => {
|
||||
const i18n = useI18n();
|
||||
|
||||
const text = !isI18nString(item.label)
|
||||
? i18n.t(item.label.title)
|
||||
: i18n.t(item.label);
|
||||
return <HighlightText text={text} start="<b>" end="</b>" />;
|
||||
};
|
||||
@@ -0,0 +1,43 @@
|
||||
import { cssVarV2 } from '@toeverything/theme/v2';
|
||||
import { style } from '@vanilla-extract/css';
|
||||
|
||||
export const item = style({
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
gap: 12,
|
||||
|
||||
borderBottom: `0.5px solid ${cssVarV2('layer/insideBorder/border')}`,
|
||||
height: 44,
|
||||
|
||||
color: 'unset',
|
||||
':visited': { color: 'unset' },
|
||||
':hover': { color: 'unset' },
|
||||
':active': { color: 'unset' },
|
||||
':focus': { color: 'unset' },
|
||||
});
|
||||
export const iconWrapper = style({
|
||||
width: 32,
|
||||
height: 32,
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
flexShrink: 0,
|
||||
fontSize: 24,
|
||||
color: cssVarV2('icon/primary'),
|
||||
});
|
||||
export const content = style({
|
||||
width: 0,
|
||||
flex: 1,
|
||||
fontSize: 17,
|
||||
lineHeight: '22px',
|
||||
fontWeight: 400,
|
||||
letterSpacing: -0.43,
|
||||
color: cssVarV2('text/primary'),
|
||||
|
||||
textOverflow: 'ellipsis',
|
||||
whiteSpace: 'nowrap',
|
||||
overflow: 'hidden',
|
||||
});
|
||||
export const suffixIcon = style({
|
||||
color: cssVarV2('icon/secondary'),
|
||||
});
|
||||
@@ -0,0 +1,32 @@
|
||||
import type { QuickSearchItem } from '@affine/core/modules/quicksearch';
|
||||
import { WorkbenchLink } from '@affine/core/modules/workbench';
|
||||
import { ArrowRightSmallIcon } from '@blocksuite/icons/rc';
|
||||
|
||||
import { SearchResLabel } from './search-res-label';
|
||||
import * as styles from './universal-item.css';
|
||||
|
||||
export interface UniversalSearchResultItemProps {
|
||||
id: string;
|
||||
item: QuickSearchItem;
|
||||
category: 'tag' | 'collection';
|
||||
}
|
||||
export const UniversalSearchResultItem = ({
|
||||
id,
|
||||
item,
|
||||
category,
|
||||
}: UniversalSearchResultItemProps) => {
|
||||
return (
|
||||
<WorkbenchLink to={`/${category}/${id}`} className={styles.item}>
|
||||
<div className={styles.iconWrapper}>
|
||||
{item.icon &&
|
||||
(typeof item.icon === 'function' ? <item.icon /> : item.icon)}
|
||||
</div>
|
||||
|
||||
<div className={styles.content}>
|
||||
<SearchResLabel item={item} />
|
||||
</div>
|
||||
|
||||
<ArrowRightSmallIcon fontSize="16px" className={styles.suffixIcon} />
|
||||
</WorkbenchLink>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,52 @@
|
||||
import {
|
||||
ServerConfigService,
|
||||
SubscriptionService,
|
||||
} from '@affine/core/modules/cloud';
|
||||
import { SubscriptionPlan } from '@affine/graphql';
|
||||
import { useLiveData, useServices } from '@toeverything/infra';
|
||||
import clsx from 'clsx';
|
||||
import { forwardRef, type HTMLProps, useEffect } from 'react';
|
||||
|
||||
import { tag } from './style.css';
|
||||
|
||||
export const UserPlanTag = forwardRef<
|
||||
HTMLDivElement,
|
||||
HTMLProps<HTMLDivElement>
|
||||
>(function UserPlanTag({ className, ...attrs }, ref) {
|
||||
const { serverConfigService, subscriptionService } = useServices({
|
||||
ServerConfigService,
|
||||
SubscriptionService,
|
||||
});
|
||||
const hasPayment = useLiveData(
|
||||
serverConfigService.serverConfig.features$.map(r => r?.payment)
|
||||
);
|
||||
const plan = useLiveData(
|
||||
subscriptionService.subscription.pro$.map(subscription =>
|
||||
subscription !== null ? subscription?.plan : null
|
||||
)
|
||||
);
|
||||
const isBeliever = useLiveData(subscriptionService.subscription.isBeliever$);
|
||||
const isLoading = plan === null;
|
||||
|
||||
useEffect(() => {
|
||||
// revalidate subscription to get the latest status
|
||||
subscriptionService.subscription.revalidate();
|
||||
}, [subscriptionService]);
|
||||
|
||||
if (!hasPayment) return null;
|
||||
|
||||
if (isLoading) return null;
|
||||
|
||||
const planLabel = isBeliever ? 'Believer' : (plan ?? SubscriptionPlan.Free);
|
||||
|
||||
return (
|
||||
<div
|
||||
ref={ref}
|
||||
className={clsx(tag, className)}
|
||||
data-is-believer={isBeliever}
|
||||
{...attrs}
|
||||
>
|
||||
{planLabel}
|
||||
</div>
|
||||
);
|
||||
});
|
||||
@@ -0,0 +1,22 @@
|
||||
import { cssVar } from '@toeverything/theme';
|
||||
import { style } from '@vanilla-extract/css';
|
||||
|
||||
export const tag = style({
|
||||
display: 'flex',
|
||||
fontSize: cssVar('fontXs'),
|
||||
height: 20,
|
||||
fontWeight: 500,
|
||||
cursor: 'pointer',
|
||||
color: cssVar('pureWhite'),
|
||||
backgroundColor: cssVar('brandColor'),
|
||||
padding: '0 4px',
|
||||
borderRadius: 4,
|
||||
justifyContent: 'center',
|
||||
alignItems: 'center',
|
||||
|
||||
selectors: {
|
||||
'&[data-is-believer="true"]': {
|
||||
backgroundColor: '#374151',
|
||||
},
|
||||
},
|
||||
});
|
||||
@@ -0,0 +1,24 @@
|
||||
import { cssVarV2 } from '@toeverything/theme/v2';
|
||||
import { style } from '@vanilla-extract/css';
|
||||
|
||||
export const card = style({
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
gap: 10,
|
||||
});
|
||||
|
||||
export const label = style({
|
||||
display: 'flex',
|
||||
gap: 4,
|
||||
|
||||
fontSize: 17,
|
||||
fontWeight: 600,
|
||||
lineHeight: '22px',
|
||||
color: cssVarV2('text/primary'),
|
||||
letterSpacing: -0.43,
|
||||
});
|
||||
|
||||
export const dropdownIcon = style({
|
||||
fontSize: 24,
|
||||
color: cssVarV2('icon/primary'),
|
||||
});
|
||||
@@ -0,0 +1,44 @@
|
||||
import { WorkspaceAvatar } from '@affine/component/workspace-avatar';
|
||||
import { useWorkspaceInfo } from '@affine/core/hooks/use-workspace-info';
|
||||
import { UNTITLED_WORKSPACE_NAME } from '@affine/env/constant';
|
||||
import { ArrowDownSmallIcon } from '@blocksuite/icons/rc';
|
||||
import { useService, WorkspaceService } from '@toeverything/infra';
|
||||
import clsx from 'clsx';
|
||||
import { forwardRef, type HTMLAttributes } from 'react';
|
||||
|
||||
import { card, dropdownIcon, label } from './card.css';
|
||||
|
||||
export interface CurrentWorkspaceCardProps
|
||||
extends HTMLAttributes<HTMLDivElement> {}
|
||||
|
||||
export const CurrentWorkspaceCard = forwardRef<
|
||||
HTMLDivElement,
|
||||
CurrentWorkspaceCardProps
|
||||
>(function CurrentWorkspaceCard({ onClick, className, ...attrs }, ref) {
|
||||
const currentWorkspace = useService(WorkspaceService).workspace;
|
||||
const info = useWorkspaceInfo(currentWorkspace.meta);
|
||||
const name = info?.name ?? UNTITLED_WORKSPACE_NAME;
|
||||
|
||||
return (
|
||||
<div
|
||||
ref={ref}
|
||||
onClick={onClick}
|
||||
className={clsx(card, className)}
|
||||
{...attrs}
|
||||
>
|
||||
<WorkspaceAvatar
|
||||
key={currentWorkspace.id}
|
||||
meta={currentWorkspace.meta}
|
||||
rounded={3}
|
||||
data-testid="workspace-avatar"
|
||||
size={40}
|
||||
name={name}
|
||||
colorfulFallback
|
||||
/>
|
||||
<div className={label}>
|
||||
{name}
|
||||
<ArrowDownSmallIcon className={dropdownIcon} />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
});
|
||||
@@ -0,0 +1,39 @@
|
||||
import { MobileMenu } from '@affine/component';
|
||||
import { track } from '@affine/core/mixpanel';
|
||||
import { useService, WorkspacesService } from '@toeverything/infra';
|
||||
import { useCallback, useEffect, useState } from 'react';
|
||||
|
||||
import { CurrentWorkspaceCard } from './current-card';
|
||||
import { SelectorMenu } from './menu';
|
||||
|
||||
export const WorkspaceSelector = () => {
|
||||
const [open, setOpen] = useState(false);
|
||||
const workspaceManager = useService(WorkspacesService);
|
||||
|
||||
const openMenu = useCallback(() => {
|
||||
track.$.navigationPanel.workspaceList.open();
|
||||
setOpen(true);
|
||||
}, []);
|
||||
const close = useCallback(() => {
|
||||
setOpen(false);
|
||||
}, []);
|
||||
|
||||
// revalidate workspace list when open workspace list
|
||||
useEffect(() => {
|
||||
if (open) workspaceManager.list.revalidate();
|
||||
}, [workspaceManager, open]);
|
||||
|
||||
return (
|
||||
<MobileMenu
|
||||
items={<SelectorMenu onClose={close} />}
|
||||
rootOptions={{ open }}
|
||||
contentOptions={{
|
||||
onInteractOutside: close,
|
||||
onEscapeKeyDown: close,
|
||||
style: { padding: 0 },
|
||||
}}
|
||||
>
|
||||
<CurrentWorkspaceCard onClick={openMenu} />
|
||||
</MobileMenu>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,76 @@
|
||||
import { cssVar } from '@toeverything/theme';
|
||||
import { cssVarV2 } from '@toeverything/theme/v2';
|
||||
import { style } from '@vanilla-extract/css';
|
||||
|
||||
export const root = style({
|
||||
maxHeight: 'calc(100dvh - 100px)',
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
});
|
||||
|
||||
export const divider = style({
|
||||
height: 16,
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
position: 'relative',
|
||||
':before': {
|
||||
content: '""',
|
||||
width: '100%',
|
||||
height: 0.5,
|
||||
background: cssVar('dividerColor'),
|
||||
},
|
||||
});
|
||||
|
||||
export const head = style({
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'space-between',
|
||||
gap: 4,
|
||||
padding: '10px 16px',
|
||||
fontSize: 17,
|
||||
fontWeight: 600,
|
||||
lineHeight: '22px',
|
||||
letterSpacing: -0.43,
|
||||
color: cssVarV2('text/primary'),
|
||||
});
|
||||
export const body = style({
|
||||
overflowY: 'auto',
|
||||
flexShrink: 0,
|
||||
flex: 1,
|
||||
});
|
||||
export const wsList = style({});
|
||||
export const wsListTitle = style({
|
||||
padding: '6px 16px',
|
||||
fontSize: 13,
|
||||
lineHeight: '18px',
|
||||
letterSpacing: -0.08,
|
||||
color: cssVar('textSecondaryColor'),
|
||||
});
|
||||
export const wsItem = style({
|
||||
padding: '4px 12px',
|
||||
});
|
||||
export const wsCard = style({
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
border: 'none',
|
||||
background: 'none',
|
||||
width: '100%',
|
||||
padding: 8,
|
||||
borderRadius: 8,
|
||||
gap: 8,
|
||||
|
||||
':active': {
|
||||
background: cssVarV2('layer/background/hoverOverlay'),
|
||||
},
|
||||
});
|
||||
export const wsName = style({
|
||||
width: 0,
|
||||
flex: 1,
|
||||
textOverflow: 'ellipsis',
|
||||
whiteSpace: 'nowrap',
|
||||
overflow: 'hidden',
|
||||
fontSize: 17,
|
||||
lineHeight: '22px',
|
||||
letterSpacing: -0.43,
|
||||
textAlign: 'left',
|
||||
});
|
||||
@@ -0,0 +1,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>
|
||||
);
|
||||
};
|
||||
54
packages/frontend/apps/mobile/src/hooks/use-global-events.ts
Normal file
54
packages/frontend/apps/mobile/src/hooks/use-global-events.ts
Normal file
@@ -0,0 +1,54 @@
|
||||
import { useEffect } from 'react';
|
||||
|
||||
type Handler<T extends Event> = (event: T) => void;
|
||||
|
||||
const _handlesMap = new Map<
|
||||
keyof WindowEventMap,
|
||||
Array<Handler<WindowEventMap[keyof WindowEventMap]>>
|
||||
>();
|
||||
|
||||
function initGlobalEvent<T extends keyof WindowEventMap>(name: T) {
|
||||
const prev = _handlesMap.get(name);
|
||||
if (!prev) {
|
||||
const handlers = [] as Handler<WindowEventMap[T]>[];
|
||||
window.addEventListener(name, e => {
|
||||
handlers.forEach(handler => {
|
||||
handler(e);
|
||||
});
|
||||
});
|
||||
_handlesMap.set(name, handlers as any);
|
||||
return handlers;
|
||||
}
|
||||
return prev;
|
||||
}
|
||||
|
||||
function addListener<T extends keyof WindowEventMap>(
|
||||
name: T,
|
||||
handler: (e: WindowEventMap[T]) => void
|
||||
) {
|
||||
initGlobalEvent(name).push(handler);
|
||||
}
|
||||
|
||||
function removeListener<T extends keyof WindowEventMap>(
|
||||
name: T,
|
||||
handler: Handler<WindowEventMap[T]>
|
||||
) {
|
||||
const handlers = _handlesMap.get(name) as Handler<WindowEventMap[T]>[];
|
||||
const idx = handlers.indexOf(handler);
|
||||
if (idx !== -1) {
|
||||
handlers.splice(idx, 1);
|
||||
}
|
||||
}
|
||||
|
||||
export const useGlobalEvent = <T extends keyof WindowEventMap>(
|
||||
name: T,
|
||||
handler: (e: WindowEventMap[T]) => void
|
||||
) => {
|
||||
useEffect(() => {
|
||||
addListener(name, handler);
|
||||
|
||||
return () => {
|
||||
removeListener(name, handler);
|
||||
};
|
||||
}, [handler, name]);
|
||||
};
|
||||
67
packages/frontend/apps/mobile/src/index.tsx
Normal file
67
packages/frontend/apps/mobile/src/index.tsx
Normal 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);
|
||||
}
|
||||
7
packages/frontend/apps/mobile/src/modules/index.ts
Normal file
7
packages/frontend/apps/mobile/src/modules/index.ts
Normal file
@@ -0,0 +1,7 @@
|
||||
import type { Framework } from '@toeverything/infra';
|
||||
|
||||
import { configureMobileSearchModule } from './search';
|
||||
|
||||
export function configureMobileModules(framework: Framework) {
|
||||
configureMobileSearchModule(framework);
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
import { type Framework, WorkspaceScope } from '@toeverything/infra';
|
||||
|
||||
import { MobileSearchService } from './service/search';
|
||||
|
||||
export { MobileSearchService };
|
||||
|
||||
export function configureMobileSearchModule(framework: Framework) {
|
||||
framework.scope(WorkspaceScope).service(MobileSearchService);
|
||||
}
|
||||
@@ -0,0 +1,18 @@
|
||||
import {
|
||||
CollectionsQuickSearchSession,
|
||||
DocsQuickSearchSession,
|
||||
RecentDocsQuickSearchSession,
|
||||
TagsQuickSearchSession,
|
||||
} from '@affine/core/modules/quicksearch';
|
||||
import { Service } from '@toeverything/infra';
|
||||
|
||||
export class MobileSearchService extends Service {
|
||||
readonly recentDocs = this.framework.createEntity(
|
||||
RecentDocsQuickSearchSession
|
||||
);
|
||||
readonly collections = this.framework.createEntity(
|
||||
CollectionsQuickSearchSession
|
||||
);
|
||||
readonly docs = this.framework.createEntity(DocsQuickSearchSession);
|
||||
readonly tags = this.framework.createEntity(TagsQuickSearchSession);
|
||||
}
|
||||
3
packages/frontend/apps/mobile/src/pages/404.tsx
Normal file
3
packages/frontend/apps/mobile/src/pages/404.tsx
Normal file
@@ -0,0 +1,3 @@
|
||||
export const Component = () => {
|
||||
return <div>/404</div>;
|
||||
};
|
||||
3
packages/frontend/apps/mobile/src/pages/auth.tsx
Normal file
3
packages/frontend/apps/mobile/src/pages/auth.tsx
Normal file
@@ -0,0 +1,3 @@
|
||||
export const Component = () => {
|
||||
return <div>/auth/*</div>;
|
||||
};
|
||||
9
packages/frontend/apps/mobile/src/pages/index.tsx
Normal file
9
packages/frontend/apps/mobile/src/pages/index.tsx
Normal 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} />;
|
||||
};
|
||||
38
packages/frontend/apps/mobile/src/pages/sign-in.tsx
Normal file
38
packages/frontend/apps/mobile/src/pages/sign-in.tsx
Normal 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('/')} />;
|
||||
};
|
||||
18
packages/frontend/apps/mobile/src/pages/workspace/all.tsx
Normal file
18
packages/frontend/apps/mobile/src/pages/workspace/all.tsx
Normal 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 />
|
||||
</>
|
||||
);
|
||||
};
|
||||
@@ -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 />
|
||||
</>
|
||||
);
|
||||
};
|
||||
@@ -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 />
|
||||
</>
|
||||
);
|
||||
};
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
@@ -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',
|
||||
});
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
@@ -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',
|
||||
});
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,5 @@
|
||||
import { style } from '@vanilla-extract/css';
|
||||
|
||||
export const content = style({
|
||||
padding: '0 20px',
|
||||
});
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
@@ -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',
|
||||
});
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
40
packages/frontend/apps/mobile/src/pages/workspace/home.tsx
Normal file
40
packages/frontend/apps/mobile/src/pages/workspace/home.tsx
Normal 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>
|
||||
);
|
||||
};
|
||||
143
packages/frontend/apps/mobile/src/pages/workspace/index.tsx
Normal file
143
packages/frontend/apps/mobile/src/pages/workspace/index.tsx
Normal 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>
|
||||
);
|
||||
};
|
||||
104
packages/frontend/apps/mobile/src/pages/workspace/layout.tsx
Normal file
104
packages/frontend/apps/mobile/src/pages/workspace/layout.tsx
Normal 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>
|
||||
);
|
||||
};
|
||||
153
packages/frontend/apps/mobile/src/pages/workspace/search.tsx
Normal file
153
packages/frontend/apps/mobile/src/pages/workspace/search.tsx
Normal 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 />
|
||||
</>
|
||||
);
|
||||
};
|
||||
@@ -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} />;
|
||||
};
|
||||
@@ -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 />
|
||||
</>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,3 @@
|
||||
export const Component = () => {
|
||||
return <div>/workspace/:workspaceId/trash</div>;
|
||||
};
|
||||
2
packages/frontend/apps/mobile/src/polyfill/dispose.ts
Normal file
2
packages/frontend/apps/mobile/src/polyfill/dispose.ts
Normal file
@@ -0,0 +1,2 @@
|
||||
import 'core-js/modules/esnext.symbol.async-dispose';
|
||||
import 'core-js/modules/esnext.symbol.dispose';
|
||||
11
packages/frontend/apps/mobile/src/polyfill/intl-segmenter.ts
Normal file
11
packages/frontend/apps/mobile/src/polyfill/intl-segmenter.ts
Normal 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 {};
|
||||
@@ -0,0 +1 @@
|
||||
import 'core-js/features/promise/with-resolvers';
|
||||
@@ -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);
|
||||
};
|
||||
@@ -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 />
|
||||
</>
|
||||
);
|
||||
};
|
||||
159
packages/frontend/apps/mobile/src/router.tsx
Normal file
159
packages/frontend/apps/mobile/src/router.tsx
Normal 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,
|
||||
},
|
||||
});
|
||||
31
packages/frontend/apps/mobile/src/styles/mobile.css.ts
Normal file
31
packages/frontend/apps/mobile/src/styles/mobile.css.ts
Normal 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',
|
||||
});
|
||||
3
packages/frontend/apps/mobile/src/views/README.md
Normal file
3
packages/frontend/apps/mobile/src/views/README.md
Normal file
@@ -0,0 +1,3 @@
|
||||
# mobile views
|
||||
|
||||
Maintain complex views that used for `../pages`, view can contain mobile-components in `../components`
|
||||
@@ -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'),
|
||||
});
|
||||
@@ -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} />
|
||||
</>
|
||||
);
|
||||
};
|
||||
@@ -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
|
||||
</>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,3 @@
|
||||
export { CollectionDetail } from './detail';
|
||||
export * from './empty';
|
||||
export * from './list';
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
@@ -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]);
|
||||
@@ -0,0 +1,3 @@
|
||||
export * from './list';
|
||||
export * from './masonry';
|
||||
export * from './menu';
|
||||
@@ -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,
|
||||
});
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
@@ -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,
|
||||
});
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
@@ -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,
|
||||
},
|
||||
},
|
||||
});
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
31
packages/frontend/apps/mobile/src/views/all-docs/header.tsx
Normal file
31
packages/frontend/apps/mobile/src/views/all-docs/header.tsx
Normal 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>
|
||||
</>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,4 @@
|
||||
export * from './collection';
|
||||
export * from './doc/';
|
||||
export * from './header';
|
||||
export * from './tag';
|
||||
@@ -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'),
|
||||
},
|
||||
},
|
||||
});
|
||||
50
packages/frontend/apps/mobile/src/views/all-docs/tabs.tsx
Normal file
50
packages/frontend/apps/mobile/src/views/all-docs/tabs.tsx
Normal 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>
|
||||
);
|
||||
};
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
@@ -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',
|
||||
},
|
||||
});
|
||||
@@ -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 />
|
||||
</>
|
||||
);
|
||||
};
|
||||
@@ -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
|
||||
</>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,3 @@
|
||||
export { TagDetail } from './detail';
|
||||
export * from './item';
|
||||
export * from './list';
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
@@ -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,
|
||||
});
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
@@ -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,
|
||||
},
|
||||
},
|
||||
});
|
||||
4
packages/frontend/apps/mobile/src/views/index.ts
Normal file
4
packages/frontend/apps/mobile/src/views/index.ts
Normal file
@@ -0,0 +1,4 @@
|
||||
export * from './all-docs';
|
||||
export * from './home-header';
|
||||
export * from './recent-docs';
|
||||
export * from './settings';
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
@@ -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%',
|
||||
});
|
||||
@@ -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}
|
||||
</>
|
||||
);
|
||||
};
|
||||
74
packages/frontend/apps/mobile/src/views/search/style.css.ts
Normal file
74
packages/frontend/apps/mobile/src/views/search/style.css.ts
Normal 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'),
|
||||
});
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
@@ -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'),
|
||||
});
|
||||
@@ -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
Reference in New Issue
Block a user