snapshot of https://github.com/toeverything/AFFiNE.git (canary, 2026-09-16; blobs > 131072B omitted)
Some checks failed
Build & Test / Analyze (javascript, affine) (push) Failing after 4s
Build & Test / Analyze (typescript, affine) (push) Failing after 3s
Build & Test / Rust test filter (push) Failing after 4s
Build & Test / Check yarn binary (push) Failing after 3s
Build & Test / E2E BlockSuite Test (3) (push) Failing after 3s
Build & Test / Analyze (javascript, blocksuite) (push) Failing after 3s
Build & Test / Analyze (typescript, blocksuite) (push) Failing after 3s
Build & Test / Mobile native build filter (push) Failing after 3s
Build & Test / E2E BlockSuite Test (1) (push) Failing after 4s
Build & Test / E2E BlockSuite Test (2) (push) Failing after 4s
Build & Test / E2E Mobile Test (1) (push) Failing after 3s
Build & Test / Unit Test (1) (push) Has been skipped
Build & Test / Unit Test (2) (push) Has been skipped
Build & Test / Unit Test (3) (push) Has been skipped
Build & Test / Unit Test (4) (push) Has been skipped
Build & Test / Unit Test (5) (push) Has been skipped
Build & Test / Native Unit Test (push) Has been skipped
Build & Test / E2E BlockSuite Test (4) (push) Failing after 3s
Build & Test / E2E BlockSuite Test (5) (push) Failing after 3s
Build & Test / E2E BlockSuite Cross Browser Test (push) Failing after 3s
Build & Test / E2E Mobile Test (2) (push) Failing after 3s
Build & Test / Build AFFiNE native (x86_64-unknown-linux-gnu) (push) Failing after 3s
Build & Test / Build Server native (push) Failing after 3s
Build & Test / Check Git Status (push) Has been skipped
Build & Test / Server Test (0, 4) (push) Has been skipped
Build & Test / Server Test (1, 4) (push) Has been skipped
Build & Test / Server Test (2, 4) (push) Has been skipped
Build & Test / Server Test (3, 4) (push) Has been skipped
Build & Test / Server Test with Elasticsearch (push) Has been skipped
Build & Test / Build @affine/electron renderer (push) Failing after 3s
Build & Test / Copilot test filter (push) Failing after 4s
Build & Test / Lint Rust (push) Has been skipped
Build & Test / Server Test with Embedded Search (push) Has been skipped
Build & Test / Server Test with Manticore Search (push) Has been skipped
Build & Test / Server E2E Test (push) Has been skipped
Build & Test / Cloud Desktop E2E Test (push) Has been skipped
Build & Test / Cloud E2E Test 1/2 (push) Has been skipped
Build & Test / Cloud E2E Test 2/2 (push) Has been skipped
Build & Test / Build Android app (push) Has been skipped
Build & Test / Test Android app (push) Has been skipped
Build & Test / Run native tests (push) Has been skipped
Build & Test / Server Copilot Api Test (push) Has been skipped
Build & Test / Frontend Copilot E2E Test (1, 5) (push) Has been skipped
Build & Test / Frontend Copilot E2E Test (2, 5) (push) Has been skipped
Build & Test / Frontend Copilot E2E Test (3, 5) (push) Has been skipped
Build & Test / Frontend Copilot E2E Test (4, 5) (push) Has been skipped
Build & Test / Frontend Copilot E2E Test (5, 5) (push) Has been skipped
Release / Canary Gate (push) Has been skipped
Build & Test / Build AFFiNE native (aarch64-apple-darwin) (push) Has been cancelled
Build & Test / Lint (push) Has been cancelled
Build & Test / Typecheck (push) Has been cancelled
Build & Test / Build AFFiNE native (x86_64-apple-darwin) (push) Has been cancelled
Build & Test / Desktop Test (ubuntu-latest, linux, x64, x86_64-unknown-linux-gnu, true) (push) Has been cancelled
Build & Test / Desktop Test (windows-latest, windows, x64, x86_64-pc-windows-msvc, true) (push) Has been cancelled
Build & Test / E2E Test (1) (push) Has been cancelled
Build & Test / E2E Test (2) (push) Has been cancelled
Build & Test / E2E Test (3) (push) Has been cancelled
Build & Test / E2E Test (4) (push) Has been cancelled
Build & Test / E2E Test (5) (push) Has been cancelled
Build & Test / Build AFFiNE native (aarch64-pc-windows-msvc) (push) Has been cancelled
Build & Test / Build AFFiNE native (x86_64-pc-windows-msvc) (push) Has been cancelled
Build & Test / Desktop Test (macos-latest, macos, arm64, aarch64-apple-darwin, true) (push) Has been cancelled
Build & Test / Desktop Test (macos-latest, macos, x64, x86_64-apple-darwin, false) (push) Has been cancelled
Build & Test / 3, 2, 1 Launch (push) Has been cancelled
Build & Test / Build iOS app (push) Has been cancelled
Copilot Test Automatically / Setup Test (push) Failing after 1s
Release / Prepare (push) Failing after 2s
Release / Release Cloud (push) Has been skipped
Release / Release Desktop (push) Has been skipped
Release / Release Docker Image (push) Has been skipped
Release / Release Mobile (push) Has been skipped
Some checks failed
Build & Test / Analyze (javascript, affine) (push) Failing after 4s
Build & Test / Analyze (typescript, affine) (push) Failing after 3s
Build & Test / Rust test filter (push) Failing after 4s
Build & Test / Check yarn binary (push) Failing after 3s
Build & Test / E2E BlockSuite Test (3) (push) Failing after 3s
Build & Test / Analyze (javascript, blocksuite) (push) Failing after 3s
Build & Test / Analyze (typescript, blocksuite) (push) Failing after 3s
Build & Test / Mobile native build filter (push) Failing after 3s
Build & Test / E2E BlockSuite Test (1) (push) Failing after 4s
Build & Test / E2E BlockSuite Test (2) (push) Failing after 4s
Build & Test / E2E Mobile Test (1) (push) Failing after 3s
Build & Test / Unit Test (1) (push) Has been skipped
Build & Test / Unit Test (2) (push) Has been skipped
Build & Test / Unit Test (3) (push) Has been skipped
Build & Test / Unit Test (4) (push) Has been skipped
Build & Test / Unit Test (5) (push) Has been skipped
Build & Test / Native Unit Test (push) Has been skipped
Build & Test / E2E BlockSuite Test (4) (push) Failing after 3s
Build & Test / E2E BlockSuite Test (5) (push) Failing after 3s
Build & Test / E2E BlockSuite Cross Browser Test (push) Failing after 3s
Build & Test / E2E Mobile Test (2) (push) Failing after 3s
Build & Test / Build AFFiNE native (x86_64-unknown-linux-gnu) (push) Failing after 3s
Build & Test / Build Server native (push) Failing after 3s
Build & Test / Check Git Status (push) Has been skipped
Build & Test / Server Test (0, 4) (push) Has been skipped
Build & Test / Server Test (1, 4) (push) Has been skipped
Build & Test / Server Test (2, 4) (push) Has been skipped
Build & Test / Server Test (3, 4) (push) Has been skipped
Build & Test / Server Test with Elasticsearch (push) Has been skipped
Build & Test / Build @affine/electron renderer (push) Failing after 3s
Build & Test / Copilot test filter (push) Failing after 4s
Build & Test / Lint Rust (push) Has been skipped
Build & Test / Server Test with Embedded Search (push) Has been skipped
Build & Test / Server Test with Manticore Search (push) Has been skipped
Build & Test / Server E2E Test (push) Has been skipped
Build & Test / Cloud Desktop E2E Test (push) Has been skipped
Build & Test / Cloud E2E Test 1/2 (push) Has been skipped
Build & Test / Cloud E2E Test 2/2 (push) Has been skipped
Build & Test / Build Android app (push) Has been skipped
Build & Test / Test Android app (push) Has been skipped
Build & Test / Run native tests (push) Has been skipped
Build & Test / Server Copilot Api Test (push) Has been skipped
Build & Test / Frontend Copilot E2E Test (1, 5) (push) Has been skipped
Build & Test / Frontend Copilot E2E Test (2, 5) (push) Has been skipped
Build & Test / Frontend Copilot E2E Test (3, 5) (push) Has been skipped
Build & Test / Frontend Copilot E2E Test (4, 5) (push) Has been skipped
Build & Test / Frontend Copilot E2E Test (5, 5) (push) Has been skipped
Release / Canary Gate (push) Has been skipped
Build & Test / Build AFFiNE native (aarch64-apple-darwin) (push) Has been cancelled
Build & Test / Lint (push) Has been cancelled
Build & Test / Typecheck (push) Has been cancelled
Build & Test / Build AFFiNE native (x86_64-apple-darwin) (push) Has been cancelled
Build & Test / Desktop Test (ubuntu-latest, linux, x64, x86_64-unknown-linux-gnu, true) (push) Has been cancelled
Build & Test / Desktop Test (windows-latest, windows, x64, x86_64-pc-windows-msvc, true) (push) Has been cancelled
Build & Test / E2E Test (1) (push) Has been cancelled
Build & Test / E2E Test (2) (push) Has been cancelled
Build & Test / E2E Test (3) (push) Has been cancelled
Build & Test / E2E Test (4) (push) Has been cancelled
Build & Test / E2E Test (5) (push) Has been cancelled
Build & Test / Build AFFiNE native (aarch64-pc-windows-msvc) (push) Has been cancelled
Build & Test / Build AFFiNE native (x86_64-pc-windows-msvc) (push) Has been cancelled
Build & Test / Desktop Test (macos-latest, macos, arm64, aarch64-apple-darwin, true) (push) Has been cancelled
Build & Test / Desktop Test (macos-latest, macos, x64, x86_64-apple-darwin, false) (push) Has been cancelled
Build & Test / 3, 2, 1 Launch (push) Has been cancelled
Build & Test / Build iOS app (push) Has been cancelled
Copilot Test Automatically / Setup Test (push) Failing after 1s
Release / Prepare (push) Failing after 2s
Release / Release Cloud (push) Has been skipped
Release / Release Desktop (push) Has been skipped
Release / Release Docker Image (push) Has been skipped
Release / Release Mobile (push) Has been skipped
This commit is contained in:
@@ -0,0 +1,32 @@
|
||||
import { CloseIcon } from '@blocksuite/icons/rc';
|
||||
import type { ReactNode } from 'react';
|
||||
|
||||
import {
|
||||
browserWarningStyle,
|
||||
closeButtonStyle,
|
||||
closeIconStyle,
|
||||
} from './index.css';
|
||||
|
||||
export const BrowserWarning = ({
|
||||
show,
|
||||
onClose,
|
||||
message,
|
||||
}: {
|
||||
show: boolean;
|
||||
onClose: () => void;
|
||||
message: ReactNode;
|
||||
}) => {
|
||||
if (!show) {
|
||||
return null;
|
||||
}
|
||||
return (
|
||||
<div className={browserWarningStyle}>
|
||||
{message}
|
||||
<div className={closeButtonStyle} onClick={onClose}>
|
||||
<CloseIcon className={closeIconStyle} />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default BrowserWarning;
|
||||
@@ -0,0 +1,68 @@
|
||||
import { cssVar } from '@toeverything/theme';
|
||||
import { style } from '@vanilla-extract/css';
|
||||
export const browserWarningStyle = style({
|
||||
backgroundColor: cssVar('backgroundWarningColor'),
|
||||
color: cssVar('warningColor'),
|
||||
width: '100%',
|
||||
padding: '8px 16px',
|
||||
fontSize: cssVar('fontSm'),
|
||||
display: 'flex',
|
||||
justifyContent: 'center',
|
||||
alignItems: 'center',
|
||||
position: 'absolute',
|
||||
zIndex: 1,
|
||||
});
|
||||
export const closeButtonStyle = style({
|
||||
width: '36px',
|
||||
height: '36px',
|
||||
color: cssVar('iconColor'),
|
||||
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 tipsContainer = style({
|
||||
backgroundColor: cssVar('backgroundErrorColor'),
|
||||
color: cssVar('errorColor'),
|
||||
width: '100%',
|
||||
fontSize: cssVar('fontSm'),
|
||||
fontWeight: '700',
|
||||
display: 'flex',
|
||||
justifyContent: 'space-between',
|
||||
alignItems: 'center',
|
||||
padding: '12px 16px',
|
||||
position: 'absolute',
|
||||
zIndex: 1,
|
||||
gap: '16px',
|
||||
containerType: 'inline-size',
|
||||
'@media': {
|
||||
'screen and (max-width: 520px)': {
|
||||
flexWrap: 'wrap',
|
||||
},
|
||||
},
|
||||
});
|
||||
export const tipsMessage = style({
|
||||
color: cssVar('errorColor'),
|
||||
flexGrow: 1,
|
||||
flexShrink: 1,
|
||||
});
|
||||
export const tipsRightItem = style({
|
||||
display: 'flex',
|
||||
flexShrink: 0,
|
||||
justifyContent: 'space-between',
|
||||
alignItems: 'center',
|
||||
gap: '16px',
|
||||
'@media': {
|
||||
'screen and (max-width: 520px)': {
|
||||
width: '100%',
|
||||
},
|
||||
},
|
||||
});
|
||||
@@ -0,0 +1,2 @@
|
||||
export * from './browser-warning';
|
||||
export * from './local-demo-tips';
|
||||
@@ -0,0 +1,56 @@
|
||||
import { Button, IconButton } from '@affine/component/ui/button';
|
||||
import { useI18n } from '@affine/i18n';
|
||||
import { CloseIcon } from '@blocksuite/icons/rc';
|
||||
import { cssVar } from '@toeverything/theme';
|
||||
import { useCallback } from 'react';
|
||||
|
||||
import * as styles from './index.css';
|
||||
|
||||
type LocalDemoTipsProps = {
|
||||
isLoggedIn: boolean;
|
||||
onLogin: () => void;
|
||||
onEnableCloud: () => void;
|
||||
onClose: () => void;
|
||||
};
|
||||
|
||||
export const LocalDemoTips = ({
|
||||
onClose,
|
||||
isLoggedIn,
|
||||
onLogin,
|
||||
onEnableCloud,
|
||||
}: LocalDemoTipsProps) => {
|
||||
const t = useI18n();
|
||||
const buttonLabel = isLoggedIn
|
||||
? t['Enable AFFiNE Cloud']()
|
||||
: t['Sign in and Enable']();
|
||||
|
||||
const handleClick = useCallback(() => {
|
||||
if (isLoggedIn) {
|
||||
return onEnableCloud();
|
||||
}
|
||||
return onLogin();
|
||||
}, [isLoggedIn, onEnableCloud, onLogin]);
|
||||
|
||||
return (
|
||||
<div className={styles.tipsContainer} data-testid="local-demo-tips">
|
||||
<div className={styles.tipsMessage}>
|
||||
{t['com.affine.banner.local-warning']()}
|
||||
</div>
|
||||
|
||||
<div className={styles.tipsRightItem}>
|
||||
<Button style={{ background: cssVar('white') }} onClick={handleClick}>
|
||||
{buttonLabel}
|
||||
</Button>
|
||||
<IconButton
|
||||
onClick={onClose}
|
||||
size="20"
|
||||
data-testid="local-demo-tips-close-button"
|
||||
>
|
||||
<CloseIcon />
|
||||
</IconButton>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default LocalDemoTips;
|
||||
@@ -0,0 +1,24 @@
|
||||
import * as styles from './index.css';
|
||||
import { useNavConfig } from './use-nav-config';
|
||||
|
||||
export const DesktopNavbar = () => {
|
||||
const config = useNavConfig();
|
||||
|
||||
return (
|
||||
<div className={styles.topNavLinks}>
|
||||
{config.map(item => {
|
||||
return (
|
||||
<a
|
||||
key={item.title}
|
||||
href={item.path}
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
className={styles.topNavLink}
|
||||
>
|
||||
{item.title}
|
||||
</a>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,110 @@
|
||||
import { cssVar } from '@toeverything/theme';
|
||||
import { style } from '@vanilla-extract/css';
|
||||
export const root = style({
|
||||
height: '100%',
|
||||
width: '100%',
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
fontSize: cssVar('fontBase'),
|
||||
position: 'relative',
|
||||
backgroundColor: cssVar('backgroundPrimaryColor'),
|
||||
backgroundSize: 'cover',
|
||||
});
|
||||
export const affineLogo = style({
|
||||
color: 'inherit',
|
||||
});
|
||||
export const topNav = style({
|
||||
top: 0,
|
||||
left: 0,
|
||||
right: 0,
|
||||
display: 'flex',
|
||||
position: 'fixed',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'space-between',
|
||||
padding: '16px 120px',
|
||||
'@media': {
|
||||
'screen and (max-width: 1024px)': {
|
||||
padding: '16px 20px',
|
||||
},
|
||||
},
|
||||
});
|
||||
export const draggableHeader = style({
|
||||
height: '52px',
|
||||
width: '100%',
|
||||
position: 'fixed',
|
||||
['WebkitAppRegion' as string]: 'drag',
|
||||
});
|
||||
export const topNavLinks = style({
|
||||
display: 'flex',
|
||||
columnGap: 4,
|
||||
'@media': {
|
||||
'screen and (max-width: 1024px)': {
|
||||
display: 'none',
|
||||
},
|
||||
},
|
||||
});
|
||||
export const topNavLink = style({
|
||||
color: cssVar('textPrimaryColor'),
|
||||
fontSize: cssVar('fontSm'),
|
||||
fontWeight: 500,
|
||||
textDecoration: 'none',
|
||||
padding: '4px 18px',
|
||||
});
|
||||
export const iconButton = style({
|
||||
fontSize: '24px',
|
||||
pointerEvents: 'auto',
|
||||
selectors: {
|
||||
'&.plain': {
|
||||
color: cssVar('textPrimaryColor'),
|
||||
},
|
||||
},
|
||||
});
|
||||
export const hideInWideScreen = style({
|
||||
'@media': {
|
||||
'screen and (min-width: 1024px)': {
|
||||
display: 'none',
|
||||
position: 'absolute',
|
||||
},
|
||||
},
|
||||
});
|
||||
export const hideInSmallScreen = style({
|
||||
'@media': {
|
||||
'screen and (max-width: 1024px)': {
|
||||
display: 'none',
|
||||
},
|
||||
},
|
||||
});
|
||||
export const menu = style({
|
||||
width: '100vw',
|
||||
height: '100vh',
|
||||
padding: '0',
|
||||
background: cssVar('backgroundPrimaryColor'),
|
||||
borderRadius: '0',
|
||||
border: 'none',
|
||||
boxShadow: 'none',
|
||||
});
|
||||
export const menuItem = style({
|
||||
color: cssVar('textPrimaryColor'),
|
||||
fontSize: cssVar('fontSm'),
|
||||
fontWeight: 500,
|
||||
textDecoration: 'none',
|
||||
padding: '12px 20px',
|
||||
maxWidth: '100%',
|
||||
position: 'relative',
|
||||
borderRadius: '0',
|
||||
transition: 'background 0.3s ease',
|
||||
selectors: {
|
||||
'&:after': {
|
||||
position: 'absolute',
|
||||
content: '""',
|
||||
bottom: 0,
|
||||
display: 'block',
|
||||
width: 'calc(100% - 40px)',
|
||||
height: '0.5px',
|
||||
background: cssVar('black10'),
|
||||
},
|
||||
'&:not(:last-of-type)': {
|
||||
marginBottom: '0',
|
||||
},
|
||||
},
|
||||
});
|
||||
@@ -0,0 +1 @@
|
||||
export * from './layout';
|
||||
@@ -0,0 +1,55 @@
|
||||
import { Button } from '@affine/component/ui/button';
|
||||
import { useI18n } from '@affine/i18n';
|
||||
import { Logo1Icon } from '@blocksuite/icons/rc';
|
||||
import { useTheme } from 'next-themes';
|
||||
import { type ReactNode, useCallback } from 'react';
|
||||
|
||||
import dotBgDark from './assets/dot-bg.dark.png';
|
||||
import dotBgLight from './assets/dot-bg.light.png';
|
||||
import { DesktopNavbar } from './desktop-navbar';
|
||||
import * as styles from './index.css';
|
||||
import { MobileNavbar } from './mobile-navbar';
|
||||
|
||||
export const AffineOtherPageLayout = ({
|
||||
children,
|
||||
}: {
|
||||
children: ReactNode;
|
||||
}) => {
|
||||
const t = useI18n();
|
||||
|
||||
const openDownloadLink = useCallback(() => {
|
||||
open(BUILD_CONFIG.downloadUrl, '_blank');
|
||||
}, []);
|
||||
|
||||
const { resolvedTheme } = useTheme();
|
||||
const backgroundImage =
|
||||
resolvedTheme === 'dark' && dotBgDark ? dotBgDark : dotBgLight;
|
||||
|
||||
return (
|
||||
<div
|
||||
className={styles.root}
|
||||
style={{ backgroundImage: `url(${backgroundImage})` }}
|
||||
>
|
||||
{BUILD_CONFIG.isElectron ? (
|
||||
<div className={styles.draggableHeader} />
|
||||
) : (
|
||||
<div className={styles.topNav}>
|
||||
<a href="/" rel="noreferrer" className={styles.affineLogo}>
|
||||
<Logo1Icon width={24} height={24} />
|
||||
</a>
|
||||
|
||||
<DesktopNavbar />
|
||||
<Button
|
||||
onClick={openDownloadLink}
|
||||
className={styles.hideInSmallScreen}
|
||||
>
|
||||
{t['com.affine.auth.open.affine.download-app']()}
|
||||
</Button>
|
||||
<MobileNavbar />
|
||||
</div>
|
||||
)}
|
||||
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,50 @@
|
||||
import { IconButton } from '@affine/component/ui/button';
|
||||
import { Menu, MenuItem } from '@affine/component/ui/menu';
|
||||
import { CloseIcon, PropertyIcon } from '@blocksuite/icons/rc';
|
||||
import { useState } from 'react';
|
||||
|
||||
import * as styles from './index.css';
|
||||
import { useNavConfig } from './use-nav-config';
|
||||
|
||||
export const MobileNavbar = () => {
|
||||
const [openMenu, setOpenMenu] = useState(false);
|
||||
const navConfig = useNavConfig();
|
||||
|
||||
const menuItems = (
|
||||
<>
|
||||
{navConfig.map(item => {
|
||||
return (
|
||||
<MenuItem
|
||||
key={item.title}
|
||||
onClick={() => {
|
||||
open(item.path, '_blank');
|
||||
}}
|
||||
className={styles.menuItem}
|
||||
>
|
||||
{item.title}
|
||||
</MenuItem>
|
||||
);
|
||||
})}
|
||||
</>
|
||||
);
|
||||
|
||||
return (
|
||||
<div className={styles.hideInWideScreen}>
|
||||
<Menu
|
||||
items={menuItems}
|
||||
contentOptions={{
|
||||
className: styles.menu,
|
||||
sideOffset: 20,
|
||||
}}
|
||||
rootOptions={{
|
||||
open: openMenu,
|
||||
onOpenChange: setOpenMenu,
|
||||
}}
|
||||
>
|
||||
<IconButton variant="plain" size="24" className={styles.iconButton}>
|
||||
{openMenu ? <CloseIcon /> : <PropertyIcon />}
|
||||
</IconButton>
|
||||
</Menu>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,23 @@
|
||||
import { useI18n } from '@affine/i18n';
|
||||
import { useMemo } from 'react';
|
||||
|
||||
export const useNavConfig = () => {
|
||||
const t = useI18n();
|
||||
return useMemo(
|
||||
() => [
|
||||
{
|
||||
title: t['com.affine.other-page.nav.official-website'](),
|
||||
path: 'https://affine.pro',
|
||||
},
|
||||
{
|
||||
title: t['com.affine.other-page.nav.blog'](),
|
||||
path: 'https://affine.pro/blog',
|
||||
},
|
||||
{
|
||||
title: t['com.affine.other-page.nav.contact-us'](),
|
||||
path: 'https://affine.pro/about-us',
|
||||
},
|
||||
],
|
||||
[t]
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,7 @@
|
||||
import type { PropsWithChildren } from 'react';
|
||||
|
||||
import { authContainer } from './share.css';
|
||||
|
||||
export function AuthContainer(props: PropsWithChildren) {
|
||||
return <div className={authContainer}>{props.children}</div>;
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,7 @@
|
||||
import type { PropsWithChildren } from 'react';
|
||||
|
||||
import { authFooter } from './share.css';
|
||||
|
||||
export function AuthFooter(props: PropsWithChildren) {
|
||||
return <div className={authFooter}>{props.children}</div>;
|
||||
}
|
||||
@@ -0,0 +1,21 @@
|
||||
import { Logo1Icon } from '@blocksuite/icons/rc';
|
||||
import clsx from 'clsx';
|
||||
import type { FC } from 'react';
|
||||
|
||||
import { authHeaderWrapper } from './share.css';
|
||||
|
||||
export const AuthHeader: FC<{
|
||||
title: string;
|
||||
subTitle?: string;
|
||||
className?: string;
|
||||
}> = ({ title, subTitle, className }) => {
|
||||
return (
|
||||
<div className={clsx(authHeaderWrapper, className)}>
|
||||
<p>
|
||||
<Logo1Icon className="logo" />
|
||||
{title}
|
||||
</p>
|
||||
<p>{subTitle}</p>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,43 @@
|
||||
import clsx from 'clsx';
|
||||
import type { ReactNode } from 'react';
|
||||
|
||||
import type { InputProps } from '../../ui/input';
|
||||
import { Input } from '../../ui/input';
|
||||
import * as styles from './share.css';
|
||||
export type AuthInputProps = InputProps & {
|
||||
label?: string;
|
||||
error?: boolean;
|
||||
errorHint?: ReactNode;
|
||||
onEnter?: () => void;
|
||||
};
|
||||
export const AuthInput = ({
|
||||
label,
|
||||
error,
|
||||
errorHint,
|
||||
onEnter,
|
||||
className,
|
||||
...inputProps
|
||||
}: AuthInputProps) => {
|
||||
return (
|
||||
<div
|
||||
className={clsx(styles.authInputWrapper, {
|
||||
'with-hint': !!errorHint,
|
||||
})}
|
||||
>
|
||||
{label ? <label>{label}</label> : null}
|
||||
<Input
|
||||
className={clsx(className)}
|
||||
size="extraLarge"
|
||||
status={error ? 'error' : 'default'}
|
||||
onEnter={onEnter}
|
||||
{...inputProps}
|
||||
/>
|
||||
<div
|
||||
className={styles.authInputError}
|
||||
style={{ visibility: error ? 'visible' : 'hidden' }}
|
||||
>
|
||||
{errorHint}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,40 @@
|
||||
import type { FC, PropsWithChildren, ReactNode } from 'react';
|
||||
|
||||
import { ThemedImg } from '../../ui/themed-img';
|
||||
import { AffineOtherPageLayout } from '../affine-other-page-layout';
|
||||
import illustrationDark from '../affine-other-page-layout/assets/other-page.dark.png';
|
||||
import illustrationLight from '../affine-other-page-layout/assets/other-page.light.png';
|
||||
import {
|
||||
authPageContainer,
|
||||
hideInSmallScreen,
|
||||
illustration,
|
||||
} from './share.css';
|
||||
|
||||
export const AuthPageContainer: FC<
|
||||
PropsWithChildren<{
|
||||
title?: ReactNode;
|
||||
subtitle?: ReactNode;
|
||||
}>
|
||||
> = ({ children, title, subtitle }) => {
|
||||
return (
|
||||
<AffineOtherPageLayout>
|
||||
<div className={authPageContainer}>
|
||||
<div className="wrapper">
|
||||
<div className="content">
|
||||
<p className="title">{title}</p>
|
||||
<div className="subtitle">{subtitle}</div>
|
||||
{children}
|
||||
</div>
|
||||
<div className={hideInSmallScreen}>
|
||||
<ThemedImg
|
||||
draggable={false}
|
||||
className={illustration}
|
||||
lightSrc={illustrationLight}
|
||||
darkSrc={illustrationDark}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</AffineOtherPageLayout>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,22 @@
|
||||
import { useI18n } from '@affine/i18n';
|
||||
import { ArrowLeftSmallIcon } from '@blocksuite/icons/rc';
|
||||
import type { FC } from 'react';
|
||||
|
||||
import type { ButtonProps } from '../../ui/button';
|
||||
import { Button } from '../../ui/button';
|
||||
|
||||
export const BackButton: FC<ButtonProps> = props => {
|
||||
const t = useI18n();
|
||||
return (
|
||||
<Button
|
||||
variant="plain"
|
||||
style={{
|
||||
padding: '2px 8px 2px 0',
|
||||
}}
|
||||
prefix={<ArrowLeftSmallIcon />}
|
||||
{...props}
|
||||
>
|
||||
{t['com.affine.backButton']()}
|
||||
</Button>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,72 @@
|
||||
import { useI18n } from '@affine/i18n';
|
||||
import { useCallback, useState } from 'react';
|
||||
|
||||
import { Button } from '../../ui/button';
|
||||
import { AuthInput } from './auth-input';
|
||||
import { AuthPageContainer } from './auth-page-container';
|
||||
import * as styles from './share.css';
|
||||
import { emailRegex } from './utils';
|
||||
|
||||
export const ChangeEmailPage = ({
|
||||
onChangeEmail: propsOnChangeEmail,
|
||||
}: {
|
||||
onChangeEmail: (email: string) => Promise<boolean>;
|
||||
onOpenAffine: () => void;
|
||||
}) => {
|
||||
const t = useI18n();
|
||||
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
|
||||
className={styles.input}
|
||||
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
|
||||
variant="primary"
|
||||
size="large"
|
||||
onClick={onContinue}
|
||||
loading={loading}
|
||||
disabled={hasSetUp}
|
||||
>
|
||||
{t['com.affine.auth.set.email.save']()}
|
||||
</Button>
|
||||
</>
|
||||
</AuthPageContainer>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,61 @@
|
||||
import type { PasswordLimitsFragment } from '@affine/graphql';
|
||||
import { useI18n } from '@affine/i18n';
|
||||
import type { FC } from 'react';
|
||||
import { useCallback, useState } from 'react';
|
||||
|
||||
import { Button } from '../../ui/button';
|
||||
import { notify } from '../../ui/notification';
|
||||
import { AuthPageContainer } from './auth-page-container';
|
||||
import { SetPassword } from './set-password';
|
||||
|
||||
export const ChangePasswordPage: FC<{
|
||||
passwordLimits: PasswordLimitsFragment;
|
||||
onSetPassword: (password: string) => Promise<void>;
|
||||
onOpenAffine: () => void;
|
||||
}> = ({ passwordLimits, onSetPassword: propsOnSetPassword, onOpenAffine }) => {
|
||||
const t = useI18n();
|
||||
const [hasSetUp, setHasSetUp] = useState(false);
|
||||
|
||||
const onSetPassword = useCallback(
|
||||
(passWord: string) => {
|
||||
propsOnSetPassword(passWord)
|
||||
.then(() => setHasSetUp(true))
|
||||
.catch(e =>
|
||||
notify.error({
|
||||
title: t['com.affine.auth.password.set-failed'](),
|
||||
message: String(e),
|
||||
})
|
||||
);
|
||||
},
|
||||
[propsOnSetPassword, t]
|
||||
);
|
||||
|
||||
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']({
|
||||
min: String(passwordLimits.minLength),
|
||||
max: String(passwordLimits.maxLength),
|
||||
})
|
||||
}
|
||||
>
|
||||
{hasSetUp ? (
|
||||
<Button variant="primary" size="large" onClick={onOpenAffine}>
|
||||
{t['com.affine.auth.open.affine']()}
|
||||
</Button>
|
||||
) : (
|
||||
<SetPassword
|
||||
passwordLimits={passwordLimits}
|
||||
onSetPassword={onSetPassword}
|
||||
/>
|
||||
)}
|
||||
</AuthPageContainer>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,17 @@
|
||||
export * from './auth-container';
|
||||
export * from './auth-content';
|
||||
export * from './auth-footer';
|
||||
export * from './auth-header';
|
||||
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 './onboarding-page';
|
||||
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';
|
||||
export type { User } from './type';
|
||||
export * from './utils';
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,157 @@
|
||||
import { cssVar } from '@toeverything/theme';
|
||||
import { globalStyle, style } from '@vanilla-extract/css';
|
||||
export const layout = style({
|
||||
backgroundColor: cssVar('backgroundPrimaryColor'),
|
||||
height: '100vh',
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
overflow: 'hidden',
|
||||
selectors: {
|
||||
'&[data-is-macos-electron="true"]': {
|
||||
margin: '8px',
|
||||
borderRadius: '8px',
|
||||
height: 'calc(100vh - 16px)',
|
||||
},
|
||||
},
|
||||
});
|
||||
export const header = style({
|
||||
paddingTop: '24px',
|
||||
paddingRight: '24px',
|
||||
position: 'sticky',
|
||||
top: 0,
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
alignItems: 'flex-end',
|
||||
['WebkitAppRegion' as string]: 'drag',
|
||||
selectors: {
|
||||
'&[data-is-windows-electron="true"]': {
|
||||
paddingTop: '0',
|
||||
paddingRight: '0',
|
||||
gap: '16px',
|
||||
},
|
||||
},
|
||||
});
|
||||
export const footer = style({
|
||||
padding: '20px',
|
||||
position: 'sticky',
|
||||
bottom: 0,
|
||||
backgroundColor: cssVar('backgroundPrimaryColor'),
|
||||
});
|
||||
export const scrollableContainer = style({
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
justifyContent: 'center',
|
||||
alignItems: 'center',
|
||||
padding: '80px 200px 160px',
|
||||
'@media': {
|
||||
'screen and (max-width: 1024px)': {
|
||||
padding: '0px 36px 80px',
|
||||
},
|
||||
},
|
||||
});
|
||||
export const onboardingContainer = style({
|
||||
maxWidth: '600px',
|
||||
'@media': {
|
||||
'screen and (max-width: 1024px)': {
|
||||
padding: '40px 0',
|
||||
maxWidth: '100%',
|
||||
},
|
||||
},
|
||||
});
|
||||
export const content = style({
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
justifyContent: 'space-between',
|
||||
gap: '36px',
|
||||
minHeight: '450px',
|
||||
});
|
||||
export const question = style({
|
||||
fontFamily: 'Inter',
|
||||
fontSize: cssVar('fontH1'),
|
||||
fontStyle: 'normal',
|
||||
fontWeight: 600,
|
||||
lineHeight: '36px',
|
||||
});
|
||||
export const optionsWrapper = style({
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
alignItems: 'flex-start',
|
||||
gap: '16px',
|
||||
flexGrow: 1,
|
||||
});
|
||||
export const buttonWrapper = style({
|
||||
display: 'flex',
|
||||
flexDirection: 'row',
|
||||
alignItems: 'flex-start',
|
||||
gap: '24px',
|
||||
flexShrink: 0,
|
||||
});
|
||||
export const checkBox = style({
|
||||
alignItems: 'center',
|
||||
fontSize: '24px',
|
||||
});
|
||||
globalStyle(`${checkBox} svg`, {
|
||||
color: cssVar('brandColor'),
|
||||
flexShrink: 0,
|
||||
marginRight: '8px',
|
||||
});
|
||||
export const label = style({
|
||||
fontSize: cssVar('fontBase'),
|
||||
fontWeight: 500,
|
||||
});
|
||||
export const input = style({
|
||||
width: '520px',
|
||||
'@media': {
|
||||
'screen and (max-width: 768px)': {
|
||||
width: '100%',
|
||||
},
|
||||
},
|
||||
});
|
||||
export const button = style({
|
||||
fontWeight: 600,
|
||||
fontSize: cssVar('fontBase'),
|
||||
});
|
||||
export const openAFFiNEButton = style({
|
||||
alignSelf: 'flex-start',
|
||||
});
|
||||
export const disableButton = style({
|
||||
position: 'absolute',
|
||||
display: 'none',
|
||||
pointerEvents: 'none',
|
||||
});
|
||||
export const windowsAppButton = style({
|
||||
marginRight: '24px',
|
||||
});
|
||||
export const thankContainer = style({
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
gap: '24px',
|
||||
});
|
||||
export const thankTitle = style({
|
||||
fontSize: cssVar('fontTitle'),
|
||||
fontWeight: '700',
|
||||
lineHeight: '44px',
|
||||
});
|
||||
export const thankText = style({
|
||||
fontSize: cssVar('fontH6'),
|
||||
height: '300px',
|
||||
fontWeight: '600',
|
||||
lineHeight: '26px',
|
||||
});
|
||||
export const linkGroup = style({
|
||||
display: 'flex',
|
||||
fontSize: cssVar('fontXs'),
|
||||
height: '16px',
|
||||
gap: '6px',
|
||||
width: '100%',
|
||||
justifyContent: 'flex-end',
|
||||
backgroundColor: cssVar('backgroundPrimaryColor'),
|
||||
});
|
||||
export const link = style({
|
||||
color: cssVar('textSecondaryColor'),
|
||||
selectors: {
|
||||
'&:visited': {
|
||||
color: cssVar('textSecondaryColor'),
|
||||
},
|
||||
},
|
||||
});
|
||||
@@ -0,0 +1,270 @@
|
||||
import { ArrowRightSmallIcon } from '@blocksuite/icons/rc';
|
||||
import clsx from 'clsx';
|
||||
import { useMemo, useState } from 'react';
|
||||
import type { Location } from 'react-router-dom';
|
||||
import { useLocation, useNavigate } from 'react-router-dom';
|
||||
import useSWR from 'swr';
|
||||
|
||||
import { Button } from '../../ui/button';
|
||||
import { Checkbox } from '../../ui/checkbox';
|
||||
import { Divider } from '../../ui/divider';
|
||||
import Input from '../../ui/input';
|
||||
import { ScrollableContainer } from '../../ui/scrollbar';
|
||||
import * as styles from './onboarding-page.css';
|
||||
import type { User } from './type';
|
||||
|
||||
type QuestionOption = {
|
||||
type: 'checkbox' | 'input';
|
||||
label: string;
|
||||
value: string;
|
||||
};
|
||||
|
||||
type Question = {
|
||||
id?: string;
|
||||
question: string;
|
||||
options?: QuestionOption[];
|
||||
};
|
||||
|
||||
type QuestionnaireAnswer = {
|
||||
form: string;
|
||||
ask: string;
|
||||
answer: string[];
|
||||
};
|
||||
|
||||
function getCallbackUrl(location: Location) {
|
||||
try {
|
||||
const url =
|
||||
location.state?.callbackURL ||
|
||||
new URLSearchParams(location.search).get('redirect_uri');
|
||||
if (typeof url === 'string' && url) {
|
||||
if (!url.startsWith('http:') && !url.startsWith('https:')) {
|
||||
return url;
|
||||
}
|
||||
// we will ignore host to avoid redirect hack
|
||||
const parsedUrl = new URL(url);
|
||||
return parsedUrl.pathname + parsedUrl.search;
|
||||
}
|
||||
} catch {}
|
||||
return null;
|
||||
}
|
||||
|
||||
export const ScrollableLayout = ({
|
||||
headerItems,
|
||||
children,
|
||||
isMacosDesktop,
|
||||
isWindowsDesktop,
|
||||
}: {
|
||||
isMacosDesktop?: boolean;
|
||||
isWindowsDesktop?: boolean;
|
||||
headerItems?: React.ReactNode;
|
||||
children: React.ReactNode;
|
||||
}) => {
|
||||
return (
|
||||
<div className={styles.layout} data-is-macos-electron={isMacosDesktop}>
|
||||
<header
|
||||
className={styles.header}
|
||||
data-is-windows-electron={isWindowsDesktop}
|
||||
>
|
||||
{headerItems}
|
||||
</header>
|
||||
<ScrollableContainer className={styles.scrollableContainer}>
|
||||
<div className={styles.onboardingContainer}>{children}</div>
|
||||
</ScrollableContainer>
|
||||
<footer className={styles.footer}>
|
||||
<div className={styles.linkGroup}>
|
||||
<a
|
||||
className={styles.link}
|
||||
href="https://affine.pro/terms"
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
>
|
||||
Terms of Conditions
|
||||
</a>
|
||||
<Divider orientation="vertical" />
|
||||
<a
|
||||
className={styles.link}
|
||||
href="https://affine.pro/privacy"
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
>
|
||||
Privacy Policy
|
||||
</a>
|
||||
</div>
|
||||
</footer>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export const OnboardingPage = ({
|
||||
user,
|
||||
onOpenAffine,
|
||||
}: {
|
||||
user: User;
|
||||
onOpenAffine: () => void;
|
||||
}) => {
|
||||
const location = useLocation();
|
||||
const navigate = useNavigate();
|
||||
const [questionIdx, setQuestionIdx] = useState(0);
|
||||
const { data: questions } = useSWR<Question[]>(
|
||||
'/api/worker/questionnaire',
|
||||
url => fetch(url).then(r => r.json()),
|
||||
{ suspense: true, revalidateOnFocus: false }
|
||||
);
|
||||
const [options, setOptions] = useState(new Set<string>());
|
||||
const [inputs, setInputs] = useState<Record<string, string>>({});
|
||||
|
||||
const callbackUrl = useMemo(() => getCallbackUrl(location), [location]);
|
||||
const question = useMemo(
|
||||
() => questions?.[questionIdx],
|
||||
[questionIdx, questions]
|
||||
);
|
||||
const isMacosDesktop = BUILD_CONFIG.isElectron && environment.isMacOs;
|
||||
const isWindowsDesktop = BUILD_CONFIG.isElectron && environment.isWindows;
|
||||
|
||||
if (!questions) {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (question) {
|
||||
return (
|
||||
<ScrollableLayout
|
||||
headerItems={
|
||||
<Button
|
||||
className={clsx(styles.button, {
|
||||
[styles.disableButton]: questionIdx === 0,
|
||||
[styles.windowsAppButton]: isWindowsDesktop,
|
||||
})}
|
||||
size="extraLarge"
|
||||
onClick={() => setQuestionIdx(questions.length)}
|
||||
>
|
||||
Skip
|
||||
</Button>
|
||||
}
|
||||
isMacosDesktop={isMacosDesktop}
|
||||
isWindowsDesktop={isWindowsDesktop}
|
||||
>
|
||||
<div className={styles.content}>
|
||||
<h1 className={styles.question}>{question.question}</h1>
|
||||
<div className={styles.optionsWrapper}>
|
||||
{question.options &&
|
||||
question.options.length > 0 &&
|
||||
question.options.map(option => {
|
||||
if (option.type === 'checkbox') {
|
||||
return (
|
||||
<Checkbox
|
||||
key={option.label}
|
||||
name={option.value}
|
||||
className={styles.checkBox}
|
||||
labelClassName={styles.label}
|
||||
checked={options.has(option.value)}
|
||||
onChange={e => {
|
||||
setOptions(set => {
|
||||
if (e.target.checked) {
|
||||
set.add(option.value);
|
||||
} else {
|
||||
set.delete(option.value);
|
||||
}
|
||||
return new Set(set);
|
||||
});
|
||||
}}
|
||||
label={option.label}
|
||||
/>
|
||||
);
|
||||
} else if (option.type === 'input') {
|
||||
return (
|
||||
<Input
|
||||
key={option.label}
|
||||
className={styles.input}
|
||||
type="text"
|
||||
size="large"
|
||||
placeholder={option.label}
|
||||
value={inputs[option.value] || ''}
|
||||
onChange={value =>
|
||||
setInputs(prev => ({ ...prev, [option.value]: value }))
|
||||
}
|
||||
/>
|
||||
);
|
||||
}
|
||||
return null;
|
||||
})}
|
||||
</div>
|
||||
<div className={styles.buttonWrapper}>
|
||||
<Button
|
||||
className={clsx(styles.button, {
|
||||
[styles.disableButton]: questionIdx !== 0,
|
||||
})}
|
||||
size="extraLarge"
|
||||
onClick={() => setQuestionIdx(questions.length)}
|
||||
>
|
||||
Skip
|
||||
</Button>
|
||||
<Button
|
||||
className={styles.button}
|
||||
variant="primary"
|
||||
size="extraLarge"
|
||||
itemType="submit"
|
||||
onClick={() => {
|
||||
if (question.id && user?.id) {
|
||||
const answer: QuestionnaireAnswer = {
|
||||
form: user.id,
|
||||
ask: question.id,
|
||||
answer: [
|
||||
...Array.from(options),
|
||||
...Object.entries(inputs).map(
|
||||
([key, value]) => `${key}:${value}`
|
||||
),
|
||||
],
|
||||
};
|
||||
|
||||
// oxlint-disable-next-line typescript/no-floating-promises
|
||||
fetch('/api/worker/questionnaire', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify(answer),
|
||||
}).finally(() => {
|
||||
setOptions(new Set());
|
||||
setInputs({});
|
||||
setQuestionIdx(questionIdx + 1);
|
||||
});
|
||||
} else {
|
||||
setQuestionIdx(questionIdx + 1);
|
||||
}
|
||||
}}
|
||||
suffix={<ArrowRightSmallIcon />}
|
||||
>
|
||||
{questionIdx === 0 ? 'start' : 'Next'}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</ScrollableLayout>
|
||||
);
|
||||
}
|
||||
return (
|
||||
<ScrollableLayout
|
||||
isMacosDesktop={isMacosDesktop}
|
||||
isWindowsDesktop={isWindowsDesktop}
|
||||
>
|
||||
<div className={styles.thankContainer}>
|
||||
<h1 className={styles.thankTitle}>Thank you!</h1>
|
||||
<p className={styles.thankText}>
|
||||
We will continue to enhance our products based on your feedback. Thank
|
||||
you once again for your supports.
|
||||
</p>
|
||||
<Button
|
||||
className={clsx(styles.button, styles.openAFFiNEButton)}
|
||||
variant="primary"
|
||||
size="extraLarge"
|
||||
onClick={() => {
|
||||
if (callbackUrl) {
|
||||
navigate(callbackUrl);
|
||||
} else {
|
||||
onOpenAffine();
|
||||
}
|
||||
}}
|
||||
suffix={<ArrowRightSmallIcon />}
|
||||
>
|
||||
Get Started
|
||||
</Button>
|
||||
</div>
|
||||
</ScrollableLayout>
|
||||
);
|
||||
};
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,197 @@
|
||||
import { type PasswordLimitsFragment } from '@affine/graphql';
|
||||
import { useI18n } from '@affine/i18n';
|
||||
import { type Options, passwordStrength } from 'check-password-strength';
|
||||
import { type FC, useCallback, useEffect, useMemo, useState } from 'react';
|
||||
import { z, type ZodCustomIssue, ZodIssueCode } from 'zod';
|
||||
|
||||
import type { InputProps } from '../../../ui/input';
|
||||
import { Input } from '../../../ui/input';
|
||||
import * as styles from '../share.css';
|
||||
import { ErrorIcon } from './error';
|
||||
import { statusWrapper } from './style.css';
|
||||
import { SuccessIcon } from './success';
|
||||
import { Tag } from './tag';
|
||||
|
||||
export type Status = 'weak' | 'medium' | 'strong' | 'minimum' | 'maximum';
|
||||
|
||||
const PASSWORD_STRENGTH_OPTIONS: Options<string> = [
|
||||
{
|
||||
id: 0,
|
||||
value: 'weak',
|
||||
minDiversity: 0,
|
||||
minLength: 0,
|
||||
},
|
||||
{
|
||||
id: 1,
|
||||
value: 'medium',
|
||||
minDiversity: 4,
|
||||
minLength: 8,
|
||||
},
|
||||
{
|
||||
id: 2,
|
||||
value: 'strong',
|
||||
minDiversity: 4,
|
||||
minLength: 10,
|
||||
},
|
||||
];
|
||||
|
||||
export const PasswordInput: FC<
|
||||
InputProps & {
|
||||
passwordLimits: PasswordLimitsFragment;
|
||||
onPass: (password: string) => void;
|
||||
onPrevent: () => void;
|
||||
}
|
||||
> = ({ passwordLimits, onPass, onPrevent, ...inputProps }) => {
|
||||
const t = useI18n();
|
||||
|
||||
const [status, setStatus] = useState<Status | null>(null);
|
||||
const [confirmStatus, setConfirmStatus] = useState<
|
||||
'success' | 'error' | null
|
||||
>(null);
|
||||
const [canSubmit, setCanSubmit] = useState(false);
|
||||
|
||||
const [password, setPassWord] = useState('');
|
||||
const [confirmPassword, setConfirmPassword] = useState('');
|
||||
const validationSchema = useMemo(() => {
|
||||
const { minLength, maxLength } = passwordLimits;
|
||||
return z.string().superRefine((val, ctx) => {
|
||||
if (val.length < minLength) {
|
||||
ctx.addIssue({
|
||||
code: ZodIssueCode.custom,
|
||||
params: {
|
||||
status: 'minimum',
|
||||
},
|
||||
});
|
||||
} else if (val.length > maxLength) {
|
||||
ctx.addIssue({
|
||||
code: ZodIssueCode.custom,
|
||||
params: {
|
||||
status: 'maximum',
|
||||
},
|
||||
});
|
||||
}
|
||||
// https://github.com/deanilvincent/check-password-strength?tab=readme-ov-file#default-options
|
||||
const { value: status } = passwordStrength(
|
||||
val,
|
||||
PASSWORD_STRENGTH_OPTIONS
|
||||
);
|
||||
|
||||
ctx.addIssue({
|
||||
code: ZodIssueCode.custom,
|
||||
message: 'password strength',
|
||||
path: ['strength'],
|
||||
params: {
|
||||
status,
|
||||
},
|
||||
});
|
||||
});
|
||||
}, [passwordLimits]);
|
||||
|
||||
const validatePasswords = useCallback(
|
||||
(password: string, confirmPassword: string) => {
|
||||
const result = validationSchema.safeParse(password);
|
||||
let canSubmit = false;
|
||||
if (!result.success) {
|
||||
const issues = result.error.issues as ZodCustomIssue[];
|
||||
const firstIssue = issues[0];
|
||||
setStatus(firstIssue.params?.status || null);
|
||||
// ignore strength error
|
||||
if (firstIssue.path.includes('strength')) {
|
||||
canSubmit = true;
|
||||
}
|
||||
}
|
||||
if (confirmPassword) {
|
||||
const isEqual = password === confirmPassword;
|
||||
if (isEqual) {
|
||||
setConfirmStatus('success');
|
||||
} else {
|
||||
setConfirmStatus('error');
|
||||
}
|
||||
canSubmit &&= isEqual;
|
||||
} else {
|
||||
canSubmit &&= false;
|
||||
setConfirmStatus(null);
|
||||
}
|
||||
setCanSubmit(canSubmit);
|
||||
},
|
||||
[validationSchema]
|
||||
);
|
||||
|
||||
const onPasswordChange = useCallback(
|
||||
(value: string) => {
|
||||
const password = value.trim();
|
||||
setPassWord(password);
|
||||
validatePasswords(password, confirmPassword);
|
||||
},
|
||||
[validatePasswords, confirmPassword]
|
||||
);
|
||||
|
||||
const onConfirmPasswordChange = useCallback(
|
||||
(value: string) => {
|
||||
const confirmPassword = value.trim();
|
||||
setConfirmPassword(confirmPassword);
|
||||
validatePasswords(password, confirmPassword);
|
||||
},
|
||||
[validatePasswords, password]
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
if (canSubmit) {
|
||||
onPass(password);
|
||||
} else {
|
||||
onPrevent();
|
||||
}
|
||||
}, [canSubmit, password, onPass, onPrevent]);
|
||||
|
||||
return (
|
||||
<>
|
||||
<Input
|
||||
name="password"
|
||||
className={styles.input}
|
||||
type="password"
|
||||
size="extraLarge"
|
||||
style={{ marginBottom: 20 }}
|
||||
placeholder={t['com.affine.auth.set.password.placeholder']({
|
||||
min: String(passwordLimits.minLength),
|
||||
})}
|
||||
onChange={onPasswordChange}
|
||||
endFix={
|
||||
<div className={statusWrapper}>
|
||||
{status ? (
|
||||
<Tag
|
||||
status={status}
|
||||
minimum={t['com.affine.auth.set.password.message.minlength']({
|
||||
min: String(passwordLimits.minLength),
|
||||
})}
|
||||
maximum={t['com.affine.auth.set.password.message.maxlength']({
|
||||
max: String(passwordLimits.maxLength),
|
||||
})}
|
||||
/>
|
||||
) : null}
|
||||
</div>
|
||||
}
|
||||
{...inputProps}
|
||||
/>
|
||||
<Input
|
||||
name="confirmPassword"
|
||||
className={styles.input}
|
||||
type="password"
|
||||
size="extraLarge"
|
||||
placeholder={t['com.affine.auth.set.password.placeholder.confirm']()}
|
||||
onChange={onConfirmPasswordChange}
|
||||
endFix={
|
||||
<div className={statusWrapper}>
|
||||
{confirmStatus ? (
|
||||
confirmStatus === 'success' ? (
|
||||
<SuccessIcon />
|
||||
) : (
|
||||
<ErrorIcon />
|
||||
)
|
||||
) : null}
|
||||
</div>
|
||||
}
|
||||
{...inputProps}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,32 @@
|
||||
import { cssVar } from '@toeverything/theme';
|
||||
import { style } from '@vanilla-extract/css';
|
||||
export const statusWrapper = style({
|
||||
position: 'absolute',
|
||||
right: 10,
|
||||
});
|
||||
export const tag = style({
|
||||
padding: '2px 15px',
|
||||
height: 20,
|
||||
display: 'inline-flex',
|
||||
alignItems: 'center',
|
||||
borderRadius: 10,
|
||||
fontSize: cssVar('fontXs'),
|
||||
selectors: {
|
||||
'&.weak': {
|
||||
backgroundColor: cssVar('tagRed'),
|
||||
color: cssVar('errorColor'),
|
||||
},
|
||||
'&.medium': {
|
||||
backgroundColor: cssVar('tagOrange'),
|
||||
color: cssVar('warningColor'),
|
||||
},
|
||||
'&.strong': {
|
||||
backgroundColor: cssVar('tagGreen'),
|
||||
color: cssVar('successColor'),
|
||||
},
|
||||
'&.minimum, &.maximum': {
|
||||
backgroundColor: cssVar('tagRed'),
|
||||
color: cssVar('errorColor'),
|
||||
},
|
||||
},
|
||||
});
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,38 @@
|
||||
import clsx from 'clsx';
|
||||
import type { FC } from 'react';
|
||||
import { useMemo } from 'react';
|
||||
|
||||
import type { Status } from './index';
|
||||
import { tag } from './style.css';
|
||||
|
||||
type TagProps = {
|
||||
status: Status;
|
||||
minimum: string;
|
||||
maximum: string;
|
||||
};
|
||||
|
||||
export const Tag: FC<TagProps> = ({ status, minimum, maximum }) => {
|
||||
const textMap = useMemo<{ [K in Status]: string }>(() => {
|
||||
return {
|
||||
weak: 'Weak',
|
||||
medium: 'Medium',
|
||||
strong: 'Strong',
|
||||
minimum,
|
||||
maximum,
|
||||
};
|
||||
}, [minimum, maximum]);
|
||||
|
||||
return (
|
||||
<div
|
||||
className={clsx(tag, {
|
||||
weak: status === 'weak',
|
||||
medium: status === 'medium',
|
||||
strong: status === 'strong',
|
||||
minimum: status === 'minimum',
|
||||
maximum: status === 'maximum',
|
||||
})}
|
||||
>
|
||||
{textMap[status]}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,61 @@
|
||||
import type { PasswordLimitsFragment } from '@affine/graphql';
|
||||
import { useI18n } from '@affine/i18n';
|
||||
import type { FC } from 'react';
|
||||
import { useCallback, useState } from 'react';
|
||||
|
||||
import { Button } from '../../ui/button';
|
||||
import { notify } from '../../ui/notification';
|
||||
import { AuthPageContainer } from './auth-page-container';
|
||||
import { SetPassword } from './set-password';
|
||||
|
||||
export const SetPasswordPage: FC<{
|
||||
passwordLimits: PasswordLimitsFragment;
|
||||
onSetPassword: (password: string) => Promise<void>;
|
||||
onOpenAffine: () => void;
|
||||
}> = ({ passwordLimits, onSetPassword: propsOnSetPassword, onOpenAffine }) => {
|
||||
const t = useI18n();
|
||||
const [hasSetUp, setHasSetUp] = useState(false);
|
||||
|
||||
const onSetPassword = useCallback(
|
||||
(passWord: string) => {
|
||||
propsOnSetPassword(passWord)
|
||||
.then(() => setHasSetUp(true))
|
||||
.catch(e =>
|
||||
notify.error({
|
||||
title: t['com.affine.auth.password.set-failed'](),
|
||||
message: String(e),
|
||||
})
|
||||
);
|
||||
},
|
||||
[propsOnSetPassword, t]
|
||||
);
|
||||
|
||||
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']({
|
||||
min: String(passwordLimits.minLength),
|
||||
max: String(passwordLimits.maxLength),
|
||||
})
|
||||
}
|
||||
>
|
||||
{hasSetUp ? (
|
||||
<Button variant="primary" size="large" onClick={onOpenAffine}>
|
||||
{t['com.affine.auth.open.affine']()}
|
||||
</Button>
|
||||
) : (
|
||||
<SetPassword
|
||||
passwordLimits={passwordLimits}
|
||||
onSetPassword={onSetPassword}
|
||||
/>
|
||||
)}
|
||||
</AuthPageContainer>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,53 @@
|
||||
import type { PasswordLimitsFragment } from '@affine/graphql';
|
||||
import { useI18n } from '@affine/i18n';
|
||||
import type { FC } from 'react';
|
||||
import { useCallback, useRef, useState } from 'react';
|
||||
|
||||
import { Button } from '../../ui/button';
|
||||
import { Wrapper } from '../../ui/layout';
|
||||
import { PasswordInput } from './password-input';
|
||||
|
||||
export const SetPassword: FC<{
|
||||
passwordLimits: PasswordLimitsFragment;
|
||||
showLater?: boolean;
|
||||
onLater?: () => void;
|
||||
onSetPassword: (password: string) => void;
|
||||
}> = ({ passwordLimits, onLater, onSetPassword, showLater = false }) => {
|
||||
const t = useI18n();
|
||||
|
||||
const [passwordPass, setPasswordPass] = useState(false);
|
||||
const passwordRef = useRef('');
|
||||
|
||||
return (
|
||||
<>
|
||||
<Wrapper marginTop={30} marginBottom={42}>
|
||||
<PasswordInput
|
||||
passwordLimits={passwordLimits}
|
||||
onPass={useCallback(password => {
|
||||
setPasswordPass(true);
|
||||
passwordRef.current = password;
|
||||
}, [])}
|
||||
onPrevent={useCallback(() => {
|
||||
setPasswordPass(false);
|
||||
}, [])}
|
||||
/>
|
||||
</Wrapper>
|
||||
<Button
|
||||
variant="primary"
|
||||
size="large"
|
||||
disabled={!passwordPass}
|
||||
style={{ marginRight: 20 }}
|
||||
onClick={useCallback(() => {
|
||||
onSetPassword(passwordRef.current);
|
||||
}, [onSetPassword])}
|
||||
>
|
||||
{t['com.affine.auth.set.password.save']()}
|
||||
</Button>
|
||||
{showLater ? (
|
||||
<Button variant="plain" size="large" onClick={onLater}>
|
||||
{t['com.affine.auth.later']()}
|
||||
</Button>
|
||||
) : null}
|
||||
</>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,167 @@
|
||||
import { cssVar } from '@toeverything/theme';
|
||||
import { globalStyle, style } from '@vanilla-extract/css';
|
||||
export const authContainer = style({
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
height: '100%',
|
||||
width: '100%',
|
||||
minHeight: '422px',
|
||||
});
|
||||
|
||||
export const authHeaderWrapper = style({
|
||||
marginBottom: '20px',
|
||||
});
|
||||
globalStyle(`${authHeaderWrapper} .logo`, {
|
||||
fontSize: cssVar('fontH3'),
|
||||
fontWeight: 600,
|
||||
color: cssVar('black'),
|
||||
marginRight: '6px',
|
||||
verticalAlign: 'middle',
|
||||
});
|
||||
|
||||
globalStyle(`${authHeaderWrapper} > p:first-of-type`, {
|
||||
fontSize: cssVar('fontH5'),
|
||||
fontWeight: 600,
|
||||
marginBottom: '4px',
|
||||
lineHeight: '28px',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
});
|
||||
globalStyle(`${authHeaderWrapper} > p:last-of-type`, {
|
||||
fontSize: cssVar('fontH4'),
|
||||
fontWeight: 600,
|
||||
lineHeight: '28px',
|
||||
});
|
||||
|
||||
export const authContent = style({
|
||||
fontSize: cssVar('fontBase'),
|
||||
lineHeight: cssVar('fontH3'),
|
||||
flexGrow: 1,
|
||||
});
|
||||
|
||||
globalStyle(`${authContent} > *:not(:last-child)`, {
|
||||
marginBottom: '20px',
|
||||
});
|
||||
|
||||
export const authInputWrapper = style({
|
||||
position: 'relative',
|
||||
selectors: {
|
||||
'&.with-hint': {
|
||||
marginBottom: '8px',
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
export const authFooter = style({});
|
||||
|
||||
globalStyle(`${authFooter} > *:not(:last-child)`, {
|
||||
marginBottom: '20px',
|
||||
});
|
||||
|
||||
globalStyle(`${authInputWrapper} label`, {
|
||||
display: 'block',
|
||||
color: cssVar('textSecondaryColor'),
|
||||
marginBottom: '4px',
|
||||
fontSize: cssVar('fontSm'),
|
||||
fontWeight: 600,
|
||||
lineHeight: '22px',
|
||||
});
|
||||
|
||||
export const authInputError = style({
|
||||
color: cssVar('errorColor'),
|
||||
fontSize: cssVar('fontXs'),
|
||||
lineHeight: '20px',
|
||||
minHeight: '20px',
|
||||
});
|
||||
|
||||
globalStyle(`${authContent} a`, {
|
||||
color: cssVar('linkColor'),
|
||||
});
|
||||
|
||||
export const authPageContainer = style({
|
||||
height: '100vh',
|
||||
width: '100vw',
|
||||
display: 'flex',
|
||||
justifyContent: 'center',
|
||||
alignItems: 'center',
|
||||
fontSize: cssVar('fontBase'),
|
||||
'@media': {
|
||||
'screen and (max-width: 1024px)': {
|
||||
flexDirection: 'column',
|
||||
padding: '100px 20px',
|
||||
justifyContent: 'flex-start',
|
||||
},
|
||||
},
|
||||
});
|
||||
globalStyle(`${authPageContainer} .wrapper`, {
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
width: '100%',
|
||||
justifyContent: 'center',
|
||||
overflow: 'hidden',
|
||||
'@media': {
|
||||
'screen and (max-width: 1024px)': {
|
||||
flexDirection: 'column',
|
||||
justifyContent: 'flex-start',
|
||||
},
|
||||
},
|
||||
});
|
||||
globalStyle(`${authPageContainer} .content`, {
|
||||
maxWidth: '810px',
|
||||
'@media': {
|
||||
'screen and (min-width: 1024px)': {
|
||||
marginLeft: '200px',
|
||||
minWidth: '500px',
|
||||
marginRight: '60px',
|
||||
flexGrow: 1,
|
||||
flexShrink: 0,
|
||||
flexBasis: 0,
|
||||
},
|
||||
'screen and (max-width: 1024px)': {
|
||||
maxWidth: '600px',
|
||||
width: '100%',
|
||||
margin: 'auto',
|
||||
},
|
||||
},
|
||||
});
|
||||
globalStyle(`${authPageContainer} .title`, {
|
||||
fontSize: cssVar('fontTitle'),
|
||||
fontWeight: 600,
|
||||
marginBottom: '28px',
|
||||
});
|
||||
globalStyle(`${authPageContainer} .subtitle`, {
|
||||
marginBottom: '28px',
|
||||
});
|
||||
globalStyle(`${authPageContainer} a`, {
|
||||
color: cssVar('linkColor'),
|
||||
});
|
||||
export const signInPageContainer = style({
|
||||
height: '100vh',
|
||||
width: '100%',
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
justifyContent: 'center',
|
||||
alignItems: 'center',
|
||||
});
|
||||
export const input = style({
|
||||
width: '330px',
|
||||
position: 'relative',
|
||||
'@media': {
|
||||
'screen and (max-width: 520px)': {
|
||||
width: '100%',
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
export const hideInSmallScreen = style({
|
||||
'@media': {
|
||||
'screen and (max-width: 1024px)': {
|
||||
display: 'none',
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
export const illustration = style({
|
||||
flexShrink: 0,
|
||||
width: '670px',
|
||||
});
|
||||
@@ -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>;
|
||||
};
|
||||
@@ -0,0 +1,21 @@
|
||||
import { useI18n } from '@affine/i18n';
|
||||
import type { FC } from 'react';
|
||||
|
||||
import { Button } from '../../ui/button';
|
||||
import { AuthPageContainer } from './auth-page-container';
|
||||
|
||||
export const SignInSuccessPage: FC<{
|
||||
onOpenAffine: () => void;
|
||||
}> = ({ onOpenAffine }) => {
|
||||
const t = useI18n();
|
||||
return (
|
||||
<AuthPageContainer
|
||||
title={t['com.affine.auth.signed.success.title']()}
|
||||
subtitle={t['com.affine.auth.signed.success.subtitle']()}
|
||||
>
|
||||
<Button variant="primary" size="large" onClick={onOpenAffine}>
|
||||
{t['com.affine.auth.open.affine']()}
|
||||
</Button>
|
||||
</AuthPageContainer>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,79 @@
|
||||
import type { PasswordLimitsFragment } from '@affine/graphql';
|
||||
import { useI18n } from '@affine/i18n';
|
||||
import type { FC } from 'react';
|
||||
import { useCallback, useState } from 'react';
|
||||
|
||||
import { Button } from '../../ui/button';
|
||||
import { notify } from '../../ui/notification';
|
||||
import { AuthPageContainer } from './auth-page-container';
|
||||
import { SetPassword } from './set-password';
|
||||
|
||||
export const SignUpPage: FC<{
|
||||
passwordLimits: PasswordLimitsFragment;
|
||||
user: { email?: string };
|
||||
onSetPassword: (password: string) => Promise<void>;
|
||||
openButtonText?: string;
|
||||
onOpenAffine: () => void;
|
||||
}> = ({
|
||||
passwordLimits,
|
||||
user: { email },
|
||||
onSetPassword: propsOnSetPassword,
|
||||
onOpenAffine,
|
||||
openButtonText,
|
||||
}) => {
|
||||
const t = useI18n();
|
||||
const [hasSetUp, setHasSetUp] = useState(false);
|
||||
|
||||
const onSetPassword = useCallback(
|
||||
(passWord: string) => {
|
||||
propsOnSetPassword(passWord)
|
||||
.then(() => setHasSetUp(true))
|
||||
.catch(e =>
|
||||
notify.error({
|
||||
title: t['com.affine.auth.password.set-failed'](),
|
||||
message: String(e),
|
||||
})
|
||||
);
|
||||
},
|
||||
[propsOnSetPassword, t]
|
||||
);
|
||||
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']({
|
||||
min: String(passwordLimits.minLength),
|
||||
max: String(passwordLimits.maxLength),
|
||||
})}
|
||||
<a href={`mailto:${email}`}>{email}</a>
|
||||
</>
|
||||
)
|
||||
}
|
||||
>
|
||||
{hasSetUp ? (
|
||||
<Button variant="primary" size="large" onClick={onOpenAffine}>
|
||||
{openButtonText ?? t['com.affine.auth.open.affine']()}
|
||||
</Button>
|
||||
) : (
|
||||
<SetPassword
|
||||
passwordLimits={passwordLimits}
|
||||
onSetPassword={onSetPassword}
|
||||
onLater={onLater}
|
||||
showLater={true}
|
||||
/>
|
||||
)}
|
||||
</AuthPageContainer>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,7 @@
|
||||
export interface User {
|
||||
id: string;
|
||||
label: string;
|
||||
email?: string;
|
||||
image?: string | null;
|
||||
avatar?: string | null;
|
||||
}
|
||||
@@ -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,}))$/;
|
||||
@@ -0,0 +1,34 @@
|
||||
import type { HTMLAttributes, ReactNode } from 'react';
|
||||
import { forwardRef } 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';
|
||||
@@ -0,0 +1,41 @@
|
||||
import { cssVar } from '@toeverything/theme';
|
||||
import { style } from '@vanilla-extract/css';
|
||||
export const blockCard = style({
|
||||
display: 'flex',
|
||||
gap: '12px',
|
||||
padding: '8px 12px',
|
||||
color: cssVar('textPrimaryColor'),
|
||||
backgroundColor: cssVar('white80'),
|
||||
borderRadius: '8px',
|
||||
userSelect: 'none',
|
||||
cursor: 'pointer',
|
||||
textAlign: 'start',
|
||||
boxShadow: cssVar('shadow1'),
|
||||
selectors: {
|
||||
'&:hover': {
|
||||
backgroundColor: cssVar('hoverColor'),
|
||||
},
|
||||
'&[aria-disabled]': {
|
||||
color: cssVar('textDisableColor'),
|
||||
},
|
||||
'&[aria-disabled]:hover': {
|
||||
backgroundColor: cssVar('white80'),
|
||||
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: cssVar('textSecondaryColor'),
|
||||
fontSize: cssVar('fontXs'),
|
||||
});
|
||||
@@ -0,0 +1,35 @@
|
||||
import { atom } from 'jotai';
|
||||
import { nanoid } from 'nanoid';
|
||||
|
||||
export type GlobalLoadingEvent = {
|
||||
key?: string;
|
||||
};
|
||||
|
||||
const globalLoadingEventsBaseAtom = atom<GlobalLoadingEvent[]>([]);
|
||||
|
||||
export const globalLoadingEventsAtom = atom<GlobalLoadingEvent[]>(get =>
|
||||
get(globalLoadingEventsBaseAtom)
|
||||
);
|
||||
|
||||
export const resolveGlobalLoadingEventAtom = atom(
|
||||
null,
|
||||
(_, set, key: string) => {
|
||||
set(globalLoadingEventsBaseAtom, globalLoadingEvent =>
|
||||
globalLoadingEvent.filter(notification => notification.key !== key)
|
||||
);
|
||||
}
|
||||
);
|
||||
|
||||
export const pushGlobalLoadingEventAtom = atom<
|
||||
null,
|
||||
[GlobalLoadingEvent],
|
||||
void
|
||||
>(null, (_, set, newGlobalLoadingEvent) => {
|
||||
newGlobalLoadingEvent.key = newGlobalLoadingEvent.key || nanoid();
|
||||
|
||||
set(globalLoadingEventsBaseAtom, globalLoadingEvents => [
|
||||
// push to the top
|
||||
{ ...newGlobalLoadingEvent },
|
||||
...globalLoadingEvents,
|
||||
]);
|
||||
});
|
||||
@@ -0,0 +1,6 @@
|
||||
export {
|
||||
type GlobalLoadingEvent,
|
||||
globalLoadingEventsAtom,
|
||||
pushGlobalLoadingEventAtom,
|
||||
resolveGlobalLoadingEventAtom,
|
||||
} from './index.jotai';
|
||||
@@ -0,0 +1,41 @@
|
||||
import { cssVar } from '@toeverything/theme';
|
||||
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: cssVar('popoverShadow'),
|
||||
background: cssVar('backgroundOverlayPanelColor'),
|
||||
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: cssVar('fontH6'),
|
||||
fontWeight: 600,
|
||||
});
|
||||
globalStyle(`${importPageBodyStyle} a`, {
|
||||
whiteSpace: 'nowrap',
|
||||
wordBreak: 'break-word',
|
||||
color: cssVar('linkColor'),
|
||||
textDecoration: 'none',
|
||||
cursor: 'pointer',
|
||||
});
|
||||
@@ -0,0 +1,90 @@
|
||||
import {
|
||||
CloseIcon,
|
||||
ExportToHtmlIcon,
|
||||
ExportToMarkdownIcon,
|
||||
HelpIcon,
|
||||
NewIcon,
|
||||
NotionIcon,
|
||||
} from '@blocksuite/icons/rc';
|
||||
|
||||
import { IconButton } from '../../ui/button';
|
||||
import { Tooltip } from '../../ui/tooltip';
|
||||
import { BlockCard } from '../card/block-card';
|
||||
import {
|
||||
importPageBodyStyle,
|
||||
importPageButtonContainerStyle,
|
||||
importPageContainerStyle,
|
||||
} from './index.css';
|
||||
|
||||
/**
|
||||
* @deprecated Not used
|
||||
*/
|
||||
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();
|
||||
}}
|
||||
>
|
||||
<CloseIcon />
|
||||
</IconButton>
|
||||
<div className={importPageBodyStyle}>
|
||||
<div className="title">Import</div>
|
||||
<span>
|
||||
AFFiNE will gradually support more and more file types for import.
|
||||
<a
|
||||
href="https://affine.pro/redirect/discord"
|
||||
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>
|
||||
);
|
||||
@@ -0,0 +1,91 @@
|
||||
import type { AnimationItem } from 'lottie-web';
|
||||
import lottie from 'lottie-web';
|
||||
import { useEffect, useRef } from 'react';
|
||||
|
||||
export 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 | null>(null);
|
||||
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>;
|
||||
};
|
||||
@@ -0,0 +1,50 @@
|
||||
import { AuthPageContainer } from '@affine/component/auth-components';
|
||||
import type { GetInviteInfoQuery } from '@affine/graphql';
|
||||
import { useI18n } from '@affine/i18n';
|
||||
|
||||
import { Avatar } from '../../ui/avatar';
|
||||
import { Button } from '../../ui/button';
|
||||
import * as styles from './styles.css';
|
||||
export const AcceptInvitePage = ({
|
||||
onOpenWorkspace,
|
||||
inviteInfo,
|
||||
}: {
|
||||
onOpenWorkspace: () => void;
|
||||
inviteInfo: GetInviteInfoQuery['getInviteInfo'];
|
||||
}) => {
|
||||
const t = useI18n();
|
||||
return (
|
||||
<AuthPageContainer
|
||||
title={t['Successfully joined!']()}
|
||||
subtitle={
|
||||
<div className={styles.content}>
|
||||
<div className={styles.userWrapper}>
|
||||
<Avatar
|
||||
url={inviteInfo.user.avatarUrl || ''}
|
||||
name={inviteInfo.user.name}
|
||||
size={20}
|
||||
/>
|
||||
<span className={styles.inviteName}>{inviteInfo.user.name}</span>
|
||||
</div>
|
||||
<div>{t['invited you to join']()}</div>
|
||||
<div className={styles.userWrapper}>
|
||||
<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>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
>
|
||||
<Button variant="primary" size="large" onClick={onOpenWorkspace}>
|
||||
{t['Visit Workspace']()}
|
||||
</Button>
|
||||
</AuthPageContainer>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,18 @@
|
||||
import { useI18n } from '@affine/i18n';
|
||||
|
||||
import { Button } from '../../ui/button';
|
||||
import { AuthPageContainer } from '../auth-components';
|
||||
|
||||
export const ExpiredPage = ({ onOpenAffine }: { onOpenAffine: () => void }) => {
|
||||
const t = useI18n();
|
||||
return (
|
||||
<AuthPageContainer
|
||||
title={t['com.affine.expired.page.title']()}
|
||||
subtitle={t['com.affine.expired.page.new-subtitle']()}
|
||||
>
|
||||
<Button variant="primary" size="large" onClick={onOpenAffine}>
|
||||
{t['com.affine.auth.open.affine']()}
|
||||
</Button>
|
||||
</AuthPageContainer>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,48 @@
|
||||
import {
|
||||
AuthPageContainer,
|
||||
type User,
|
||||
} from '@affine/component/auth-components';
|
||||
import type { GetInviteInfoQuery } from '@affine/graphql';
|
||||
import { Trans, useI18n } from '@affine/i18n';
|
||||
|
||||
import { Avatar } from '../../ui/avatar';
|
||||
import * as styles from './styles.css';
|
||||
export const FailedToSendPage = ({
|
||||
user,
|
||||
inviteInfo,
|
||||
}: {
|
||||
user: User | null;
|
||||
inviteInfo: GetInviteInfoQuery['getInviteInfo'];
|
||||
}) => {
|
||||
const t = useI18n();
|
||||
return (
|
||||
<AuthPageContainer
|
||||
title={t['com.affine.failed-to-send-request.title']()}
|
||||
subtitle={
|
||||
<div className={styles.lineHeight}>
|
||||
<Trans
|
||||
i18nKey="com.affine.failed-to-send-request.description"
|
||||
components={{
|
||||
1: (
|
||||
<div className={styles.avatarWrapper}>
|
||||
<Avatar
|
||||
url={`data:image/png;base64,${inviteInfo.workspace.avatar}`}
|
||||
name={inviteInfo.workspace.name}
|
||||
size={20}
|
||||
colorfulFallback
|
||||
/>
|
||||
</div>
|
||||
),
|
||||
2: <span className={styles.inviteName} />,
|
||||
3: <span className={styles.inviteName} />,
|
||||
}}
|
||||
values={{
|
||||
workspaceName: inviteInfo.workspace.name,
|
||||
userEmail: user?.email,
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
}
|
||||
></AuthPageContainer>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,10 @@
|
||||
export * from './accept-invite-page';
|
||||
export * from './expired';
|
||||
export * from './failed-to-send-page';
|
||||
export * from './invitation-account-mismatch';
|
||||
export * from './invite-modal';
|
||||
export * from './invite-team-modal';
|
||||
export * from './join-failed-page';
|
||||
export * from './member-limit-modal';
|
||||
export * from './request-to-join-page';
|
||||
export * from './sent-request-page';
|
||||
@@ -0,0 +1,52 @@
|
||||
import {
|
||||
AuthPageContainer,
|
||||
type User,
|
||||
} from '@affine/component/auth-components';
|
||||
import { useI18n } from '@affine/i18n';
|
||||
|
||||
import { Avatar } from '../../ui/avatar';
|
||||
import { Button } from '../../ui/button';
|
||||
import * as styles from './styles.css';
|
||||
|
||||
export const InvitationAccountMismatchPage = ({
|
||||
user,
|
||||
switchingAccount,
|
||||
onSwitchAccount,
|
||||
onOpenAffine,
|
||||
}: {
|
||||
user: User | null;
|
||||
switchingAccount: boolean;
|
||||
onSwitchAccount: () => void;
|
||||
onOpenAffine: () => void;
|
||||
}) => {
|
||||
const t = useI18n();
|
||||
|
||||
return (
|
||||
<AuthPageContainer
|
||||
title={t['com.affine.invitation.account-mismatch.title']()}
|
||||
subtitle={t['com.affine.invitation.account-mismatch.description']()}
|
||||
>
|
||||
{user ? (
|
||||
<div className={styles.currentAccount}>
|
||||
<Avatar url={user.avatar ?? user.image} name={user.label} />
|
||||
<span>{user.email}</span>
|
||||
</div>
|
||||
) : null}
|
||||
<div className={styles.accountMismatchActions}>
|
||||
<Button
|
||||
variant="primary"
|
||||
size="large"
|
||||
loading={switchingAccount}
|
||||
disabled={switchingAccount}
|
||||
onClick={onSwitchAccount}
|
||||
block
|
||||
>
|
||||
{t['com.affine.invitation.account-mismatch.switch-account']()}
|
||||
</Button>
|
||||
<Button size="large" onClick={onOpenAffine} block>
|
||||
{t['com.affine.invitation.account-mismatch.back-to-affine']()}
|
||||
</Button>
|
||||
</div>
|
||||
</AuthPageContainer>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,80 @@
|
||||
import { Permission } from '@affine/graphql';
|
||||
import { useI18n } from '@affine/i18n';
|
||||
import { useCallback, useEffect, useState } from 'react';
|
||||
|
||||
import { ConfirmModal } from '../../ui/modal';
|
||||
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 = useI18n();
|
||||
const [inviteEmail, setInviteEmail] = useState('');
|
||||
const [permission] = useState(Permission.Collaborator);
|
||||
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',
|
||||
},
|
||||
}}
|
||||
confirmText={t['Invite']()}
|
||||
confirmButtonOptions={{
|
||||
loading: isMutating,
|
||||
variant: 'primary',
|
||||
'data-testid': 'confirm-enable-affine-cloud-button',
|
||||
}}
|
||||
onConfirm={handleConfirm}
|
||||
>
|
||||
<AuthInput
|
||||
disabled={isMutating}
|
||||
placeholder="email@example.com"
|
||||
value={inviteEmail}
|
||||
onChange={setInviteEmail}
|
||||
error={!isValidEmail}
|
||||
errorHint={isValidEmail ? '' : t['com.affine.auth.sign.email.error']()}
|
||||
onEnter={handleConfirm}
|
||||
size="large"
|
||||
/>
|
||||
</ConfirmModal>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,49 @@
|
||||
import { useI18n } from '@affine/i18n';
|
||||
import { cssVar } from '@toeverything/theme';
|
||||
|
||||
import Input from '../../../ui/input';
|
||||
import * as styles from './styles.css';
|
||||
|
||||
export const EmailInvite = ({
|
||||
inviteEmail,
|
||||
setInviteEmail,
|
||||
handleConfirm,
|
||||
importCSV,
|
||||
isMutating,
|
||||
isValidEmail,
|
||||
}: {
|
||||
inviteEmail: string;
|
||||
setInviteEmail: (value: string) => void;
|
||||
handleConfirm: () => void;
|
||||
isMutating: boolean;
|
||||
isValidEmail: boolean;
|
||||
importCSV: React.ReactNode;
|
||||
}) => {
|
||||
const t = useI18n();
|
||||
return (
|
||||
<>
|
||||
<div className={styles.modalSubTitle}>
|
||||
{t['com.affine.payment.member.team.invite.email-invite']()}
|
||||
</div>
|
||||
<div>
|
||||
<Input
|
||||
inputStyle={{ fontSize: cssVar('fontXs') }}
|
||||
disabled={isMutating}
|
||||
placeholder={t[
|
||||
'com.affine.payment.member.team.invite.email-placeholder'
|
||||
]()}
|
||||
value={inviteEmail}
|
||||
onChange={setInviteEmail}
|
||||
onEnter={handleConfirm}
|
||||
size="large"
|
||||
/>
|
||||
{!isValidEmail ? (
|
||||
<div className={styles.errorHint}>
|
||||
{t['com.affine.auth.sign.email.error']()}
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
<div>{importCSV}</div>
|
||||
</>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,121 @@
|
||||
import { emailRegex } from '@affine/component/auth-components';
|
||||
import type {
|
||||
InviteLink,
|
||||
WorkspaceInviteLinkExpireTime,
|
||||
} from '@affine/graphql';
|
||||
import { useI18n } from '@affine/i18n';
|
||||
import { useCallback, useEffect, useState } from 'react';
|
||||
|
||||
import { ConfirmModal } from '../../../ui/modal';
|
||||
import { type InviteMethodType, ModalContent } from './modal-content';
|
||||
import * as styles from './styles.css';
|
||||
|
||||
export interface InviteTeamMemberModalProps {
|
||||
open: boolean;
|
||||
setOpen: (value: boolean) => void;
|
||||
onConfirm: (params: { emails: string[] }) => void;
|
||||
isMutating: boolean;
|
||||
copyTextToClipboard: (text: string) => Promise<boolean>;
|
||||
onGenerateInviteLink: (
|
||||
expireTime: WorkspaceInviteLinkExpireTime
|
||||
) => Promise<string>;
|
||||
onRevokeInviteLink: () => Promise<boolean>;
|
||||
importCSV: React.ReactNode;
|
||||
invitationLink: InviteLink | null;
|
||||
}
|
||||
|
||||
const parseEmailString = (emailString: string): string[] => {
|
||||
return emailString
|
||||
.split(',')
|
||||
.map(email => email.trim())
|
||||
.filter(email => email.length > 0);
|
||||
};
|
||||
|
||||
export const InviteTeamMemberModal = ({
|
||||
open,
|
||||
setOpen,
|
||||
onConfirm,
|
||||
isMutating,
|
||||
copyTextToClipboard,
|
||||
onGenerateInviteLink,
|
||||
onRevokeInviteLink,
|
||||
importCSV,
|
||||
invitationLink,
|
||||
}: InviteTeamMemberModalProps) => {
|
||||
const t = useI18n();
|
||||
const [inviteEmails, setInviteEmails] = useState('');
|
||||
const [isValidEmail, setIsValidEmail] = useState(true);
|
||||
const [inviteMethod, setInviteMethod] = useState<InviteMethodType>('email');
|
||||
|
||||
const handleConfirm = useCallback(() => {
|
||||
if (inviteMethod === 'link') {
|
||||
setOpen(false);
|
||||
return;
|
||||
}
|
||||
const inviteEmailsArray = parseEmailString(inviteEmails);
|
||||
const invalidEmail = inviteEmailsArray.find(
|
||||
email => !emailRegex.test(email)
|
||||
);
|
||||
if (invalidEmail) {
|
||||
setIsValidEmail(false);
|
||||
return;
|
||||
}
|
||||
setIsValidEmail(true);
|
||||
|
||||
onConfirm({
|
||||
emails: inviteEmailsArray,
|
||||
});
|
||||
}, [inviteEmails, inviteMethod, onConfirm, setOpen]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!open) {
|
||||
setInviteEmails('');
|
||||
setIsValidEmail(true);
|
||||
}
|
||||
}, [open]);
|
||||
|
||||
return (
|
||||
<ConfirmModal
|
||||
width={480}
|
||||
open={open}
|
||||
onOpenChange={setOpen}
|
||||
title={t['com.affine.payment.member.team.invite.title']()}
|
||||
cancelText={t['com.affine.inviteModal.button.cancel']()}
|
||||
cancelButtonOptions={{
|
||||
variant: 'secondary',
|
||||
}}
|
||||
contentOptions={{
|
||||
['data-testid' as string]: 'invite-modal',
|
||||
style: {
|
||||
padding: '20px 24px',
|
||||
},
|
||||
}}
|
||||
confirmText={
|
||||
inviteMethod === 'email'
|
||||
? t['com.affine.payment.member.team.invite.send-invites']()
|
||||
: t['com.affine.payment.member.team.invite.done']()
|
||||
}
|
||||
confirmButtonOptions={{
|
||||
loading: isMutating,
|
||||
variant: 'primary',
|
||||
}}
|
||||
onConfirm={handleConfirm}
|
||||
childrenContentClassName={styles.contentStyle}
|
||||
>
|
||||
<ModalContent
|
||||
invitationLink={invitationLink}
|
||||
inviteEmail={inviteEmails}
|
||||
setInviteEmail={setInviteEmails}
|
||||
handleConfirm={handleConfirm}
|
||||
isMutating={isMutating}
|
||||
isValidEmail={isValidEmail}
|
||||
inviteMethod={inviteMethod}
|
||||
importCSV={importCSV}
|
||||
onInviteMethodChange={setInviteMethod}
|
||||
copyTextToClipboard={copyTextToClipboard}
|
||||
onGenerateInviteLink={onGenerateInviteLink}
|
||||
onRevokeInviteLink={onRevokeInviteLink}
|
||||
/>
|
||||
</ConfirmModal>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,193 @@
|
||||
import {
|
||||
type InviteLink,
|
||||
WorkspaceInviteLinkExpireTime,
|
||||
} from '@affine/graphql';
|
||||
import { useI18n } from '@affine/i18n';
|
||||
import { CloseIcon } from '@blocksuite/icons/rc';
|
||||
import { cssVar } from '@toeverything/theme';
|
||||
import { cssVarV2 } from '@toeverything/theme/v2';
|
||||
import { useCallback, useMemo, useState } from 'react';
|
||||
|
||||
import { Button, IconButton } from '../../../ui/button';
|
||||
import Input from '../../../ui/input';
|
||||
import { Menu, MenuItem, MenuTrigger } from '../../../ui/menu';
|
||||
import { notify } from '../../../ui/notification';
|
||||
import * as styles from './styles.css';
|
||||
|
||||
const getMenuItems = (t: ReturnType<typeof useI18n>) => [
|
||||
{
|
||||
label: t['com.affine.payment.member.team.invite.expiration-date']({
|
||||
number: '1',
|
||||
}),
|
||||
value: WorkspaceInviteLinkExpireTime.OneDay,
|
||||
},
|
||||
{
|
||||
label: t['com.affine.payment.member.team.invite.expiration-date']({
|
||||
number: '3',
|
||||
}),
|
||||
value: WorkspaceInviteLinkExpireTime.ThreeDays,
|
||||
},
|
||||
{
|
||||
label: t['com.affine.payment.member.team.invite.expiration-date']({
|
||||
number: '7',
|
||||
}),
|
||||
value: WorkspaceInviteLinkExpireTime.OneWeek,
|
||||
},
|
||||
{
|
||||
label: t['com.affine.payment.member.team.invite.expiration-date']({
|
||||
number: '30',
|
||||
}),
|
||||
value: WorkspaceInviteLinkExpireTime.OneMonth,
|
||||
},
|
||||
];
|
||||
|
||||
export const LinkInvite = ({
|
||||
invitationLink,
|
||||
copyTextToClipboard,
|
||||
generateInvitationLink,
|
||||
revokeInvitationLink,
|
||||
}: {
|
||||
invitationLink: InviteLink | null;
|
||||
generateInvitationLink: (
|
||||
expireTime: WorkspaceInviteLinkExpireTime
|
||||
) => Promise<string>;
|
||||
revokeInvitationLink: () => Promise<boolean>;
|
||||
copyTextToClipboard: (text: string) => Promise<boolean>;
|
||||
}) => {
|
||||
const t = useI18n();
|
||||
const [selectedValue, setSelectedValue] = useState(
|
||||
WorkspaceInviteLinkExpireTime.OneWeek
|
||||
);
|
||||
const menuItems = getMenuItems(t);
|
||||
const items = useMemo(() => {
|
||||
return menuItems.map(item => (
|
||||
<MenuItem key={item.value} onSelect={() => setSelectedValue(item.value)}>
|
||||
{item.label}
|
||||
</MenuItem>
|
||||
));
|
||||
}, [menuItems]);
|
||||
|
||||
const currentSelectedLabel = useMemo(
|
||||
() => menuItems.find(item => item.value === selectedValue)?.label,
|
||||
[menuItems, selectedValue]
|
||||
);
|
||||
|
||||
const onGenerate = useCallback(() => {
|
||||
generateInvitationLink(selectedValue).catch(err => {
|
||||
console.error('Failed to generate invitation link: ', err);
|
||||
notify.error({
|
||||
title: 'Failed to generate invitation link',
|
||||
message: err.message,
|
||||
});
|
||||
});
|
||||
}, [generateInvitationLink, selectedValue]);
|
||||
|
||||
const onCopy = useCallback(() => {
|
||||
if (!invitationLink) {
|
||||
return;
|
||||
}
|
||||
copyTextToClipboard(invitationLink.link)
|
||||
.then(() =>
|
||||
notify.success({
|
||||
title: t['Copied link to clipboard'](),
|
||||
})
|
||||
)
|
||||
.catch(err => {
|
||||
console.error('Failed to copy text: ', err);
|
||||
notify.error({
|
||||
title: 'Failed to copy link to clipboard',
|
||||
message: err.message,
|
||||
});
|
||||
});
|
||||
}, [copyTextToClipboard, invitationLink, t]);
|
||||
|
||||
const onReset = useCallback(() => {
|
||||
revokeInvitationLink().catch(err => {
|
||||
console.error('Failed to revoke invitation link: ', err);
|
||||
notify.error({
|
||||
title: 'Failed to revoke invitation link',
|
||||
message: err.message,
|
||||
});
|
||||
});
|
||||
}, [revokeInvitationLink]);
|
||||
|
||||
const expireTime = useMemo(() => {
|
||||
return t['com.affine.payment.member.team.invite.expire-at']({
|
||||
expireTime: invitationLink?.expireTime
|
||||
? new Date(invitationLink.expireTime).toLocaleString()
|
||||
: '',
|
||||
});
|
||||
}, [invitationLink, t]);
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className={styles.invitationLinkContainer}>
|
||||
<div className={styles.modalSubTitle}>
|
||||
{t['com.affine.payment.member.team.invite.link-expiration']()}
|
||||
</div>
|
||||
{invitationLink ? (
|
||||
<Input
|
||||
value={expireTime}
|
||||
disabled
|
||||
style={{
|
||||
backgroundColor: cssVarV2('input/background'),
|
||||
}}
|
||||
/>
|
||||
) : (
|
||||
<Menu
|
||||
items={items}
|
||||
contentOptions={{
|
||||
style: {
|
||||
width: 'var(--radix-dropdown-menu-trigger-width)',
|
||||
},
|
||||
}}
|
||||
>
|
||||
<MenuTrigger style={{ width: '100%' }}>
|
||||
{currentSelectedLabel}
|
||||
</MenuTrigger>
|
||||
</Menu>
|
||||
)}
|
||||
</div>
|
||||
<div className={styles.invitationLinkContainer}>
|
||||
<div className={styles.modalSubTitle}>
|
||||
{t['com.affine.payment.member.team.invite.invitation-link']()}
|
||||
</div>
|
||||
<div className={styles.invitationLinkContent}>
|
||||
<Input
|
||||
value={
|
||||
invitationLink
|
||||
? invitationLink.link
|
||||
: 'https://your-app.com/invite/xxxxxxxx'
|
||||
}
|
||||
inputMode="none"
|
||||
disabled
|
||||
inputStyle={{
|
||||
fontSize: cssVar('fontXs'),
|
||||
color: cssVarV2(
|
||||
invitationLink ? 'text/primary' : 'text/placeholder'
|
||||
),
|
||||
backgroundColor: cssVarV2('layer/background/primary'),
|
||||
}}
|
||||
/>
|
||||
{invitationLink ? (
|
||||
<>
|
||||
<Button onClick={onCopy} variant="secondary">
|
||||
{t['com.affine.payment.member.team.invite.copy']()}
|
||||
</Button>
|
||||
<IconButton icon={<CloseIcon />} onClick={onReset} />
|
||||
</>
|
||||
) : (
|
||||
<Button onClick={onGenerate} variant="secondary">
|
||||
{t['com.affine.payment.member.team.invite.generate']()}
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
<p className={styles.invitationLinkDescription}>
|
||||
{t[
|
||||
'com.affine.payment.member.team.invite.invitation-link.description'
|
||||
]()}
|
||||
</p>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,109 @@
|
||||
import type {
|
||||
InviteLink,
|
||||
WorkspaceInviteLinkExpireTime,
|
||||
} from '@affine/graphql';
|
||||
import { useI18n } from '@affine/i18n';
|
||||
import { EmailIcon, LinkIcon } from '@blocksuite/icons/rc';
|
||||
|
||||
import { RadioGroup } from '../../../ui/radio';
|
||||
import { EmailInvite } from './email-invite';
|
||||
import { LinkInvite } from './link-invite';
|
||||
import * as styles from './styles.css';
|
||||
|
||||
export type InviteMethodType = 'email' | 'link';
|
||||
export const ModalContent = ({
|
||||
inviteEmail,
|
||||
setInviteEmail,
|
||||
inviteMethod,
|
||||
onInviteMethodChange,
|
||||
handleConfirm,
|
||||
isMutating,
|
||||
isValidEmail,
|
||||
copyTextToClipboard,
|
||||
onGenerateInviteLink,
|
||||
onRevokeInviteLink,
|
||||
importCSV,
|
||||
invitationLink,
|
||||
}: {
|
||||
inviteEmail: string;
|
||||
importCSV: React.ReactNode;
|
||||
invitationLink: InviteLink | null;
|
||||
setInviteEmail: (value: string) => void;
|
||||
inviteMethod: InviteMethodType;
|
||||
onInviteMethodChange: (value: InviteMethodType) => void;
|
||||
handleConfirm: () => void;
|
||||
isMutating: boolean;
|
||||
isValidEmail: boolean;
|
||||
copyTextToClipboard: (text: string) => Promise<boolean>;
|
||||
onGenerateInviteLink: (
|
||||
expireTime: WorkspaceInviteLinkExpireTime
|
||||
) => Promise<string>;
|
||||
onRevokeInviteLink: () => Promise<boolean>;
|
||||
}) => {
|
||||
const t = useI18n();
|
||||
|
||||
return (
|
||||
<div className={styles.modalContent}>
|
||||
<div>{t['com.affine.payment.member.team.invite.description']()}</div>
|
||||
<RadioGroup
|
||||
width={'100%'}
|
||||
value={inviteMethod}
|
||||
onChange={onInviteMethodChange}
|
||||
items={[
|
||||
{
|
||||
label: (
|
||||
<RadioItem
|
||||
icon={<EmailIcon className={styles.iconStyle} />}
|
||||
label={t[
|
||||
'com.affine.payment.member.team.invite.email-invite'
|
||||
]()}
|
||||
/>
|
||||
),
|
||||
value: 'email',
|
||||
},
|
||||
{
|
||||
label: (
|
||||
<RadioItem
|
||||
icon={<LinkIcon className={styles.iconStyle} />}
|
||||
label={t['com.affine.payment.member.team.invite.invite-link']()}
|
||||
/>
|
||||
),
|
||||
value: 'link',
|
||||
},
|
||||
]}
|
||||
/>
|
||||
{inviteMethod === 'email' ? (
|
||||
<EmailInvite
|
||||
inviteEmail={inviteEmail}
|
||||
setInviteEmail={setInviteEmail}
|
||||
handleConfirm={handleConfirm}
|
||||
isMutating={isMutating}
|
||||
isValidEmail={isValidEmail}
|
||||
importCSV={importCSV}
|
||||
/>
|
||||
) : (
|
||||
<LinkInvite
|
||||
invitationLink={invitationLink}
|
||||
copyTextToClipboard={copyTextToClipboard}
|
||||
generateInvitationLink={onGenerateInviteLink}
|
||||
revokeInvitationLink={onRevokeInviteLink}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
const RadioItem = ({
|
||||
icon,
|
||||
label,
|
||||
}: {
|
||||
icon: React.ReactNode;
|
||||
label: string;
|
||||
}) => {
|
||||
return (
|
||||
<div className={styles.radioItem}>
|
||||
{icon}
|
||||
<div>{label}</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,120 @@
|
||||
import { cssVar } from '@toeverything/theme';
|
||||
import { cssVarV2 } from '@toeverything/theme/v2';
|
||||
import { globalStyle, style } from '@vanilla-extract/css';
|
||||
|
||||
export const inviteModalTitle = style({
|
||||
fontWeight: '600',
|
||||
fontSize: cssVar('fontH6'),
|
||||
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: cssVarV2('text/primary'),
|
||||
});
|
||||
|
||||
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: cssVar('fontXs'),
|
||||
color: cssVarV2('text/primary'),
|
||||
borderRadius: '4px',
|
||||
|
||||
selectors: {
|
||||
'&:hover': {
|
||||
background: cssVarV2('layer/background/hoverOverlay'),
|
||||
},
|
||||
'&.active': {
|
||||
color: cssVarV2('text/emphasis'),
|
||||
cursor: 'default',
|
||||
pointerEvents: 'none',
|
||||
},
|
||||
'&.label': {
|
||||
color: cssVarV2('icon/primary'),
|
||||
fontSize: '16px',
|
||||
},
|
||||
'&.disabled': {
|
||||
opacity: '.4',
|
||||
cursor: 'default',
|
||||
color: cssVarV2('text/disable'),
|
||||
pointerEvents: 'none',
|
||||
},
|
||||
},
|
||||
});
|
||||
globalStyle(`${pageItem} a`, {
|
||||
width: '100%',
|
||||
display: 'flex',
|
||||
justifyContent: 'center',
|
||||
alignItems: 'center',
|
||||
});
|
||||
|
||||
export const modalContent = style({
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
gap: '12px',
|
||||
});
|
||||
|
||||
export const modalSubTitle = style({
|
||||
fontSize: cssVar('fontSm'),
|
||||
fontWeight: '500',
|
||||
});
|
||||
|
||||
export const invitationLinkContainer = style({
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
gap: '8px',
|
||||
});
|
||||
|
||||
export const radioItem = style({
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
gap: '4px',
|
||||
});
|
||||
|
||||
export const iconStyle = style({
|
||||
color: cssVarV2('icon/primary'),
|
||||
fontSize: '16px',
|
||||
});
|
||||
|
||||
export const errorHint = style({
|
||||
color: cssVarV2('status/error'),
|
||||
fontSize: cssVar('fontXs'),
|
||||
});
|
||||
|
||||
export const contentStyle = style({
|
||||
paddingLeft: '0',
|
||||
paddingRight: '0',
|
||||
overflowY: 'visible',
|
||||
});
|
||||
|
||||
export const invitationLinkContent = style({
|
||||
display: 'flex',
|
||||
gap: '8px',
|
||||
});
|
||||
|
||||
export const invitationLinkDescription = style({
|
||||
color: cssVarV2('text/secondary'),
|
||||
fontSize: cssVar('fontSm'),
|
||||
});
|
||||
@@ -0,0 +1,55 @@
|
||||
import { AuthPageContainer } from '@affine/component/auth-components';
|
||||
import { UserFriendlyError } from '@affine/error';
|
||||
import { ErrorNames, type GetInviteInfoQuery } from '@affine/graphql';
|
||||
import { Trans, useI18n } from '@affine/i18n';
|
||||
|
||||
import { Avatar } from '../../ui/avatar';
|
||||
import * as styles from './styles.css';
|
||||
|
||||
export const JoinFailedPage = ({
|
||||
inviteInfo,
|
||||
error,
|
||||
}: {
|
||||
inviteInfo?: GetInviteInfoQuery['getInviteInfo'];
|
||||
error?: any;
|
||||
}) => {
|
||||
const userFriendlyError = UserFriendlyError.fromAny(error);
|
||||
const t = useI18n();
|
||||
return (
|
||||
<AuthPageContainer
|
||||
title={t['com.affine.fail-to-join-workspace.title']()}
|
||||
subtitle={
|
||||
userFriendlyError.name === ErrorNames.MEMBER_QUOTA_EXCEEDED ? (
|
||||
<div className={styles.lineHeight}>
|
||||
<Trans
|
||||
i18nKey={'com.affine.fail-to-join-workspace.description-1'}
|
||||
components={{
|
||||
1: (
|
||||
<div className={styles.avatarWrapper}>
|
||||
<Avatar
|
||||
url={`data:image/png;base64,${inviteInfo?.workspace.avatar}`}
|
||||
name={inviteInfo?.workspace.name}
|
||||
size={20}
|
||||
colorfulFallback
|
||||
/>
|
||||
</div>
|
||||
),
|
||||
2: <span className={styles.inviteName} />,
|
||||
}}
|
||||
values={{
|
||||
workspaceName: inviteInfo?.workspace.name,
|
||||
}}
|
||||
/>
|
||||
<div>{t['com.affine.fail-to-join-workspace.description-2']()}</div>
|
||||
</div>
|
||||
) : (
|
||||
<div>
|
||||
{t['error.' + userFriendlyError.name]()}
|
||||
<br />
|
||||
{userFriendlyError.message}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
/>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,27 @@
|
||||
import { cssVar } from '@toeverything/theme';
|
||||
import { cssVarV2 } from '@toeverything/theme/v2';
|
||||
import { style } from '@vanilla-extract/css';
|
||||
|
||||
export const ulStyle = style({
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
justifyContent: 'center',
|
||||
gap: '8px',
|
||||
marginTop: '12px',
|
||||
});
|
||||
|
||||
export const liStyle = style({
|
||||
display: 'flex',
|
||||
flexDirection: 'row',
|
||||
alignItems: 'start',
|
||||
fontSize: cssVar('fontBase'),
|
||||
});
|
||||
|
||||
export const prefixDot = style({
|
||||
background: cssVarV2('icon/activated'),
|
||||
width: '5px',
|
||||
height: '5px',
|
||||
borderRadius: '50%',
|
||||
marginRight: '12px',
|
||||
marginTop: '10px',
|
||||
});
|
||||
@@ -0,0 +1,83 @@
|
||||
import { ConfirmModal } from '@affine/component/ui/modal';
|
||||
import { useI18n } from '@affine/i18n';
|
||||
import { useCallback } from 'react';
|
||||
|
||||
import * as styles from './member-limit-modal.css';
|
||||
|
||||
export interface MemberLimitModalProps {
|
||||
isFreePlan: boolean;
|
||||
open: boolean;
|
||||
plan: string;
|
||||
quota: string;
|
||||
setOpen: (value: boolean) => void;
|
||||
onConfirm: () => void;
|
||||
}
|
||||
|
||||
export const MemberLimitModal = ({
|
||||
isFreePlan,
|
||||
open,
|
||||
plan,
|
||||
quota,
|
||||
setOpen,
|
||||
onConfirm,
|
||||
}: MemberLimitModalProps) => {
|
||||
const t = useI18n();
|
||||
const handleConfirm = useCallback(() => {
|
||||
setOpen(false);
|
||||
onConfirm();
|
||||
}, [onConfirm, setOpen]);
|
||||
|
||||
return (
|
||||
<ConfirmModal
|
||||
open={open}
|
||||
onOpenChange={setOpen}
|
||||
title={t['com.affine.payment.member-limit.title']()}
|
||||
description={
|
||||
<ConfirmDescription plan={plan} quota={quota} isFreePlan={isFreePlan} />
|
||||
}
|
||||
confirmText={t['com.affine.payment.upgrade']()}
|
||||
confirmButtonOptions={{
|
||||
variant: 'primary',
|
||||
}}
|
||||
onConfirm={handleConfirm}
|
||||
></ConfirmModal>
|
||||
);
|
||||
};
|
||||
|
||||
export const ConfirmDescription = ({
|
||||
isFreePlan,
|
||||
plan,
|
||||
quota,
|
||||
}: {
|
||||
isFreePlan: boolean;
|
||||
plan: string;
|
||||
quota: string;
|
||||
}) => {
|
||||
const t = useI18n();
|
||||
return (
|
||||
<div>
|
||||
{t['com.affine.payment.member-limit.description']({
|
||||
planName: plan,
|
||||
quota: quota,
|
||||
})}
|
||||
<ul className={styles.ulStyle}>
|
||||
{isFreePlan && (
|
||||
<li className={styles.liStyle}>
|
||||
<div className={styles.prefixDot} />
|
||||
{t[
|
||||
'com.affine.payment.member-limit.description.tips-for-free-plan'
|
||||
]()}
|
||||
</li>
|
||||
)}
|
||||
<li className={styles.liStyle}>
|
||||
<div className={styles.prefixDot} />
|
||||
{t['com.affine.payment.member-limit.description.tips-1']()}
|
||||
</li>
|
||||
<li className={styles.liStyle}>
|
||||
<div className={styles.prefixDot} />
|
||||
{t['com.affine.payment.member-limit.description.tips-2']()}
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,76 @@
|
||||
import {
|
||||
AuthPageContainer,
|
||||
type User,
|
||||
} from '@affine/component/auth-components';
|
||||
import {
|
||||
type GetInviteInfoQuery,
|
||||
WorkspaceMemberStatus,
|
||||
} from '@affine/graphql';
|
||||
import { useI18n } from '@affine/i18n';
|
||||
import { SignOutIcon } from '@blocksuite/icons/rc';
|
||||
|
||||
import { Avatar } from '../../ui/avatar';
|
||||
import { Button, IconButton } from '../../ui/button';
|
||||
import * as styles from './styles.css';
|
||||
export const RequestToJoinPage = ({
|
||||
user,
|
||||
inviteInfo,
|
||||
requestToJoin,
|
||||
onSignOut,
|
||||
}: {
|
||||
user: User | null;
|
||||
inviteInfo?: GetInviteInfoQuery['getInviteInfo'];
|
||||
requestToJoin: () => void;
|
||||
onSignOut: () => void;
|
||||
}) => {
|
||||
const t = useI18n();
|
||||
|
||||
return (
|
||||
<AuthPageContainer
|
||||
subtitle={
|
||||
<div className={styles.content}>
|
||||
<div className={styles.userWrapper}>
|
||||
<Avatar
|
||||
url={inviteInfo?.user.avatarUrl || ''}
|
||||
name={inviteInfo?.user.name}
|
||||
size={20}
|
||||
/>
|
||||
<span className={styles.inviteName}>{inviteInfo?.user.name}</span>
|
||||
</div>
|
||||
<div>{t['invited you to join']()}</div>
|
||||
<div className={styles.userWrapper}>
|
||||
<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>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
>
|
||||
<Button variant="primary" size="large" onClick={requestToJoin}>
|
||||
{inviteInfo?.status === WorkspaceMemberStatus.Pending
|
||||
? t['com.affine.notification.invitation.accept']()
|
||||
: t['com.affine.request-to-join-workspace.button']()}
|
||||
</Button>
|
||||
{user ? (
|
||||
<div className={styles.userInfoWrapper}>
|
||||
<Avatar url={user.avatar ?? user.image} name={user.label} />
|
||||
<span>{user.email}</span>
|
||||
<IconButton
|
||||
onClick={onSignOut}
|
||||
size="20"
|
||||
tooltip={t['404.signOut']()}
|
||||
>
|
||||
<SignOutIcon />
|
||||
</IconButton>
|
||||
</div>
|
||||
) : null}
|
||||
</AuthPageContainer>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,48 @@
|
||||
import {
|
||||
AuthPageContainer,
|
||||
type User,
|
||||
} from '@affine/component/auth-components';
|
||||
import type { GetInviteInfoQuery } from '@affine/graphql';
|
||||
import { Trans, useI18n } from '@affine/i18n';
|
||||
|
||||
import { Avatar } from '../../ui/avatar';
|
||||
import * as styles from './styles.css';
|
||||
export const SentRequestPage = ({
|
||||
user,
|
||||
inviteInfo,
|
||||
}: {
|
||||
user: User | null;
|
||||
inviteInfo: GetInviteInfoQuery['getInviteInfo'];
|
||||
}) => {
|
||||
const t = useI18n();
|
||||
return (
|
||||
<AuthPageContainer
|
||||
title={t['com.affine.sent-request-to-join-workspace.title']()}
|
||||
subtitle={
|
||||
<div className={styles.lineHeight}>
|
||||
<Trans
|
||||
i18nKey="com.affine.sent-request-to-join-workspace.description"
|
||||
components={{
|
||||
1: (
|
||||
<div className={styles.avatarWrapper}>
|
||||
<Avatar
|
||||
url={`data:image/png;base64,${inviteInfo.workspace.avatar}`}
|
||||
name={inviteInfo.workspace.name}
|
||||
size={20}
|
||||
colorfulFallback
|
||||
/>
|
||||
</div>
|
||||
),
|
||||
2: <span className={styles.inviteName} />,
|
||||
3: <span className={styles.inviteName} />,
|
||||
}}
|
||||
values={{
|
||||
workspaceName: inviteInfo.workspace.name,
|
||||
userEmail: user?.email,
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
}
|
||||
></AuthPageContainer>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,97 @@
|
||||
import { cssVar } from '@toeverything/theme';
|
||||
import { cssVarV2 } from '@toeverything/theme/v2';
|
||||
import { style } from '@vanilla-extract/css';
|
||||
|
||||
export const inviteModalTitle = style({
|
||||
fontWeight: '600',
|
||||
fontSize: cssVar('fontH6'),
|
||||
marginBottom: '20px',
|
||||
});
|
||||
|
||||
export const inviteModalContent = style({
|
||||
marginBottom: '10px',
|
||||
});
|
||||
|
||||
export const inviteModalButtonContainer = style({
|
||||
display: 'flex',
|
||||
justifyContent: 'flex-end',
|
||||
});
|
||||
|
||||
export const inviteName = style({
|
||||
color: cssVarV2('text/primary'),
|
||||
fontWeight: '600',
|
||||
});
|
||||
|
||||
export const avatarWrapper = style({
|
||||
verticalAlign: 'sub',
|
||||
display: 'inline-block',
|
||||
});
|
||||
|
||||
export const userInfoWrapper = style({
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
gap: '12px',
|
||||
marginTop: '28px',
|
||||
});
|
||||
|
||||
export const currentAccount = style({
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
gap: '12px',
|
||||
});
|
||||
|
||||
export const accountMismatchActions = style({
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
gap: '12px',
|
||||
width: '100%',
|
||||
});
|
||||
|
||||
export const lineHeight = style({
|
||||
lineHeight: '1.5',
|
||||
});
|
||||
|
||||
export const content = style({
|
||||
display: 'flex',
|
||||
flexWrap: 'wrap',
|
||||
alignItems: 'center',
|
||||
gap: '8px',
|
||||
});
|
||||
|
||||
export const userWrapper = style({
|
||||
display: 'inline-flex',
|
||||
alignItems: 'center',
|
||||
gap: '4px',
|
||||
});
|
||||
|
||||
export const modalContent = style({
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
gap: '12px',
|
||||
});
|
||||
|
||||
export const modalSubTitle = style({
|
||||
fontSize: cssVar('fontSm'),
|
||||
fontWeight: '500',
|
||||
});
|
||||
|
||||
export const radioItem = style({
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
gap: '4px',
|
||||
});
|
||||
|
||||
export const iconStyle = style({
|
||||
color: cssVarV2('icon/primary'),
|
||||
fontSize: '16px',
|
||||
});
|
||||
|
||||
export const errorHint = style({
|
||||
color: cssVarV2('status/error'),
|
||||
fontSize: cssVar('fontXs'),
|
||||
});
|
||||
|
||||
export const importButton = style({
|
||||
padding: '4px 8px',
|
||||
});
|
||||
@@ -0,0 +1 @@
|
||||
export * from './not-found-page';
|
||||
@@ -0,0 +1,127 @@
|
||||
import { useI18n } from '@affine/i18n';
|
||||
import { SignOutIcon } from '@blocksuite/icons/rc';
|
||||
import type { JSX } from 'react';
|
||||
|
||||
import { Avatar } from '../../ui/avatar';
|
||||
import { Button, IconButton } from '../../ui/button';
|
||||
import { ThemedImg } from '../../ui/themed-img';
|
||||
import { AffineOtherPageLayout } from '../affine-other-page-layout';
|
||||
import illustrationDark from '../affine-other-page-layout/assets/other-page.dark.png';
|
||||
import illustrationLight from '../affine-other-page-layout/assets/other-page.light.png';
|
||||
import type { User } from '../auth-components';
|
||||
import {
|
||||
illustration,
|
||||
info,
|
||||
largeButtonEffect,
|
||||
notFoundPageContainer,
|
||||
wrapper,
|
||||
} from './styles.css';
|
||||
|
||||
export interface NotFoundPageProps {
|
||||
user?: User | null;
|
||||
signInComponent?: JSX.Element;
|
||||
onBack: () => void;
|
||||
onSignOut: () => void;
|
||||
}
|
||||
export const NoPermissionOrNotFound = ({
|
||||
user,
|
||||
onBack,
|
||||
onSignOut,
|
||||
signInComponent,
|
||||
}: NotFoundPageProps) => {
|
||||
const t = useI18n();
|
||||
|
||||
return (
|
||||
<AffineOtherPageLayout>
|
||||
<div className={notFoundPageContainer} data-testid="not-found">
|
||||
{user ? (
|
||||
<>
|
||||
<div className={info}>
|
||||
<p className={wrapper}>{t['404.hint']()}</p>
|
||||
<div className={wrapper}>
|
||||
<Button
|
||||
variant="primary"
|
||||
size="extraLarge"
|
||||
onClick={onBack}
|
||||
className={largeButtonEffect}
|
||||
>
|
||||
{t['404.back']()}
|
||||
</Button>
|
||||
</div>
|
||||
<div className={wrapper}>
|
||||
<Avatar url={user.avatar ?? user.image} name={user.label} />
|
||||
<span style={{ margin: '0 12px' }}>{user.email}</span>
|
||||
<IconButton
|
||||
onClick={onSignOut}
|
||||
size="20"
|
||||
tooltip={t['404.signOut']()}
|
||||
>
|
||||
<SignOutIcon />
|
||||
</IconButton>
|
||||
</div>
|
||||
</div>
|
||||
<div className={wrapper}>
|
||||
<ThemedImg
|
||||
draggable={false}
|
||||
className={illustration}
|
||||
lightSrc={illustrationLight}
|
||||
darkSrc={illustrationDark}
|
||||
/>
|
||||
</div>
|
||||
</>
|
||||
) : (
|
||||
signInComponent
|
||||
)}
|
||||
</div>
|
||||
</AffineOtherPageLayout>
|
||||
);
|
||||
};
|
||||
|
||||
export const NotFoundPage = ({
|
||||
user,
|
||||
onBack,
|
||||
onSignOut,
|
||||
}: NotFoundPageProps) => {
|
||||
const t = useI18n();
|
||||
|
||||
return (
|
||||
<AffineOtherPageLayout>
|
||||
<div className={notFoundPageContainer} data-testid="not-found">
|
||||
<div className={info}>
|
||||
<p className={wrapper}>{t['404.hint']()}</p>
|
||||
<div className={wrapper}>
|
||||
<Button
|
||||
variant="primary"
|
||||
size="extraLarge"
|
||||
onClick={onBack}
|
||||
className={largeButtonEffect}
|
||||
>
|
||||
{t['404.back']()}
|
||||
</Button>
|
||||
</div>
|
||||
{user ? (
|
||||
<div className={wrapper}>
|
||||
<Avatar url={user.avatar ?? user.image} name={user.label} />
|
||||
<span style={{ margin: '0 12px' }}>{user.email}</span>
|
||||
<IconButton
|
||||
onClick={onSignOut}
|
||||
size="20"
|
||||
tooltip={t['404.signOut']()}
|
||||
>
|
||||
<SignOutIcon />
|
||||
</IconButton>
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
<div className={wrapper}>
|
||||
<ThemedImg
|
||||
draggable={false}
|
||||
className={illustration}
|
||||
lightSrc={illustrationLight}
|
||||
darkSrc={illustrationDark}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</AffineOtherPageLayout>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,37 @@
|
||||
import { cssVar } from '@toeverything/theme';
|
||||
import { style } from '@vanilla-extract/css';
|
||||
export const notFoundPageContainer = style({
|
||||
fontSize: cssVar('fontBase'),
|
||||
color: cssVar('textPrimaryColor'),
|
||||
height: '100%',
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
alignItems: 'center',
|
||||
width: '100%',
|
||||
padding: '0 20px',
|
||||
});
|
||||
export const wrapper = style({
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
margin: '0 auto',
|
||||
});
|
||||
export const info = style({
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
gap: '24px',
|
||||
textAlign: 'center',
|
||||
marginTop: 'auto',
|
||||
paddingTop: '120px',
|
||||
marginBottom: 'auto',
|
||||
});
|
||||
export const largeButtonEffect = style({
|
||||
boxShadow: `${cssVar('largeButtonEffect')} !important`,
|
||||
});
|
||||
|
||||
export const illustration = style({
|
||||
maxWidth: '100%',
|
||||
width: '670px',
|
||||
});
|
||||
@@ -0,0 +1,23 @@
|
||||
import type { SVGProps } from 'react';
|
||||
|
||||
export const SuccessIcon = ({
|
||||
width = 24,
|
||||
height = 24,
|
||||
}: SVGProps<SVGElement>) => {
|
||||
return (
|
||||
<svg
|
||||
width={width}
|
||||
height={height}
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
>
|
||||
<path
|
||||
fillRule="evenodd"
|
||||
clipRule="evenodd"
|
||||
d="M12 21.5C17.2467 21.5 21.5 17.2467 21.5 12C21.5 6.75329 17.2467 2.5 12 2.5C6.75329 2.5 2.5 6.75329 2.5 12C2.5 17.2467 6.75329 21.5 12 21.5ZM16.2247 9.36363C16.5176 9.07073 16.5176 8.59586 16.2247 8.30296C15.9318 8.01007 15.4569 8.01007 15.164 8.30296L9.88882 13.5782L8.30804 11.9974C8.01515 11.7045 7.54027 11.7045 7.24738 11.9974C6.95449 12.2903 6.95449 12.7652 7.24738 13.0581L9.35849 15.1692C9.65138 15.4621 10.1263 15.4621 10.4192 15.1692L16.2247 9.36363Z"
|
||||
fill="currentColor"
|
||||
/>
|
||||
</svg>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,313 @@
|
||||
import { cssVar } from '@toeverything/theme';
|
||||
// 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': 'calc(1 - var(--toasts-before)* 0.05)',
|
||||
'--y':
|
||||
'translateY(calc(var(--lift-amount) * var(--toasts-before))) scale(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: cssVar('processingColor'),
|
||||
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: cssVar('shadow1'),
|
||||
border: `1px solid ${cssVar('borderColor')}`,
|
||||
background: cssVar('white'),
|
||||
transition: 'all 0.3s',
|
||||
});
|
||||
export const notificationContentStyle = style({
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
justifyContent: 'center',
|
||||
paddingTop: '16px',
|
||||
paddingBottom: '16px',
|
||||
width: '380px',
|
||||
borderRadius: '8px',
|
||||
boxShadow: cssVar('shadow1'),
|
||||
border: `1px solid ${cssVar('black10')}`,
|
||||
background: cssVar('white'),
|
||||
transition: 'all 0.3s',
|
||||
});
|
||||
export const notificationTitleContactStyle = style({
|
||||
marginRight: '22px',
|
||||
width: '200px',
|
||||
overflow: 'wrap',
|
||||
lineHeight: '24px',
|
||||
fontSize: cssVar('fontBase'),
|
||||
});
|
||||
export const notificationTitleStyle = style({
|
||||
display: 'flex',
|
||||
alignItems: 'flex-start',
|
||||
width: '100%',
|
||||
justifyContent: 'flex-start',
|
||||
});
|
||||
export const notificationDescriptionStyle = style({
|
||||
fontSize: cssVar('fontSm'),
|
||||
color: cssVar('textSecondaryColor'),
|
||||
marginBottom: '4px',
|
||||
lineHeight: '22px',
|
||||
});
|
||||
export const notificationTimeStyle = style({
|
||||
fontSize: cssVar('fontSm'),
|
||||
color: cssVar('textSecondaryColor'),
|
||||
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: cssVar('pureBlack'),
|
||||
':hover': {
|
||||
background: cssVar('hoverColor'),
|
||||
},
|
||||
});
|
||||
export const closeButtonColorStyle = style({
|
||||
color: cssVar('textPrimaryColor'),
|
||||
});
|
||||
export const undoButtonStyle = style({
|
||||
fontSize: cssVar('fontSm'),
|
||||
background: cssVar('hoverColor'),
|
||||
padding: '3px 6px',
|
||||
borderRadius: '4px',
|
||||
color: cssVar('processingColor'),
|
||||
cursor: 'pointer',
|
||||
});
|
||||
export const undoButtonWithMediaStyle = style({
|
||||
marginLeft: 'auto',
|
||||
marginRight: '16px',
|
||||
});
|
||||
export const messageStyle = style({
|
||||
fontSize: cssVar('fontSm'),
|
||||
width: '200px',
|
||||
marginLeft: '50px',
|
||||
lineHeight: '18px',
|
||||
});
|
||||
export const progressBarStyle = style({
|
||||
fontSize: cssVar('fontSm'),
|
||||
width: '100%',
|
||||
height: '10px',
|
||||
marginTop: '10px',
|
||||
padding: '0 16px',
|
||||
borderRadius: '2px',
|
||||
marginBottom: '16px',
|
||||
});
|
||||
export const darkSuccessStyle = style({
|
||||
background: cssVar('successColor'),
|
||||
borderRadius: '8px',
|
||||
});
|
||||
export const darkInfoStyle = style({
|
||||
background: cssVar('processingColor'),
|
||||
borderRadius: '8px',
|
||||
});
|
||||
export const darkErrorStyle = style({
|
||||
background: cssVar('errorColor'),
|
||||
borderRadius: '8px',
|
||||
});
|
||||
export const darkWarningStyle = style({
|
||||
background: cssVar('warningColor'),
|
||||
borderRadius: '8px',
|
||||
});
|
||||
export const lightSuccessStyle = style({
|
||||
background: cssVar('backgroundSuccessColor'),
|
||||
borderRadius: '8px',
|
||||
});
|
||||
export const lightInfoStyle = style({
|
||||
background: cssVar('backgroundProcessingColor'),
|
||||
borderRadius: '8px',
|
||||
});
|
||||
export const lightErrorStyle = style({
|
||||
background: cssVar('backgroundErrorColor'),
|
||||
borderRadius: '8px',
|
||||
});
|
||||
export const lightWarningStyle = style({
|
||||
background: cssVar('backgroundWarningColor'),
|
||||
borderRadius: '8px',
|
||||
});
|
||||
export const darkColorStyle = style({
|
||||
color: cssVar('pureWhite'),
|
||||
});
|
||||
export const lightInfoIconStyle = style({
|
||||
color: cssVar('iconColor'),
|
||||
});
|
||||
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',
|
||||
},
|
||||
},
|
||||
});
|
||||
@@ -0,0 +1,82 @@
|
||||
import { atom } from 'jotai';
|
||||
import { nanoid } from 'nanoid';
|
||||
import type { JSX, ReactNode } from 'react';
|
||||
|
||||
/**
|
||||
* @deprecated use `import type { Notification } from '@affine/component'` instead
|
||||
*/
|
||||
export type Notification = {
|
||||
key?: string;
|
||||
title: string;
|
||||
message?: string;
|
||||
type: 'success' | 'error' | 'warning' | 'info';
|
||||
theme?: 'light' | 'dark' | 'default';
|
||||
timeout?: number;
|
||||
progressingBar?: boolean;
|
||||
multimedia?: ReactNode | JSX.Element;
|
||||
// actions
|
||||
action?: () => Promise<void>;
|
||||
actionLabel?: string;
|
||||
};
|
||||
|
||||
const notificationsBaseAtom = atom<Notification[]>([]);
|
||||
|
||||
const expandNotificationCenterBaseAtom = atom(false);
|
||||
const cleanupQueueAtom = atom<(() => unknown)[]>([]);
|
||||
/**
|
||||
* @deprecated use `import { notify } from '@affine/component'` instead
|
||||
*/
|
||||
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);
|
||||
}
|
||||
);
|
||||
/**
|
||||
* @deprecated use `import { notify } from '@affine/component'` instead
|
||||
*/
|
||||
export const notificationsAtom = atom<Notification[]>(get =>
|
||||
get(notificationsBaseAtom)
|
||||
);
|
||||
/**
|
||||
* @deprecated use `import { notify } from '@affine/component'` instead
|
||||
*/
|
||||
export const removeNotificationAtom = atom(null, (_, set, key: string) => {
|
||||
set(notificationsBaseAtom, notifications =>
|
||||
notifications.filter(notification => notification.key !== key)
|
||||
);
|
||||
});
|
||||
|
||||
/**
|
||||
* @deprecated use `import { notify } from '@affine/component'` instead
|
||||
*/
|
||||
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 action: (() => Promise<void>) | undefined = newNotification.action
|
||||
? (() => {
|
||||
const action: () => Promise<void> = newNotification.action;
|
||||
return async function actionNotificationWrapper() {
|
||||
removeNotification();
|
||||
return action();
|
||||
};
|
||||
})()
|
||||
: undefined;
|
||||
|
||||
set(notificationsBaseAtom, notifications => [
|
||||
// push to the top
|
||||
{ ...newNotification, action },
|
||||
...notifications,
|
||||
]);
|
||||
}
|
||||
);
|
||||
@@ -0,0 +1,425 @@
|
||||
// Credits to sonner
|
||||
// License on the MIT
|
||||
// https://github.com/emilkowalski/sonner/blob/5cb703edc108a23fd74979235c2f3c4005edd2a7/src/index.tsx
|
||||
|
||||
import { useI18n } from '@affine/i18n';
|
||||
import { CloseIcon, InformationFillDuotoneIcon } from '@blocksuite/icons/rc';
|
||||
import * as Toast from '@radix-ui/react-toast';
|
||||
import clsx from 'clsx';
|
||||
import { useAtom, useAtomValue, useSetAtom } from 'jotai';
|
||||
import type { ReactNode } from 'react';
|
||||
import {
|
||||
useCallback,
|
||||
useEffect,
|
||||
useLayoutEffect,
|
||||
useMemo,
|
||||
useRef,
|
||||
useState,
|
||||
} from 'react';
|
||||
|
||||
import { IconButton } from '../../ui/button';
|
||||
import { SuccessIcon } from './icons';
|
||||
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: '',
|
||||
icon: <InformationFillDuotoneIcon />,
|
||||
},
|
||||
success: {
|
||||
light: styles.lightSuccessStyle,
|
||||
dark: styles.darkSuccessStyle,
|
||||
default: '',
|
||||
icon: <SuccessIcon />,
|
||||
},
|
||||
warning: {
|
||||
light: styles.lightWarningStyle,
|
||||
dark: styles.darkWarningStyle,
|
||||
default: '',
|
||||
icon: <InformationFillDuotoneIcon />,
|
||||
},
|
||||
error: {
|
||||
light: styles.lightErrorStyle,
|
||||
dark: styles.darkErrorStyle,
|
||||
default: '',
|
||||
icon: <InformationFillDuotoneIcon />,
|
||||
},
|
||||
};
|
||||
|
||||
function NotificationCard(props: NotificationCardProps): ReactNode {
|
||||
const t = useI18n();
|
||||
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 | null>(null);
|
||||
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 onClickAction = useCallback(() => {
|
||||
if (notification.action) {
|
||||
notification.action().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',
|
||||
})}
|
||||
>
|
||||
{typeColorMap[notification.type]?.icon ?? (
|
||||
<InformationFillDuotoneIcon />
|
||||
)}
|
||||
</div>
|
||||
<div className={styles.notificationTitleContactStyle}>
|
||||
{notification.title}
|
||||
</div>
|
||||
{notification.action && (
|
||||
<div
|
||||
className={clsx(styles.undoButtonStyle, {
|
||||
[styles.darkColorStyle]:
|
||||
notification.theme !== 'light' &&
|
||||
notification.theme !== 'default',
|
||||
[styles.undoButtonWithMediaStyle]: notification.multimedia,
|
||||
})}
|
||||
onClick={onClickAction}
|
||||
>
|
||||
{notification.actionLabel ??
|
||||
t['com.affine.keyboardShortcuts.undo']()}
|
||||
</div>
|
||||
)}
|
||||
{notification.multimedia ? null : (
|
||||
<IconButton
|
||||
className={clsx(styles.closeButtonStyle, {
|
||||
[styles.closeButtonWithoutUndoStyle]: !notification.action,
|
||||
})}
|
||||
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>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* @deprecated use `import { NotificationCenter } from '@affine/component'` instead
|
||||
*/
|
||||
export function NotificationCenter(): ReactNode {
|
||||
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 null;
|
||||
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>
|
||||
);
|
||||
}
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 102 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 126 KiB |
@@ -0,0 +1,83 @@
|
||||
import { cssVar } from '@toeverything/theme';
|
||||
import { cssVarV2 } from '@toeverything/theme/v2';
|
||||
import { style } from '@vanilla-extract/css';
|
||||
export const pageDetailSkeletonStyle = style({
|
||||
padding: '0 20px',
|
||||
height: '100%',
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
justifyContent: 'center',
|
||||
alignItems: 'center',
|
||||
});
|
||||
export const blockSuiteEditorStyle = style({
|
||||
margin: 'auto 2rem',
|
||||
padding: '0 24px',
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
justifyContent: 'center',
|
||||
alignItems: 'center',
|
||||
height: '100%',
|
||||
});
|
||||
export const illustration = style({
|
||||
maxWidth: '100%',
|
||||
width: 300,
|
||||
alignSelf: 'center',
|
||||
});
|
||||
export const content = style({
|
||||
width: '100%',
|
||||
textAlign: 'center',
|
||||
maxWidth: '261px',
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
margin: '16px auto 0',
|
||||
selectors: {
|
||||
'&[data-longer-loading="true"]': {
|
||||
maxWidth: '400px',
|
||||
},
|
||||
},
|
||||
});
|
||||
export const loadingIcon = style({
|
||||
marginRight: '8px',
|
||||
});
|
||||
export const title = style({
|
||||
fontSize: cssVar('fontBase'),
|
||||
lineHeight: 1.6,
|
||||
fontWeight: 500,
|
||||
color: cssVarV2('text/primary'),
|
||||
textAlign: 'center',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
gap: '8px',
|
||||
marginBottom: '4px',
|
||||
});
|
||||
export const text = style({
|
||||
fontSize: cssVar('fontSm'),
|
||||
lineHeight: 1.6,
|
||||
fontWeight: 400,
|
||||
color: cssVarV2('text/secondary'),
|
||||
textWrap: 'wrap',
|
||||
wordBreak: 'break-word',
|
||||
textAlign: 'center',
|
||||
selectors: {
|
||||
'&[data-longer-loading="true"]': {
|
||||
textAlign: 'start',
|
||||
},
|
||||
},
|
||||
});
|
||||
export const actionButton = style({ marginTop: '24px' });
|
||||
export const mobileActionButton = style({
|
||||
padding: '8px 18px',
|
||||
height: 'auto',
|
||||
fontWeight: 600,
|
||||
marginTop: '24px',
|
||||
});
|
||||
|
||||
export const actionContent = style({
|
||||
padding: '0 4px',
|
||||
});
|
||||
export const mobileActionContent = style({
|
||||
padding: '0 4px',
|
||||
});
|
||||
@@ -0,0 +1,90 @@
|
||||
import { Trans, useI18n } from '@affine/i18n';
|
||||
import clsx from 'clsx';
|
||||
import { useCallback } from 'react';
|
||||
|
||||
import { Button } from '../../ui/button';
|
||||
import { Loading } from '../../ui/loading';
|
||||
import { ThemedImg } from '../../ui/themed-img';
|
||||
import imageUrlForDarkLoading from './assets/loading.dark.png';
|
||||
import imageUrlForLightLoading from './assets/loading.light.png';
|
||||
import * as styles from './index.css';
|
||||
|
||||
export const EditorLoading = ({
|
||||
longerLoading = false,
|
||||
}: {
|
||||
longerLoading?: boolean;
|
||||
}) => {
|
||||
const t = useI18n();
|
||||
const reloadPage = useCallback(() => {
|
||||
document.location.reload();
|
||||
}, []);
|
||||
return (
|
||||
<div className={styles.blockSuiteEditorStyle}>
|
||||
<ThemedImg
|
||||
style={{ width: '300px' }}
|
||||
draggable={false}
|
||||
className={styles.illustration}
|
||||
lightSrc={imageUrlForLightLoading}
|
||||
darkSrc={imageUrlForDarkLoading}
|
||||
/>
|
||||
{longerLoading ? (
|
||||
<div className={styles.content} data-longer-loading={true}>
|
||||
<div>
|
||||
<div className={styles.text} data-longer-loading={true}>
|
||||
{t['com.affine.error.loading-timeout-error']()}
|
||||
</div>
|
||||
<div className={styles.text} data-longer-loading={true}>
|
||||
<Trans
|
||||
i18nKey="com.affine.error.contact-us"
|
||||
components={{
|
||||
1: (
|
||||
<a
|
||||
style={{ color: 'var(--affine-primary-color)' }}
|
||||
href="https://affine.pro/redirect/discord"
|
||||
target="__blank"
|
||||
/>
|
||||
),
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<Button
|
||||
size="large"
|
||||
className={clsx(
|
||||
BUILD_CONFIG.isMobileEdition
|
||||
? styles.mobileActionButton
|
||||
: styles.actionButton
|
||||
)}
|
||||
contentClassName={clsx(
|
||||
BUILD_CONFIG.isMobileEdition
|
||||
? styles.mobileActionContent
|
||||
: styles.actionContent
|
||||
)}
|
||||
onClick={reloadPage}
|
||||
variant="primary"
|
||||
>
|
||||
{t['com.affine.error.reload']()}
|
||||
</Button>
|
||||
</div>
|
||||
) : (
|
||||
<div className={styles.content}>
|
||||
<div className={styles.title}>
|
||||
<Loading size={20} className={styles.loadingIcon} />
|
||||
{t['com.affine.loading']()}
|
||||
</div>
|
||||
<div className={styles.text}>
|
||||
{t['com.affine.loading.description']()}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export const PageDetailLoading = () => {
|
||||
return (
|
||||
<div className={styles.pageDetailSkeletonStyle}>
|
||||
<EditorLoading />
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,7 @@
|
||||
// Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html
|
||||
|
||||
exports[`ProviderComposer 1`] = `
|
||||
<DocumentFragment>
|
||||
test1
|
||||
</DocumentFragment>
|
||||
`;
|
||||
@@ -0,0 +1,31 @@
|
||||
/**
|
||||
* @vitest-environment happy-dom
|
||||
*/
|
||||
import { render } from '@testing-library/react';
|
||||
import type React from 'react';
|
||||
import { createContext, useContext } from 'react';
|
||||
import { expect, test } from 'vitest';
|
||||
|
||||
import { ProviderComposer } from '..';
|
||||
|
||||
test('ProviderComposer', async () => {
|
||||
const Context = createContext('null');
|
||||
const Provider = ({ children }: React.PropsWithChildren) => {
|
||||
return <Context.Provider value="test1">{children}</Context.Provider>;
|
||||
};
|
||||
const ConsumerComponent = () => {
|
||||
const value = useContext(Context);
|
||||
return value;
|
||||
};
|
||||
const Component = () => {
|
||||
return (
|
||||
<ProviderComposer contexts={[<Provider key={1} />]}>
|
||||
<ConsumerComponent />
|
||||
</ProviderComposer>
|
||||
);
|
||||
};
|
||||
|
||||
const result = render(<Component />);
|
||||
await result.findByText('test1');
|
||||
expect(result.asFragment()).toMatchSnapshot();
|
||||
});
|
||||
@@ -0,0 +1,19 @@
|
||||
import type { ReactNode } from 'react';
|
||||
import { cloneElement } from 'react';
|
||||
|
||||
interface ProviderComposerProps {
|
||||
contexts: any;
|
||||
children: ReactNode;
|
||||
}
|
||||
|
||||
export const ProviderComposer = ({
|
||||
contexts,
|
||||
children,
|
||||
}: ProviderComposerProps) =>
|
||||
contexts.reduceRight(
|
||||
(kids: ReactNode, parent: any) =>
|
||||
cloneElement(parent, {
|
||||
children: kids,
|
||||
}),
|
||||
children
|
||||
);
|
||||
@@ -0,0 +1,76 @@
|
||||
import { cssVar } from '@toeverything/theme';
|
||||
import type { KeyboardEvent, ReactElement } from 'react';
|
||||
import { useCallback, useEffect, useState } from 'react';
|
||||
|
||||
import Input from '../../ui/input';
|
||||
import { Menu } from '../../ui/menu';
|
||||
|
||||
export const RenameModal = ({
|
||||
onRename,
|
||||
currentName,
|
||||
open,
|
||||
width = 220,
|
||||
children,
|
||||
onOpenChange,
|
||||
}: {
|
||||
open: boolean;
|
||||
onOpenChange: (open: boolean) => void;
|
||||
onRename: (newName: string) => void;
|
||||
currentName: string;
|
||||
width?: string | number;
|
||||
children?: ReactElement;
|
||||
}) => {
|
||||
const [value, setValue] = useState(currentName);
|
||||
|
||||
const handleRename = useCallback(() => {
|
||||
onRename(value);
|
||||
onOpenChange(false);
|
||||
}, [onOpenChange, onRename, value]);
|
||||
|
||||
const onKeyDown = useCallback(
|
||||
(e: KeyboardEvent<HTMLInputElement>) => {
|
||||
if (e.key !== 'Escape') return;
|
||||
if (currentName !== value) setValue(currentName);
|
||||
onOpenChange(false);
|
||||
},
|
||||
[currentName, value, onOpenChange]
|
||||
);
|
||||
|
||||
// Synchronize when the title is changed in the page header or title.
|
||||
useEffect(() => setValue(currentName), [currentName]);
|
||||
|
||||
return (
|
||||
<Menu
|
||||
rootOptions={{
|
||||
modal: true,
|
||||
open: open,
|
||||
onOpenChange: onOpenChange,
|
||||
}}
|
||||
contentOptions={{
|
||||
side: 'left',
|
||||
onPointerDownOutside: handleRename,
|
||||
sideOffset: -12,
|
||||
onClick: e => e.stopPropagation(),
|
||||
style: { borderRadius: 10, padding: 8 },
|
||||
role: 'rename-modal',
|
||||
}}
|
||||
items={
|
||||
<Input
|
||||
inputStyle={{
|
||||
fontSize: cssVar('fontBase'),
|
||||
}}
|
||||
autoFocus
|
||||
autoSelect
|
||||
value={value}
|
||||
onChange={setValue}
|
||||
onEnter={handleRename}
|
||||
onKeyDown={onKeyDown}
|
||||
data-testid="rename-modal-input"
|
||||
style={{ width, height: 34, borderRadius: 4 }}
|
||||
/>
|
||||
}
|
||||
>
|
||||
{children ?? <div />}
|
||||
</Menu>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1 @@
|
||||
export * from './resize-panel';
|
||||
@@ -0,0 +1,111 @@
|
||||
import { cssVar } from '@toeverything/theme';
|
||||
import { createVar, style } from '@vanilla-extract/css';
|
||||
export const panelWidthVar = createVar('panel-width');
|
||||
export const resizeHandleOffsetVar = createVar('resize-handle-offset');
|
||||
export const resizeHandleVerticalPadding = createVar(
|
||||
'resize-handle-vertical-padding'
|
||||
);
|
||||
export const animationTimeout = createVar();
|
||||
|
||||
export const root = style({
|
||||
vars: {
|
||||
[panelWidthVar]: '256px',
|
||||
[resizeHandleOffsetVar]: '0',
|
||||
},
|
||||
position: 'relative',
|
||||
width: panelWidthVar,
|
||||
minWidth: panelWidthVar,
|
||||
height: '100%',
|
||||
zIndex: 4,
|
||||
transform: 'translateX(0)',
|
||||
maxWidth: '50%',
|
||||
selectors: {
|
||||
'&[data-open="false"][data-handle-position="right"],&[data-is-floating="true"][data-handle-position="right"]':
|
||||
{
|
||||
marginLeft: `calc(${panelWidthVar} * -1)`,
|
||||
},
|
||||
'&[data-open="false"][data-handle-position="left"],&[data-is-floating="true"][data-handle-position="left"]':
|
||||
{
|
||||
marginRight: `calc(${panelWidthVar} * -1)`,
|
||||
},
|
||||
'&[data-open="true"][data-handle-position="right"][data-is-floating="true"]':
|
||||
{
|
||||
transform: `translateX(calc(${panelWidthVar} + 4px))`,
|
||||
},
|
||||
'&[data-open="true"][data-handle-position="left"][data-is-floating="true"]':
|
||||
{
|
||||
transform: `translateX(calc(${panelWidthVar} * -1))`,
|
||||
},
|
||||
'&[data-enable-animation="true"]': {
|
||||
transition: `margin-left ${animationTimeout}, margin-right ${animationTimeout}, transform ${animationTimeout}, background ${animationTimeout}`,
|
||||
},
|
||||
'&[data-transition-state="exited"]': {
|
||||
// avoid focus on hidden panel
|
||||
visibility: 'hidden',
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
export const content = style({
|
||||
contain: 'strict',
|
||||
width: '100%',
|
||||
height: '100%',
|
||||
});
|
||||
|
||||
export const panelContent = style({
|
||||
position: 'relative',
|
||||
height: '100%',
|
||||
overflow: 'auto',
|
||||
});
|
||||
export const resizeHandleContainer = style({
|
||||
position: 'absolute',
|
||||
right: resizeHandleOffsetVar,
|
||||
top: resizeHandleVerticalPadding,
|
||||
bottom: resizeHandleVerticalPadding,
|
||||
width: 8,
|
||||
zIndex: '1',
|
||||
transform: 'translateX(50%)',
|
||||
backgroundColor: 'transparent',
|
||||
opacity: 0,
|
||||
display: 'flex',
|
||||
justifyContent: 'center',
|
||||
cursor: 'col-resize',
|
||||
'@media': {
|
||||
'(max-width: 600px)': {
|
||||
// do not allow resizing on small screen
|
||||
display: 'none',
|
||||
},
|
||||
},
|
||||
transition: 'opacity 0.15s ease 0.1s',
|
||||
selectors: {
|
||||
'&[data-resizing="true"], &:hover': {
|
||||
opacity: 1,
|
||||
},
|
||||
'&[data-open="false"]': {
|
||||
display: 'none',
|
||||
},
|
||||
'&[data-open="open"]': {
|
||||
display: 'block',
|
||||
},
|
||||
'&[data-handle-position="left"]': {
|
||||
left: resizeHandleOffsetVar,
|
||||
right: 'auto',
|
||||
transform: 'translateX(-50%)',
|
||||
},
|
||||
},
|
||||
});
|
||||
export const resizerInner = style({
|
||||
position: 'absolute',
|
||||
height: '100%',
|
||||
width: '2px',
|
||||
borderRadius: '2px',
|
||||
backgroundColor: cssVar('primaryColor'),
|
||||
transition: 'all 0.2s ease-in-out',
|
||||
transform: 'translateX(0.5px)',
|
||||
selectors: {
|
||||
[`${resizeHandleContainer}[data-resizing="true"] &`]: {
|
||||
width: '4px',
|
||||
borderRadius: '4px',
|
||||
},
|
||||
},
|
||||
});
|
||||
@@ -0,0 +1,247 @@
|
||||
import { assignInlineVars } from '@vanilla-extract/dynamic';
|
||||
import clsx from 'clsx';
|
||||
import { forwardRef, useCallback, useLayoutEffect, useRef } from 'react';
|
||||
import { useTransitionState } from 'react-transition-state';
|
||||
|
||||
import { useDropTarget } from '../../ui/dnd';
|
||||
import { Tooltip, type TooltipProps } from '../../ui/tooltip';
|
||||
import * as styles from './resize-panel.css';
|
||||
|
||||
export interface ResizeHandleProps extends React.HtmlHTMLAttributes<HTMLDivElement> {
|
||||
resizing: boolean;
|
||||
open: boolean;
|
||||
minWidth: number;
|
||||
maxWidth: number;
|
||||
resizeHandlePos: 'left' | 'right';
|
||||
resizeHandleOffset?: number;
|
||||
resizeHandleVerticalPadding?: number;
|
||||
onOpen: (open: boolean) => void;
|
||||
onResizing: (resizing: boolean) => void;
|
||||
onWidthChange: (width: number) => void;
|
||||
onWidthChanged?: (width: number) => void;
|
||||
tooltip?: TooltipProps['content'];
|
||||
tooltipShortcut?: TooltipProps['shortcut'];
|
||||
tooltipOptions?: Partial<Omit<TooltipProps, 'content' | 'shortcut'>>;
|
||||
tooltipShortcutClassName?: string;
|
||||
dropTargetOptions?: Parameters<typeof useDropTarget>[0];
|
||||
}
|
||||
|
||||
export interface ResizePanelProps extends React.HtmlHTMLAttributes<HTMLDivElement> {
|
||||
resizing: boolean;
|
||||
open: boolean;
|
||||
floating?: boolean;
|
||||
minWidth: number;
|
||||
maxWidth: number;
|
||||
resizeHandlePos: 'left' | 'right';
|
||||
resizeHandleOffset?: number;
|
||||
resizeHandleVerticalPadding?: number;
|
||||
resizeHandleTooltip?: TooltipProps['content'];
|
||||
resizeHandleTooltipShortcut?: TooltipProps['shortcut'];
|
||||
resizeHandleTooltipShortcutClassName?: string;
|
||||
resizeHandleTooltipOptions?: Partial<
|
||||
Omit<TooltipProps, 'content' | 'shortcut'>
|
||||
>;
|
||||
resizeHandleDropTargetOptions?: Parameters<typeof useDropTarget>[0];
|
||||
enableAnimation?: boolean;
|
||||
width: number;
|
||||
unmountOnExit?: boolean;
|
||||
onOpen: (open: boolean) => void;
|
||||
onResizing: (resizing: boolean) => void;
|
||||
onWidthChange: (width: number) => void;
|
||||
onWidthChanged?: (width: number) => void;
|
||||
}
|
||||
|
||||
const ResizeHandle = ({
|
||||
className,
|
||||
resizing,
|
||||
minWidth,
|
||||
maxWidth,
|
||||
resizeHandlePos,
|
||||
resizeHandleOffset,
|
||||
resizeHandleVerticalPadding,
|
||||
dropTargetOptions,
|
||||
open,
|
||||
onOpen,
|
||||
onResizing,
|
||||
onWidthChange,
|
||||
onWidthChanged,
|
||||
tooltip,
|
||||
tooltipShortcut,
|
||||
tooltipOptions,
|
||||
tooltipShortcutClassName,
|
||||
...rest
|
||||
}: ResizeHandleProps) => {
|
||||
const ref = useRef<HTMLDivElement>(null);
|
||||
const onResizeStart = useCallback(
|
||||
(event: React.MouseEvent<HTMLDivElement>) => {
|
||||
event.preventDefault();
|
||||
let resized = false;
|
||||
const panelContainer = ref.current?.parentElement;
|
||||
if (!panelContainer) return;
|
||||
|
||||
// add cursor style to body
|
||||
document.body.style.cursor = 'col-resize';
|
||||
|
||||
const { left: anchorLeft, right: anchorRight } =
|
||||
panelContainer.getBoundingClientRect();
|
||||
|
||||
let lastWidth: number;
|
||||
function onMouseMove(e: MouseEvent) {
|
||||
e.preventDefault();
|
||||
if (!panelContainer) return;
|
||||
const newWidth = Math.min(
|
||||
maxWidth,
|
||||
Math.max(
|
||||
resizeHandlePos === 'right'
|
||||
? e.clientX - anchorLeft
|
||||
: anchorRight - e.clientX,
|
||||
minWidth
|
||||
)
|
||||
);
|
||||
lastWidth = newWidth;
|
||||
onWidthChange(newWidth);
|
||||
onResizing(true);
|
||||
resized = true;
|
||||
}
|
||||
|
||||
document.addEventListener('mousemove', onMouseMove);
|
||||
document.addEventListener(
|
||||
'mouseup',
|
||||
() => {
|
||||
// if not resized, toggle sidebar
|
||||
if (!resized) {
|
||||
onOpen(false);
|
||||
}
|
||||
onResizing(false);
|
||||
lastWidth && onWidthChanged?.(lastWidth);
|
||||
document.removeEventListener('mousemove', onMouseMove);
|
||||
document.body.style.cursor = '';
|
||||
},
|
||||
{ once: true }
|
||||
);
|
||||
},
|
||||
[
|
||||
maxWidth,
|
||||
resizeHandlePos,
|
||||
minWidth,
|
||||
onWidthChange,
|
||||
onResizing,
|
||||
onWidthChanged,
|
||||
onOpen,
|
||||
]
|
||||
);
|
||||
|
||||
const { dropTargetRef } = useDropTarget(dropTargetOptions, [
|
||||
dropTargetOptions,
|
||||
]);
|
||||
|
||||
return (
|
||||
<Tooltip
|
||||
content={tooltip}
|
||||
shortcut={tooltipShortcut}
|
||||
shortcutClassName={tooltipShortcutClassName}
|
||||
{...tooltipOptions}
|
||||
>
|
||||
<div
|
||||
{...rest}
|
||||
data-testid="resize-handle"
|
||||
ref={node => {
|
||||
ref.current = node;
|
||||
dropTargetRef.current = node;
|
||||
}}
|
||||
style={assignInlineVars({
|
||||
[styles.resizeHandleOffsetVar]: `${resizeHandleOffset ?? 0}px`,
|
||||
[styles.resizeHandleVerticalPadding]: `${
|
||||
resizeHandleVerticalPadding ?? 0
|
||||
}px`,
|
||||
})}
|
||||
className={clsx(styles.resizeHandleContainer, className)}
|
||||
data-handle-position={resizeHandlePos}
|
||||
data-resizing={resizing}
|
||||
data-open={open}
|
||||
onMouseDown={onResizeStart}
|
||||
>
|
||||
<div className={styles.resizerInner} />
|
||||
</div>
|
||||
</Tooltip>
|
||||
);
|
||||
};
|
||||
|
||||
const animationTimeout = 300;
|
||||
|
||||
export const ResizePanel = forwardRef<HTMLDivElement, ResizePanelProps>(
|
||||
function ResizePanel(
|
||||
{
|
||||
children,
|
||||
className,
|
||||
resizing,
|
||||
minWidth,
|
||||
maxWidth,
|
||||
width,
|
||||
floating,
|
||||
enableAnimation = true,
|
||||
open,
|
||||
unmountOnExit,
|
||||
onOpen,
|
||||
onResizing,
|
||||
onWidthChange,
|
||||
onWidthChanged,
|
||||
resizeHandlePos,
|
||||
resizeHandleOffset,
|
||||
resizeHandleVerticalPadding,
|
||||
resizeHandleTooltip,
|
||||
resizeHandleTooltipShortcut,
|
||||
resizeHandleTooltipShortcutClassName,
|
||||
resizeHandleTooltipOptions,
|
||||
resizeHandleDropTargetOptions,
|
||||
...rest
|
||||
},
|
||||
ref
|
||||
) {
|
||||
const safeWidth = Math.min(maxWidth, Math.max(minWidth, width));
|
||||
const [{ status }, toggle] = useTransitionState({
|
||||
timeout: animationTimeout,
|
||||
});
|
||||
useLayoutEffect(() => {
|
||||
toggle(open);
|
||||
}, [open, toggle]);
|
||||
return (
|
||||
<div
|
||||
{...rest}
|
||||
ref={ref}
|
||||
style={assignInlineVars({
|
||||
[styles.panelWidthVar]: `${safeWidth}px`,
|
||||
[styles.animationTimeout]: `${animationTimeout}ms`,
|
||||
})}
|
||||
className={clsx(className, styles.root)}
|
||||
data-open={open}
|
||||
data-transition-state={status}
|
||||
data-is-floating={floating}
|
||||
data-handle-position={resizeHandlePos}
|
||||
data-enable-animation={enableAnimation && !resizing}
|
||||
>
|
||||
<div className={styles.content}>
|
||||
{!(status === 'exited' && unmountOnExit !== false) && children}
|
||||
</div>
|
||||
<ResizeHandle
|
||||
resizeHandlePos={resizeHandlePos}
|
||||
resizeHandleOffset={resizeHandleOffset}
|
||||
resizeHandleVerticalPadding={resizeHandleVerticalPadding}
|
||||
tooltip={resizeHandleTooltip}
|
||||
tooltipOptions={resizeHandleTooltipOptions}
|
||||
tooltipShortcut={resizeHandleTooltipShortcut}
|
||||
tooltipShortcutClassName={resizeHandleTooltipShortcutClassName}
|
||||
maxWidth={maxWidth}
|
||||
minWidth={minWidth}
|
||||
onOpen={onOpen}
|
||||
onResizing={onResizing}
|
||||
onWidthChange={onWidthChange}
|
||||
onWidthChanged={onWidthChanged}
|
||||
open={open}
|
||||
resizing={resizing}
|
||||
dropTargetOptions={resizeHandleDropTargetOptions}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
);
|
||||
@@ -0,0 +1,6 @@
|
||||
export * from './pagination';
|
||||
export { SettingHeader } from './setting-header';
|
||||
export { SettingRow } from './setting-row';
|
||||
export * from './workspace-detail-skeleton';
|
||||
export * from './workspace-list-skeleton';
|
||||
export { SettingWrapper } from './wrapper';
|
||||
@@ -0,0 +1,50 @@
|
||||
import { cssVar } from '@toeverything/theme';
|
||||
import { cssVarV2 } from '@toeverything/theme/v2';
|
||||
import { globalStyle, style } from '@vanilla-extract/css';
|
||||
|
||||
export const pageItem = style({
|
||||
display: 'inline-flex',
|
||||
justifyContent: 'center',
|
||||
alignItems: 'center',
|
||||
width: '20px',
|
||||
height: '20px',
|
||||
fontSize: cssVar('fontXs'),
|
||||
color: cssVarV2('text/primary'),
|
||||
borderRadius: '4px',
|
||||
|
||||
selectors: {
|
||||
'&:hover': {
|
||||
background: cssVarV2('layer/background/hoverOverlay'),
|
||||
},
|
||||
'&.active': {
|
||||
color: cssVarV2('text/emphasis'),
|
||||
cursor: 'default',
|
||||
pointerEvents: 'none',
|
||||
},
|
||||
'&.label': {
|
||||
color: cssVarV2('icon/primary'),
|
||||
fontSize: '16px',
|
||||
},
|
||||
'&.disabled': {
|
||||
opacity: '.4',
|
||||
cursor: 'default',
|
||||
color: cssVarV2('text/disable'),
|
||||
pointerEvents: 'none',
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
globalStyle(`${pageItem} a`, {
|
||||
width: '100%',
|
||||
display: 'flex',
|
||||
justifyContent: 'center',
|
||||
alignItems: 'center',
|
||||
});
|
||||
|
||||
export const pagination = style({
|
||||
display: 'flex',
|
||||
justifyContent: 'center',
|
||||
alignItems: 'center',
|
||||
gap: '6px',
|
||||
marginTop: 5,
|
||||
});
|
||||
@@ -0,0 +1,52 @@
|
||||
import { ArrowLeftSmallIcon, ArrowRightSmallIcon } from '@blocksuite/icons/rc';
|
||||
import clsx from 'clsx';
|
||||
import { useCallback, useMemo } from 'react';
|
||||
import ReactPaginate from 'react-paginate';
|
||||
|
||||
import * as styles from './pagination.css';
|
||||
export interface PaginationProps {
|
||||
totalCount: number;
|
||||
pageNum?: number;
|
||||
countPerPage: number;
|
||||
onPageChange: (skip: number, pageNum: number) => void;
|
||||
}
|
||||
|
||||
export const Pagination = ({
|
||||
totalCount,
|
||||
countPerPage,
|
||||
pageNum,
|
||||
onPageChange,
|
||||
}: PaginationProps) => {
|
||||
const handlePageClick = useCallback(
|
||||
(e: { selected: number }) => {
|
||||
const newOffset = (e.selected * countPerPage) % totalCount;
|
||||
onPageChange(newOffset, e.selected);
|
||||
},
|
||||
[countPerPage, onPageChange, totalCount]
|
||||
);
|
||||
|
||||
const pageCount = useMemo(
|
||||
() => Math.ceil(totalCount / countPerPage),
|
||||
[countPerPage, totalCount]
|
||||
);
|
||||
|
||||
return (
|
||||
<ReactPaginate
|
||||
onPageChange={handlePageClick}
|
||||
pageRangeDisplayed={3}
|
||||
marginPagesDisplayed={2}
|
||||
pageCount={pageCount}
|
||||
forcePage={pageNum}
|
||||
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}
|
||||
/>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,29 @@
|
||||
import type { HTMLAttributes, ReactNode } from 'react';
|
||||
|
||||
import { settingHeader, settingHeaderBeta } from './share.css';
|
||||
|
||||
interface SettingHeaderProps extends Omit<
|
||||
HTMLAttributes<HTMLDivElement>,
|
||||
'title'
|
||||
> {
|
||||
title: ReactNode;
|
||||
subtitle?: ReactNode;
|
||||
beta?: boolean;
|
||||
}
|
||||
|
||||
export const SettingHeader = ({
|
||||
title,
|
||||
subtitle,
|
||||
beta,
|
||||
...otherProps
|
||||
}: SettingHeaderProps) => {
|
||||
return (
|
||||
<div className={settingHeader} {...otherProps}>
|
||||
<div className="title">
|
||||
{title}
|
||||
{beta ? <div className={settingHeaderBeta}>Beta</div> : null}
|
||||
</div>
|
||||
{subtitle ? <div className="subtitle">{subtitle}</div> : null}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,49 @@
|
||||
import clsx from 'clsx';
|
||||
import type { CSSProperties, PropsWithChildren, ReactNode } from 'react';
|
||||
|
||||
import { settingRow } from './share.css';
|
||||
|
||||
export type SettingRowProps = PropsWithChildren<{
|
||||
name: ReactNode;
|
||||
desc?: ReactNode;
|
||||
style?: CSSProperties;
|
||||
onClick?: () => void;
|
||||
spreadCol?: boolean;
|
||||
'data-testid'?: string;
|
||||
disabled?: boolean;
|
||||
className?: string;
|
||||
}>;
|
||||
|
||||
export const SettingRow = ({
|
||||
name,
|
||||
desc,
|
||||
children,
|
||||
onClick,
|
||||
style,
|
||||
spreadCol = true,
|
||||
disabled = false,
|
||||
className,
|
||||
...props
|
||||
}: PropsWithChildren<SettingRowProps>) => {
|
||||
return (
|
||||
<div
|
||||
className={clsx(
|
||||
settingRow,
|
||||
{
|
||||
'two-col': spreadCol,
|
||||
disabled,
|
||||
},
|
||||
className
|
||||
)}
|
||||
style={style}
|
||||
onClick={onClick}
|
||||
data-testid={props['data-testid']}
|
||||
>
|
||||
<div className="left-col">
|
||||
<div className="name">{name}</div>
|
||||
{desc && <div className="desc">{desc}</div>}
|
||||
</div>
|
||||
{spreadCol ? <div className="right-col">{children}</div> : children}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,109 @@
|
||||
import { cssVar } from '@toeverything/theme';
|
||||
import { cssVarV2 } from '@toeverything/theme/v2';
|
||||
import { globalStyle, style } from '@vanilla-extract/css';
|
||||
export const settingHeader = style({
|
||||
borderBottom: `1px solid ${cssVar('borderColor')}`,
|
||||
paddingBottom: '16px',
|
||||
marginBottom: '24px',
|
||||
whiteSpace: 'pre-wrap',
|
||||
});
|
||||
globalStyle(`${settingHeader} .title`, {
|
||||
fontSize: cssVar('fontBase'),
|
||||
fontWeight: 600,
|
||||
lineHeight: '24px',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
gap: '12px',
|
||||
position: 'relative',
|
||||
});
|
||||
globalStyle(`${settingHeader} .subtitle`, {
|
||||
paddingTop: '4px',
|
||||
paddingBottom: '8px',
|
||||
fontSize: cssVar('fontXs'),
|
||||
lineHeight: '20px',
|
||||
color: cssVar('textSecondaryColor'),
|
||||
});
|
||||
export const wrapper = style({
|
||||
borderBottom: `1px solid ${cssVar('borderColor')}`,
|
||||
paddingBottom: '24px',
|
||||
marginBottom: '24px',
|
||||
selectors: {
|
||||
'&:last-of-type': {
|
||||
borderBottom: 'none',
|
||||
paddingBottom: '0',
|
||||
marginBottom: '0',
|
||||
},
|
||||
},
|
||||
});
|
||||
export const wrapperDisabled = style({
|
||||
opacity: 0.5,
|
||||
pointerEvents: 'none',
|
||||
});
|
||||
globalStyle(`${wrapper} .title`, {
|
||||
fontSize: cssVar('fontSm'),
|
||||
fontWeight: 600,
|
||||
lineHeight: '18px',
|
||||
color: cssVar('textSecondaryColor'),
|
||||
marginBottom: '16px',
|
||||
});
|
||||
export const settingRow = style({
|
||||
marginBottom: '25px',
|
||||
color: cssVar('textPrimaryColor'),
|
||||
borderRadius: '8px',
|
||||
selectors: {
|
||||
'&.two-col': {
|
||||
display: 'flex',
|
||||
justifyContent: 'space-between',
|
||||
alignItems: 'center',
|
||||
},
|
||||
'&:last-of-type': {
|
||||
marginBottom: '0',
|
||||
},
|
||||
'&.disabled': {
|
||||
position: 'relative',
|
||||
},
|
||||
'&.disabled::after': {
|
||||
content: '',
|
||||
position: 'absolute',
|
||||
left: 0,
|
||||
top: 0,
|
||||
width: '100%',
|
||||
height: '100%',
|
||||
backgroundColor: 'rgba(255,255,255,0.5)',
|
||||
},
|
||||
},
|
||||
});
|
||||
globalStyle(`${settingRow} .left-col`, {
|
||||
flex: 1,
|
||||
maxWidth: '100%',
|
||||
});
|
||||
globalStyle(`${settingRow}.two-col .left-col`, {
|
||||
flexShrink: 0,
|
||||
maxWidth: '80%',
|
||||
});
|
||||
globalStyle(`${settingRow} .name`, {
|
||||
marginBottom: '2px',
|
||||
fontSize: cssVar('fontSm'),
|
||||
fontWeight: 600,
|
||||
});
|
||||
globalStyle(`${settingRow} .desc`, {
|
||||
fontSize: cssVar('fontXs'),
|
||||
color: cssVar('textSecondaryColor'),
|
||||
});
|
||||
globalStyle(`${settingRow} .right-col`, {
|
||||
display: 'flex',
|
||||
justifyContent: 'flex-end',
|
||||
paddingLeft: '15px',
|
||||
flexShrink: 0,
|
||||
});
|
||||
|
||||
export const settingHeaderBeta = style({
|
||||
fontSize: cssVar('fontXs'),
|
||||
background: cssVarV2('chip/label/blue'),
|
||||
padding: '0 8px',
|
||||
borderRadius: '4px',
|
||||
display: 'inline-flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
height: 20,
|
||||
});
|
||||
@@ -0,0 +1,22 @@
|
||||
import { Skeleton } from '../../ui/skeleton';
|
||||
import { SettingHeader } from './setting-header';
|
||||
import { SettingRow } from './setting-row';
|
||||
import { SettingWrapper } from './wrapper';
|
||||
|
||||
export const WorkspaceDetailSkeleton = () => {
|
||||
return (
|
||||
<>
|
||||
<SettingHeader title={<Skeleton />} subtitle={<Skeleton />} />
|
||||
|
||||
{Array.from({ length: 3 }, (_, index) => (
|
||||
<SettingWrapper title={<Skeleton />} key={index}>
|
||||
<SettingRow
|
||||
name={<Skeleton />}
|
||||
desc={<Skeleton />}
|
||||
spreadCol={false}
|
||||
></SettingRow>
|
||||
</SettingWrapper>
|
||||
))}
|
||||
</>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,34 @@
|
||||
import { FlexWrapper } from '../../ui/layout';
|
||||
import { Skeleton } from '../../ui/skeleton';
|
||||
|
||||
export const WorkspaceListItemSkeleton = () => {
|
||||
return (
|
||||
<FlexWrapper
|
||||
alignItems="center"
|
||||
style={{ padding: '0 24px', height: 30, marginBottom: 4 }}
|
||||
>
|
||||
<Skeleton
|
||||
variant="circular"
|
||||
width={14}
|
||||
height={14}
|
||||
style={{ marginRight: 10 }}
|
||||
/>
|
||||
<Skeleton
|
||||
variant="rectangular"
|
||||
height={16}
|
||||
width={0}
|
||||
style={{ flexGrow: 1 }}
|
||||
/>
|
||||
</FlexWrapper>
|
||||
);
|
||||
};
|
||||
|
||||
export const WorkspaceListSkeleton = () => {
|
||||
return (
|
||||
<>
|
||||
{Array.from({ length: 5 }, (_, index) => (
|
||||
<WorkspaceListItemSkeleton key={index} />
|
||||
))}
|
||||
</>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,31 @@
|
||||
import clsx from 'clsx';
|
||||
import type { PropsWithChildren, ReactNode } from 'react';
|
||||
|
||||
import { wrapper, wrapperDisabled } from './share.css';
|
||||
|
||||
interface SettingWrapperProps {
|
||||
id?: string;
|
||||
title?: ReactNode;
|
||||
disabled?: boolean;
|
||||
testId?: string;
|
||||
}
|
||||
|
||||
export const SettingWrapper = ({
|
||||
id,
|
||||
title,
|
||||
children,
|
||||
disabled,
|
||||
testId,
|
||||
}: PropsWithChildren<SettingWrapperProps>) => {
|
||||
return (
|
||||
<div
|
||||
id={id}
|
||||
className={clsx(wrapper, disabled && wrapperDisabled)}
|
||||
data-testid={testId}
|
||||
aria-disabled={disabled}
|
||||
>
|
||||
{title ? <div className="title">{title}</div> : null}
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
Reference in New Issue
Block a user