refactor(infra): directory structure (#4615)

This commit is contained in:
Joooye_34
2023-10-18 23:30:08 +08:00
committed by GitHub
parent 814d552be8
commit bed9310519
1150 changed files with 539 additions and 584 deletions

View File

@@ -0,0 +1,32 @@
import { CloseIcon } from '@blocksuite/icons';
import type React from 'react';
import {
browserWarningStyle,
closeButtonStyle,
closeIconStyle,
} from './index.css';
export const BrowserWarning = ({
show,
onClose,
message,
}: {
show: boolean;
onClose: () => void;
message: React.ReactNode;
}) => {
if (!show) {
return null;
}
return (
<div className={browserWarningStyle}>
{message}
<div className={closeButtonStyle} onClick={onClose}>
<CloseIcon className={closeIconStyle} />
</div>
</div>
);
};
export default BrowserWarning;

View File

@@ -0,0 +1,47 @@
import { Trans } from '@affine/i18n';
import { CloseIcon, Logo1Icon } from '@blocksuite/icons';
import {
downloadCloseButtonStyle,
downloadMessageStyle,
downloadTipContainerStyle,
downloadTipIconStyle,
downloadTipStyle,
linkStyle,
} from './index.css';
export const DownloadTips = ({ onClose }: { onClose: () => void }) => {
return (
<div
className={downloadTipContainerStyle}
data-testid="download-client-tip"
>
<div className={downloadTipStyle}>
<Logo1Icon className={downloadTipIconStyle} />
<div className={downloadMessageStyle}>
<Trans i18nKey="com.affine.banner.content">
This demo is limited.
<a
className={linkStyle}
href="https://affine.pro/download"
target="_blank"
rel="noreferrer"
>
Download the AFFiNE Client
</a>
for the latest features and Performance.
</Trans>
</div>
</div>
<div
className={downloadCloseButtonStyle}
onClick={onClose}
data-testid="download-client-tip-close-button"
>
<CloseIcon className={downloadTipIconStyle} />
</div>
</div>
);
};
export default DownloadTips;

View File

@@ -0,0 +1,87 @@
import { keyframes, style } from '@vanilla-extract/css';
const slideDown = keyframes({
'0%': {
height: '0px',
},
'100%': {
height: '44px',
},
});
export const browserWarningStyle = style({
backgroundColor: 'var(--affine-background-warning-color)',
color: 'var(--affine-warning-color)',
height: '36px',
fontSize: 'var(--affine-font-sm)',
display: 'flex',
justifyContent: 'center',
alignItems: 'center',
position: 'relative',
});
export const closeButtonStyle = style({
width: '36px',
height: '36px',
color: 'var(--affine-icon-color)',
cursor: 'pointer',
display: 'flex',
justifyContent: 'flex-end',
alignItems: 'center',
position: 'absolute',
right: '16px',
});
export const closeIconStyle = style({
width: '15px',
height: '15px',
position: 'relative',
zIndex: 1,
});
export const downloadTipContainerStyle = style({
backgroundColor: 'var(--affine-primary-color)',
color: 'var(--affine-white)',
width: '100%',
height: '44px',
fontSize: 'var(--affine-font-base)',
display: 'flex',
justifyContent: 'center',
alignItems: 'center',
position: 'relative',
animation: `${slideDown} .3s ease-in-out forwards`,
});
export const downloadTipStyle = style({
display: 'flex',
justifyContent: 'center',
alignItems: 'center',
});
export const downloadTipIconStyle = style({
color: 'var(--affine-white)',
width: '24px',
height: '24px',
fontSize: '24px',
position: 'relative',
zIndex: 1,
});
export const downloadCloseButtonStyle = style({
color: 'var(--affine-white)',
cursor: 'pointer',
display: 'flex',
justifyContent: 'center',
alignItems: 'center',
position: 'absolute',
right: '24px',
});
export const downloadMessageStyle = style({
color: 'var(--affine-white)',
marginLeft: '8px',
});
export const linkStyle = style({
color: 'var(--affine-white)',
textDecoration: 'underline',
':hover': {
textDecoration: 'underline',
},
':visited': {
color: 'var(--affine-white)',
textDecoration: 'underline',
},
});

View File

@@ -0,0 +1,2 @@
export * from './browser-warning';
export * from './download-client';

View File

@@ -0,0 +1,32 @@
import { style } from '@vanilla-extract/css';
export const root = style({
display: 'inline-flex',
background: 'var(--affine-white-30)',
alignItems: 'center',
borderRadius: '8px',
border: '1px solid var(--affine-black-10)',
fontSize: 'var(--affine-font-sm)',
width: '100%',
position: 'relative',
height: '52px',
userSelect: 'none',
cursor: 'pointer',
padding: '0 24px',
transition: 'background 0.2s ease',
selectors: {
'&:active': {
background: 'var(--affine-white-40)',
},
},
});
export const icon = style({
marginRight: '18px',
color: 'var(--affine-icon-color)',
fontSize: '24px',
});
export const spacer = style({
flex: 1,
});

View File

@@ -0,0 +1,16 @@
import type { Meta, StoryFn } from '@storybook/react';
import { AddPageButton } from '.';
export default {
title: 'Components/AppSidebar/AddPageButton',
component: AddPageButton,
} satisfies Meta;
export const Default: StoryFn = () => {
return (
<main style={{ width: '240px' }}>
<AddPageButton onClick={() => alert('opened')} />
</main>
);
};

View File

@@ -0,0 +1,33 @@
import { useAFFiNEI18N } from '@affine/i18n/hooks';
import { PlusIcon } from '@blocksuite/icons';
import clsx from 'clsx';
import type React from 'react';
import { Spotlight } from '../spolight';
import * as styles from './index.css';
interface AddPageButtonProps {
onClick?: () => void;
className?: string;
style?: React.CSSProperties;
}
export function AddPageButton({
onClick,
className,
style,
}: AddPageButtonProps) {
const t = useAFFiNEI18N();
return (
<button
data-testid="new-page-button"
style={style}
className={clsx([styles.root, className])}
onClick={onClick}
>
<PlusIcon className={styles.icon} /> {t['New Page']()}
<Spotlight />
</button>
);
}

View File

@@ -0,0 +1,34 @@
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 122 116">
<path id="b" stroke="#fff" stroke-linecap="round" stroke-width="0" d="M17.9256 115C17.434 111.774 13.1701 104.086 13.4282 95.6465C13.6862 87.207 18.6628 76.0721 17.9256 64.3628C17.1883 52.6535 8.7772 35.9512 9.00452 25.3907C9.23185 14.8302 16.2114 5.06512 17.9256 1"/>
<path id="d" stroke="#fff" stroke-linecap="round" stroke-width="0" d="M84.1628 115C85.2376 112.055 94.5618 98.8394 93.9975 91.1338C93.4332 83.4281 82.5505 73.2615 84.1628 62.5704C85.775 51.8793 96.4803 35.4248 95.9832 25.7826C95.4861 16.1404 87.9113 4.71163 84.1628 1"/>
<path id="f" stroke="#fff" stroke-linecap="round" stroke-width="0" d="M37.0913 115C37.9604 111.921 44.4347 99.4545 45.3816 92.9773C48.9305 68.7011 35.7877 73.9552 37.0913 62.7781C38.3949 51.6011 47.3889 36.9895 46.9869 26.9091C46.585 16.8286 40.1222 4.88034 37.0913 1"/>
<path id="h" stroke="#fff" stroke-linecap="round" stroke-width="0" d="M112.443 115C111.698 112.235 108.25 106.542 107.715 93.7582C107.241 82.4286 107.229 83.9543 112.443 66.1429C116.085 44.0408 100.661 42.5908 101.006 33.539C101.35 24.4871 109.843 4.48439 112.443 1"/>
<g>
<circle r="1.5" fill="rgba(96, 70, 254, 0.3)">
<animateMotion dur="10s" repeatCount="indefinite">
<mpath href="#b" />
</animateMotion>
</circle>
</g>
<g>
<circle r="1" fill="rgba(96, 70, 254, 0.3)" fill-opacity="1" shape-rendering="crispEdges">
<animateMotion dur="8s" repeatCount="indefinite">
<mpath href="#d" />
</animateMotion>
</circle>
</g>
<g>
<circle r=".5" fill="rgba(96, 70, 254, 0.3)" fill-opacity="1" shape-rendering="crispEdges">
<animateMotion dur="4s" repeatCount="indefinite">
<mpath href="#f" />
</animateMotion>
</circle>
</g>
<g>
<circle r=".8" fill="rgba(96, 70, 254, 0.3)" fill-opacity="1" shape-rendering="crispEdges">
<animateMotion dur="6s" repeatCount="indefinite">
<mpath href="#h" />
</animateMotion>
</circle>
</g>
</svg>

After

Width:  |  Height:  |  Size: 2.0 KiB

View File

@@ -0,0 +1,235 @@
import { style } from '@vanilla-extract/css';
export const root = style({
display: 'inline-flex',
background: 'var(--affine-white-30)',
alignItems: 'center',
borderRadius: '8px',
border: '1px solid var(--affine-black-10)',
fontSize: 'var(--affine-font-sm)',
width: '100%',
height: '52px',
userSelect: 'none',
cursor: 'pointer',
padding: '0 12px',
position: 'relative',
transition: 'all 0.3s ease',
selectors: {
'&:hover': {
background: 'var(--affine-white-60)',
},
'&[data-disabled="true"]': {
pointerEvents: 'none',
},
'&:after': {
content: "''",
position: 'absolute',
top: '-2px',
right: '-2px',
width: '8px',
height: '8px',
backgroundColor: 'var(--affine-primary-color)',
borderRadius: '50%',
zIndex: 1,
transition: 'opacity 0.3s',
},
'&:hover:after': {
opacity: 0,
},
},
vars: {
'--svg-dot-animation': `url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' fill='none' viewBox='0 0 122 116'%3E%3Cpath id='b' stroke='%23fff' stroke-linecap='round' stroke-width='0' d='M17.9256 115C17.434 111.774 13.1701 104.086 13.4282 95.6465C13.6862 87.207 18.6628 76.0721 17.9256 64.3628C17.1883 52.6535 8.7772 35.9512 9.00452 25.3907C9.23185 14.8302 16.2114 5.06512 17.9256 1'/%3E%3Cpath id='d' stroke='%23fff' stroke-linecap='round' stroke-width='0' d='M84.1628 115C85.2376 112.055 94.5618 98.8394 93.9975 91.1338C93.4332 83.4281 82.5505 73.2615 84.1628 62.5704C85.775 51.8793 96.4803 35.4248 95.9832 25.7826C95.4861 16.1404 87.9113 4.71163 84.1628 1'/%3E%3Cpath id='f' stroke='%23fff' stroke-linecap='round' stroke-width='0' d='M37.0913 115C37.9604 111.921 44.4347 99.4545 45.3816 92.9773C48.9305 68.7011 35.7877 73.9552 37.0913 62.7781C38.3949 51.6011 47.3889 36.9895 46.9869 26.9091C46.585 16.8286 40.1222 4.88034 37.0913 1'/%3E%3Cpath id='h' stroke='%23fff' stroke-linecap='round' stroke-width='0' d='M112.443 115C111.698 112.235 108.25 106.542 107.715 93.7582C107.241 82.4286 107.229 83.9543 112.443 66.1429C116.085 44.0408 100.661 42.5908 101.006 33.539C101.35 24.4871 109.843 4.48439 112.443 1'/%3E%3Cg%3E%3Ccircle r='1.5' fill='rgba(30, 150, 235, 0.3)'%3E%3CanimateMotion dur='10s' repeatCount='indefinite'%3E%3Cmpath href='%23b' /%3E%3C/animateMotion%3E%3C/circle%3E%3C/g%3E%3Cg%3E%3Ccircle r='1' fill='rgba(30, 150, 235, 0.3)' fill-opacity='1' shape-rendering='crispEdges'%3E%3CanimateMotion dur='8s' repeatCount='indefinite'%3E%3Cmpath href='%23d' /%3E%3C/animateMotion%3E%3C/circle%3E%3C/g%3E%3Cg%3E%3Ccircle r='.5' fill='rgba(30, 150, 235, 0.3)' fill-opacity='1' shape-rendering='crispEdges'%3E%3CanimateMotion dur='4s' repeatCount='indefinite'%3E%3Cmpath href='%23f' /%3E%3C/animateMotion%3E%3C/circle%3E%3C/g%3E%3Cg%3E%3Ccircle r='.8' fill='rgba(30, 150, 235, 0.3)' fill-opacity='1' shape-rendering='crispEdges'%3E%3CanimateMotion dur='6s' repeatCount='indefinite'%3E%3Cmpath href='%23h' /%3E%3C/animateMotion%3E%3C/circle%3E%3C/g%3E%3C/svg%3E")`,
},
});
export const icon = style({
marginRight: '18px',
color: 'var(--affine-icon-color)',
fontSize: '24px',
});
export const closeIcon = style({
position: 'absolute',
top: '4px',
right: '4px',
height: '14px',
width: '14px',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
boxShadow: 'var(--affine-shadow-1)',
color: 'var(--affine-text-secondary-color)',
backgroundColor: 'var(--affine-background-primary-color)',
fontSize: '14px',
cursor: 'pointer',
transition: '0.1s',
borderRadius: '50%',
transform: 'scale(0.6)',
zIndex: 1,
opacity: 0,
selectors: {
'&:hover': {
transform: 'scale(1.1)',
},
[`${root}:hover &`]: {
opacity: 1,
transform: 'scale(1)',
},
},
});
export const installLabel = style({
display: 'flex',
alignItems: 'center',
width: '100%',
flex: 1,
fontSize: 'var(--affine-font-sm)',
whiteSpace: 'nowrap',
});
export const installLabelNormal = style([
installLabel,
{
justifyContent: 'space-between',
selectors: {
[`${root}:hover &, ${root}[data-updating=true] &`]: {
display: 'none',
},
},
},
]);
export const installLabelHover = style([
installLabel,
{
display: 'none',
selectors: {
[`${root}:hover &, ${root}[data-updating=true] &`]: {
display: 'flex',
},
},
},
]);
export const updateAvailableWrapper = style({
display: 'flex',
flexDirection: 'column',
position: 'relative',
width: '100%',
height: '100%',
padding: '8px 0',
});
export const versionLabel = style({
padding: '0 6px',
color: 'var(--affine-text-secondary-color)',
background: 'var(--affine-background-primary-color)',
fontSize: '10px',
lineHeight: '18px',
borderRadius: '4px',
maxWidth: '100px',
overflow: 'hidden',
textOverflow: 'ellipsis',
});
export const whatsNewLabel = style({
display: 'flex',
alignItems: 'center',
width: '100%',
height: '100%',
fontSize: 'var(--affine-font-sm)',
whiteSpace: 'nowrap',
});
export const ellipsisTextOverflow = style({
whiteSpace: 'nowrap',
overflow: 'hidden',
textOverflow: 'ellipsis',
});
export const progress = style({
position: 'relative',
width: '100%',
height: '4px',
borderRadius: '12px',
background: 'var(--affine-black-10)',
});
export const progressInner = style({
position: 'absolute',
top: 0,
left: 0,
height: '100%',
borderRadius: '12px',
background: 'var(--affine-primary-color)',
transition: '0.1s',
});
export const particles = style({
background: `var(--svg-dot-animation), var(--svg-dot-animation)`,
backgroundRepeat: 'no-repeat, repeat',
backgroundPosition: 'center, center top 100%',
backgroundSize: '100%, 130%',
maskImage: 'linear-gradient(to top, transparent, black, black, transparent)',
width: '100%',
height: '100%',
position: 'absolute',
left: 0,
pointerEvents: 'none',
display: 'none',
selectors: {
[`${root}:hover &, ${root}[data-updating=true] &`]: {
display: 'block',
},
'&:before': {
content: '""',
display: 'block',
position: 'absolute',
width: '100%',
height: '100%',
background: `var(--svg-dot-animation), var(--svg-dot-animation), var(--svg-dot-animation)`,
backgroundRepeat: 'no-repeat, repeat, repeat',
backgroundPosition: 'center, center top 100%, center center',
backgroundSize: '100% 120%, 150%, 120%',
filter: 'blur(1px)',
willChange: 'filter',
pointerEvents: 'none',
},
},
});
export const halo = style({
overflow: 'hidden',
position: 'absolute',
inset: 0,
':before': {
content: '""',
display: 'block',
inset: 0,
position: 'absolute',
filter: 'blur(10px) saturate(1.2)',
transition: '0.3s ease',
willChange: 'filter, transform',
transform: 'translateY(100%) scale(0.6)',
background:
'radial-gradient(ellipse 60% 80% at bottom, rgba(30, 150, 235, 0.35), transparent)',
},
':after': {
content: '""',
display: 'block',
inset: 0,
position: 'absolute',
filter: 'blur(10px) saturate(1.2)',
transition: '0.1s ease',
willChange: 'filter, transform',
transform: 'translateY(100%) scale(0.6)',
background:
'radial-gradient(ellipse 30% 45% at bottom, rgba(30, 150, 235, 0.6), transparent)',
},
selectors: {
[`${root}:hover &:before, ${root}:hover &:after,
${root}[data-updating=true] &:before, ${root}[data-updating=true] &:after`]:
{
transform: 'translateY(0) scale(1)',
},
},
});

View File

@@ -0,0 +1,69 @@
import { isBrowser } from '@affine/env/constant';
import type { UpdateMeta } from '@toeverything/infra/type';
import { atomWithObservable, atomWithStorage } from 'jotai/utils';
import { Observable } from 'rxjs';
// todo: move to utils?
function rpcToObservable<
T,
H extends () => Promise<T>,
E extends (callback: (t: T) => void) => () => void,
>(
initialValue: T | null,
{
event,
handler,
onSubscribe,
}: {
event?: E;
handler?: H;
onSubscribe?: () => void;
}
): Observable<T | null> {
return new Observable<T | null>(subscriber => {
subscriber.next(initialValue);
onSubscribe?.();
if (!isBrowser || !environment.isDesktop || !event) {
subscriber.complete();
return;
}
handler?.()
.then(t => {
subscriber.next(t);
})
.catch(err => {
subscriber.error(err);
});
return event(t => {
subscriber.next(t);
});
});
}
export const updateReadyAtom = atomWithObservable(() => {
return rpcToObservable(null as UpdateMeta | null, {
event: window.events?.updater.onUpdateReady,
});
});
export const updateAvailableAtom = atomWithObservable(() => {
return rpcToObservable(null as UpdateMeta | null, {
event: window.events?.updater.onUpdateAvailable,
onSubscribe: () => {
window.apis?.updater.checkForUpdatesAndNotify().catch(err => {
console.error(err);
});
},
});
});
export const downloadProgressAtom = atomWithObservable(() => {
return rpcToObservable(null as number | null, {
event: window.events?.updater.onDownloadProgress,
});
});
export const changelogCheckedAtom = atomWithStorage<Record<string, boolean>>(
'affine:client-changelog-checked',
{}
);

View File

@@ -0,0 +1,263 @@
import { isBrowser, Unreachable } from '@affine/env/constant';
import { useAFFiNEI18N } from '@affine/i18n/hooks';
import { CloseIcon, NewIcon, ResetIcon } from '@blocksuite/icons';
import { Tooltip } from '@toeverything/components/tooltip';
import clsx from 'clsx';
import { atom, useAtomValue, useSetAtom } from 'jotai';
import { startTransition, useCallback, useState } from 'react';
import * as styles from './index.css';
import {
changelogCheckedAtom,
downloadProgressAtom,
updateAvailableAtom,
updateReadyAtom,
} from './index.jotai';
export interface AddPageButtonPureProps {
onClickUpdate: () => void;
onDismissCurrentChangelog: () => void;
currentChangelogUnread: boolean;
updateReady: boolean;
updateAvailable: {
version: string;
allowAutoUpdate: boolean;
} | null;
downloadProgress: number | null;
appQuitting: boolean;
className?: string;
style?: React.CSSProperties;
}
const currentVersionAtom = atom(async () => {
if (!isBrowser) {
return null;
}
const currentVersion = await window.apis?.updater.currentVersion();
return currentVersion;
});
const currentChangelogUnreadAtom = atom(async get => {
if (!isBrowser) {
return false;
}
const mapping = get(changelogCheckedAtom);
const currentVersion = await get(currentVersionAtom);
if (currentVersion) {
return !mapping[currentVersion];
}
return false;
});
export function AppUpdaterButtonPure({
updateReady,
onClickUpdate,
onDismissCurrentChangelog,
currentChangelogUnread,
updateAvailable,
downloadProgress,
appQuitting,
className,
style,
}: AddPageButtonPureProps) {
const t = useAFFiNEI18N();
if (!updateAvailable && !currentChangelogUnread) {
return null;
}
const updateAvailableNode = updateAvailable
? updateAvailable.allowAutoUpdate
? renderUpdateAvailableAllowAutoUpdate()
: renderUpdateAvailableNotAllowAutoUpdate()
: null;
const whatsNew =
!updateAvailable && currentChangelogUnread ? renderWhatsNew() : null;
const wrapWithTooltip = (
node: React.ReactElement,
tooltip?: React.ReactElement | string
) => {
if (!tooltip) {
return node;
}
return (
<Tooltip content={tooltip} side="top">
{node}
</Tooltip>
);
};
return wrapWithTooltip(
<button
style={style}
className={clsx([styles.root, className])}
data-has-update={!!updateAvailable}
data-updating={appQuitting}
data-disabled={
(updateAvailable?.allowAutoUpdate && !updateReady) || appQuitting
}
onClick={onClickUpdate}
>
{updateAvailableNode}
{whatsNew}
<div className={styles.particles} aria-hidden="true"></div>
<span className={styles.halo} aria-hidden="true"></span>
</button>,
updateAvailable?.version
);
function renderUpdateAvailableAllowAutoUpdate() {
return (
<div className={clsx([styles.updateAvailableWrapper])}>
<div className={clsx([styles.installLabelNormal])}>
<span className={styles.ellipsisTextOverflow}>
{!updateReady
? t['com.affine.appUpdater.downloading']()
: t['com.affine.appUpdater.updateAvailable']()}
</span>
<span className={styles.versionLabel}>
{updateAvailable?.version}
</span>
</div>
{updateReady ? (
<div className={clsx([styles.installLabelHover])}>
<ResetIcon className={styles.icon} />
<span className={styles.ellipsisTextOverflow}>
{t[
appQuitting ? 'Loading' : 'com.affine.appUpdater.installUpdate'
]()}
</span>
</div>
) : (
<div className={styles.progress}>
<div
className={styles.progressInner}
style={{ width: `${downloadProgress}%` }}
/>
</div>
)}
</div>
);
}
function renderUpdateAvailableNotAllowAutoUpdate() {
return (
<>
<div className={clsx([styles.installLabelNormal])}>
<span className={styles.ellipsisTextOverflow}>
{t['com.affine.appUpdater.updateAvailable']()}
</span>
<span className={styles.versionLabel}>
{updateAvailable?.version}
</span>
</div>
<div className={clsx([styles.installLabelHover])}>
<span className={styles.ellipsisTextOverflow}>
{t['com.affine.appUpdater.openDownloadPage']()}
</span>
</div>
</>
);
}
function renderWhatsNew() {
return (
<>
<div className={clsx([styles.whatsNewLabel])}>
<NewIcon className={styles.icon} />
<span className={styles.ellipsisTextOverflow}>
{t['com.affine.appUpdater.whatsNew']()}
</span>
</div>
<div
className={styles.closeIcon}
onClick={e => {
onDismissCurrentChangelog();
e.stopPropagation();
}}
>
<CloseIcon />
</div>
</>
);
}
}
// Although it is called an input, it is actually a button.
export function AppUpdaterButton({
className,
style,
}: {
className?: string;
style?: React.CSSProperties;
}) {
const currentChangelogUnread = useAtomValue(currentChangelogUnreadAtom);
const updateReady = useAtomValue(updateReadyAtom);
const updateAvailable = useAtomValue(updateAvailableAtom);
const currentVersion = useAtomValue(currentVersionAtom);
const downloadProgress = useAtomValue(downloadProgressAtom);
const setChangelogCheckAtom = useSetAtom(changelogCheckedAtom);
const [appQuitting, setAppQuitting] = useState(false);
const onDismissCurrentChangelog = useCallback(() => {
if (!currentVersion) {
return;
}
startTransition(() =>
setChangelogCheckAtom(mapping => {
return {
...mapping,
[currentVersion]: true,
};
})
);
}, [currentVersion, setChangelogCheckAtom]);
const onClickUpdate = useCallback(() => {
if (updateReady) {
setAppQuitting(true);
window.apis?.updater.quitAndInstall().catch(err => {
// TODO: add error toast here
console.error(err);
});
} else if (updateAvailable) {
if (updateAvailable.allowAutoUpdate) {
// wait for download to finish
} else {
window.open(
`https://github.com/toeverything/AFFiNE/releases/tag/v${currentVersion}`,
'_blank'
);
}
} else if (currentChangelogUnread) {
window.open(runtimeConfig.changelogUrl, '_blank');
onDismissCurrentChangelog();
} else {
throw new Unreachable();
}
}, [
currentChangelogUnread,
currentVersion,
onDismissCurrentChangelog,
updateAvailable,
updateReady,
]);
return (
<AppUpdaterButtonPure
appQuitting={appQuitting}
updateReady={!!updateReady}
onClickUpdate={onClickUpdate}
onDismissCurrentChangelog={onDismissCurrentChangelog}
currentChangelogUnread={currentChangelogUnread}
updateAvailable={updateAvailable}
downloadProgress={downloadProgress}
className={className}
style={style}
/>
);
}
export * from './index.jotai';

View File

@@ -0,0 +1,22 @@
import { style } from '@vanilla-extract/css';
export const root = style({
fontSize: 'var(--affine-font-xs)',
minHeight: '16px',
width: 'calc(100% + 6px)',
userSelect: 'none',
display: 'flex',
alignItems: 'center',
justifyContent: 'space-between',
marginBottom: '4px',
padding: '0 8px',
selectors: {
'&:not(:first-of-type)': {
marginTop: '16px',
},
},
});
export const label = style({
color: 'var(--affine-black-30)',
});

View File

@@ -0,0 +1,16 @@
import type { Meta, StoryFn } from '@storybook/react';
import { CategoryDivider } from '.';
export default {
title: 'Components/AppSidebar/CategoryDivider',
component: CategoryDivider,
} satisfies Meta;
export const Default: StoryFn = () => {
return (
<main style={{ width: '240px' }}>
<CategoryDivider label="Favorites" />
</main>
);
};

View File

@@ -0,0 +1,17 @@
import clsx from 'clsx';
import type { PropsWithChildren } from 'react';
import * as styles from './index.css';
interface CategoryDividerProps extends PropsWithChildren {
label: string;
}
export function CategoryDivider({ label, children }: CategoryDividerProps) {
return (
<div className={clsx([styles.root])}>
<div className={styles.label}>{label}</div>
{children}
</div>
);
}

View File

@@ -0,0 +1,15 @@
import { style } from '@vanilla-extract/css';
export const fallbackStyle = style({
margin: '12px 16px',
height: '100%',
});
export const fallbackHeaderStyle = style({
height: '56px',
width: '100%',
display: 'flex',
alignItems: 'center',
flexDirection: 'row',
gap: '8px',
});

View File

@@ -0,0 +1,115 @@
import { baseTheme } from '@toeverything/theme';
import type { ComplexStyleRule } from '@vanilla-extract/css';
import { createVar, style } from '@vanilla-extract/css';
export const floatingMaxWidth = 768;
export const navWidthVar = createVar('nav-width');
export const navWrapperStyle = style({
vars: {
[navWidthVar]: '256px',
},
position: 'relative',
width: navWidthVar,
minWidth: navWidthVar,
height: '100%',
zIndex: 3,
paddingBottom: '8px',
backgroundColor: 'transparent',
'@media': {
print: {
display: 'none',
zIndex: -1,
},
},
selectors: {
'&[data-is-floating="true"]': {
position: 'absolute',
width: `calc(${navWidthVar})`,
zIndex: 4,
backgroundColor: 'var(--affine-background-primary-color)',
},
'&[data-open="false"]': {
marginLeft: `calc(${navWidthVar} * -1)`,
},
'&[data-enable-animation="true"]': {
transition: 'margin-left .3s .05s, width .3s .05s',
},
'&[data-is-floating="false"].has-background': {
backgroundColor: 'var(--affine-white-60)',
borderRight: '1px solid var(--affine-border-color)',
},
'&.has-border': {
borderRight: '1px solid var(--affine-border-color)',
},
},
});
export const navHeaderButton = style({
width: '32px',
height: '32px',
flexShrink: 0,
});
export const navHeaderNavigationButtons = style({
display: 'flex',
alignItems: 'center',
columnGap: '32px',
});
export const navStyle = style({
position: 'relative',
width: '100%',
height: '100%',
display: 'flex',
flexDirection: 'column',
zIndex: parseInt(baseTheme.zIndexModal),
});
export const navHeaderStyle = style({
flex: '0 0 auto',
height: '52px',
padding: '0px 16px',
display: 'flex',
justifyContent: 'space-between',
alignItems: 'center',
WebkitAppRegion: 'drag',
selectors: {
'&[data-is-macos-electron="true"]': {
paddingLeft: '90px',
},
},
} as ComplexStyleRule);
export const navBodyStyle = style({
flex: '1 1 auto',
height: 'calc(100% - 52px)',
display: 'flex',
flexDirection: 'column',
rowGap: '4px',
});
export const sidebarFloatMaskStyle = style({
transition: 'opacity .15s',
opacity: 0,
pointerEvents: 'none',
position: 'fixed',
top: 0,
left: 0,
right: '100%',
bottom: 0,
background: 'var(--affine-background-modal-color)',
selectors: {
'&[data-open="true"][data-is-floating="true"]': {
opacity: 1,
pointerEvents: 'auto',
right: '0',
zIndex: 3,
},
},
'@media': {
print: {
display: 'none',
},
},
});

View File

@@ -0,0 +1,12 @@
import { atom } from 'jotai';
import { atomWithStorage } from 'jotai/utils';
export const APP_SIDEBAR_OPEN = 'app-sidebar-open';
export const appSidebarOpenAtom = atomWithStorage(APP_SIDEBAR_OPEN, true);
export const appSidebarFloatingAtom = atom(false);
export const appSidebarResizingAtom = atom(false);
export const appSidebarWidthAtom = atomWithStorage(
'app-sidebar-width',
256 /* px */
);

View File

@@ -0,0 +1,169 @@
import { Skeleton } from '@mui/material';
import { assignInlineVars } from '@vanilla-extract/dynamic';
import clsx from 'clsx';
import { useAtom, useAtomValue } from 'jotai';
import debounce from 'lodash/debounce';
import type { PropsWithChildren, ReactElement } from 'react';
import { useEffect, useRef, useState } from 'react';
import { fallbackHeaderStyle, fallbackStyle } from './fallback.css';
import {
floatingMaxWidth,
navBodyStyle,
navHeaderStyle,
navStyle,
navWidthVar,
navWrapperStyle,
sidebarFloatMaskStyle,
} from './index.css';
import {
APP_SIDEBAR_OPEN,
appSidebarFloatingAtom,
appSidebarOpenAtom,
appSidebarResizingAtom,
appSidebarWidthAtom,
} from './index.jotai';
import { ResizeIndicator } from './resize-indicator';
import type { SidebarHeaderProps } from './sidebar-header';
import { SidebarHeader } from './sidebar-header';
export type AppSidebarProps = PropsWithChildren<
SidebarHeaderProps & {
hasBackground?: boolean;
}
>;
function useEnableAnimation() {
const [enable, setEnable] = useState(false);
useEffect(() => {
window.setTimeout(() => {
setEnable(true);
}, 500);
}, []);
return enable;
}
export type History = {
stack: string[];
current: number;
};
export function AppSidebar(props: AppSidebarProps): ReactElement {
const [open, setOpen] = useAtom(appSidebarOpenAtom);
const appSidebarWidth = useAtomValue(appSidebarWidthAtom);
const [appSidebarFloating, setAppSidebarFloating] = useAtom(
appSidebarFloatingAtom
);
const isResizing = useAtomValue(appSidebarResizingAtom);
const navRef = useRef<HTMLDivElement>(null);
useEffect(() => {
function onResize() {
const isFloatingMaxWidth = window.matchMedia(
`(max-width: ${floatingMaxWidth}px)`
).matches;
const isOverflowWidth = window.matchMedia(
`(max-width: ${appSidebarWidth / 0.4}px)`
).matches;
const isFloating = isFloatingMaxWidth || isOverflowWidth;
if (
open === undefined &&
localStorage.getItem(APP_SIDEBAR_OPEN) === null
) {
// give the initial value,
// so that the sidebar can be closed on mobile by default
setOpen(!isFloating);
}
setAppSidebarFloating(isFloating && !!open);
}
const dOnResize = debounce(onResize, 50);
window.addEventListener('resize', dOnResize);
return () => {
window.removeEventListener('resize', dOnResize);
};
}, [appSidebarWidth, open, setAppSidebarFloating, setOpen]);
// disable animation to avoid UI flash
const enableAnimation = useEnableAnimation();
const isMacosDesktop = environment.isDesktop && environment.isMacOs;
return (
<>
<div
style={assignInlineVars({
[navWidthVar]: `${appSidebarWidth}px`,
})}
className={clsx(navWrapperStyle, {
'has-background': environment.isDesktop && props.hasBackground,
'has-border':
!environment.isDesktop ||
(environment.isDesktop && props.hasBackground),
})}
data-open={open}
data-testid="app-sidebar-wrapper"
data-is-macos-electron={isMacosDesktop}
data-is-floating={appSidebarFloating}
data-has-background={props.hasBackground}
data-enable-animation={enableAnimation && !isResizing}
>
<nav className={navStyle} ref={navRef} data-testid="app-sidebar">
<SidebarHeader
router={props.router}
generalShortcutsInfo={props.generalShortcutsInfo}
/>
<div className={navBodyStyle} data-testid="sliderBar-inner">
{props.children}
</div>
</nav>
<ResizeIndicator targetElement={navRef.current} />
</div>
<div
data-testid="app-sidebar-float-mask"
data-open={open}
data-is-floating={appSidebarFloating}
className={sidebarFloatMaskStyle}
onClick={() => setOpen(false)}
/>
</>
);
}
export const AppSidebarFallback = (): ReactElement | null => {
const appSidebarWidth = useAtomValue(appSidebarWidthAtom);
return (
<div
style={assignInlineVars({
[navWidthVar]: `${appSidebarWidth}px`,
})}
className={clsx(navWrapperStyle, {
'has-border': true,
})}
data-open="true"
>
<nav className={navStyle}>
<div className={navHeaderStyle} data-open="true" />
<div className={navBodyStyle}>
<div className={fallbackStyle}>
<div className={fallbackHeaderStyle}>
<Skeleton variant="circular" width={40} height={40} />
<Skeleton variant="rectangular" width={150} height={40} />
</div>
</div>
</div>
</nav>
</div>
);
};
export * from './add-page-button';
export * from './app-updater-button';
export * from './category-divider';
export * from './index.css';
export * from './menu-item';
export * from './quick-search-input';
export * from './sidebar-containers';
export * from './sidebar-header';
export { appSidebarFloatingAtom, appSidebarOpenAtom, appSidebarResizingAtom };

View File

@@ -0,0 +1,127 @@
import { style } from '@vanilla-extract/css';
export const linkItemRoot = style({
color: 'inherit',
display: 'contents',
});
export const root = style({
display: 'inline-flex',
alignItems: 'center',
borderRadius: '4px',
textAlign: 'left',
color: 'inherit',
width: '100%',
minHeight: '30px',
userSelect: 'none',
cursor: 'pointer',
padding: '0 12px',
fontSize: 'var(--affine-font-sm)',
marginTop: '4px',
selectors: {
'&:hover': {
background: 'var(--affine-hover-color)',
},
'&[data-active="true"]': {
background: 'var(--affine-hover-color)',
},
'&[data-disabled="true"]': {
cursor: 'default',
color: 'var(--affine-text-secondary-color)',
pointerEvents: 'none',
},
// this is not visible in dark mode
// '&[data-active="true"]:hover': {
// background:
// // make this a variable?
// 'linear-gradient(0deg, rgba(0, 0, 0, 0.04), rgba(0, 0, 0, 0.04)), rgba(0, 0, 0, 0.04)',
// },
'&[data-collapsible="true"]': {
paddingLeft: '4px',
paddingRight: '4px',
},
'&[data-type="collection-list-item"][data-collapsible="false"][data-active="true"],&[data-type="favorite-list-item"][data-collapsible="false"][data-active="true"], &[data-type="favorite-list-item"][data-collapsible="false"]:hover, &[data-type="collection-list-item"][data-collapsible="false"]:hover':
{
width: 'calc(100% + 8px)',
transform: 'translateX(-8px)',
paddingLeft: '20px',
paddingRight: '12px',
},
[`${linkItemRoot}:first-of-type &`]: {
marginTop: '0px',
},
},
});
export const content = style({
overflow: 'hidden',
textOverflow: 'ellipsis',
whiteSpace: 'nowrap',
flex: 1,
});
export const postfix = style({
justifySelf: 'flex-end',
opacity: 0,
pointerEvents: 'none',
selectors: {
[`${root}:hover &`]: {
opacity: 1,
pointerEvents: 'all',
},
},
});
export const icon = style({
color: 'var(--affine-icon-color)',
fontSize: '20px',
});
export const collapsedIconContainer = style({
width: '16px',
height: '16px',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
borderRadius: '2px',
transition: 'transform 0.2s',
color: 'inherit',
selectors: {
'&[data-collapsed="true"]': {
transform: 'rotate(-90deg)',
},
'&[data-disabled="true"]': {
opacity: 0.3,
pointerEvents: 'none',
},
'&:hover': {
background: 'var(--affine-hover-color)',
},
},
});
export const iconsContainer = style({
display: 'flex',
alignItems: 'center',
justifyContent: 'flex-start',
width: '28px',
flexShrink: 0,
selectors: {
'&[data-collapsible="true"]': {
width: '44px',
},
},
});
export const collapsedIcon = style({
transition: 'transform 0.2s ease-in-out',
selectors: {
'&[data-collapsed="true"]': {
transform: 'rotate(-90deg)',
},
},
});
export const spacer = style({
flex: 1,
});

View File

@@ -0,0 +1,44 @@
import { SettingsIcon } from '@blocksuite/icons';
import type { Meta, StoryFn } from '@storybook/react';
import { useState } from 'react';
import { MenuItem, MenuLinkItem } from '.';
export default {
title: 'Components/AppSidebar/MenuItem',
component: MenuItem,
} satisfies Meta;
export const Default: StoryFn = () => {
const [collapsed, setCollapsed] = useState(false);
return (
<main style={{ width: '240px' }}>
<MenuItem icon={<SettingsIcon />} onClick={() => alert('opened')}>
Normal Item
</MenuItem>
<MenuLinkItem
icon={<SettingsIcon />}
to="/test"
onClick={() => alert('opened')}
>
Normal Link Item
</MenuLinkItem>
<MenuLinkItem
active
icon={<SettingsIcon />}
to="/test"
onClick={() => alert('opened')}
>
Primary Item
</MenuLinkItem>
<MenuItem
collapsed={collapsed}
onCollapsedChange={setCollapsed}
icon={<SettingsIcon />}
onClick={() => alert('opened')}
>
Collapsible Item
</MenuItem>
</main>
);
};

View File

@@ -0,0 +1,102 @@
import { ArrowDownSmallIcon } from '@blocksuite/icons';
import clsx from 'clsx';
import React from 'react';
import type { LinkProps } from 'react-router-dom';
import { Link } from 'react-router-dom';
import * as styles from './index.css';
export interface MenuItemProps extends React.HTMLAttributes<HTMLDivElement> {
icon?: React.ReactElement;
active?: boolean;
disabled?: boolean;
// true, false, undefined. undefined means no collapse
collapsed?: boolean;
// if onCollapsedChange is given, but collapsed is undefined, then we will render the collapse button as disabled
onCollapsedChange?: (collapsed: boolean) => void;
postfix?: React.ReactElement;
}
export interface MenuLinkItemProps
extends MenuItemProps,
Pick<LinkProps, 'to'> {}
const stopPropagation: React.MouseEventHandler = e => {
e.stopPropagation();
};
export const MenuItem = React.forwardRef<HTMLDivElement, MenuItemProps>(
(
{
onClick,
icon,
active,
children,
disabled,
collapsed,
onCollapsedChange,
postfix,
...props
},
ref
) => {
const collapsible = onCollapsedChange !== undefined;
return (
<div
ref={ref}
{...props}
onClick={onClick}
className={clsx([styles.root, props.className])}
data-active={active}
data-disabled={disabled}
data-collapsible={collapsible}
>
{icon && (
<div className={styles.iconsContainer} data-collapsible={collapsible}>
{collapsible && (
<div
data-disabled={collapsed === undefined ? true : undefined}
onClick={e => {
e.stopPropagation();
e.preventDefault(); // for links
onCollapsedChange?.(!collapsed);
}}
data-testid="fav-collapsed-button"
className={styles.collapsedIconContainer}
>
<ArrowDownSmallIcon
className={styles.collapsedIcon}
data-collapsed={collapsed !== false}
/>
</div>
)}
{React.cloneElement(icon, {
className: clsx([styles.icon, icon.props.className]),
})}
</div>
)}
<div className={styles.content}>{children}</div>
{postfix ? (
<div className={styles.postfix} onClick={stopPropagation}>
{postfix}
</div>
) : null}
</div>
);
}
);
MenuItem.displayName = 'MenuItem';
export const MenuLinkItem = React.forwardRef<HTMLDivElement, MenuLinkItemProps>(
({ to, ...props }, ref) => {
return (
<Link to={to} className={styles.linkItemRoot}>
{/* The <a> element rendered by Link does not generate display box due to `display: contents` style */}
{/* Thus ref is passed to MenuItem instead of Link */}
<MenuItem ref={ref} {...props}></MenuItem>
</Link>
);
}
);
MenuLinkItem.displayName = 'MenuLinkItem';

View File

@@ -0,0 +1,32 @@
import { style } from '@vanilla-extract/css';
export const root = style({
display: 'inline-flex',
background: 'var(--affine-white-10)',
alignItems: 'center',
borderRadius: '8px',
border: '1px solid var(--affine-black-10)',
fontSize: 'var(--affine-font-sm)',
width: '100%',
height: '36px',
userSelect: 'none',
cursor: 'pointer',
padding: '0 12px',
margin: '20px 0',
position: 'relative',
});
export const icon = style({
marginRight: '8px',
color: 'var(--affine-icon-color)',
fontSize: '20px',
});
export const spacer = style({
flex: 1,
});
export const shortcutHint = style({
color: 'var(--affine-black-30)',
fontSize: 'var(--affine-font-base)',
});

View File

@@ -0,0 +1,16 @@
import type { Meta, StoryFn } from '@storybook/react';
import { QuickSearchInput } from '.';
export default {
title: 'Components/AppSidebar/QuickSearchInput',
component: QuickSearchInput,
} satisfies Meta;
export const Default: StoryFn = () => {
return (
<main style={{ width: '240px' }}>
<QuickSearchInput onClick={() => alert('opened')} />
</main>
);
};

View File

@@ -0,0 +1,33 @@
import { useAFFiNEI18N } from '@affine/i18n/hooks';
import { SearchIcon } from '@blocksuite/icons';
import clsx from 'clsx';
import type { HTMLAttributes } from 'react';
import { Spotlight } from '../spolight';
import * as styles from './index.css';
interface QuickSearchInputProps extends HTMLAttributes<HTMLDivElement> {
onClick?: () => void;
}
// Although it is called an input, it is actually a button.
export function QuickSearchInput({ onClick, ...props }: QuickSearchInputProps) {
const t = useAFFiNEI18N();
const isMac = environment.isBrowser && environment.isMacOs;
return (
<div
{...props}
className={clsx([props.className, styles.root])}
onClick={onClick}
>
<SearchIcon className={styles.icon} />
{t['Quick search']()}
<div className={styles.spacer} />
<div className={styles.shortcutHint}>
{isMac ? ' ⌘ + K' : ' Ctrl + K'}
</div>
<Spotlight />
</div>
);
}

View File

@@ -0,0 +1,45 @@
import { style } from '@vanilla-extract/css';
export const resizerContainer = style({
position: 'absolute',
right: 0,
top: '16px',
bottom: '16px',
width: '16px',
zIndex: 'calc(var(--affine-z-index-modal) + 1)',
transform: 'translateX(50%)',
backgroundColor: 'transparent',
opacity: 0,
cursor: 'col-resize',
'@media': {
'(max-width: 600px)': {
// do not allow resizing on mobile
display: 'none',
},
},
transition: 'opacity 0.15s ease 0.1s',
selectors: {
'&:hover': {
opacity: 1,
},
'&[data-resizing="true"]': {
opacity: 1,
transition: 'width .3s, min-width .3s, max-width .3s',
},
'&[data-open="false"]': {
display: 'none',
},
'&[data-open="open"]': {
display: 'block',
},
},
});
export const resizerInner = style({
position: 'absolute',
height: '100%',
width: '4px',
left: '6px',
borderRadius: '4px',
backgroundColor: 'var(--affine-primary-color)',
});

View File

@@ -0,0 +1,62 @@
import { assertExists } from '@blocksuite/global/utils';
import { useAtom, useSetAtom } from 'jotai';
import type { ReactElement } from 'react';
import { useCallback } from 'react';
import {
appSidebarOpenAtom,
appSidebarResizingAtom,
appSidebarWidthAtom,
} from '../index.jotai';
import * as styles from './index.css';
type ResizeIndicatorProps = {
targetElement: HTMLElement | null;
};
export const ResizeIndicator = (props: ResizeIndicatorProps): ReactElement => {
const setWidth = useSetAtom(appSidebarWidthAtom);
const [sidebarOpen, setSidebarOpen] = useAtom(appSidebarOpenAtom);
const [isResizing, setIsResizing] = useAtom(appSidebarResizingAtom);
const onResizeStart = useCallback(() => {
let resized = false;
assertExists(props.targetElement);
const { left: anchorLeft } = props.targetElement.getBoundingClientRect();
function onMouseMove(e: MouseEvent) {
e.preventDefault();
if (!props.targetElement) return;
const newWidth = Math.min(480, Math.max(e.clientX - anchorLeft, 256));
setWidth(newWidth);
setIsResizing(true);
resized = true;
}
document.addEventListener('mousemove', onMouseMove);
document.addEventListener(
'mouseup',
() => {
// if not resized, toggle sidebar
if (!resized) {
setSidebarOpen(o => !o);
}
setIsResizing(false);
document.removeEventListener('mousemove', onMouseMove);
},
{ once: true }
);
}, [props.targetElement, setIsResizing, setSidebarOpen, setWidth]);
return (
<div
className={styles.resizerContainer}
data-testid="app-sidebar-resizer"
data-resizing={isResizing}
data-open={sidebarOpen}
onMouseDown={onResizeStart}
>
<div className={styles.resizerInner} />
</div>
);
};

View File

@@ -0,0 +1,86 @@
import { globalStyle, style } from '@vanilla-extract/css';
export const baseContainer = style({
padding: '4px 16px',
display: 'flex',
flexFlow: 'column nowrap',
});
export const scrollableContainerRoot = style({
flex: '1 1 auto',
overflowY: 'hidden',
vars: {
'--scrollbar-width': '10px',
},
});
export const scrollTopBorder = style({
position: 'absolute',
top: 0,
left: '16px',
right: '16px',
height: '1px',
transition: 'opacity .3s .2s',
opacity: 0,
background: 'var(--affine-black-10)',
selectors: {
'&[data-has-scroll-top="true"]': {
opacity: 1,
},
},
});
export const scrollableViewport = style({
height: '100%',
marginTop: '4px',
});
globalStyle(`${scrollableViewport} > div`, {
maxWidth: '100%',
display: 'block !important',
});
export const scrollableContainer = style([
baseContainer,
{
height: '100%',
padding: '4px 8px',
},
]);
export const scrollbar = style({
display: 'flex',
flexDirection: 'column',
userSelect: 'none',
touchAction: 'none',
padding: '0 2px',
width: 'var(--scrollbar-width)',
height: '100%',
opacity: 1,
transition: 'opacity .15s',
selectors: {
'&[data-state="hidden"]': {
opacity: 0,
},
},
});
export const scrollbarThumb = style({
position: 'relative',
background: 'var(--affine-black-30)',
borderRadius: '4px',
overflow: 'hidden',
selectors: {
'&::before': {
content: '""',
position: 'absolute',
top: '50%',
left: '50%',
transform: 'translate(-50%, -50%)',
width: '100%',
height: '100%',
minWidth: '44px',
minHeight: '44px',
},
},
});

View File

@@ -0,0 +1,35 @@
import * as ScrollArea from '@radix-ui/react-scroll-area';
import clsx from 'clsx';
import { type PropsWithChildren } from 'react';
import * as styles from './index.css';
import { useHasScrollTop } from './use-has-scroll-top';
export function SidebarContainer({ children }: PropsWithChildren) {
return <div className={clsx([styles.baseContainer])}>{children}</div>;
}
export function SidebarScrollableContainer({ children }: PropsWithChildren) {
const [hasScrollTop, ref] = useHasScrollTop();
return (
<ScrollArea.Root className={styles.scrollableContainerRoot}>
<div
data-has-scroll-top={hasScrollTop}
className={styles.scrollTopBorder}
/>
<ScrollArea.Viewport
className={clsx([styles.scrollableViewport])}
ref={ref}
>
<div className={clsx([styles.scrollableContainer])}>{children}</div>
</ScrollArea.Viewport>
<ScrollArea.Scrollbar
forceMount
orientation="vertical"
className={styles.scrollbar}
>
<ScrollArea.Thumb className={styles.scrollbarThumb} />
</ScrollArea.Scrollbar>
</ScrollArea.Root>
);
}

View File

@@ -0,0 +1,29 @@
import { useEffect, useRef, useState } from 'react';
export function useHasScrollTop() {
const ref = useRef<HTMLDivElement>(null);
const [hasScrollTop, setHasScrollTop] = useState(false);
useEffect(() => {
if (!ref.current) {
return;
}
const container = ref.current;
function updateScrollTop() {
if (container) {
const hasScrollTop = container.scrollTop > 0;
setHasScrollTop(hasScrollTop);
}
}
container.addEventListener('scroll', updateScrollTop);
updateScrollTop();
return () => {
container.removeEventListener('scroll', updateScrollTop);
};
}, []);
return [hasScrollTop, ref] as const;
}

View File

@@ -0,0 +1,99 @@
import { useAFFiNEI18N } from '@affine/i18n/hooks';
import { ArrowLeftSmallIcon, ArrowRightSmallIcon } from '@blocksuite/icons';
import { IconButton } from '@toeverything/components/button';
import { Tooltip } from '@toeverything/components/tooltip';
import { useAtomValue } from 'jotai';
import { useMemo } from 'react';
import type { History } from '..';
import {
navHeaderButton,
navHeaderNavigationButtons,
navHeaderStyle,
} from '../index.css';
import { appSidebarOpenAtom } from '../index.jotai';
import { SidebarSwitch } from './sidebar-switch';
export type SidebarHeaderProps = {
router?: {
back: () => unknown;
forward: () => unknown;
history: History;
};
generalShortcutsInfo?: {
shortcuts: {
[title: string]: string[];
};
};
};
export const SidebarHeader = (props: SidebarHeaderProps) => {
const open = useAtomValue(appSidebarOpenAtom);
const t = useAFFiNEI18N();
const shortcuts = props.generalShortcutsInfo?.shortcuts;
const shortcutsObject = useMemo(() => {
const goBack = t['com.affine.keyboardShortcuts.goBack']();
const goBackShortcut = shortcuts?.[goBack];
const goForward = t['com.affine.keyboardShortcuts.goForward']();
const goForwardShortcut = shortcuts?.[goForward];
return {
goBack,
goBackShortcut,
goForward,
goForwardShortcut,
};
}, [shortcuts, t]);
return (
<div
className={navHeaderStyle}
data-open={open}
data-is-macos-electron={environment.isDesktop && environment.isMacOs}
>
<SidebarSwitch show={open} />
<div className={navHeaderNavigationButtons}>
<Tooltip
content={`${shortcutsObject.goBack} ${shortcutsObject.goBackShortcut}`}
side="bottom"
>
<IconButton
className={navHeaderButton}
data-testid="app-sidebar-arrow-button-back"
disabled={props.router?.history.current === 0}
onClick={() => {
props.router?.back();
}}
>
<ArrowLeftSmallIcon />
</IconButton>
</Tooltip>
<Tooltip
content={`${shortcutsObject.goForward} ${shortcutsObject.goForwardShortcut}`}
side="bottom"
>
<IconButton
className={navHeaderButton}
data-testid="app-sidebar-arrow-button-forward"
disabled={
props.router
? (props.router.history.stack.length > 0 &&
props.router.history.current ===
props.router.history.stack.length - 1) ||
props.router.history.stack.length === 0
: true
}
onClick={() => {
props.router?.forward();
}}
>
<ArrowRightSmallIcon />
</IconButton>
</Tooltip>
</div>
</div>
);
};
export * from './sidebar-switch';

View File

@@ -0,0 +1,18 @@
import { style } from '@vanilla-extract/css';
export const sidebarSwitch = style({
opacity: 0,
width: 0,
overflow: 'hidden',
pointerEvents: 'none',
transition: 'all .3s ease-in-out',
selectors: {
'&[data-show=true]': {
opacity: 1,
width: '32px',
flexShrink: 0,
fontSize: '24px',
pointerEvents: 'auto',
},
},
});

View File

@@ -0,0 +1,38 @@
import { useAFFiNEI18N } from '@affine/i18n/hooks';
import { SidebarIcon } from '@blocksuite/icons';
import { IconButton } from '@toeverything/components/button';
import { Tooltip } from '@toeverything/components/tooltip';
import { useAtom } from 'jotai';
import { appSidebarOpenAtom } from '../index.jotai';
import * as styles from './sidebar-switch.css';
export const SidebarSwitch = ({ show }: { show: boolean }) => {
const [open, setOpen] = useAtom(appSidebarOpenAtom);
const t = useAFFiNEI18N();
const tooltipContent = open
? t['com.affine.sidebarSwitch.collapse']()
: t['com.affine.sidebarSwitch.expand']();
const collapseKeyboardShortcuts =
environment.isBrowser && environment.isMacOs ? ' ⌘+/' : ' Ctrl+/';
return (
<Tooltip
content={tooltipContent + ' ' + collapseKeyboardShortcuts}
side={open ? 'bottom' : 'right'}
>
<IconButton
className={styles.sidebarSwitch}
data-show={show}
size="large"
data-testid={`app-sidebar-arrow-button-${open ? 'collapse' : 'expand'}`}
style={{
zIndex: 1,
}}
onClick={() => setOpen(open => !open)}
>
<SidebarIcon />
</IconButton>
</Tooltip>
);
};

View File

@@ -0,0 +1,24 @@
import { createVar, style } from '@vanilla-extract/css';
export const spotlightX = createVar();
export const spotlightY = createVar();
export const spotlightOpacity = createVar();
export const spotlightSize = createVar();
export const spotlight = style({
vars: {
[spotlightX]: '0px',
[spotlightY]: '0px',
[spotlightOpacity]: '0',
[spotlightSize]: '64px',
},
position: 'absolute',
background: `radial-gradient(${spotlightSize} circle at ${spotlightX} ${spotlightY}, var(--affine-text-primary-color), transparent)`,
inset: '0px',
pointerEvents: 'none',
willChange: 'background, opacity',
opacity: spotlightOpacity,
zIndex: 1,
transition: 'all 0.2s',
borderRadius: 'inherit',
});

View File

@@ -0,0 +1,30 @@
import type { Meta, StoryFn } from '@storybook/react';
import { type PropsWithChildren } from 'react';
import { Spotlight } from '.';
export default {
title: 'Components/AppSidebar/Spotlight',
component: Spotlight,
} satisfies Meta;
const Container = ({ children }: PropsWithChildren) => (
<main
style={{
position: 'relative',
width: '320px',
height: '320px',
border: '1px solid #ccc',
}}
>
{children}
</main>
);
export const Default: StoryFn = () => {
return (
<Container>
<Spotlight />
</Container>
);
};

View File

@@ -0,0 +1,50 @@
import { assignInlineVars } from '@vanilla-extract/dynamic';
import { useTheme } from 'next-themes';
import React, { useEffect, useRef } from 'react';
import * as styles from './index.css';
function useMouseOffset() {
const [offset, setOffset] = React.useState<{ x: number; y: number }>();
const [outside, setOutside] = React.useState(true);
const ref = useRef<HTMLDivElement>(null);
useEffect(() => {
if (ref.current && ref.current.parentElement) {
const el = ref.current.parentElement;
// debounce?
const onMouseMove = (e: MouseEvent) => {
const bound = el.getBoundingClientRect();
setOffset({ x: e.clientX - bound.x, y: e.clientY - bound.y });
setOutside(false);
};
const onMouseLeave = () => {
setOutside(true);
};
el.addEventListener('mousemove', onMouseMove);
el.addEventListener('mouseleave', onMouseLeave);
return () => {
el.removeEventListener('mousemove', onMouseMove);
el.removeEventListener('mouseleave', onMouseLeave);
};
}
return;
}, []);
return [offset, outside, ref] as const;
}
export function Spotlight() {
const [offset, outside, ref] = useMouseOffset();
const { theme } = useTheme();
const isDark = theme === 'dark';
const offsetVars = assignInlineVars({
[styles.spotlightX]: (offset?.x ?? 0) + 'px',
[styles.spotlightY]: (offset?.y ?? 0) + 'px',
[styles.spotlightOpacity]: outside ? '0' : isDark ? '.1' : '0.07',
});
return <div style={offsetVars} ref={ref} className={styles.spotlight} />;
}

View File

@@ -0,0 +1,14 @@
import clsx from 'clsx';
import type { FC, HTMLAttributes, PropsWithChildren } from 'react';
import { authContent } from './share.css';
export const AuthContent: FC<
PropsWithChildren & HTMLAttributes<HTMLDivElement>
> = ({ children, className, ...otherProps }) => {
return (
<div className={clsx(authContent, className)} {...otherProps}>
{children}
</div>
);
};

View File

@@ -0,0 +1,52 @@
import clsx from 'clsx';
import type { FC, HTMLAttributes } from 'react';
import { Input, type InputProps } from '../../ui/input';
import { authInputWrapper, formHint } from './share.css';
export type AuthInputProps = InputProps & {
label?: string;
error?: boolean;
errorHint?: string;
withoutHint?: boolean;
onEnter?: () => void;
wrapperProps?: HTMLAttributes<HTMLDivElement>;
};
export const AuthInput: FC<AuthInputProps> = ({
label,
error,
errorHint,
withoutHint = false,
onEnter,
wrapperProps: { className, ...otherWrapperProps } = {},
...inputProps
}) => {
return (
<div
className={clsx(authInputWrapper, className, {
'without-hint': withoutHint,
})}
{...otherWrapperProps}
>
{label ? <label>{label}</label> : null}
<Input
size="extraLarge"
status={error ? 'error' : 'default'}
onKeyDown={e => {
if (e.key === 'Enter') {
onEnter?.();
}
}}
{...inputProps}
/>
{error && errorHint && !withoutHint ? (
<div
className={clsx(formHint, {
error: error,
})}
>
{errorHint}
</div>
) : null}
</div>
);
};

View File

@@ -0,0 +1,32 @@
import type { FC, PropsWithChildren, ReactNode } from 'react';
import { Empty } from '../../ui/empty';
import { Wrapper } from '../../ui/layout';
import { Logo } from './logo';
import { authPageContainer } from './share.css';
export const AuthPageContainer: FC<
PropsWithChildren<{ title?: ReactNode; subtitle?: ReactNode }>
> = ({ children, title, subtitle }) => {
return (
<div className={authPageContainer}>
<Wrapper
style={{
position: 'absolute',
top: 25,
left: 20,
}}
>
<Logo />
</Wrapper>
<div className="wrapper">
<div className="content">
<p className="title">{title}</p>
<p className="subtitle">{subtitle}</p>
{children}
</div>
<Empty />
</div>
</div>
);
};

View File

@@ -0,0 +1,24 @@
import { useAFFiNEI18N } from '@affine/i18n/hooks';
import { ArrowLeftSmallIcon } from '@blocksuite/icons';
import { Button, type ButtonProps } from '@toeverything/components/button';
import { type FC } from 'react';
export const BackButton: FC<ButtonProps> = props => {
const t = useAFFiNEI18N();
return (
<Button
type="plain"
style={{
marginTop: 12,
marginLeft: -5,
paddingLeft: 0,
paddingRight: 5,
color: 'var(--affine-text-secondary-color)',
}}
icon={<ArrowLeftSmallIcon />}
{...props}
>
{t['com.affine.backButton']()}
</Button>
);
};

View File

@@ -0,0 +1,71 @@
import { useAFFiNEI18N } from '@affine/i18n/hooks';
import { Button } from '@toeverything/components/button';
import { useCallback, useState } from 'react';
import { AuthInput } from './auth-input';
import { AuthPageContainer } from './auth-page-container';
import { emailRegex } from './utils';
export const ChangeEmailPage = ({
onChangeEmail: propsOnChangeEmail,
}: {
onChangeEmail: (email: string) => Promise<boolean>;
onOpenAffine: () => void;
}) => {
const t = useAFFiNEI18N();
const [hasSetUp, setHasSetUp] = useState(false);
const [email, setEmail] = useState('');
const [isValidEmail, setIsValidEmail] = useState(true);
const [loading, setLoading] = useState(false);
const onContinue = useCallback(
() =>
void (async () => {
if (!emailRegex.test(email)) {
setIsValidEmail(false);
return;
}
setIsValidEmail(true);
setLoading(true);
const setup = await propsOnChangeEmail(email);
setLoading(false);
setHasSetUp(setup);
})(),
[email, propsOnChangeEmail]
);
const onEmailChange = useCallback((value: string) => {
setEmail(value);
}, []);
return (
<AuthPageContainer
title={t['com.affine.auth.change.email.page.title']()}
subtitle={t['com.affine.auth.change.email.page.subtitle']()}
>
<>
<AuthInput
width={320}
label={t['com.affine.settings.email']()}
placeholder={t['com.affine.auth.sign.email.placeholder']()}
value={email}
onChange={onEmailChange}
error={!isValidEmail}
errorHint={
isValidEmail ? '' : t['com.affine.auth.sign.email.error']()
}
onEnter={onContinue}
disabled={hasSetUp}
/>
<Button
type="primary"
size="large"
onClick={onContinue}
loading={loading}
disabled={hasSetUp}
>
{t['com.affine.auth.set.email.save']()}
</Button>
</>
</AuthPageContainer>
);
};

View File

@@ -0,0 +1,58 @@
import { useAFFiNEI18N } from '@affine/i18n/hooks';
import { Button } from '@toeverything/components/button';
import type { FC } from 'react';
import { useCallback, useState } from 'react';
import { AuthPageContainer } from './auth-page-container';
import { SetPassword } from './set-password';
type User = {
id: string;
name: string;
email: string;
image: string;
};
export const ChangePasswordPage: FC<{
user: User;
onSetPassword: (password: string) => void;
onOpenAffine: () => void;
}> = ({ user: { email }, onSetPassword: propsOnSetPassword, onOpenAffine }) => {
const t = useAFFiNEI18N();
const [hasSetUp, setHasSetUp] = useState(false);
const onSetPassword = useCallback(
(passWord: string) => {
propsOnSetPassword(passWord);
setHasSetUp(true);
},
[propsOnSetPassword]
);
return (
<AuthPageContainer
title={
hasSetUp
? t['com.affine.auth.reset.password.page.success']()
: t['com.affine.auth.reset.password.page.title']()
}
subtitle={
hasSetUp ? (
t['com.affine.auth.sent.reset.password.success.message']()
) : (
<>
{t['com.affine.auth.page.sent.email.subtitle']()}
<a href={`mailto:${email}`}>{email}</a>
</>
)
}
>
{hasSetUp ? (
<Button type="primary" size="large" onClick={onOpenAffine}>
{t['com.affine.auth.open.affine']()}
</Button>
) : (
<SetPassword onSetPassword={onSetPassword} />
)}
</AuthPageContainer>
);
};

View File

@@ -0,0 +1,22 @@
import { useAFFiNEI18N } from '@affine/i18n/hooks';
import { Button } from '@toeverything/components/button';
import type { FC } from 'react';
import { AuthPageContainer } from './auth-page-container';
export const ConfirmChangeEmail: FC<{
onOpenAffine: () => void;
}> = ({ onOpenAffine }) => {
const t = useAFFiNEI18N();
return (
<AuthPageContainer
title={t['com.affine.auth.change.email.page.success.title']()}
subtitle={t['com.affine.auth.change.email.page.success.subtitle']()}
>
<Button type="primary" size="large" onClick={onOpenAffine}>
{t['com.affine.auth.open.affine']()}
</Button>
</AuthPageContainer>
);
};

View File

@@ -0,0 +1,24 @@
import { forwardRef, type HTMLAttributes } from 'react';
const formatTime = (time: number): string => {
const minutes = Math.floor(time / 60);
const seconds = time % 60;
const formattedMinutes = minutes.toString().padStart(2, '0');
const formattedSeconds = seconds.toString().padStart(2, '0');
return `${formattedMinutes}:${formattedSeconds}`;
};
export const CountDownRender = forwardRef<
HTMLDivElement,
{ timeLeft: number } & HTMLAttributes<HTMLDivElement>
>(({ timeLeft, ...props }, ref) => {
return (
<div {...props} ref={ref}>
{formatTime(timeLeft)}
</div>
);
});
CountDownRender.displayName = 'CountDownRender';

View File

@@ -0,0 +1,15 @@
export * from './auth-content';
export * from './auth-input';
export * from './auth-page-container';
export * from './back-button';
export * from './change-email-page';
export * from './change-password-page';
export * from './confirm-change-email';
export * from './count-down-render';
export * from './modal';
export * from './modal-header';
export * from './password-input';
export * from './set-password-page';
export * from './sign-in-page-container';
export * from './sign-in-success-page';
export * from './sign-up-page';

View File

@@ -0,0 +1,18 @@
export const Logo = () => {
return (
<svg
width="149"
height="48"
viewBox="0 0 149 48"
fill="currentColor"
xmlns="http://www.w3.org/2000/svg"
>
<path d="M41.2519 35.7795C40.6446 34.7245 39.6331 32.973 38.6338 31.243C38.3306 30.718 38.0295 30.1962 37.7406 29.6957C37.1466 28.6663 36.6056 27.7277 36.2197 27.0578C33.5423 22.4283 28.2713 13.2624 25.6378 8.72996C24.8263 7.46664 22.9461 7.56672 22.248 8.87498C21.4559 10.2486 20.5842 11.7591 19.6635 13.3553C19.3715 13.8619 19.0735 14.3776 18.7724 14.8995C14.9365 21.546 10.4473 29.326 7.24932 34.8675C7.08601 35.1637 6.78183 35.6467 6.62974 35.9664C6.36333 36.5434 6.41539 37.2624 6.75121 37.7955C7.1299 38.4266 7.83828 38.7728 8.56198 38.7371C9.43267 38.7371 11.2792 38.7361 13.6667 38.7371C14.2332 38.7371 14.8303 38.7371 15.4519 38.7371C23.7535 38.7371 36.4351 38.7422 39.5473 38.7371C41.059 38.7402 42.0073 37.0816 41.254 35.7785L41.2519 35.7795ZM23.382 29.8929L21.854 27.2447C21.5855 26.779 21.9213 26.1969 22.4582 26.1969H25.5143C26.0522 26.1969 26.388 26.779 26.1186 27.2447L24.5905 29.8929C24.3221 30.3586 23.6504 30.3586 23.381 29.8929H23.382ZM20.822 24.9611C20.6954 24.6384 20.5862 24.3106 20.4964 23.9776L25.5521 24.9611H20.822ZM23.1309 31.9283C22.9155 32.1999 22.6858 32.4583 22.4429 32.7034L20.7659 27.8309L23.1299 31.9283H23.1309ZM28.0079 26.444C28.3499 26.4951 28.6888 26.5655 29.0215 26.6534L25.6429 30.5424L28.0079 26.444ZM20.1851 22.318C20.131 21.8258 20.1075 21.3284 20.1095 20.83L26.7861 24.0889L20.184 22.3191L20.1851 22.318ZM19.393 27.1967L21.1609 33.7992C20.7618 34.0923 20.3433 34.362 19.9105 34.6091L19.3919 27.1967H19.393ZM30.6139 27.214C31.0671 27.4132 31.5091 27.642 31.9398 27.8932L25.7807 32.0498L30.6139 27.214ZM20.2626 18.7283C20.3851 17.8234 20.574 16.9318 20.8026 16.077L30.9456 24.8988L20.2637 18.7283H20.2626ZM18.015 35.5293C17.1708 35.8755 16.3053 36.1574 15.4509 36.3861L18.015 23.1882V35.5293ZM33.6822 29.0758C34.4038 29.6345 35.0816 30.2442 35.7073 30.8692L23.0002 35.2464L33.6822 29.0758ZM24.4303 11.1167C26.3003 14.3633 29.0154 19.0714 31.6019 23.5528L21.9448 13.8905C22.5123 12.908 23.0502 11.9756 23.5463 11.1146C23.7433 10.7746 24.2333 10.7746 24.4303 11.1146V11.1167ZM9.34693 35.7213C9.87873 34.8031 10.6096 33.5439 10.7719 33.2539C12.3111 30.5863 14.3842 26.9945 16.5257 23.2831L12.9889 36.4883C11.7222 36.4883 10.63 36.4883 9.7889 36.4883C9.39592 36.4883 9.14993 36.0624 9.34693 35.7223V35.7213ZM38.1867 36.4903C35.2378 36.4903 29.5125 36.4903 23.828 36.4903L37.0271 32.9516C37.7192 34.1506 38.2734 35.1106 38.6287 35.7254C38.8257 36.0654 38.5797 36.4903 38.1877 36.4903H38.1867Z" />
<path d="M60.656 11.908C60.5365 11.4259 60.1037 11.0879 59.6077 11.0879H57.9326C57.4366 11.0879 57.0038 11.4259 56.8844 11.908L51.205 34.8244C51.0366 35.5056 51.551 36.1643 52.2533 36.1643H52.831C53.3332 36.1643 53.7691 35.8181 53.8824 35.3289L55.0919 30.1296C55.2052 29.6404 55.6411 29.2942 56.1433 29.2942H61.396C61.8982 29.2942 62.334 29.6404 62.4473 30.1296L63.6569 35.3289C63.7702 35.8181 64.2061 36.1643 64.7083 36.1643H65.286C65.9872 36.1643 66.5027 35.5056 66.3343 34.8244L60.6549 11.908H60.656ZM60.3344 26.8891H57.2059C56.5108 26.8891 55.9963 26.2416 56.1545 25.5645L58.245 15.4978C58.3746 14.9412 59.1667 14.9412 59.2963 15.4978L61.3868 25.5645C61.544 26.2416 61.0306 26.8891 60.3354 26.8891H60.3344Z" />
<path d="M101.52 22.0453H88.56C87.9639 22.0453 87.4801 21.5613 87.4801 20.9648V15.5439C87.4801 14.4103 88.3987 13.4911 89.5318 13.4911H95.945C96.5411 13.4911 97.0249 13.007 97.0249 12.4106V12.1665C97.0249 11.5701 96.5411 11.086 95.945 11.086H88.7672C86.5012 11.086 84.6649 12.9243 84.6649 15.1905V22.0433H73.1266C72.5304 22.0433 72.0466 21.5592 72.0466 20.9628V15.5419C72.0466 14.4082 72.9653 13.4891 74.0983 13.4891H80.5116C81.1077 13.4891 81.5915 13.005 81.5915 12.4086V12.1645C81.5915 11.5681 81.1077 11.084 80.5116 11.084H73.3338C71.0677 11.084 69.2314 12.9223 69.2314 15.1885V35.0799C69.2314 35.6763 69.7153 36.1604 70.3114 36.1604H70.9677C71.5638 36.1604 72.0476 35.6763 72.0476 35.0799V25.5269C72.0476 24.9305 72.5315 24.4464 73.1276 24.4464H84.6659V35.0799C84.6659 35.6763 85.1498 36.1604 85.7459 36.1604H86.4022C86.9983 36.1604 87.4821 35.6763 87.4821 35.0799V25.5269C87.4821 24.9305 87.966 24.4464 88.5621 24.4464H100.827C101.96 24.4464 102.879 25.3655 102.879 26.4991V35.0922C102.879 35.6886 103.363 36.1727 103.959 36.1727H104.546C105.142 36.1727 105.626 35.6886 105.626 35.0922V26.1468C105.626 23.8796 103.788 22.0423 101.523 22.0423L101.52 22.0453Z" />
<path d="M122.781 11.0879H122.19C121.595 11.0879 121.112 11.57 121.11 12.1644L121.045 32.7288L115.155 11.8743C115.023 11.4086 114.6 11.0879 114.116 11.0879H111.549C110.953 11.0879 110.469 11.572 110.469 12.1684V35.0849C110.469 35.6813 110.953 36.1654 111.549 36.1654H112.14C112.735 36.1654 113.218 35.6833 113.22 35.089L113.285 14.5245L119.175 35.379C119.306 35.8447 119.73 36.1654 120.214 36.1654H122.781C123.377 36.1654 123.861 35.6813 123.861 35.0849V12.1674C123.861 11.571 123.377 11.0869 122.781 11.0869V11.0879Z" />
<path d="M132.074 13.492H138.179C138.775 13.492 139.259 13.0079 139.259 12.4115V12.1674C139.259 11.571 138.775 11.0869 138.179 11.0869H131.345C129.079 11.0869 127.243 12.9252 127.243 15.1914V32.0578C127.243 34.325 129.08 36.1623 131.345 36.1623H138.179C138.775 36.1623 139.259 35.6782 139.259 35.0818V34.8377C139.259 34.2413 138.775 33.7572 138.179 33.7572H132.074C130.941 33.7572 130.023 32.8381 130.023 31.7044V25.1846C130.023 24.5882 130.506 24.1041 131.103 24.1041H137.835C138.431 24.1041 138.915 23.62 138.915 23.0236V22.7795C138.915 22.1831 138.431 21.699 137.835 21.699H131.103C130.506 21.699 130.023 21.2149 130.023 20.6185V15.5407C130.023 14.4071 130.941 13.4879 132.074 13.4879V13.492Z" />
<path d="M103.979 14.1446C103.499 13.6646 102.681 13.8842 102.506 14.5398L101.713 17.5025C101.537 18.1572 102.136 18.7567 102.792 18.582L105.753 17.7885C106.408 17.6128 106.626 16.7938 106.148 16.3148L103.981 14.1466L103.979 14.1446Z" />
</svg>
);
};

View File

@@ -0,0 +1,18 @@
import { Logo1Icon } from '@blocksuite/icons';
import type { FC } from 'react';
import { modalHeaderWrapper } from './share.css';
export const ModalHeader: FC<{
title: string;
subTitle: string;
}> = ({ title, subTitle }) => {
return (
<div className={modalHeaderWrapper}>
<p>
<Logo1Icon className="logo" />
{title}
</p>
<p>{subTitle}</p>
</div>
);
};

View File

@@ -0,0 +1,28 @@
import { Modal } from '@toeverything/components/modal';
import type { FC, PropsWithChildren } from 'react';
export type AuthModalProps = {
open: boolean;
setOpen: (value: boolean) => void;
};
export const AuthModal: FC<PropsWithChildren<AuthModalProps>> = ({
children,
open,
setOpen,
}) => {
return (
<Modal
open={open}
onOpenChange={setOpen}
width={400}
height={468}
contentOptions={{
['data-testid' as string]: 'auth-modal',
style: { padding: '44px 40px 0' },
}}
>
{children}
</Modal>
);
};

View File

@@ -0,0 +1,30 @@
export const ErrorIcon = () => {
return (
<svg
width="16"
height="16"
viewBox="0 0 16 16"
fill="none"
xmlns="http://www.w3.org/2000/svg"
>
<g id="InformationFill_Duotone">
<g id="Icon (Stroke)">
<path
fillRule="evenodd"
clipRule="evenodd"
d="M1.33398 8.00065C1.33398 4.31875 4.31875 1.33398 8.00065 1.33398C11.6826 1.33398 14.6673 4.31875 14.6673 8.00065C14.6673 11.6826 11.6826 14.6673 8.00065 14.6673C4.31875 14.6673 1.33398 11.6826 1.33398 8.00065ZM7.33398 5.33398C7.33398 4.96579 7.63246 4.66732 8.00065 4.66732H8.00732C8.37551 4.66732 8.67398 4.96579 8.67398 5.33398C8.67398 5.70217 8.37551 6.00065 8.00732 6.00065H8.00065C7.63246 6.00065 7.33398 5.70217 7.33398 5.33398ZM8.00065 6.66732C8.36884 6.66732 8.66732 6.96579 8.66732 7.33398V10.6673C8.66732 11.0355 8.36884 11.334 8.00065 11.334C7.63246 11.334 7.33398 11.0355 7.33398 10.6673V7.33398C7.33398 6.96579 7.63246 6.66732 8.00065 6.66732Z"
fill="#EB4335"
/>
<path
d="M8.66732 7.33398C8.66732 6.96579 8.36884 6.66732 8.00065 6.66732C7.63246 6.66732 7.33398 6.96579 7.33398 7.33398V10.6673C7.33398 11.0355 7.63246 11.334 8.00065 11.334C8.36884 11.334 8.66732 11.0355 8.66732 10.6673V7.33398Z"
fill="white"
/>
<path
d="M8.00065 4.66732C7.63246 4.66732 7.33398 4.96579 7.33398 5.33398C7.33398 5.70217 7.63246 6.00065 8.00065 6.00065H8.00732C8.37551 6.00065 8.67398 5.70217 8.67398 5.33398C8.67398 4.96579 8.37551 4.66732 8.00732 4.66732H8.00065Z"
fill="white"
/>
</g>
</g>
</svg>
);
};

View File

@@ -0,0 +1,103 @@
import { useAFFiNEI18N } from '@affine/i18n/hooks';
import { passwordStrength } from 'check-password-strength';
import { type FC, useEffect } from 'react';
import { useCallback, useState } from 'react';
import { Input, type InputProps } from '../../../ui/input';
import { ErrorIcon } from './error';
import { SuccessIcon } from './success';
import { Tag } from './tag';
export type Status = 'weak' | 'medium' | 'strong' | 'maximum';
export const PasswordInput: FC<
InputProps & {
onPass: (password: string) => void;
onPrevent: () => void;
}
> = ({ onPass, onPrevent, ...inputProps }) => {
const t = useAFFiNEI18N();
const [status, setStatus] = useState<Status | null>(null);
const [confirmStatus, setConfirmStatus] = useState<
'success' | 'error' | null
>(null);
const [password, setPassWord] = useState('');
const [confirmPassword, setConfirmPassword] = useState('');
const onPasswordChange = useCallback((value: string) => {
setPassWord(value);
if (!value) {
return setStatus(null);
}
if (value.length > 20) {
return setStatus('maximum');
}
switch (passwordStrength(value).id) {
case 0:
case 1:
setStatus('weak');
break;
case 2:
setStatus('medium');
break;
case 3:
setStatus('strong');
break;
}
}, []);
const onConfirmPasswordChange = useCallback((value: string) => {
setConfirmPassword(value);
}, []);
useEffect(() => {
if (!password || !confirmPassword) {
return;
}
if (password === confirmPassword) {
setConfirmStatus('success');
} else {
setConfirmStatus('error');
}
}, [confirmPassword, password]);
useEffect(() => {
if (confirmStatus === 'success' && password.length > 7) {
onPass(password);
} else {
onPrevent();
}
}, [confirmStatus, onPass, onPrevent, password]);
return (
<>
<Input
type="password"
size="extraLarge"
style={{ marginBottom: 20 }}
placeholder={t['com.affine.auth.set.password.placeholder']()}
onChange={onPasswordChange}
endFix={status ? <Tag status={status} /> : null}
{...inputProps}
/>
<Input
type="password"
size="extraLarge"
placeholder={t['com.affine.auth.set.password.placeholder.confirm']()}
onChange={onConfirmPasswordChange}
endFix={
confirmStatus ? (
confirmStatus === 'success' ? (
<SuccessIcon />
) : (
<ErrorIcon />
)
) : null
}
{...inputProps}
/>
</>
);
};

View File

@@ -0,0 +1,28 @@
import { style } from '@vanilla-extract/css';
export const tag = style({
padding: '0 15px',
height: 20,
lineHeight: '20px',
borderRadius: 10,
fontSize: 'var(--affine-font-xs)',
selectors: {
'&.weak': {
backgroundColor: 'var(--affine-tag-red)',
color: 'var(--affine-error-color)',
},
'&.medium': {
backgroundColor: 'var(--affine-tag-orange)',
color: 'var(--affine-warning-color)',
},
'&.strong': {
backgroundColor: 'var(--affine-tag-green)',
color: 'var(--affine-success-color)',
},
'&.maximum': {
backgroundColor: 'var(--affine-tag-red)',
color: 'var(--affine-error-color)',
},
},
});

View File

@@ -0,0 +1,28 @@
export const SuccessIcon = () => {
return (
<svg
width="16"
height="16"
viewBox="0 0 16 16"
fill="none"
xmlns="http://www.w3.org/2000/svg"
>
<g id="SingleSelect">
<path
id="Ellipse 2102 (Stroke)"
fillRule="evenodd"
clipRule="evenodd"
d="M1.5 8C1.5 4.41015 4.41015 1.5 8 1.5C11.5899 1.5 14.5 4.41015 14.5 8C14.5 11.5899 11.5899 14.5 8 14.5C4.41015 14.5 1.5 11.5899 1.5 8Z"
fill="#10CB86"
/>
<path
id="Icon (Stroke)"
fillRule="evenodd"
clipRule="evenodd"
d="M11.0052 5.63143C11.2087 5.81802 11.2225 6.13431 11.0359 6.33787L7.36923 10.3379C7.27708 10.4384 7.14786 10.4969 7.01151 10.4999C6.87517 10.5028 6.74353 10.45 6.6471 10.3536L4.98043 8.68689C4.78517 8.49163 4.78517 8.17505 4.98043 7.97978C5.17569 7.78452 5.49228 7.78452 5.68754 7.97978L6.98495 9.27719L10.2987 5.66214C10.4853 5.45858 10.8016 5.44483 11.0052 5.63143Z"
fill="white"
/>
</g>
</svg>
);
};

View File

@@ -0,0 +1,28 @@
import clsx from 'clsx';
import { type FC, useMemo } from 'react';
import type { Status } from './index';
import { tag } from './style.css';
export const Tag: FC<{ status: Status }> = ({ status }) => {
const textMap = useMemo<{ [K in Status]: string }>(() => {
return {
weak: 'Weak',
medium: 'Medium',
strong: 'Strong',
maximum: 'Maximum',
};
}, []);
return (
<div
className={clsx(tag, {
weak: status === 'weak',
medium: status === 'medium',
strong: status === 'strong',
maximum: status === 'maximum',
})}
>
{textMap[status]}
</div>
);
};

View File

@@ -0,0 +1,59 @@
import { useAFFiNEI18N } from '@affine/i18n/hooks';
import { Button } from '@toeverything/components/button';
import type { FC } from 'react';
import { useCallback, useState } from 'react';
import { AuthPageContainer } from './auth-page-container';
import { SetPassword } from './set-password';
type User = {
id: string;
name: string;
email: string;
image: string;
};
export const SetPasswordPage: FC<{
user: User;
onSetPassword: (password: string) => void;
onOpenAffine: () => void;
}> = ({ user: { email }, onSetPassword: propsOnSetPassword, onOpenAffine }) => {
const t = useAFFiNEI18N();
const [hasSetUp, setHasSetUp] = useState(false);
const onSetPassword = useCallback(
(passWord: string) => {
propsOnSetPassword(passWord);
setHasSetUp(true);
},
[propsOnSetPassword]
);
return (
<AuthPageContainer
title={
hasSetUp
? t['com.affine.auth.set.password.page.success']()
: t['com.affine.auth.set.password.page.title']()
}
subtitle={
hasSetUp ? (
t['com.affine.auth.sent.set.password.success.message']()
) : (
<>
{t['com.affine.auth.page.sent.email.subtitle']()}
<a href={`mailto:${email}`}>{email}</a>
</>
)
}
>
{hasSetUp ? (
<Button type="primary" size="large" onClick={onOpenAffine}>
{t['com.affine.auth.open.affine']()}
</Button>
) : (
<SetPassword onSetPassword={onSetPassword} />
)}
</AuthPageContainer>
);
};

View File

@@ -0,0 +1,50 @@
import { useAFFiNEI18N } from '@affine/i18n/hooks';
import { Button } from '@toeverything/components/button';
import { type FC, useCallback, useRef, useState } from 'react';
import { Wrapper } from '../../ui/layout';
import { PasswordInput } from './password-input';
export const SetPassword: FC<{
showLater?: boolean;
onLater?: () => void;
onSetPassword: (password: string) => void;
}> = ({ onLater, onSetPassword, showLater = false }) => {
const t = useAFFiNEI18N();
const [passwordPass, setPasswordPass] = useState(false);
const passwordRef = useRef('');
return (
<>
<Wrapper marginTop={30} marginBottom={42}>
<PasswordInput
width={320}
onPass={useCallback(password => {
setPasswordPass(true);
passwordRef.current = password;
}, [])}
onPrevent={useCallback(() => {
setPasswordPass(false);
}, [])}
/>
</Wrapper>
<Button
type="primary"
size="large"
disabled={!passwordPass}
style={{ marginRight: 20 }}
onClick={useCallback(() => {
onSetPassword(passwordRef.current);
}, [onSetPassword])}
>
{t['com.affine.auth.set.password.save']()}
</Button>
{showLater ? (
<Button type="plain" size="large" onClick={onLater}>
{t['com.affine.auth.later']()}
</Button>
) : null}
</>
);
};

View File

@@ -0,0 +1,180 @@
import { globalStyle, keyframes, style } from '@vanilla-extract/css';
export const modalHeaderWrapper = style({});
globalStyle(`${modalHeaderWrapper} .logo`, {
fontSize: 'var(--affine-font-h-3)',
fontWeight: 600,
color: 'var(--affine-blue)',
marginRight: '6px',
verticalAlign: 'middle',
});
globalStyle(`${modalHeaderWrapper} > p:first-of-type`, {
fontSize: 'var(--affine-font-h-5)',
fontWeight: 600,
marginBottom: '4px',
lineHeight: '28px',
display: 'flex',
alignItems: 'center',
});
globalStyle(`${modalHeaderWrapper} > p:last-of-type`, {
fontSize: 'var(--affine-font-h-4)',
fontWeight: 600,
lineHeight: '28px',
});
export const authInputWrapper = style({
paddingBottom: '30px',
position: 'relative',
selectors: {
'&.without-hint': {
paddingBottom: '20px',
},
},
});
globalStyle(`${authInputWrapper} label`, {
display: 'block',
color: 'var(--light-text-color-text-secondary-color, #8E8D91)',
marginBottom: '4px',
fontSize: 'var(--affine-font-sm)',
fontWeight: 600,
lineHeight: '22px',
});
export const formHint = style({
fontSize: 'var(--affine-font-sm)',
position: 'absolute',
bottom: '4px',
left: 0,
lineHeight: '22px',
selectors: {
'&.error': {
color: 'var(--affine-error-color)',
},
'&.warning': {
color: 'var(--affine-warning-color)',
},
},
});
const rotate = keyframes({
'0%': { transform: 'rotate(0deg)' },
'50%': { transform: 'rotate(180deg)' },
'100%': { transform: 'rotate(360deg)' },
});
export const loading = style({
width: '15px',
height: '15px',
position: 'relative',
borderRadius: '50%',
overflow: 'hidden',
backgroundColor: 'var(--affine-border-color)',
selectors: {
'&::after': {
content: '""',
width: '12px',
height: '12px',
position: 'absolute',
left: '0',
right: '0',
top: '0',
bottom: '0',
margin: 'auto',
backgroundColor: '#fff',
zIndex: 2,
borderRadius: '50%',
},
'&::before': {
content: '""',
width: '20px',
height: '20px',
backgroundColor: 'var(--affine-blue)',
position: 'absolute',
left: '50%',
bottom: '50%',
zIndex: '1',
transformOrigin: 'left bottom',
animation: `${rotate} 1.5s infinite linear`,
},
},
});
export const authContent = style({
fontSize: 'var(--affine-font-base)',
lineHeight: 'var(--affine-font-h-3)',
marginTop: '30px',
});
globalStyle(`${authContent} a`, {
color: 'var(--affine-link-color)',
});
export const authCodeContainer = style({
paddingBottom: '40px',
position: 'relative',
});
export const authCodeWrapper = style({
display: 'flex',
justifyContent: 'space-between',
alignItems: 'center',
});
export const authCodeErrorMessage = style({
color: 'var(--affine-error-color)',
fontSize: 'var(--affine-font-sm)',
textAlign: 'center',
lineHeight: '1.5',
position: 'absolute',
left: 0,
right: 0,
bottom: 5,
margin: 'auto',
});
export const resendButtonWrapper = style({
height: 32,
display: 'flex',
justifyContent: 'center',
alignItems: 'center',
marginTop: 30,
});
globalStyle(`${resendButtonWrapper} .resend-code-hint`, {
fontWeight: 600,
fontSize: 'var(--affine-font-sm)',
marginRight: 8,
});
export const authPageContainer = style({
height: '100vh',
width: '100vw',
display: 'flex',
justifyContent: 'center',
alignItems: 'center',
fontSize: 'var(--affine-font-base)',
});
globalStyle(`${authPageContainer} .wrapper`, {
display: 'flex',
alignItems: 'center',
});
globalStyle(`${authPageContainer} .content`, {
maxWidth: '700px',
minWidth: '550px',
});
globalStyle(`${authPageContainer} .title`, {
fontSize: 'var(--affine-font-title)',
fontWeight: 600,
marginBottom: '28px',
});
globalStyle(`${authPageContainer} .subtitle`, {
marginBottom: '28px',
});
globalStyle(`${authPageContainer} a`, {
color: 'var(--affine-link-color)',
});
export const signInPageContainer = style({
width: '400px',
margin: '205px auto 0',
});

View File

@@ -0,0 +1,6 @@
import type { PropsWithChildren } from 'react';
import { signInPageContainer } from './share.css';
export const SignInPageContainer = ({ children }: PropsWithChildren) => {
return <div className={signInPageContainer}>{children}</div>;
};

View File

@@ -0,0 +1,21 @@
import { useAFFiNEI18N } from '@affine/i18n/hooks';
import { Button } from '@toeverything/components/button';
import type { FC } from 'react';
import { AuthPageContainer } from './auth-page-container';
export const SignInSuccessPage: FC<{
onOpenAffine: () => void;
}> = ({ onOpenAffine }) => {
const t = useAFFiNEI18N();
return (
<AuthPageContainer
title={t['com.affine.auth.signed.success.title']()}
subtitle={t['com.affine.auth.signed.success.subtitle']()}
>
<Button type="primary" size="large" onClick={onOpenAffine}>
{t['com.affine.auth.open.affine']()}
</Button>
</AuthPageContainer>
);
};

View File

@@ -0,0 +1,65 @@
import { useAFFiNEI18N } from '@affine/i18n/hooks';
import { Button } from '@toeverything/components/button';
import type { FC } from 'react';
import { useCallback, useState } from 'react';
import { AuthPageContainer } from './auth-page-container';
import { SetPassword } from './set-password';
type User = {
id: string;
name: string;
email: string;
image: string;
};
export const SignUpPage: FC<{
user: User;
onSetPassword: (password: string) => void;
onOpenAffine: () => void;
}> = ({ user: { email }, onSetPassword: propsOnSetPassword, onOpenAffine }) => {
const t = useAFFiNEI18N();
const [hasSetUp, setHasSetUp] = useState(false);
const onSetPassword = useCallback(
(passWord: string) => {
propsOnSetPassword(passWord);
setHasSetUp(true);
},
[propsOnSetPassword]
);
const onLater = useCallback(() => {
setHasSetUp(true);
}, []);
return (
<AuthPageContainer
title={
hasSetUp
? t['com.affine.auth.sign.up.success.title']()
: t['com.affine.auth.page.sent.email.title']()
}
subtitle={
hasSetUp ? (
t['com.affine.auth.sign.up.success.subtitle']()
) : (
<>
{t['com.affine.auth.page.sent.email.subtitle']()}
<a href={`mailto:${email}`}>{email}</a>
</>
)
}
>
{hasSetUp ? (
<Button type="primary" size="large" onClick={onOpenAffine}>
{t['com.affine.auth.open.affine']()}
</Button>
) : (
<SetPassword
onSetPassword={onSetPassword}
onLater={onLater}
showLater={true}
/>
)}
</AuthPageContainer>
);
};

View File

@@ -0,0 +1,2 @@
export const emailRegex =
/^(?:(?:[^<>()[\]\\.,;:\s@"]+(\.[^<>()[\]\\.,;:\s@"]+)*)|(".+"))@(?:(?:\[[0-9]{1,3}\.[0-9]{1,3}\.[0-9]{1,3}\.[0-9]{1,3}\])|((?:[a-zA-Z\-0-9]+\.)+[a-zA-Z]{2,}))$/;

View File

@@ -0,0 +1,26 @@
import type { BlockHub } from '@blocksuite/blocks';
import type { Atom } from 'jotai';
import { useAtomValue } from 'jotai';
import type { HTMLAttributes, ReactElement } from 'react';
import { useEffect, useRef } from 'react';
export interface BlockHubProps extends HTMLAttributes<HTMLDivElement> {
blockHubAtom: Atom<Readonly<BlockHub> | null>;
}
export const BlockHubWrapper = (props: BlockHubProps): ReactElement => {
const blockHub = useAtomValue(props.blockHubAtom);
const ref = useRef<HTMLDivElement>(null);
useEffect(() => {
if (ref.current) {
const div = ref.current;
if (blockHub) {
if (div.hasChildNodes()) {
div.removeChild(div.firstChild as ChildNode);
}
div.appendChild(blockHub);
}
}
}, [blockHub]);
return <div ref={ref} data-testid="block-hub" />;
};

View File

@@ -0,0 +1,12 @@
import { style } from '@vanilla-extract/css';
export const blockSuiteEditorStyle = style({
maxWidth: 'var(--affine-editor-width)',
margin: '0 2rem',
padding: '0 24px',
});
export const blockSuiteEditorHeaderStyle = style({
marginTop: '40px',
marginBottom: '40px',
});

View File

@@ -0,0 +1,183 @@
import type { BlockHub } from '@blocksuite/blocks';
import { EditorContainer } from '@blocksuite/editor';
import { assertExists } from '@blocksuite/global/utils';
import type { Page } from '@blocksuite/store';
import { Skeleton } from '@mui/material';
import { use } from 'foxact/use';
import type { CSSProperties, ReactElement } from 'react';
import { memo, Suspense, useCallback, useEffect, useRef } from 'react';
import type { FallbackProps } from 'react-error-boundary';
import { ErrorBoundary } from 'react-error-boundary';
import {
blockSuiteEditorHeaderStyle,
blockSuiteEditorStyle,
} from './index.css';
export type EditorProps = {
page: Page;
mode: 'page' | 'edgeless';
onInit: (page: Page, editor: Readonly<EditorContainer>) => void;
setBlockHub?: (blockHub: BlockHub | null) => void;
onLoad?: (page: Page, editor: EditorContainer) => () => void;
style?: CSSProperties;
className?: string;
};
export type ErrorBoundaryProps = {
onReset?: () => void;
};
declare global {
// eslint-disable-next-line no-var
var currentPage: Page | undefined;
// eslint-disable-next-line no-var
var currentEditor: EditorContainer | undefined;
}
const BlockSuiteEditorImpl = (props: EditorProps): ReactElement => {
const { onLoad, page, mode, style } = props;
if (!page.loaded) {
use(page.waitForLoaded());
}
assertExists(page, 'page should not be null');
const editorRef = useRef<EditorContainer | null>(null);
const blockHubRef = useRef<BlockHub | null>(null);
if (editorRef.current === null) {
editorRef.current = new EditorContainer();
editorRef.current.autofocus = true;
globalThis.currentEditor = editorRef.current;
}
const editor = editorRef.current;
assertExists(editorRef, 'editorRef.current should not be null');
if (editor.mode !== mode) {
editor.mode = mode;
}
if (editor.page !== page) {
editor.page = page;
}
useEffect(() => {
if (editor.page && onLoad) {
const disposes = [] as ((() => void) | undefined)[];
disposes.push(onLoad?.(page, editor));
return () => {
disposes
.filter((dispose): dispose is () => void => !!dispose)
.forEach(dispose => dispose());
};
}
return;
}, [editor, editor.page, page, onLoad]);
const ref = useRef<HTMLDivElement>(null);
const setBlockHub = props.setBlockHub;
useEffect(() => {
const editor = editorRef.current;
assertExists(editor);
const container = ref.current;
if (!container) {
return;
}
container.appendChild(editor);
return () => {
container.removeChild(editor);
};
}, [editor]);
useEffect(() => {
if (page.meta.trash) {
return;
}
editor
.createBlockHub()
.then(blockHub => {
if (blockHubRef.current) {
blockHubRef.current.remove();
}
blockHubRef.current = blockHub;
if (setBlockHub) {
setBlockHub(blockHub);
}
})
.catch(err => {
console.error(err);
});
return () => {
if (setBlockHub) {
setBlockHub(null);
}
blockHubRef.current?.remove();
};
}, [editor, page.awarenessStore, page.meta.trash, setBlockHub]);
// issue: https://github.com/toeverything/AFFiNE/issues/2004
const className = `editor-wrapper ${editor.mode}-mode ${
props.className || ''
}`;
return (
<div
data-testid={`editor-${page.id}`}
className={className}
style={style}
ref={ref}
/>
);
};
const BlockSuiteErrorFallback = (
props: FallbackProps & ErrorBoundaryProps
): ReactElement => {
return (
<div>
<h1>Sorry.. there was an error</h1>
<div>{props.error.message}</div>
<button
data-testid="error-fallback-reset-button"
onClick={() => {
props.onReset?.();
props.resetErrorBoundary();
}}
>
Try again
</button>
</div>
);
};
export const BlockSuiteFallback = memo(function BlockSuiteFallback() {
return (
<div className={blockSuiteEditorStyle}>
<Skeleton
className={blockSuiteEditorHeaderStyle}
animation="wave"
height={50}
/>
<Skeleton animation="wave" height={30} width="40%" />
</div>
);
});
export const BlockSuiteEditor = memo(function BlockSuiteEditor(
props: EditorProps & ErrorBoundaryProps
): ReactElement {
return (
<ErrorBoundary
fallbackRender={useCallback(
(fallbackProps: FallbackProps) => (
<BlockSuiteErrorFallback {...fallbackProps} onReset={props.onReset} />
),
[props.onReset]
)}
>
<Suspense fallback={<BlockSuiteFallback />}>
<BlockSuiteEditorImpl key={props.page.id} {...props} />
</Suspense>
</ErrorBoundary>
);
});
BlockSuiteEditor.displayName = 'BlockSuiteEditor';

View File

@@ -0,0 +1,33 @@
import { forwardRef, type HTMLAttributes, type ReactNode } from 'react';
import * as styles from './styles.css';
export const BlockCard = forwardRef<
HTMLDivElement,
{
left?: ReactNode;
title: string;
desc?: string;
right?: ReactNode;
disabled?: boolean;
} & HTMLAttributes<HTMLDivElement>
>(({ left, title, desc, right, disabled, onClick, ...props }, ref) => {
return (
<div
ref={ref}
className={styles.blockCard}
role="button"
aria-disabled={disabled}
onClick={disabled ? undefined : onClick}
{...props}
>
{left && <div className={styles.blockCardAround}>{left}</div>}
<div className={styles.blockCardContent}>
<div>{title}</div>
<div className={styles.blockCardDesc}>{desc}</div>
</div>
{right && <div className={styles.blockCardAround}>{right}</div>}
</div>
);
});
BlockCard.displayName = 'BlockCard';

View File

@@ -0,0 +1,44 @@
import { style } from '@vanilla-extract/css';
export const blockCard = style({
display: 'flex',
gap: '12px',
padding: '8px 12px',
color: 'var(--affine-text-primary-color)',
backgroundColor: 'var(--affine-white-80)',
borderRadius: '8px',
userSelect: 'none',
cursor: 'pointer',
textAlign: 'start',
boxShadow: 'var(--affine-shadow-1)',
selectors: {
'&:hover': {
backgroundColor: 'var(--affine-hover-color)',
},
'&[aria-disabled]': {
color: 'var(--affine-text-disable-color)',
},
'&[aria-disabled]:hover': {
backgroundColor: 'var(--affine-white-80)',
cursor: 'not-allowed',
},
// TODO active styles
},
});
export const blockCardAround = style({
display: 'flex',
justifyContent: 'center',
alignItems: 'center',
});
export const blockCardContent = style({
display: 'flex',
flexDirection: 'column',
flex: 1,
});
export const blockCardDesc = style({
color: 'var(--affine-text-secondary-color)',
fontSize: 'var(--affine-font-xs)',
});

View File

@@ -0,0 +1,134 @@
import { WorkspaceFlavour } from '@affine/env/workspace';
import { useAFFiNEI18N } from '@affine/i18n/hooks';
import type { RootWorkspaceMetadata } from '@affine/workspace/atom';
import { CollaborationIcon, SettingsIcon } from '@blocksuite/icons';
import { Skeleton } from '@mui/material';
import { Avatar } from '@toeverything/components/avatar';
import { Divider } from '@toeverything/components/divider';
import { Tooltip } from '@toeverything/components/tooltip';
import { useBlockSuiteWorkspaceAvatarUrl } from '@toeverything/hooks/use-block-suite-workspace-avatar-url';
import { useBlockSuiteWorkspaceName } from '@toeverything/hooks/use-block-suite-workspace-name';
import { getBlockSuiteWorkspaceAtom } from '@toeverything/infra/__internal__/workspace';
import { useAtomValue } from 'jotai/react';
import { useCallback } from 'react';
import {
StyledCard,
StyledIconContainer,
StyledSettingLink,
StyledWorkspaceInfo,
StyledWorkspaceTitle,
StyledWorkspaceTitleArea,
StyledWorkspaceType,
StyledWorkspaceTypeEllipse,
StyledWorkspaceTypeText,
} from './styles';
export interface WorkspaceTypeProps {
flavour: WorkspaceFlavour;
isOwner: boolean;
}
const WorkspaceType = ({ flavour, isOwner }: WorkspaceTypeProps) => {
const t = useAFFiNEI18N();
if (flavour === WorkspaceFlavour.LOCAL) {
return (
<StyledWorkspaceType>
<StyledWorkspaceTypeEllipse />
<StyledWorkspaceTypeText>{t['Local']()}</StyledWorkspaceTypeText>
</StyledWorkspaceType>
);
}
return isOwner ? (
<StyledWorkspaceType>
<StyledWorkspaceTypeEllipse cloud={true} />
<StyledWorkspaceTypeText>
{t['com.affine.brand.affineCloud']()}
</StyledWorkspaceTypeText>
</StyledWorkspaceType>
) : (
<StyledWorkspaceType>
<StyledWorkspaceTypeEllipse cloud={true} />
<StyledWorkspaceTypeText>
{t['com.affine.brand.affineCloud']()}
</StyledWorkspaceTypeText>
<Divider
orientation="vertical"
size="thinner"
style={{ margin: '0px 8px', height: '7px' }}
/>
<Tooltip content={t['com.affine.workspaceType.joined']()}>
<StyledIconContainer>
<CollaborationIcon />
</StyledIconContainer>
</Tooltip>
</StyledWorkspaceType>
);
};
export interface WorkspaceCardProps {
currentWorkspaceId: string | null;
meta: RootWorkspaceMetadata;
onClick: (workspaceId: string) => void;
onSettingClick: (workspaceId: string) => void;
isOwner?: boolean;
}
export const WorkspaceCardSkeleton = () => {
return (
<div>
<StyledCard data-testid="workspace-card">
<Skeleton variant="circular" width={28} height={28} />
<Skeleton
variant="rectangular"
height={43}
width={220}
style={{ marginLeft: '12px' }}
/>
</StyledCard>
</div>
);
};
export const WorkspaceCard = ({
onClick,
onSettingClick,
currentWorkspaceId,
meta,
isOwner = true,
}: WorkspaceCardProps) => {
const [workspaceAtom] = getBlockSuiteWorkspaceAtom(meta.id);
const workspace = useAtomValue(workspaceAtom);
const [name] = useBlockSuiteWorkspaceName(workspace);
const [workspaceAvatar] = useBlockSuiteWorkspaceAvatarUrl(workspace);
return (
<StyledCard
data-testid="workspace-card"
onClick={useCallback(() => {
onClick(meta.id);
}, [onClick, meta.id])}
active={workspace.id === currentWorkspaceId}
>
<Avatar size={28} url={workspaceAvatar} name={name} colorfulFallback />
<StyledWorkspaceInfo>
<StyledWorkspaceTitleArea style={{ display: 'flex' }}>
<StyledWorkspaceTitle>{name}</StyledWorkspaceTitle>
<StyledSettingLink
size="small"
className="setting-entry"
onClick={e => {
e.stopPropagation();
onSettingClick(meta.id);
}}
withoutHoverStyle={true}
>
<SettingsIcon />
</StyledSettingLink>
</StyledWorkspaceTitleArea>
<WorkspaceType isOwner={isOwner} flavour={meta.flavour} />
</StyledWorkspaceInfo>
</StyledCard>
);
};

View File

@@ -0,0 +1,137 @@
import { IconButton } from '@toeverything/components/button';
import { displayFlex, styled, textEllipsis } from '../../../styles';
export const StyledWorkspaceInfo = styled('div')(() => {
return {
marginLeft: '12px',
width: '100%',
};
});
export const StyledWorkspaceTitle = styled('div')(() => {
return {
fontSize: 'var(--affine-font-sm)',
fontWeight: 700,
lineHeight: '22px',
maxWidth: '190px',
color: 'var(--affine-text-primary-color)',
...textEllipsis(1),
};
});
export const StyledCard = styled('div')<{
active?: boolean;
}>(({ active }) => {
const borderColor = active ? 'var(--affine-primary-color)' : 'transparent';
const backgroundColor = active ? 'var(--affine-white-30)' : 'transparent';
return {
width: '100%',
cursor: 'pointer',
padding: '12px',
borderRadius: '8px',
border: `1px solid ${borderColor}`,
...displayFlex('flex-start', 'flex-start'),
transition: 'background .2s',
alignItems: 'center',
position: 'relative',
color: 'var(--affine-text-secondary-color)',
background: backgroundColor,
':hover': {
background: 'var(--affine-hover-color)',
'.add-icon': {
borderColor: 'var(--affine-primary-color)',
color: 'var(--affine-primary-color)',
},
'.setting-entry': {
opacity: 1,
pointerEvents: 'auto',
backgroundColor: 'var(--affine-white-30)',
boxShadow: 'var(--affine-shadow-1)',
':hover': {
background:
'linear-gradient(0deg, var(--affine-hover-color) 0%, var(--affine-hover-color) 100%), var(--affine-white-30)',
},
},
},
'@media (max-width: 720px)': {
width: '100%',
},
};
});
export const StyledModalHeader = styled('div')(() => {
return {
width: '100%',
height: '72px',
position: 'absolute',
left: 0,
top: 0,
borderRadius: '24px 24px 0 0',
padding: '0 40px',
...displayFlex('space-between', 'center'),
};
});
export const StyledSettingLink = styled(IconButton)(() => {
return {
position: 'absolute',
right: '10px',
top: '10px',
opacity: 0,
borderRadius: '4px',
color: 'var(--affine-primary-color)',
pointerEvents: 'none',
transition: 'all .15s',
':hover': {
background: 'var(--affine-hover-color)',
},
};
});
export const StyledWorkspaceType = styled('div')(() => {
return {
...displayFlex('flex-start', 'center'),
width: '100%',
height: '20px',
};
});
export const StyledWorkspaceTitleArea = styled('div')(() => {
return {
display: 'flex',
justifyContent: 'space-between',
};
});
export const StyledWorkspaceTypeEllipse = styled('div')<{
cloud?: boolean;
}>(({ cloud }) => {
return {
width: '5px',
height: '5px',
borderRadius: '50%',
background: cloud
? 'var(--affine-palette-shape-blue)'
: 'var(--affine-palette-shape-green)',
};
});
export const StyledWorkspaceTypeText = styled('div')(() => {
return {
fontSize: '12px',
fontWeight: 500,
lineHeight: '20px',
marginLeft: '4px',
color: 'var(--affine-text-secondary-color)',
};
});
export const StyledIconContainer = styled('div')(() => {
return {
...displayFlex('flex-start', 'center'),
fontSize: '14px',
gap: '8px',
color: 'var(--affine-icon-secondary)',
};
});

View File

@@ -0,0 +1,27 @@
import { ProviderComposer } from '@affine/component/provider-composer';
import { ThemeProvider } from '@affine/component/theme-provider';
import type { createStore } from 'jotai';
import { Provider } from 'jotai';
import type { PropsWithChildren } from 'react';
import { useMemo } from 'react';
export type AffineContextProps = PropsWithChildren<{
store?: ReturnType<typeof createStore>;
}>;
export function AffineContext(props: AffineContextProps) {
return (
<ProviderComposer
contexts={useMemo(
() =>
[
<Provider key="JotaiProvider" store={props.store} />,
<ThemeProvider key="ThemeProvider" />,
].filter(Boolean),
[props.store]
)}
>
{props.children}
</ProviderComposer>
);
}

View File

@@ -0,0 +1,191 @@
import {
ArrowDownSmallIcon,
ArrowLeftSmallIcon,
ArrowRightSmallIcon,
} from '@blocksuite/icons';
import dayjs from 'dayjs';
import { useCallback, useState } from 'react';
import DatePicker from 'react-datepicker';
import * as styles from './index.css';
const months = [
'January',
'February',
'March',
'April',
'May',
'June',
'July',
'August',
'September',
'October',
'November',
'December',
];
type DatePickerProps = {
value?: string;
onChange: (value: string) => void;
};
export const AFFiNEDatePicker = (props: DatePickerProps) => {
const { value, onChange } = props;
const [openMonthPicker, setOpenMonthPicker] = useState(false);
const [selectedDate, setSelectedDate] = useState<Date | null>(
value ? dayjs(value).toDate() : null
);
const handleOpenMonthPicker = useCallback(() => {
setOpenMonthPicker(true);
}, []);
const handleCloseMonthPicker = useCallback(() => {
setOpenMonthPicker(false);
}, []);
const handleSelectDate = (date: Date | null) => {
if (date) {
setSelectedDate(date);
onChange(dayjs(date).format('YYYY-MM-DD'));
setOpenMonthPicker(false);
}
};
const renderCustomHeader = ({
date,
decreaseMonth,
increaseMonth,
prevMonthButtonDisabled,
nextMonthButtonDisabled,
}: {
date: Date;
decreaseMonth: () => void;
increaseMonth: () => void;
prevMonthButtonDisabled: boolean;
nextMonthButtonDisabled: boolean;
}) => {
const selectedYear = dayjs(date).year();
const selectedMonth = dayjs(date).month();
return (
<div className={styles.headerStyle}>
<div
data-testid="date-picker-current-month"
className={styles.mouthStyle}
>
{months[selectedMonth]}
</div>
<div
data-testid="date-picker-current-year"
className={styles.yearStyle}
>
{selectedYear}
</div>
<div
data-testid="month-picker-button"
className={styles.arrowDownStyle}
onClick={handleOpenMonthPicker}
>
<ArrowDownSmallIcon />
</div>
<button
data-testid="date-picker-prev-button"
className={styles.arrowLeftStyle}
onClick={decreaseMonth}
disabled={prevMonthButtonDisabled}
>
<ArrowLeftSmallIcon />
</button>
<button
data-testid="date-picker-next-button"
className={styles.arrowRightStyle}
onClick={increaseMonth}
disabled={nextMonthButtonDisabled}
>
<ArrowRightSmallIcon />
</button>
</div>
);
};
const renderCustomMonthHeader = ({
date,
decreaseYear,
increaseYear,
prevYearButtonDisabled,
nextYearButtonDisabled,
}: {
date: Date;
decreaseYear: () => void;
increaseYear: () => void;
prevYearButtonDisabled: boolean;
nextYearButtonDisabled: boolean;
}) => {
const selectedYear = dayjs(date).year();
return (
<div className={styles.monthHeaderStyle}>
<div
data-testid="month-picker-current-year"
className={styles.monthTitleStyle}
>
{selectedYear}
</div>
<button
data-testid="month-picker-prev-button"
className={styles.arrowLeftStyle}
onClick={decreaseYear}
disabled={prevYearButtonDisabled}
>
<ArrowLeftSmallIcon />
</button>
<button
data-testid="month-picker-next-button"
className={styles.arrowRightStyle}
onClick={increaseYear}
disabled={nextYearButtonDisabled}
>
<ArrowRightSmallIcon />
</button>
</div>
);
};
return (
<DatePicker
onClickOutside={handleCloseMonthPicker}
className={styles.inputStyle}
calendarClassName={styles.calendarStyle}
weekDayClassName={() => styles.weekStyle}
dayClassName={() => styles.dayStyle}
popperClassName={styles.popperStyle}
monthClassName={() => styles.mouthsStyle}
selected={selectedDate}
onChange={handleSelectDate}
showPopperArrow={false}
dateFormat="MMM dd"
showMonthYearPicker={openMonthPicker}
shouldCloseOnSelect={!openMonthPicker}
renderCustomHeader={({
date,
decreaseYear,
increaseYear,
decreaseMonth,
increaseMonth,
prevYearButtonDisabled,
nextYearButtonDisabled,
prevMonthButtonDisabled,
nextMonthButtonDisabled,
}) =>
openMonthPicker
? renderCustomMonthHeader({
date,
decreaseYear,
increaseYear,
prevYearButtonDisabled,
nextYearButtonDisabled,
})
: renderCustomHeader({
date,
decreaseMonth,
increaseMonth,
prevMonthButtonDisabled,
nextMonthButtonDisabled,
})
}
/>
);
};
export default AFFiNEDatePicker;

View File

@@ -0,0 +1,203 @@
import { globalStyle, style } from '@vanilla-extract/css';
export const inputStyle = style({
fontSize: 'var(--affine-font-xs)',
width: '50px',
fontWeight: '600',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
height: '22px',
textAlign: 'center',
':hover': {
background: 'var(--affine-hover-color)',
borderRadius: '4px',
},
});
export const popperStyle = style({
boxShadow: 'var(--affine-shadow-2)',
padding: '0 10px',
marginTop: '16px',
background: 'var(--affine-background-overlay-panel-color)',
borderRadius: '12px',
width: '300px',
});
globalStyle('.react-datepicker__header', {
background: 'var(--affine-background-overlay-panel-color)',
border: 'none',
marginBottom: '6px',
});
export const headerStyle = style({
background: 'var(--affine-background-overlay-panel-color)',
border: 'none',
display: 'flex',
width: '100%',
alignItems: 'center',
marginBottom: '12px',
padding: '0 14px',
position: 'relative',
});
export const monthHeaderStyle = style({
background: 'var(--affine-background-overlay-panel-color)',
border: 'none',
display: 'flex',
width: '100%',
alignItems: 'center',
marginBottom: '18px',
padding: '0 14px',
position: 'relative',
'::after': {
content: '""',
position: 'absolute',
width: 'calc(100% - 24px)',
height: '1px',
background: 'var(--affine-border-color)',
bottom: '-18px',
left: '12px',
},
});
export const monthTitleStyle = style({
color: 'var(--affine-text-primary-color)',
fontWeight: '600',
fontSize: 'var(--affine-font-sm)',
marginLeft: '12px',
});
export const yearStyle = style({
marginLeft: '8px',
color: 'var(--affine-text-primary-color)',
fontWeight: '600',
fontSize: 'var(--affine-font-sm)',
});
export const mouthStyle = style({
color: 'var(--affine-text-primary-color)',
fontWeight: '600',
fontSize: 'var(--affine-font-sm)',
});
export const arrowLeftStyle = style({
width: '16px',
height: '16px',
textAlign: 'right',
position: 'absolute',
right: '50px',
});
export const arrowRightStyle = style({
width: '16px',
height: '16px',
right: '14px',
position: 'absolute',
});
export const weekStyle = style({
fontSize: 'var(--affine-font-xs)',
color: 'var(--affine-text-secondary-color)',
display: 'inline-block',
width: '28px',
height: '28px',
lineHeight: '28px',
padding: '0 4px',
margin: '0px 6px',
verticalAlign: 'middle',
});
export const calendarStyle = style({
background: 'var(--affine-background-overlay-panel-color)',
border: 'none',
width: '100%',
});
export const dayStyle = style({
fontSize: 'var(--affine-font-xs)',
color: 'var(--affine-text-primary-color)',
display: 'inline-block',
width: '28px',
height: '28px',
lineHeight: '28px',
padding: '0 4px',
margin: '6px 12px 6px 0px',
verticalAlign: 'middle',
fontWeight: '400',
borderRadius: '8px',
selectors: {
'&:hover': {
background: 'var(--affine-hover-color)',
borderRadius: '8px',
transition: 'background-color 0.3s ease-in-out',
},
'&[aria-selected="true"]': {
color: 'var(--affine-black)',
background: 'var(--affine-hover-color)',
},
'&[aria-selected="true"]:hover': {
background: 'var(--affine-hover-color)',
},
'&[tabindex="0"][aria-selected="false"]': {
background: 'var(--affine-background-overlay-panel-color)',
},
'&.react-datepicker__day--today[aria-selected="false"]': {
background: 'var(--affine-primary-color)',
color: 'var(--affine-palette-line-white)',
},
'&.react-datepicker__day--today[aria-selected="false"]:hover': {
color: 'var(--affine-black)',
background: 'var(--affine-hover-color)',
},
'&.react-datepicker__day--outside-month[aria-selected="false"]': {
color: 'var(--affine-text-disable-color)',
},
},
});
export const arrowDownStyle = style({
width: '16px',
height: '16px',
marginLeft: '4px',
color: 'var(--affine-icon-color)',
fontSize: 'var(--affine-font-sm)',
cursor: 'pointer',
});
export const mouthsStyle = style({
fontSize: 'var(--affine-font-base)',
color: 'var(--affine-text-primary-color)',
display: 'inline-block',
lineHeight: '22px',
padding: '6px 16px',
fontWeight: '400',
borderRadius: '8px',
selectors: {
'&:hover': {
background: 'var(--affine-hover-color)',
transition: 'background-color 0.3s ease-in-out',
borderRadius: '8px',
},
'&[aria-selected="true"]': {
color: 'var(--affine-black)',
background: 'var(--affine-hover-color)',
fontWeight: '400',
},
'&[aria-selected="true"]:hover': {
background: 'var(--affine-hover-color)',
},
'&[tabindex="0"][aria-selected="false"]': {
background: 'var(--affine-background-overlay-panel-color)',
},
'&.react-datepicker__month-text--today[aria-selected="false"]': {
background: 'var(--affine-primary-color)',
color: 'var(--affine-palette-line-white)',
},
'&.react-datepicker__month-text--today[aria-selected="false"]:hover': {
background: 'var(--affine-hover-color)',
color: 'var(--affine-black)',
},
},
});
globalStyle(`${calendarStyle} .react-datepicker__month-container`, {
float: 'none',
width: '100%',
});
globalStyle(`${calendarStyle} .react-datepicker__month-wrapper`, {
display: 'flex',
justifyContent: 'space-between',
marginBottom: '18px',
});
globalStyle(`${calendarStyle} .react-datepicker__month-text`, {
margin: '0',
width: '64px',
});

View File

@@ -0,0 +1 @@
export * from './date-picker';

View File

@@ -0,0 +1,45 @@
import { globalStyle, style } from '@vanilla-extract/css';
export const importPageContainerStyle = style({
position: 'relative',
display: 'flex',
width: '480px',
flexDirection: 'column',
justifyContent: 'center',
alignItems: 'center',
borderRadius: '12px',
boxShadow: 'var(--affine-popover-shadow)',
background: 'var(--affine-background-overlay-panel-color)',
overflow: 'hidden',
});
export const importPageBodyStyle = style({
display: 'flex',
padding: '32px 40px 20px 40px',
flexDirection: 'column',
gap: '20px',
alignSelf: 'stretch',
});
export const importPageButtonContainerStyle = style({
display: 'grid',
gridTemplateColumns: '1fr 1fr',
padding: '0px 40px 36px',
flexDirection: 'column',
alignItems: 'center',
gap: '20px',
alignSelf: 'stretch',
});
globalStyle(`${importPageBodyStyle} .title`, {
fontSize: 'var(--affine-font-h-6)',
fontWeight: 600,
});
globalStyle(`${importPageBodyStyle} a`, {
whiteSpace: 'nowrap',
wordBreak: 'break-word',
color: 'var(--affine-link-color)',
textDecoration: 'none',
cursor: 'pointer',
});

View File

@@ -0,0 +1,86 @@
import {
CloseIcon,
ExportToHtmlIcon,
ExportToMarkdownIcon,
HelpIcon,
NewIcon,
NotionIcon,
} from '@blocksuite/icons';
import { IconButton } from '@toeverything/components/button';
import { Tooltip } from '@toeverything/components/tooltip';
import { BlockCard } from '../card/block-card';
import {
importPageBodyStyle,
importPageButtonContainerStyle,
importPageContainerStyle,
} from './index.css';
export const ImportPage = ({
importMarkdown,
importHtml,
importNotion,
onClose,
}: {
importMarkdown: () => void;
importHtml: () => void;
importNotion: () => void;
onClose: () => void;
}) => (
<div className={importPageContainerStyle}>
<IconButton
style={{
position: 'absolute',
right: 6,
top: 6,
}}
onClick={() => {
onClose();
}}
icon={<CloseIcon />}
/>
<div className={importPageBodyStyle}>
<div className="title">Import</div>
<span>
AFFiNE will gradually support more and more file types for import.&nbsp;
<a
href="https://community.affine.pro/c/feature-requests/import-export"
target="_blank"
rel="noreferrer"
>
Provide feedback.
</a>
</span>
</div>
<div className={importPageButtonContainerStyle}>
<BlockCard
left={<ExportToMarkdownIcon width={20} height={20} />}
title="Markdown"
onClick={importMarkdown}
/>
<BlockCard
left={<ExportToHtmlIcon width={20} height={20} />}
title="HTML"
onClick={importHtml}
/>
<BlockCard
left={<NotionIcon width={20} height={20} />}
title="Notion"
right={
<Tooltip
content={'Learn how to Import your Notion pages into AFFiNE.'}
>
<HelpIcon width={20} height={20} />
</Tooltip>
}
onClick={importNotion}
/>
<BlockCard
left={<NewIcon width={20} height={20} />}
title="Coming soon..."
disabled
onClick={importHtml}
/>
</div>
</div>
);

View File

@@ -0,0 +1,91 @@
import type { AnimationItem } from 'lottie-web';
import lottie from 'lottie-web';
import { useEffect, useRef } from 'react';
interface CustomLottieProps {
options: {
loop?: boolean | number | undefined;
autoReverse?: boolean | undefined;
autoplay?: boolean | undefined;
animationData: any;
rendererSettings?: {
preserveAspectRatio?: string | undefined;
};
};
isStopped?: boolean | undefined;
speed?: number | undefined;
width?: number | string | undefined;
height?: number | string | undefined;
}
export const InternalLottie = ({
options,
isStopped,
speed,
width,
height,
}: CustomLottieProps) => {
const element = useRef<HTMLDivElement>(null);
const lottieInstance = useRef<AnimationItem>();
const directionRef = useRef<1 | -1>(1);
useEffect(() => {
const callback = () => {
if (!lottieInstance.current) {
return;
}
const frame = lottieInstance.current.currentFrame.toFixed(0);
if (frame === '1' || frame === '0') {
directionRef.current = 1;
lottieInstance.current.setDirection(directionRef.current);
lottieInstance.current.goToAndStop(0, true);
lottieInstance.current.play();
} else {
directionRef.current = -1;
lottieInstance.current.setDirection(directionRef.current);
lottieInstance.current.goToAndStop(
lottieInstance.current.totalFrames - 1,
true
);
lottieInstance.current.play();
}
};
if (element.current) {
if (options.autoReverse && options.autoplay) {
lottieInstance.current = lottie.loadAnimation({
...options,
autoplay: false,
loop: false,
container: element.current,
});
} else {
lottieInstance.current = lottie.loadAnimation({
...options,
container: element.current,
});
}
if (options.autoReverse) {
lottieInstance.current.addEventListener('complete', callback);
}
}
return () => {
if (options.autoReverse) {
lottieInstance.current?.removeEventListener('complete', callback);
}
lottieInstance.current?.destroy();
};
}, [options]);
useEffect(() => {
if (speed) {
lottieInstance.current?.setSpeed(speed);
}
if (isStopped) {
lottieInstance.current?.stop();
} else {
lottieInstance.current?.play();
}
}, [isStopped, speed]);
return <div ref={element} style={{ width, height, lineHeight: 1 }}></div>;
};

View File

@@ -0,0 +1,13 @@
import { Skeleton } from '@mui/material';
import { memo } from 'react';
export const ListSkeleton = memo(function ListItemSkeleton() {
return (
<>
<Skeleton animation="wave" height={40} />
<Skeleton animation="wave" height={40} />
<Skeleton animation="wave" height={40} />
<Skeleton animation="wave" height={40} />
</>
);
});

View File

@@ -0,0 +1,45 @@
import { AuthPageContainer } from '@affine/component/auth-components';
import { type GetInviteInfoQuery } from '@affine/graphql';
import { useAFFiNEI18N } from '@affine/i18n/hooks';
import { Avatar } from '@toeverything/components/avatar';
import { Button } from '@toeverything/components/button';
import { FlexWrapper } from '../../ui/layout';
import * as styles from './styles.css';
export const AcceptInvitePage = ({
onOpenWorkspace,
inviteInfo,
}: {
onOpenWorkspace: () => void;
inviteInfo: GetInviteInfoQuery['getInviteInfo'];
}) => {
const t = useAFFiNEI18N();
return (
<AuthPageContainer
title={t['Successfully joined!']()}
subtitle={
<FlexWrapper alignItems="center">
<Avatar
url={inviteInfo.user.avatarUrl || ''}
name={inviteInfo.user.name}
size={20}
/>
<span className={styles.inviteName}>{inviteInfo.user.name}</span>
{t['invited you to join']()}
<Avatar
url={`data:image/png;base64,${inviteInfo.workspace.avatar}`}
name={inviteInfo.workspace.name}
size={20}
style={{ marginLeft: 4 }}
colorfulFallback
/>
<span className={styles.inviteName}>{inviteInfo.workspace.name}</span>
</FlexWrapper>
}
>
<Button type="primary" size="large" onClick={onOpenWorkspace}>
{t['Visit Workspace']()}
</Button>
</AuthPageContainer>
);
};

View File

@@ -0,0 +1,3 @@
export * from './accept-invite-page';
export * from './invite-modal';
export * from './pagination';

View File

@@ -0,0 +1,84 @@
import { Permission } from '@affine/graphql';
import { useAFFiNEI18N } from '@affine/i18n/hooks';
import { ConfirmModal } from '@toeverything/components/modal';
import { useCallback, useEffect, useState } from 'react';
import { AuthInput } from '..//auth-components';
import { emailRegex } from '..//auth-components/utils';
export interface InviteModalProps {
open: boolean;
setOpen: (value: boolean) => void;
onConfirm: (params: { email: string; permission: Permission }) => void;
isMutating: boolean;
}
export const InviteModal = ({
open,
setOpen,
onConfirm,
isMutating,
}: InviteModalProps) => {
const t = useAFFiNEI18N();
const [inviteEmail, setInviteEmail] = useState('');
const [permission] = useState(Permission.Write);
const [isValidEmail, setIsValidEmail] = useState(true);
const handleConfirm = useCallback(() => {
if (!emailRegex.test(inviteEmail)) {
setIsValidEmail(false);
return;
}
setIsValidEmail(true);
onConfirm({
email: inviteEmail,
permission,
});
}, [inviteEmail, onConfirm, permission]);
useEffect(() => {
if (!open) {
setInviteEmail('');
setIsValidEmail(true);
}
}, [open]);
return (
<ConfirmModal
open={open}
onOpenChange={setOpen}
title={t['Invite Members']()}
description={t['Invite Members Message']()}
cancelText={t['com.affine.inviteModal.button.cancel']()}
contentOptions={{
['data-testid' as string]: 'invite-modal',
style: {
padding: '20px 26px',
},
}}
confirmButtonOptions={{
loading: isMutating,
type: 'primary',
['data-testid' as string]: 'confirm-enable-affine-cloud-button',
children: t['Invite'](),
}}
onConfirm={handleConfirm}
>
{/*TODO: check email & add placeholder*/}
<AuthInput
disabled={isMutating}
placeholder="email@example.com"
value={inviteEmail}
onChange={setInviteEmail}
error={!isValidEmail}
errorHint={isValidEmail ? '' : t['com.affine.auth.sign.email.error']()}
onEnter={handleConfirm}
wrapperProps={{
style: { padding: 0 },
}}
size="large"
/>
</ConfirmModal>
);
};

View File

@@ -0,0 +1,49 @@
import { ArrowLeftSmallIcon, ArrowRightSmallIcon } from '@blocksuite/icons';
import clsx from 'clsx';
import { useCallback, useMemo } from 'react';
import ReactPaginate from 'react-paginate';
import * as styles from './styles.css';
export interface PaginationProps {
totalCount: number;
countPerPage: number;
onPageChange: (skip: number) => void;
}
export const Pagination = ({
totalCount,
countPerPage,
onPageChange,
}: PaginationProps) => {
const handlePageClick = useCallback(
(e: { selected: number }) => {
const newOffset = (e.selected * countPerPage) % totalCount;
onPageChange(newOffset);
},
[countPerPage, onPageChange, totalCount]
);
const pageCount = useMemo(
() => Math.ceil(totalCount / countPerPage),
[countPerPage, totalCount]
);
return (
<ReactPaginate
onPageChange={handlePageClick}
pageRangeDisplayed={3}
marginPagesDisplayed={2}
pageCount={pageCount}
previousLabel={<ArrowLeftSmallIcon />}
nextLabel={<ArrowRightSmallIcon />}
pageClassName={styles.pageItem}
previousClassName={clsx(styles.pageItem, 'label')}
nextClassName={clsx(styles.pageItem, 'label')}
breakLabel="..."
breakClassName={styles.pageItem}
containerClassName={styles.pagination}
activeClassName="active"
renderOnZeroPageCount={null}
/>
);
};

View File

@@ -0,0 +1,69 @@
import { globalStyle, style } from '@vanilla-extract/css';
export const inviteModalTitle = style({
fontWeight: '600',
fontSize: 'var(--affine-font-h-6)',
marginBottom: '20px',
});
export const inviteModalContent = style({
marginBottom: '10px',
});
export const inviteModalButtonContainer = style({
display: 'flex',
justifyContent: 'flex-end',
// marginTop: 10,
});
export const inviteName = style({
marginLeft: '4px',
marginRight: '10px',
color: 'var(--affine-black)',
});
export const pagination = style({
display: 'flex',
justifyContent: 'center',
alignItems: 'center',
gap: '6px',
marginTop: 5,
});
export const pageItem = style({
display: 'inline-flex',
justifyContent: 'center',
alignItems: 'center',
width: '20px',
height: '20px',
fontSize: 'var(--affine-font-xs)',
color: 'var(--affine-text-primary-color)',
borderRadius: '4px',
selectors: {
'&:hover': {
background: 'var(--affine-hover-color)',
},
'&.active': {
color: 'var(--affine-primary-color)',
cursor: 'default',
pointerEvents: 'none',
},
'&.label': {
color: 'var(--affine-icon-color)',
fontSize: '16px',
},
'&.disabled': {
opacity: '.4',
cursor: 'default',
color: 'var(--affine-disable-color)',
pointerEvents: 'none',
},
},
});
globalStyle(`${pageItem} a`, {
width: '100%',
display: 'flex',
justifyContent: 'center',
alignItems: 'center',
});

View File

@@ -0,0 +1 @@
export * from './not-found-page';

View File

@@ -0,0 +1,62 @@
import { useAFFiNEI18N } from '@affine/i18n/hooks';
import { SignOutIcon } from '@blocksuite/icons';
import { Avatar } from '@toeverything/components/avatar';
import { Button, IconButton } from '@toeverything/components/button';
import { Tooltip } from '@toeverything/components/tooltip';
import { NotFoundPattern } from './not-found-pattern';
import {
largeButtonEffect,
notFoundPageContainer,
wrapper,
} from './styles.css';
export interface NotFoundPageProps {
user: {
name: string;
email: string;
avatar: string;
} | null;
onBack: () => void;
onSignOut: () => void;
}
export const NotFoundPage = ({
user,
onBack,
onSignOut,
}: NotFoundPageProps) => {
const t = useAFFiNEI18N();
return (
<div className={notFoundPageContainer} data-testid="not-found">
<div>
<div className={wrapper}>
<NotFoundPattern />
</div>
<p className={wrapper}>{t['404.hint']()}</p>
<div className={wrapper}>
<Button
type="primary"
size="extraLarge"
onClick={onBack}
className={largeButtonEffect}
>
{t['404.back']()}
</Button>
</div>
{user ? (
<div className={wrapper}>
<Avatar url={user.avatar} name={user.name} />
<span style={{ margin: '0 12px' }}>{user.email}</span>
<Tooltip content={t['404.signOut']()}>
<IconButton onClick={onSignOut}>
<SignOutIcon />
</IconButton>
</Tooltip>
</div>
) : null}
</div>
</div>
);
};

View File

@@ -0,0 +1,121 @@
export const NotFoundPattern = () => {
return (
<svg
width="240"
height="209"
viewBox="0 0 240 209"
fill="none"
xmlns="http://www.w3.org/2000/svg"
>
<path
d="M24.4197 172.91L119.045 8.64233L213.671 172.91H24.4197Z"
stroke="var(--affine-text-disable-color)"
strokeOpacity="0.6"
/>
<path
d="M165.921 91.5342L119.045 172.161L72.1684 91.5342L165.921 91.5342Z"
stroke="var(--affine-text-disable-color)"
strokeOpacity="0.6"
/>
<path
d="M179.022 68.1181C179.022 101.243 152.169 128.096 119.045 128.096C85.9202 128.096 59.0674 101.243 59.0674 68.1181C59.0674 34.9934 85.9202 8.14062 119.045 8.14062C152.169 8.14062 179.022 34.9934 179.022 68.1181Z"
stroke="var(--affine-text-disable-color)"
strokeOpacity="0.6"
/>
<circle
cx="162.485"
cy="142.984"
r="59.9775"
transform="rotate(120 162.485 142.984)"
stroke="var(--affine-text-disable-color)"
strokeOpacity="0.6"
/>
<circle
cx="75.2925"
cy="142.984"
r="59.9775"
transform="rotate(-120 75.2925 142.984)"
stroke="var(--affine-text-disable-color)"
strokeOpacity="0.6"
/>
<path
d="M119.045 7.64062V173.158"
stroke="var(--affine-text-disable-color)"
strokeOpacity="0.6"
/>
<path
d="M214.536 173.475L71.2998 91.0352"
stroke="var(--affine-text-disable-color)"
strokeOpacity="0.6"
/>
<path
d="M23.5547 173.475L166.791 91.0352"
stroke="var(--affine-text-disable-color)"
strokeOpacity="0.6"
/>
<ellipse
cx="119.045"
cy="7.63971"
rx="5.09284"
ry="5.09284"
fill="var(--affine-text-primary-color)"
/>
<ellipse
cx="214.536"
cy="173.155"
rx="5.09284"
ry="5.09284"
fill="var(--affine-text-primary-color)"
/>
<ellipse
cx="166.79"
cy="91.0342"
rx="5.09284"
ry="5.09284"
fill="var(--affine-text-primary-color)"
/>
<ellipse
cx="119.045"
cy="173.155"
rx="5.09284"
ry="5.09284"
fill="var(--affine-text-primary-color)"
/>
<ellipse
cx="71.2999"
cy="91.0342"
rx="5.09284"
ry="5.09284"
fill="var(--affine-text-primary-color)"
/>
<ellipse
cx="119.045"
cy="91.0342"
rx="5.09284"
ry="5.09284"
fill="var(--affine-text-primary-color)"
/>
<ellipse
cx="95.4903"
cy="131.776"
rx="5.09284"
ry="5.09284"
fill="var(--affine-text-primary-color)"
/>
<ellipse
cx="143.236"
cy="131.776"
rx="5.09284"
ry="5.09284"
fill="var(--affine-text-primary-color)"
/>
<ellipse
cx="23.5548"
cy="173.155"
rx="5.09284"
ry="5.09284"
fill="var(--affine-text-primary-color)"
/>
</svg>
);
};

View File

@@ -0,0 +1,21 @@
import { style } from '@vanilla-extract/css';
export const notFoundPageContainer = style({
fontSize: 'var(--affine-font-base)',
color: 'var(--affine-text-primary-color)',
height: '100vh',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
width: '100vw',
});
export const wrapper = style({
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
margin: '24px auto 0',
});
export const largeButtonEffect = style({
boxShadow: 'var(--affine-large-button-effect) !important',
});

View File

@@ -0,0 +1,315 @@
// Credits to sonner
// License on the MIT
// https://github.com/emilkowalski/sonner/blob/5cb703edc108a23fd74979235c2f3c4005edd2a7/src/styles.css
import { globalStyle, style, styleVariants } from '@vanilla-extract/css';
export const notificationCenterViewportStyle = style({
position: 'fixed',
height: '500px',
bottom: '20px',
right: '20px',
width: '380px',
zIndex: 2147483647,
outline: 'none',
display: 'flex',
alignItems: 'flex-end',
});
export const notificationMultimediaStyle = style({
position: 'relative',
width: '100%',
height: '230px',
borderRadius: '8px 8px 0 0',
overflow: 'hidden',
marginBottom: '16px',
});
globalStyle(`${notificationMultimediaStyle} > *`, {
width: '100%',
height: '100%',
objectFit: 'cover',
cursor: 'unset',
});
export const notificationStyle = style({
position: 'absolute',
borderRadius: '8px',
transition: 'transform 0.3s,opacity 0.3s, height 0.3s',
transform: 'var(--y)',
zIndex: 'var(--z-index)',
opacity: 0,
touchAction: 'none',
willChange: 'transform, opacity, height',
selectors: {
'&[data-visible=false]': {
opacity: '0 !important',
pointerEvents: 'none',
},
'&[data-removed=true]::before': {
content: '""',
position: 'absolute',
inset: '0',
transform: 'scaleY(2)',
},
'&[data-mounted=true]': {
opacity: 1,
vars: {
'--y': 'translateY(0)',
},
},
'&[data-expanded=false][data-front=false]': {
opacity: 1,
height: 'var(--front-toast-height)',
vars: {
'--scale': 'var(--toasts-before)* 0.05 + 1',
'--y':
'translateY(calc(var(--lift-amount) * var(--toasts-before))) scale(calc(-1 * var(--scale)))',
},
},
'&[data-mounted=true][data-expanded=true]': {
height: 'var(--initial-height)',
vars: {
'--y': 'translateY(calc(var(--lift) * var(--offset)))',
},
},
'&[data-removed=true][data-front=true]': {
opacity: 0,
vars: {
'--y': 'translateY(calc(var(--lift) * -100%))',
},
},
'&[data-removed=true][data-front=false][data-expanded=true]': {
opacity: 0,
vars: {
'--y':
'translateY(calc(var(--lift) * var(--offset) + var(--lift) * -100%))',
},
},
'&[data-removed=true][data-front=false][data-expanded=false] ': {
transition: 'transform 500ms, opacity 200ms',
opacity: 0,
vars: {
'--y': 'translateY(40%)',
},
},
'&[data-removed=true][data-front=false]::before ': {
height: 'calc(var(--initial-height) + 20%)',
},
},
vars: {
'--y': 'translateY(100%)',
'--lift': '-1',
'--lift-amount': 'calc(var(--lift) * 14px)',
},
'::after': {
content: '""',
position: 'absolute',
width: '100%',
height: '15px',
left: '0',
bottom: '100%',
borderRadius: '8px',
},
});
export const notificationIconStyle = style({
fontSize: '24px',
marginLeft: '18px',
marginRight: '8px',
color: 'var(--affine-processing-color)',
display: 'flex',
justifyContent: 'center',
alignItems: 'center',
});
export const hasMediaStyle = style({
display: 'flex',
flexDirection: 'column',
justifyContent: 'center',
paddingTop: '0',
paddingBottom: '16px',
width: '380px',
borderRadius: '8px',
boxShadow: 'var(--affine-shadow-1)',
border: '1px solid var(--affine-border-color)',
background: 'var(--affine-white)',
transition: 'all 0.3s',
});
export const notificationContentStyle = style({
display: 'flex',
flexDirection: 'column',
justifyContent: 'center',
paddingTop: '16px',
paddingBottom: '16px',
width: '380px',
borderRadius: '8px',
boxShadow: 'var(--affine-shadow-1)',
border: '1px solid var(--affine-black-10)',
background: 'var(--affine-white)',
transition: 'all 0.3s',
});
export const notificationTitleContactStyle = style({
marginRight: '22px',
width: '200px',
overflow: 'wrap',
lineHeight: '24px',
fontSize: 'var(--affine-font-base)',
});
export const notificationTitleStyle = style({
display: 'flex',
alignItems: 'flex-start',
width: '100%',
justifyContent: 'flex-start',
});
export const notificationDescriptionStyle = style({
fontSize: 'var(--affine-font-sm)',
color: 'var(--affine-text-secondary-color)',
marginBottom: '4px',
lineHeight: '22px',
});
export const notificationTimeStyle = style({
fontSize: 'var(--affine-font-sm)',
color: 'var(--affine-text-secondary-color)',
marginBottom: '4px',
});
export const closeButtonStyle = style({
fontSize: '22px',
marginRight: '19px',
marginLeft: '16px',
display: 'flex',
justifyContent: 'center',
alignItems: 'center',
});
export const closeButtonWithoutUndoStyle = style({
marginLeft: '92px',
});
export const closeButtonWithMediaStyle = style({
position: 'absolute',
width: '22px',
height: '22px',
fontSize: '16px',
top: '6px',
right: '6px',
display: 'flex',
justifyContent: 'center',
alignItems: 'center',
cursor: 'pointer',
borderRadius: '4px',
color: 'var(--affine-pure-black)',
':hover': {
background: 'var(--affine-hover-color)',
},
});
export const closeButtonColorStyle = style({
color: 'var(--affine-text-primary-color)',
});
export const undoButtonStyle = style({
fontSize: 'var(--affine-font-sm)',
background: 'var(--affine-hover-color)',
padding: '3px 6px',
borderRadius: '4px',
color: 'var(--affine-processing-color)',
cursor: 'pointer',
});
export const undoButtonWithMediaStyle = style({
marginLeft: 'auto',
marginRight: '16px',
});
export const messageStyle = style({
fontSize: 'var(--affine-font-sm)',
width: '200px',
marginLeft: '50px',
lineHeight: '18px',
});
export const progressBarStyle = style({
fontSize: 'var(--affine-font-sm)',
width: '100%',
height: '10px',
marginTop: '10px',
padding: '0 16px',
borderRadius: '2px',
marginBottom: '16px',
});
export const darkSuccessStyle = style({
background: 'var(--affine-success-color)',
borderRadius: '8px',
});
export const darkInfoStyle = style({
background: 'var(--affine-processing-color)',
borderRadius: '8px',
});
export const darkErrorStyle = style({
background: 'var(--affine-error-color)',
borderRadius: '8px',
});
export const darkWarningStyle = style({
background: 'var(--affine-warning-color)',
borderRadius: '8px',
});
export const lightSuccessStyle = style({
background: 'var(--affine-background-success-color)',
borderRadius: '8px',
});
export const lightInfoStyle = style({
background: 'var(--affine-background-processing-color)',
borderRadius: '8px',
});
export const lightErrorStyle = style({
background: 'var(--affine-background-error-color)',
borderRadius: '8px',
});
export const lightWarningStyle = style({
background: 'var(--affine-background-warning-color)',
borderRadius: '8px',
});
export const darkColorStyle = style({
color: 'var(--affine-pure-white)',
});
export const lightInfoIconStyle = style({
color: 'var(--affine-icon-color)',
});
export const defaultCollapseStyle = styleVariants({
secondary: {
'::after': {
background: 'rgba(0,0,0,0.02)',
top: '0px',
transition: 'background-color 0.3s',
},
},
tertiary: {
'::after': {
background: 'rgba(0,0,0,0.04)',
top: '0px',
transition: 'background-color 0.3s',
},
},
});
export const lightCollapseStyle = styleVariants({
secondary: {
'::after': {
background: 'rgba(0,0,0,0.04)',
top: '0px',
transition: 'background-color 0.3s',
},
},
tertiary: {
'::after': {
background: 'rgba(0,0,0,0.08)',
top: '0px',
transition: 'background-color 0.3s',
},
},
});
export const darkCollapseStyle = styleVariants({
secondary: {
'::after': {
background: 'rgba(0,0,0,0.08)',
top: '0px',
transition: 'background-color 0.3s',
},
},
tertiary: {
'::after': {
background: 'rgba(0,0,0,0.16)',
top: '0px',
transition: 'background-color 0.3s',
},
},
});

View File

@@ -0,0 +1,67 @@
import { atom } from 'jotai';
import { nanoid } from 'nanoid';
export type Notification = {
key?: string;
title: string;
message?: string;
type: 'success' | 'error' | 'warning' | 'info';
theme?: 'light' | 'dark' | 'default';
timeout?: number;
progressingBar?: boolean;
multimedia?: React.ReactNode | JSX.Element | HTMLElement;
// actions
undo?: () => Promise<void>;
};
const notificationsBaseAtom = atom<Notification[]>([]);
const expandNotificationCenterBaseAtom = atom(false);
const cleanupQueueAtom = atom<(() => unknown)[]>([]);
export const expandNotificationCenterAtom = atom<boolean, [boolean], void>(
get => get(expandNotificationCenterBaseAtom),
(get, set, value) => {
if (value === false) {
get(cleanupQueueAtom).forEach(cleanup => cleanup());
set(cleanupQueueAtom, []);
}
set(expandNotificationCenterBaseAtom, value);
}
);
export const notificationsAtom = atom<Notification[]>(get =>
get(notificationsBaseAtom)
);
export const removeNotificationAtom = atom(null, (_, set, key: string) => {
set(notificationsBaseAtom, notifications =>
notifications.filter(notification => notification.key !== key)
);
});
export const pushNotificationAtom = atom<null, [Notification], void>(
null,
(_, set, newNotification) => {
newNotification.key = newNotification.key || nanoid();
const key = newNotification.key;
const removeNotification = () =>
set(notificationsBaseAtom, notifications =>
notifications.filter(notification => notification.key !== key)
);
const undo: (() => Promise<void>) | undefined = newNotification.undo
? (() => {
const undo: () => Promise<void> = newNotification.undo;
return async function undoNotificationWrapper() {
removeNotification();
return undo();
};
})()
: undefined;
set(notificationsBaseAtom, notifications => [
// push to the top
{ ...newNotification, undo },
...notifications,
]);
}
);

View File

@@ -0,0 +1,413 @@
// Credits to sonner
// License on the MIT
// https://github.com/emilkowalski/sonner/blob/5cb703edc108a23fd74979235c2f3c4005edd2a7/src/index.tsx
import { CloseIcon, InformationFillDuotoneIcon } from '@blocksuite/icons';
import * as Toast from '@radix-ui/react-toast';
import { IconButton } from '@toeverything/components/button';
import clsx from 'clsx';
import { useAtom, useAtomValue, useSetAtom } from 'jotai';
import type { ReactElement } from 'react';
import {
useCallback,
useEffect,
useLayoutEffect,
useMemo,
useRef,
useState,
} from 'react';
import * as styles from './index.css';
import type { Notification } from './index.jotai';
import {
expandNotificationCenterAtom,
notificationsAtom,
pushNotificationAtom,
removeNotificationAtom,
} from './index.jotai';
export {
expandNotificationCenterAtom,
pushNotificationAtom,
removeNotificationAtom,
};
type Height = {
height: number;
notificationKey: number | string | undefined;
};
export type NotificationCardProps = {
notification: Notification;
notifications: Notification[];
index: number;
heights: Height[];
setHeights: React.Dispatch<React.SetStateAction<Height[]>>;
};
const typeColorMap = {
info: {
light: styles.lightInfoStyle,
dark: styles.darkInfoStyle,
default: '',
},
success: {
light: styles.lightSuccessStyle,
dark: styles.darkSuccessStyle,
default: '',
},
warning: {
light: styles.lightWarningStyle,
dark: styles.darkWarningStyle,
default: '',
},
error: {
light: styles.lightErrorStyle,
dark: styles.darkErrorStyle,
default: '',
},
};
function NotificationCard(props: NotificationCardProps): ReactElement {
const removeNotification = useSetAtom(removeNotificationAtom);
const { notification, notifications, setHeights, heights, index } = props;
const [expand, setExpand] = useAtom(expandNotificationCenterAtom);
// const setNotificationRemoveAnimation = useSetAtom(notificationRemoveAnimationAtom);
const [mounted, setMounted] = useState<boolean>(false);
const [removed, setRemoved] = useState<boolean>(false);
const [offsetBeforeRemove, setOffsetBeforeRemove] = useState<number>(0);
const [initialHeight, setInitialHeight] = useState<number>(0);
const [animationKey, setAnimationKey] = useState(0);
const animationRef = useRef<SVGAnimateElement>(null);
const notificationRef = useRef<HTMLLIElement>(null);
const timerIdRef = useRef<number>();
const isFront = index === 0;
const isVisible = index + 1 <= 3;
const progressDuration = notification.timeout || 3000;
const heightIndex = useMemo(
() =>
heights.findIndex(
height => height.notificationKey === notification.key
) || 0,
[heights, notification.key]
);
const duration = notification.timeout || 3000;
const offset = useRef(0);
const notificationsHeightBefore = useMemo(() => {
return heights.reduce((prev, curr, reducerIndex) => {
// Calculate offset up until current notification
if (reducerIndex >= heightIndex) {
return prev;
}
return prev + curr.height;
}, 0);
}, [heights, heightIndex]);
offset.current = useMemo(
() => heightIndex * 14 + notificationsHeightBefore,
[heightIndex, notificationsHeightBefore]
);
useEffect(() => {
// Trigger enter animation without using CSS animation
setMounted(true);
}, []);
useEffect(() => {
if (!expand) {
animationRef.current?.beginElement();
}
}, [expand]);
const resetAnimation = () => {
setAnimationKey(prevKey => prevKey + 1);
};
useLayoutEffect(() => {
if (!mounted) return;
if (!notificationRef.current) return;
const notificationNode = notificationRef.current;
const originalHeight = notificationNode.style.height;
notificationNode.style.height = 'auto';
const newHeight = notificationNode.getBoundingClientRect().height;
notificationNode.style.height = originalHeight;
setInitialHeight(newHeight);
setHeights(heights => {
const alreadyExists = heights.find(
height => height.notificationKey === notification.key
);
if (!alreadyExists) {
return [
{ notificationKey: notification.key, height: newHeight },
...heights,
];
} else {
return heights.map(height =>
height.notificationKey === notification.key
? { ...height, height: newHeight }
: height
);
}
});
}, [notification.title, notification.key, mounted, setHeights]);
const typeStyle =
typeColorMap[notification.type][notification.theme || 'dark'];
const onClickRemove = useCallback(() => {
// Save the offset for the exit swipe animation
setRemoved(true);
setOffsetBeforeRemove(offset.current);
setHeights(h =>
h.filter(height => height.notificationKey !== notification.key)
);
window.setTimeout(() => {
if (!notification.key) {
return;
}
removeNotification(notification.key);
}, 200);
}, [setHeights, notification.key, removeNotification, offset]);
useEffect(() => {
if (timerIdRef.current) {
clearTimeout(timerIdRef.current);
}
if (!expand) {
timerIdRef.current = window.setTimeout(() => {
onClickRemove();
}, duration);
}
return () => {
if (timerIdRef.current) {
clearTimeout(timerIdRef.current);
}
};
}, [duration, expand, onClickRemove]);
const onClickUndo = useCallback(() => {
if (notification.undo) {
notification.undo().catch(err => {
console.error(err);
});
}
return void 0;
}, [notification]);
useEffect(() => {
const notificationNode = notificationRef.current;
if (notificationNode) {
const height = notificationNode.getBoundingClientRect().height;
// Add toast height tot heights array after the toast is mounted
setInitialHeight(height);
setHeights(h => [{ notificationKey: notification.key, height }, ...h]);
return () =>
setHeights(h =>
h.filter(height => height.notificationKey !== notification.key)
);
}
return;
}, [notification.key, setHeights]);
return (
<Toast.Root
className={clsx(styles.notificationStyle, {
[styles.lightCollapseStyle[index === 1 ? 'secondary' : 'tertiary']]:
!isFront && !expand && notification.theme === 'light',
[styles.darkCollapseStyle[index === 1 ? 'secondary' : 'tertiary']]:
!isFront && !expand && notification.theme === 'dark',
[styles.defaultCollapseStyle[index === 1 ? 'secondary' : 'tertiary']]:
!isFront && !expand && !notification.theme,
})}
duration={Infinity}
aria-live="polite"
aria-atomic="true"
role="status"
tabIndex={0}
ref={notificationRef}
data-mounted={mounted}
data-removed={removed}
data-visible={isVisible}
data-index={index}
data-front={isFront}
data-expanded={expand}
data-testid="affine-notification"
onMouseEnter={() => {
setExpand(true);
}}
onMouseMove={() => {
setExpand(true);
}}
onMouseLeave={() => {
setExpand(false);
}}
onSwipeEnd={event => event.preventDefault()}
onSwipeMove={event => event.preventDefault()}
style={
{
'--index': index,
'--toasts-before': index,
'--z-index': notifications.length - index,
'--offset': `${removed ? offsetBeforeRemove : offset.current}px`,
'--initial-height': `${initialHeight}px`,
userSelect: 'auto',
} as React.CSSProperties
}
>
<div
className={clsx({
[typeStyle]: notification.theme !== 'default',
[styles.hasMediaStyle]: notification.multimedia,
[styles.notificationContentStyle]: !notification.multimedia,
})}
>
{notification.multimedia ? (
<div className={styles.notificationMultimediaStyle}>
<>{notification.multimedia}</>
<IconButton className={styles.closeButtonWithMediaStyle}>
<CloseIcon onClick={onClickRemove} />
</IconButton>
</div>
) : null}
<Toast.Title
className={clsx(styles.notificationTitleStyle, {
[styles.darkColorStyle]:
notification.theme !== 'light' &&
notification.theme !== 'default',
})}
>
<div
className={clsx(styles.notificationIconStyle, {
[styles.darkColorStyle]:
notification.theme !== 'light' &&
notification.theme !== 'default',
[styles.lightInfoIconStyle]: notification.theme === 'light',
})}
>
<InformationFillDuotoneIcon />
</div>
<div className={styles.notificationTitleContactStyle}>
{notification.title}
</div>
{notification.undo && (
<div
className={clsx(styles.undoButtonStyle, {
[styles.darkColorStyle]:
notification.theme !== 'light' &&
notification.theme !== 'default',
[styles.undoButtonWithMediaStyle]: notification.multimedia,
})}
onClick={onClickUndo}
>
UNDO
</div>
)}
{notification.multimedia ? null : (
<IconButton
className={clsx(styles.closeButtonStyle, {
[styles.closeButtonWithoutUndoStyle]: !notification.undo,
})}
style={{
color:
notification.theme !== 'light' &&
notification.theme !== 'default'
? 'var(--affine-pure-white)'
: 'var(--affine-text-primary-color)',
}}
>
<CloseIcon onClick={onClickRemove} />
</IconButton>
)}
</Toast.Title>
<Toast.Description
className={clsx(styles.messageStyle, {
[styles.darkColorStyle]:
notification.theme !== 'light' &&
notification.theme !== 'default',
})}
>
{notification.message}
</Toast.Description>
{notification.progressingBar && (
<div className={styles.progressBarStyle}>
<svg width="100%" height="4">
<rect
width="100%"
height="4"
fill="var(--affine-hover-color)"
rx="2"
ry="2"
/>
<rect
width="0%"
height="4"
fill="var(--affine-primary-color)"
rx="2"
ry="2"
>
<animate
key={animationKey}
ref={animationRef}
attributeName="width"
from="0%"
to="100%"
dur={(progressDuration - 200) / 1000}
fill="freeze"
onAnimationEnd={resetAnimation}
/>
</rect>
</svg>
</div>
)}
</div>
</Toast.Root>
);
}
export function NotificationCenter(): ReactElement {
const notifications = useAtomValue(notificationsAtom);
const [expand, setExpand] = useAtom(expandNotificationCenterAtom);
if (notifications.length === 0 && expand) {
setExpand(false);
}
const [heights, setHeights] = useState<Height[]>([]);
const listRef = useRef<HTMLOListElement>(null);
useEffect(() => {
// Ensure expanded is always false when no toasts are present / only one left
if (notifications.length <= 1) {
setExpand(false);
}
}, [notifications, setExpand]);
if (!notifications.length) return <></>;
return (
<Toast.Provider swipeDirection="right">
{notifications.map((notification, index) =>
notification.key ? (
<NotificationCard
notification={notification}
index={index}
key={notification.key}
notifications={notifications}
heights={heights}
setHeights={setHeights}
/>
) : null
)}
<Toast.Viewport
tabIndex={-1}
ref={listRef}
style={
{
'--front-toast-height': `${heights[0]?.height}px`,
} as React.CSSProperties
}
className={styles.notificationCenterViewportStyle}
/>
</Toast.Provider>
);
}

View File

@@ -0,0 +1,10 @@
import { style } from '@vanilla-extract/css';
export const pageDetailSkeletonStyle = style({
padding: '0 20px',
});
export const pageDetailSkeletonTitleStyle = style({
height: '52px',
width: '100%',
});

View File

@@ -0,0 +1,14 @@
import { BlockSuiteFallback } from '../block-suite-editor';
import {
pageDetailSkeletonStyle,
pageDetailSkeletonTitleStyle,
} from './index.css';
export const PageDetailSkeleton = () => {
return (
<div className={pageDetailSkeletonStyle}>
<div className={pageDetailSkeletonTitleStyle} />
<BlockSuiteFallback />
</div>
);
};

View File

@@ -0,0 +1,189 @@
/**
* @vitest-environment happy-dom
*/
import 'fake-indexeddb/auto';
import type {
Filter,
LiteralValue,
PropertiesMeta,
Ref,
VariableMap,
} from '@affine/env/filter';
import { createI18n, I18nextProvider } from '@affine/i18n';
import { assertExists } from '@blocksuite/global/utils';
import { render } from '@testing-library/react';
import type { ReactElement } from 'react';
import { useState } from 'react';
import { describe, expect, test } from 'vitest';
import { Condition } from '../filter/condition';
import { tBoolean, tDate } from '../filter/logical/custom-type';
import { toLiteral } from '../filter/shared-types';
import type { FilterMatcherDataType } from '../filter/vars';
import { filterMatcher } from '../filter/vars';
import { filterByFilterList } from '../use-collection-manager';
const ref = (name: keyof VariableMap): Ref => {
return {
type: 'ref',
name,
};
};
const mockVariableMap = (vars: Partial<VariableMap>): VariableMap => {
return {
Created: 0,
Updated: 0,
'Is Favourited': false,
Tags: [],
...vars,
};
};
const mockPropertiesMeta = (meta: Partial<PropertiesMeta>): PropertiesMeta => {
return {
tags: {
options: [],
},
...meta,
};
};
const filter = (
matcherData: FilterMatcherDataType,
left: Ref,
args: LiteralValue[]
): Filter => {
return {
type: 'filter',
left,
funcName: matcherData.name,
args: args.map(toLiteral),
};
};
describe('match filter', () => {
test('boolean variable will match `is` filter', () => {
const is = filterMatcher
.allMatchedData(tBoolean.create())
.find(v => v.name === 'is');
expect(is?.name).toBe('is');
});
test('Date variable will match `before` filter', () => {
const before = filterMatcher
.allMatchedData(tDate.create())
.find(v => v.name === 'before');
expect(before?.name).toBe('before');
});
});
describe('eval filter', () => {
test('before', async () => {
const before = filterMatcher.findData(v => v.name === 'before');
assertExists(before);
const filter1 = filter(before, ref('Created'), [
new Date(2023, 5, 28).getTime(),
]);
const filter2 = filter(before, ref('Created'), [
new Date(2023, 5, 30).getTime(),
]);
const filter3 = filter(before, ref('Created'), [
new Date(2023, 5, 29).getTime(),
]);
const varMap = mockVariableMap({
Created: new Date(2023, 5, 29).getTime(),
});
expect(filterByFilterList([filter1], varMap)).toBe(false);
expect(filterByFilterList([filter2], varMap)).toBe(true);
expect(filterByFilterList([filter3], varMap)).toBe(false);
});
test('after', async () => {
const after = filterMatcher.findData(v => v.name === 'after');
assertExists(after);
const filter1 = filter(after, ref('Created'), [
new Date(2023, 5, 28).getTime(),
]);
const filter2 = filter(after, ref('Created'), [
new Date(2023, 5, 30).getTime(),
]);
const filter3 = filter(after, ref('Created'), [
new Date(2023, 5, 29).getTime(),
]);
const varMap = mockVariableMap({
Created: new Date(2023, 5, 29).getTime(),
});
expect(filterByFilterList([filter1], varMap)).toBe(true);
expect(filterByFilterList([filter2], varMap)).toBe(false);
expect(filterByFilterList([filter3], varMap)).toBe(false);
});
test('is', async () => {
const is = filterMatcher.findData(v => v.name === 'is');
assertExists(is);
const filter1 = filter(is, ref('Is Favourited'), [false]);
const filter2 = filter(is, ref('Is Favourited'), [true]);
const varMap = mockVariableMap({
'Is Favourited': true,
});
expect(filterByFilterList([filter1], varMap)).toBe(false);
expect(filterByFilterList([filter2], varMap)).toBe(true);
});
});
describe('render filter', () => {
test('boolean condition value change', async () => {
const i18n = createI18n();
const is = filterMatcher.match(tBoolean.create());
assertExists(is);
const Wrapper = () => {
const [value, onChange] = useState(
filter(is, ref('Is Favourited'), [true])
);
return (
<I18nextProvider i18n={i18n}>
<Condition
propertiesMeta={mockPropertiesMeta({})}
value={value}
onChange={onChange}
/>
</I18nextProvider>
);
};
const result = render(<Wrapper />);
const dom = await result.findByText('true');
dom.click();
await result.findByText('false');
result.unmount();
});
const WrapperCreator = (fn: FilterMatcherDataType) =>
function Wrapper(): ReactElement {
const [value, onChange] = useState(
filter(fn, ref('Created'), [new Date(2023, 5, 29).getTime()])
);
return (
<Condition
propertiesMeta={mockPropertiesMeta({})}
value={value}
onChange={onChange}
/>
);
};
test('date condition function change', async () => {
const dateFunction = filterMatcher.match(tDate.create());
assertExists(dateFunction);
const Wrapper = WrapperCreator(dateFunction);
const result = render(<Wrapper />);
const dom = await result.findByTestId('filter-name');
dom.click();
await result.findByTestId('filter-name');
result.unmount();
});
test('date condition variable change', async () => {
const dateFunction = filterMatcher.match(tDate.create());
assertExists(dateFunction);
const Wrapper = WrapperCreator(dateFunction);
const result = render(<Wrapper />);
const dom = await result.findByTestId('variable-name');
dom.click();
await result.findByTestId('variable-name');
result.unmount();
});
});

View File

@@ -0,0 +1,51 @@
/**
* @vitest-environment happy-dom
*/
import 'fake-indexeddb/auto';
import type { Collection } from '@affine/env/filter';
import { renderHook } from '@testing-library/react';
import { atom } from 'jotai';
import { expect, test } from 'vitest';
import { createDefaultFilter, vars } from '../filter/vars';
import {
type CollectionsAtom,
useCollectionManager,
} from '../use-collection-manager';
const defaultMeta = { tags: { options: [] } };
const baseAtom = atom<Collection[]>([]);
const mockAtom: CollectionsAtom = atom(
get => get(baseAtom),
async (_, set, update) => {
set(baseAtom, update);
}
);
test('useAllPageSetting', async () => {
const settingHook = renderHook(() => useCollectionManager(mockAtom));
const prevCollection = settingHook.result.current.currentCollection;
expect(settingHook.result.current.savedCollections).toEqual([]);
await settingHook.result.current.updateCollection({
...settingHook.result.current.currentCollection,
filterList: [createDefaultFilter(vars[0], defaultMeta)],
workspaceId: 'test',
});
settingHook.rerender();
const nextCollection = settingHook.result.current.currentCollection;
expect(nextCollection).not.toBe(prevCollection);
expect(nextCollection.filterList).toEqual([
createDefaultFilter(vars[0], defaultMeta),
]);
settingHook.result.current.backToAll();
await settingHook.result.current.saveCollection({
...settingHook.result.current.currentCollection,
id: '1',
});
settingHook.rerender();
expect(settingHook.result.current.savedCollections.length).toBe(1);
expect(settingHook.result.current.savedCollections[0].id).toBe('1');
});

View File

@@ -0,0 +1,321 @@
import { DEFAULT_SORT_KEY } from '@affine/env/constant';
import type { PropertiesMeta } from '@affine/env/filter';
import type { GetPageInfoById } from '@affine/env/page-info';
import { useAFFiNEI18N } from '@affine/i18n/hooks';
import { ArrowDownBigIcon, ArrowUpBigIcon } from '@blocksuite/icons';
import { useMediaQuery, useTheme } from '@mui/material';
import type React from 'react';
import { type CSSProperties, useMemo } from 'react';
import {
ScrollableContainer,
Table,
TableBody,
TableCell,
TableHead,
TableHeadRow,
} from '../..';
import { TableBodyRow } from '../../ui/table';
import { useHasScrollTop } from '../app-sidebar/sidebar-containers/use-has-scroll-top';
import { AllPagesBody } from './all-pages-body';
import { NewPageButton } from './components/new-page-buttton';
import { TitleCell } from './components/title-cell';
import { AllPageListMobileView, TrashListMobileView } from './mobile';
import { TrashOperationCell } from './operation-cell';
import { StyledTableContainer } from './styles';
import type { ListData, PageListProps, TrashListData } from './type';
import type { CollectionsAtom } from './use-collection-manager';
import { useSorter } from './use-sorter';
import { formatDate, useIsSmallDevices } from './utils';
import { CollectionBar } from './view/collection-bar';
interface AllPagesHeadProps {
isPublicWorkspace: boolean;
sorter: ReturnType<typeof useSorter<ListData>>;
createNewPage: () => void;
createNewEdgeless: () => void;
importFile: () => void;
getPageInfo: GetPageInfoById;
propertiesMeta: PropertiesMeta;
collectionsAtom: CollectionsAtom;
}
const AllPagesHead = ({
isPublicWorkspace,
sorter,
createNewPage,
createNewEdgeless,
importFile,
getPageInfo,
propertiesMeta,
collectionsAtom,
}: AllPagesHeadProps) => {
const t = useAFFiNEI18N();
const titleList = useMemo(
() => [
{
key: 'title',
content: t['Title'](),
proportion: 0.5,
},
{
key: 'tags',
content: t['Tags'](),
proportion: 0.2,
},
{
key: 'createDate',
content: t['Created'](),
proportion: 0.1,
tableCellStyle: {
width: '110px',
} satisfies CSSProperties,
},
{
key: 'updatedDate',
content: t['Updated'](),
proportion: 0.1,
tableCellStyle: {
width: '110px',
} satisfies CSSProperties,
},
{
key: 'unsortable_action',
content: (
<NewPageButton
createNewPage={createNewPage}
createNewEdgeless={createNewEdgeless}
importFile={importFile}
/>
),
showWhen: () => !isPublicWorkspace,
sortable: false,
tableCellStyle: {
width: '140px',
} satisfies CSSProperties,
styles: {
justifyContent: 'flex-end',
} satisfies CSSProperties,
},
],
[createNewEdgeless, createNewPage, importFile, isPublicWorkspace, t]
);
const tableItem = useMemo(
() =>
titleList
.filter(({ showWhen = () => true }) => showWhen())
.map(
({
key,
content,
proportion,
sortable = true,
styles,
tableCellStyle,
}) => (
<TableCell
key={key}
proportion={proportion}
active={sorter.key === key}
style={tableCellStyle}
onClick={
sortable
? () => sorter.shiftOrder(key as keyof ListData)
: undefined
}
>
<div
style={{
display: 'flex',
alignItems: 'center',
...styles,
}}
>
{content}
{sorter.key === key &&
(sorter.order === 'asc' ? (
<ArrowUpBigIcon width={24} height={24} />
) : (
<ArrowDownBigIcon width={24} height={24} />
))}
</div>
</TableCell>
)
),
[sorter, titleList]
);
return (
<TableHead>
<TableHeadRow>{tableItem}</TableHeadRow>
<CollectionBar
columnsCount={titleList.length}
getPageInfo={getPageInfo}
propertiesMeta={propertiesMeta}
collectionsAtom={collectionsAtom}
/>
</TableHead>
);
};
export const PageList = ({
isPublicWorkspace = false,
collectionsAtom,
list,
onCreateNewPage,
onCreateNewEdgeless,
onImportFile,
fallback,
getPageInfo,
propertiesMeta,
}: PageListProps) => {
const sorter = useSorter<ListData>({
data: list,
key: DEFAULT_SORT_KEY,
order: 'desc',
});
const [hasScrollTop, ref] = useHasScrollTop();
const isSmallDevices = useIsSmallDevices();
if (isSmallDevices) {
return (
<ScrollableContainer inTableView>
<AllPageListMobileView
isPublicWorkspace={isPublicWorkspace}
createNewPage={onCreateNewPage}
createNewEdgeless={onCreateNewEdgeless}
importFile={onImportFile}
list={sorter.data}
/>
</ScrollableContainer>
);
}
const groupKey =
sorter.key === 'createDate' || sorter.key === 'updatedDate'
? sorter.key
: // default sort
!sorter.key
? DEFAULT_SORT_KEY
: undefined;
return sorter.data.length === 0 && fallback ? (
<StyledTableContainer>{fallback}</StyledTableContainer>
) : (
<ScrollableContainer inTableView>
<StyledTableContainer ref={ref}>
<Table showBorder={hasScrollTop} style={{ maxHeight: '100%' }}>
<AllPagesHead
collectionsAtom={collectionsAtom}
propertiesMeta={propertiesMeta}
isPublicWorkspace={isPublicWorkspace}
sorter={sorter}
createNewPage={onCreateNewPage}
createNewEdgeless={onCreateNewEdgeless}
importFile={onImportFile}
getPageInfo={getPageInfo}
/>
<AllPagesBody
isPublicWorkspace={isPublicWorkspace}
groupKey={groupKey}
data={sorter.data}
/>
</Table>
</StyledTableContainer>
</ScrollableContainer>
);
};
const TrashListHead = () => {
const t = useAFFiNEI18N();
return (
<TableHead>
<TableHeadRow>
<TableCell proportion={0.5}>{t['Title']()}</TableCell>
<TableCell proportion={0.2}>{t['Created']()}</TableCell>
<TableCell proportion={0.2}>{t['Moved to Trash']()}</TableCell>
<TableCell proportion={0.1}></TableCell>
</TableHeadRow>
</TableHead>
);
};
interface PageListTrashViewProps {
list: TrashListData[];
fallback?: React.ReactNode;
}
export const PageListTrashView = ({
list,
fallback,
}: PageListTrashViewProps) => {
const t = useAFFiNEI18N();
const theme = useTheme();
const [hasScrollTop, ref] = useHasScrollTop();
const isSmallDevices = useMediaQuery(theme.breakpoints.down('sm'));
if (isSmallDevices) {
const mobileList = list.map(({ pageId, icon, title, onClickPage }) => ({
title,
icon,
pageId,
onClickPage,
}));
return <TrashListMobileView list={mobileList} />;
}
const ListItems = list.map(
(
{
pageId,
title,
preview,
icon,
createDate,
trashDate,
onClickPage,
onPermanentlyDeletePage,
onRestorePage,
},
index
) => {
return (
<TableBodyRow
data-testid={`page-list-item-${pageId}`}
key={`${pageId}-${index}`}
>
<TitleCell
icon={icon}
text={title || t['Untitled']()}
desc={preview}
onClick={onClickPage}
/>
<TableCell onClick={onClickPage}>{formatDate(createDate)}</TableCell>
<TableCell onClick={onClickPage}>
{trashDate ? formatDate(trashDate) : '--'}
</TableCell>
<TableCell
style={{ padding: 0 }}
data-testid={`more-actions-${pageId}`}
>
<TrashOperationCell
onPermanentlyDeletePage={onPermanentlyDeletePage}
onRestorePage={onRestorePage}
onOpenPage={onClickPage}
/>
</TableCell>
</TableBodyRow>
);
}
);
return list.length === 0 && fallback ? (
<StyledTableContainer>{fallback}</StyledTableContainer>
) : (
<ScrollableContainer inTableView>
<StyledTableContainer ref={ref}>
<Table showBorder={hasScrollTop}>
<TrashListHead />
<TableBody>{ListItems}</TableBody>
</Table>
</StyledTableContainer>
</ScrollableContainer>
);
};

View File

@@ -0,0 +1,188 @@
import { useAFFiNEI18N } from '@affine/i18n/hooks';
import { useDraggable } from '@dnd-kit/core';
import type { ReactNode } from 'react';
import { Fragment } from 'react';
import { styled } from '../../styles';
import { TableBody, TableCell } from '../../ui/table';
import { FavoriteTag } from './components/favorite-tag';
import { Tags } from './components/tags';
import { TitleCell } from './components/title-cell';
import { OperationCell } from './operation-cell';
import { StyledTableBodyRow } from './styles';
import type { DateKey, DraggableTitleCellData, ListData } from './type';
import { useDateGroup } from './use-date-group';
import { formatDate, useIsSmallDevices } from './utils';
export const GroupRow = ({ children }: { children: ReactNode }) => {
return (
<StyledTableBodyRow>
<TableCell
style={{
color: 'var(--affine-text-secondary-color)',
fontSize: 'var(--affine-font-sm)',
background: 'initial',
cursor: 'default',
}}
>
{children}
</TableCell>
</StyledTableBodyRow>
);
};
export const AllPagesBody = ({
isPublicWorkspace,
data,
groupKey,
}: {
isPublicWorkspace: boolean;
data: ListData[];
groupKey?: DateKey;
}) => {
const t = useAFFiNEI18N();
const isSmallDevices = useIsSmallDevices();
const dataWithGroup = useDateGroup({ data, key: groupKey });
return (
<TableBody style={{ overflowY: 'auto', height: '100%' }}>
{dataWithGroup.map(
(
{
groupName,
pageId,
title,
preview,
tags,
icon,
isPublicPage,
favorite,
createDate,
updatedDate,
onClickPage,
bookmarkPage,
onOpenPageInNewTab,
removeToTrash,
onDisablePublicSharing,
},
index
) => {
const displayTitle = title || t['Untitled']();
return (
<Fragment key={pageId}>
{groupName &&
(index === 0 ||
dataWithGroup[index - 1].groupName !== groupName) && (
<GroupRow>{groupName}</GroupRow>
)}
<StyledTableBodyRow data-testid={`page-list-item-${pageId}`}>
<DraggableTitleCell
pageId={pageId}
draggableData={{
pageId,
pageTitle: displayTitle,
icon,
}}
icon={icon}
text={displayTitle}
desc={preview}
data-testid="title"
onClick={onClickPage}
/>
<TableCell
data-testid="tags"
hidden={isSmallDevices}
onClick={onClickPage}
style={{ fontSize: 'var(--affine-font-xs)' }}
>
<Tags value={tags}></Tags>
</TableCell>
<TableCell
data-testid="created-date"
ellipsis={true}
hidden={isSmallDevices}
onClick={onClickPage}
style={{ fontSize: 'var(--affine-font-xs)' }}
>
{formatDate(createDate)}
</TableCell>
<TableCell
data-testid="updated-date"
ellipsis={true}
hidden={isSmallDevices}
onClick={onClickPage}
style={{ fontSize: 'var(--affine-font-xs)' }}
>
{formatDate(updatedDate ?? createDate)}
</TableCell>
{!isPublicWorkspace && (
<TableCell
style={{
padding: 0,
display: 'flex',
justifyContent: 'center',
alignItems: 'center',
gap: '10px',
}}
data-testid={`more-actions-${pageId}`}
>
<FavoriteTag
className={favorite ? '' : 'favorite-button'}
onClick={bookmarkPage}
active={!!favorite}
/>
<OperationCell
favorite={favorite}
isPublic={isPublicPage}
onOpenPageInNewTab={onOpenPageInNewTab}
onToggleFavoritePage={bookmarkPage}
onRemoveToTrash={removeToTrash}
onDisablePublicSharing={onDisablePublicSharing}
/>
</TableCell>
)}
</StyledTableBodyRow>
</Fragment>
);
}
)}
</TableBody>
);
};
const FullSizeButton = styled('button')(() => ({
width: '100%',
height: '100%',
display: 'block',
}));
type DraggableTitleCellProps = {
pageId: string;
draggableData?: DraggableTitleCellData;
} & React.ComponentProps<typeof TitleCell>;
function DraggableTitleCell({
pageId,
draggableData,
...props
}: DraggableTitleCellProps) {
const { setNodeRef, attributes, listeners, isDragging } = useDraggable({
id: 'page-list-item-title-' + pageId,
data: draggableData,
});
return (
<TitleCell
ref={setNodeRef}
style={{ opacity: isDragging ? 0.5 : 1 }}
{...props}
>
{/* Use `button` for draggable element */}
{/* See https://docs.dndkit.com/api-documentation/draggable/usedraggable#role */}
{element => (
<FullSizeButton {...listeners} {...attributes}>
{element}
</FullSizeButton>
)}
</TitleCell>
);
}

View File

@@ -0,0 +1,58 @@
import { style } from '@vanilla-extract/css';
export const divider = style({
width: '0.5px',
height: '16px',
background: 'var(--affine-divider-color)',
// fix dropdown button click area
margin: '0 4px',
marginRight: 0,
});
export const dropdownWrapper = style({
width: '100%',
height: '100%',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
paddingLeft: '4px',
paddingRight: '10px',
});
export const dropdownIcon = style({
borderRadius: '4px',
selectors: {
[`${dropdownWrapper}:hover &`]: {
background: 'var(--affine-hover-color)',
},
},
});
export const dropdownBtn = style({
display: 'inline-flex',
alignItems: 'center',
justifyContent: 'center',
padding: '0 10px',
// fix dropdown button click area
paddingRight: 0,
color: 'var(--affine-text-primary-color)',
fontWeight: 600,
background: 'var(--affine-button-gray-color)',
boxShadow: 'var(--affine-float-button-shadow)',
borderRadius: '8px',
fontSize: 'var(--affine-font-sm)',
// width: '100%',
height: '32px',
userSelect: 'none',
whiteSpace: 'nowrap',
cursor: 'pointer',
selectors: {
'&:hover': {
background: 'var(--affine-hover-color-filled)',
},
},
});
export const menuContent = style({
backgroundColor: 'var(--affine-background-overlay-panel-color)',
});

View File

@@ -0,0 +1,36 @@
import { ArrowDownSmallIcon } from '@blocksuite/icons';
import {
type ButtonHTMLAttributes,
forwardRef,
type MouseEventHandler,
} from 'react';
import * as styles from './dropdown.css';
type DropdownButtonProps = {
onClickDropDown?: MouseEventHandler<HTMLElement>;
} & ButtonHTMLAttributes<HTMLButtonElement>;
export const DropdownButton = forwardRef<
HTMLButtonElement,
DropdownButtonProps
>(({ onClickDropDown, children, ...props }, ref) => {
const handleClickDropDown: MouseEventHandler<HTMLElement> = e => {
e.stopPropagation();
onClickDropDown?.(e);
};
return (
<button ref={ref} className={styles.dropdownBtn} {...props}>
<span>{children}</span>
<span className={styles.divider} />
<span className={styles.dropdownWrapper} onClick={handleClickDropDown}>
<ArrowDownSmallIcon
className={styles.dropdownIcon}
width={16}
height={16}
/>
</span>
</button>
);
});
DropdownButton.displayName = 'DropdownButton';

View File

@@ -0,0 +1,50 @@
import { useAFFiNEI18N } from '@affine/i18n/hooks';
import { FavoritedIcon, FavoriteIcon } from '@blocksuite/icons';
import {
IconButton,
type IconButtonProps,
} from '@toeverything/components/button';
import { Tooltip } from '@toeverything/components/tooltip';
import Lottie from 'lottie-react';
import { forwardRef, useCallback, useState } from 'react';
import favoritedAnimation from './favorited-animation/data.json';
export const FavoriteTag = forwardRef<
HTMLButtonElement,
{
active: boolean;
} & Omit<IconButtonProps, 'children'>
>(({ active, onClick, ...props }, ref) => {
const [playAnimation, setPlayAnimation] = useState(false);
const t = useAFFiNEI18N();
const handleClick = useCallback(
(e: React.MouseEvent<HTMLButtonElement>) => {
e.stopPropagation();
onClick?.(e);
setPlayAnimation(!active);
},
[active, onClick]
);
return (
<Tooltip content={active ? t['Favorited']() : t['Favorite']()} side="top">
<IconButton ref={ref} active={active} onClick={handleClick} {...props}>
{active ? (
playAnimation ? (
<Lottie
loop={false}
animationData={favoritedAnimation}
onComplete={() => setPlayAnimation(false)}
style={{ width: '20px', height: '20px' }}
/>
) : (
<FavoritedIcon data-testid="favorited-icon" />
)
) : (
<FavoriteIcon />
)}
</IconButton>
</Tooltip>
);
});
FavoriteTag.displayName = 'FavoriteTag';

View File

@@ -0,0 +1,105 @@
import { useAFFiNEI18N } from '@affine/i18n/hooks';
import { EdgelessIcon, ImportIcon, PageIcon } from '@blocksuite/icons';
import { Menu } from '@toeverything/components/menu';
import { useCallback, useState } from 'react';
import { BlockCard } from '../../card/block-card';
import { DropdownButton } from './dropdown';
import { menuContent } from './dropdown.css';
type NewPageButtonProps = {
createNewPage: () => void;
createNewEdgeless: () => void;
importFile: () => void;
};
export const CreateNewPagePopup = ({
createNewPage,
createNewEdgeless,
importFile,
}: NewPageButtonProps) => {
const t = useAFFiNEI18N();
return (
<div
style={{
display: 'flex',
flexDirection: 'column',
gap: '8px',
padding: '8px',
}}
>
<BlockCard
title={t['New Page']()}
desc={t['com.affine.write_with_a_blank_page']()}
right={<PageIcon width={20} height={20} />}
onClick={createNewPage}
data-testid="new-page-button-in-all-page"
/>
<BlockCard
title={t['com.affine.new_edgeless']()}
desc={t['com.affine.draw_with_a_blank_whiteboard']()}
right={<EdgelessIcon width={20} height={20} />}
onClick={createNewEdgeless}
data-testid="new-edgeless-button-in-all-page"
/>
<BlockCard
title={t['com.affine.new_import']()}
desc={t['com.affine.import_file']()}
right={<ImportIcon width={20} height={20} />}
onClick={importFile}
data-testid="import-button-in-all-page"
/>
{/* TODO Import */}
</div>
);
};
export const NewPageButton = ({
createNewPage,
createNewEdgeless,
importFile,
}: NewPageButtonProps) => {
const t = useAFFiNEI18N();
const [open, setOpen] = useState(false);
return (
<Menu
items={
<CreateNewPagePopup
createNewPage={useCallback(() => {
createNewPage();
setOpen(false);
}, [createNewPage])}
createNewEdgeless={useCallback(() => {
createNewEdgeless();
setOpen(false);
}, [createNewEdgeless])}
importFile={useCallback(() => {
importFile();
setOpen(false);
}, [importFile])}
/>
}
rootOptions={{
open,
}}
contentOptions={{
className: menuContent,
align: 'end',
hideWhenDetached: true,
onInteractOutside: useCallback(() => {
setOpen(false);
}, []),
}}
>
<DropdownButton
onClick={useCallback(() => {
createNewPage();
setOpen(false);
}, [createNewPage])}
onClickDropDown={useCallback(() => setOpen(open => !open), [])}
>
{t['New Page']()}
</DropdownButton>
</Menu>
);
};

View File

@@ -0,0 +1,30 @@
import { style } from '@vanilla-extract/css';
export const tagList = style({
display: 'flex',
flexWrap: 'nowrap',
gap: 10,
overflow: 'hidden',
});
export const tagListFull = style({
display: 'flex',
flexWrap: 'wrap',
gap: 10,
maxWidth: 300,
padding: 10,
overflow: 'hidden',
});
export const tag = style({
flexShrink: 0,
padding: '2px 10px',
borderRadius: 6,
fontSize: 12,
lineHeight: '16px',
fontWeight: 400,
maxWidth: '100%',
color: 'var(--affine-text-primary-color)',
overflow: 'hidden',
textOverflow: 'ellipsis',
whiteSpace: 'nowrap',
});

View File

@@ -0,0 +1,24 @@
import type { Tag } from '@affine/env/filter';
import { Menu } from '@toeverything/components/menu';
import * as styles from './tags.css';
// fixme: This component should use popover instead of menu
export const Tags = ({ value }: { value: Tag[] }) => {
const list = value.map(tag => {
return (
<div
key={tag.id}
className={styles.tag}
style={{ backgroundColor: tag.color }}
>
{tag.value}
</div>
);
});
return (
<Menu items={<div className={styles.tagListFull}>{list}</div>}>
<div className={styles.tagList}>{list}</div>
</Menu>
);
};

View File

@@ -0,0 +1,66 @@
import React, { useCallback } from 'react';
import type { TableCellProps } from '../../..';
import { Content, TableCell } from '../../..';
import {
StyledTitleContentWrapper,
StyledTitleLink,
StyledTitlePreview,
} from '../styles';
type TitleCellProps = {
icon: JSX.Element;
text: string;
desc?: React.ReactNode;
suffix?: JSX.Element;
/**
* Customize the children of the cell
* @param element
* @returns
*/
children?: (element: React.ReactElement) => React.ReactNode;
} & Omit<TableCellProps, 'children'>;
export const TitleCell = React.forwardRef<HTMLTableCellElement, TitleCellProps>(
({ icon, text, desc, suffix, children: render, ...props }, ref) => {
const renderChildren = useCallback(() => {
const childElement = (
<>
<StyledTitleLink>
{icon}
<StyledTitleContentWrapper>
<Content
ellipsis={true}
maxWidth="100%"
color="inherit"
fontSize="var(--affine-font-sm)"
weight="600"
lineHeight="18px"
>
{text}
</Content>
{desc && (
<StyledTitlePreview
ellipsis={true}
color="var(--affine-text-secondary-color)"
>
{desc}
</StyledTitlePreview>
)}
</StyledTitleContentWrapper>
</StyledTitleLink>
{suffix}
</>
);
return render ? render(childElement) : childElement;
}, [desc, icon, render, suffix, text]);
return (
<TableCell ref={ref} {...props}>
{renderChildren()}
</TableCell>
);
}
);
TitleCell.displayName = 'TitleCell';

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