diff options
Diffstat (limited to 'src/components')
46 files changed, 399 insertions, 305 deletions
diff --git a/src/components/BackendList.tsx b/src/components/BackendList.tsx index 2246dd6..bb9ae53 100644 --- a/src/components/BackendList.tsx +++ b/src/components/BackendList.tsx @@ -1,6 +1,6 @@ import cx from 'clsx'; import * as React from 'react'; -import { Eye, EyeOff, X as Close } from 'react-feather'; +import { Eye, EyeOff, X as Close } from '~/components/shared/FeatherIcons'; import { useToggle } from '~/hooks/basic'; import type { ClashAPIConfigWithAddedAt } from '~/store/types'; diff --git a/src/components/Button.module.scss b/src/components/Button.module.scss index 5bb68be..4f89d4c 100644 --- a/src/components/Button.module.scss +++ b/src/components/Button.module.scss @@ -1,4 +1,4 @@ -@import '~/styles/utils/custom-media'; +@use '~/styles/utils/custom-media' as *; .btn { -webkit-appearance: none; @@ -26,10 +26,6 @@ transform: translateY(-1px); box-shadow: 0 4px 6px -1px rgba(0, 0, 0, 0.1), 0 2px 4px -1px rgba(0, 0, 0, 0.06); } - &:active { - transform: scale(0.97); - box-shadow: none; - } font-size: 0.75em; padding: 4px 7px; diff --git a/src/components/Collapsible.tsx b/src/components/Collapsible.tsx index f43dbd6..6948cef 100644 --- a/src/components/Collapsible.tsx +++ b/src/components/Collapsible.tsx @@ -1,7 +1,6 @@ +import { LazyMotion, domAnimation, m } from 'framer-motion'; import React from 'react'; -import { framerMotionResouce } from '../misc/motion'; - const { memo } = React; const variantsCollpapsibleWrap = { @@ -27,17 +26,17 @@ const variantsCollpapsibleWrap = { }; const Collapsible = memo(({ children, isOpen }: { children: React.ReactNode; isOpen: boolean }) => { - const module = framerMotionResouce.read(); - const motion = module.motion; return ( - <motion.div - initial={isOpen ? 'initialOpen' : 'closed'} - animate={isOpen ? 'open' : 'closed'} - variants={variantsCollpapsibleWrap} - style={{ overflow: 'hidden' }} - > - {children} - </motion.div> + <LazyMotion features={domAnimation}> + <m.div + initial={isOpen ? 'initialOpen' : 'closed'} + animate={isOpen ? 'open' : 'closed'} + variants={variantsCollpapsibleWrap} + style={{ overflow: 'hidden' }} + > + {children} + </m.div> + </LazyMotion> ); }); diff --git a/src/components/Config.module.scss b/src/components/Config.module.scss index 9bc3f08..1c178b4 100644 --- a/src/components/Config.module.scss +++ b/src/components/Config.module.scss @@ -1,4 +1,4 @@ -@import '~/styles/utils/custom-media'; +@use '~/styles/utils/custom-media' as *; .root { max-width: 1000px; diff --git a/src/components/Config.tsx b/src/components/Config.tsx index 3bf39af..2d3f08b 100644 --- a/src/components/Config.tsx +++ b/src/components/Config.tsx @@ -8,7 +8,7 @@ import { Settings, Tool, Trash2, -} from 'react-feather'; +} from '~/components/shared/FeatherIcons'; import { useTranslation } from 'react-i18next'; import Select from '~/components/shared/Select'; @@ -73,7 +73,6 @@ export default function Config({ return ( <div> - <ContentHeader /> <div className={s0.root}> <div className={s0.card}> <div className={s0.sectionTitle}> diff --git a/src/components/ConnectionCard.tsx b/src/components/ConnectionCard.tsx index 8a3a88a..bf16eb6 100644 --- a/src/components/ConnectionCard.tsx +++ b/src/components/ConnectionCard.tsx @@ -1,7 +1,7 @@ import { formatDistance, Locale } from 'date-fns'; import { enUS, zhCN, zhTW } from 'date-fns/locale'; import React from 'react'; -import { ArrowDown, ArrowDownCircle, ArrowUp, X } from 'react-feather'; +import { ArrowDown, ArrowDownCircle, ArrowUp, X } from '~/components/shared/FeatherIcons'; import { useTranslation } from 'react-i18next'; import { FormattedConn } from '~/store/connections'; diff --git a/src/components/ConnectionTable.tsx b/src/components/ConnectionTable.tsx index 8a2f0a6..e296706 100644 --- a/src/components/ConnectionTable.tsx +++ b/src/components/ConnectionTable.tsx @@ -4,10 +4,10 @@ import cx from 'clsx'; import { formatDistance, Locale } from 'date-fns'; import { enUS, zhCN, zhTW } from 'date-fns/locale'; import React, { useCallback, useEffect, useMemo, useState } from 'react'; -import { ArrowDown, ArrowUp, ChevronDown, Sliders, XCircle } from 'react-feather'; +import { ArrowDown, ArrowUp, ChevronDown, Sliders, XCircle } from '~/components/shared/FeatherIcons'; import { useTranslation } from 'react-i18next'; import { useSortBy, useTable } from 'react-table'; -import { FixedSizeList as List } from 'react-window'; +import { List as VirtualList, RowComponentProps } from 'react-window'; import { FormattedConn } from '~/store/connections'; @@ -40,19 +40,6 @@ const COLUMN_WIDTHS = { const TOTAL_WIDTH = Object.values(COLUMN_WIDTHS).reduce((a, b) => a + b, 0); -const InnerElement = React.forwardRef<HTMLDivElement, React.HTMLProps<HTMLDivElement>>( - ({ style, ...rest }, ref) => ( - <div - ref={ref} - style={{ - ...style, - width: TOTAL_WIDTH, - }} - {...rest} - /> - ) -); - const getColumnStyle = (columnId: string) => { const width = COLUMN_WIDTHS[columnId] || 100; const style: React.CSSProperties = { @@ -82,19 +69,6 @@ function Table({ data, columns, hiddenColumns, apiConfig, height }) { const [isMobile, setIsMobile] = useState(false); const headerRef = React.useRef<HTMLDivElement>(null); - const outerRef = React.useRef<HTMLDivElement>(null); - - useEffect(() => { - const outer = outerRef.current; - if (!outer) return; - const handleScroll = () => { - if (headerRef.current) { - headerRef.current.scrollLeft = outer.scrollLeft; - } - }; - outer.addEventListener('scroll', handleScroll); - return () => outer.removeEventListener('scroll', handleScroll); - }, []); useEffect(() => { const mql = window.matchMedia('(max-width: 768px)'); @@ -193,7 +167,7 @@ function Table({ data, columns, hiddenColumns, apiConfig, height }) { }, [state.sortBy]); const MobileRow = useCallback( - ({ index, style }) => { + ({ index, style }: RowComponentProps) => { const row = rows[index]; const conn = row.original as FormattedConn; return ( @@ -211,7 +185,7 @@ function Table({ data, columns, hiddenColumns, apiConfig, height }) { ); const DesktopRow = useCallback( - ({ index, style }) => { + ({ index, style }: RowComponentProps) => { const row = rows[index]; prepareRow(row); return ( @@ -259,6 +233,12 @@ function Table({ data, columns, hiddenColumns, apiConfig, height }) { [prepareRow, rows, renderCell, locale] ); + const handleDesktopListScroll = useCallback((e: React.UIEvent<HTMLDivElement>) => { + if (headerRef.current) { + headerRef.current.scrollLeft = e.currentTarget.scrollLeft; + } + }, []); + return ( <div className={s.tableWrapper} style={{ height, overflow: 'hidden' }}> {isMobile ? ( @@ -290,9 +270,13 @@ function Table({ data, columns, hiddenColumns, apiConfig, height }) { {currentSort.desc ? <ArrowDown size={18} /> : <ArrowUp size={18} />} </button> </div> - <List height={height - 50} itemCount={rows.length} itemSize={120} width="100%"> - {MobileRow} - </List> + <VirtualList + style={{ height: height - 50, width: '100%' }} + rowCount={rows.length} + rowHeight={120} + rowComponent={MobileRow} + rowProps={{}} + /> </div> ) : ( <div @@ -347,16 +331,14 @@ function Table({ data, columns, hiddenColumns, apiConfig, height }) { ))} </div> </div> - <List - height={height - 50} - itemCount={rows.length} - itemSize={44} - width="100%" - outerRef={outerRef} - innerElementType={InnerElement} - > - {DesktopRow} - </List> + <VirtualList + style={{ height: height - 50, width: '100%' }} + onScroll={handleDesktopListScroll} + rowCount={rows.length} + rowHeight={44} + rowComponent={DesktopRow} + rowProps={{}} + /> </div> )} <MOdalCloseConnection diff --git a/src/components/Connections.tsx b/src/components/Connections.tsx index 38da7b8..64a5998 100644 --- a/src/components/Connections.tsx +++ b/src/components/Connections.tsx @@ -1,7 +1,7 @@ import './Connections.css'; import React from 'react'; -import { Pause, Play, RefreshCcw, Settings, Tag, X as IconClose } from 'react-feather'; +import { Pause, Play, RefreshCcw, Settings, Tag, X as IconClose } from '~/components/shared/FeatherIcons'; import { useTranslation } from 'react-i18next'; import { Tab, TabList, TabPanel, Tabs } from 'react-tabs'; @@ -114,14 +114,12 @@ export default function Connections({ apiConfig }: Props) { <Tab> <span>{t('Active')}</span> <span className={s.connQty}> - {/* @ts-expect-error ts-migrate(2786) FIXME: 'ConnQty' cannot be used as a JSX component. */} <ConnQty qty={filteredConns.length} /> </span> </Tab> <Tab> <span>{t('Closed')}</span> <span className={s.connQty}> - {/* @ts-expect-error ts-migrate(2786) FIXME: 'ConnQty' cannot be used as a JSX component. */} <ConnQty qty={filteredClosedConns.length} /> </span> </Tab> diff --git a/src/components/ContentHeader.module.scss b/src/components/ContentHeader.module.scss index 37a7b86..be1697b 100644 --- a/src/components/ContentHeader.module.scss +++ b/src/components/ContentHeader.module.scss @@ -1,4 +1,4 @@ -@import '~/styles/utils/custom-media'; +@use '~/styles/utils/custom-media' as *; .root { height: 60px; diff --git a/src/components/Home.module.scss b/src/components/Home.module.scss index 8da1d49..7fa6ada 100644 --- a/src/components/Home.module.scss +++ b/src/components/Home.module.scss @@ -1,4 +1,4 @@ -@import '~/styles/utils/custom-media'; +@use '~/styles/utils/custom-media' as *; .root { padding: 6px 15px; diff --git a/src/components/Home.tsx b/src/components/Home.tsx index 195b329..489f4c5 100644 --- a/src/components/Home.tsx +++ b/src/components/Home.tsx @@ -2,7 +2,6 @@ import React from 'react'; import { ClashAPIConfig } from '~/types'; -import ContentHeader from './ContentHeader'; import s0 from './Home.module.scss'; import TrafficNow from './TrafficNow'; @@ -14,7 +13,6 @@ type Props = { export default function Home({ apiConfig, selectedChartStyleIndex }: Props) { return ( <div> - <ContentHeader /> <div className={s0.root}> <TrafficNow apiConfig={apiConfig} selectedChartStyleIndex={selectedChartStyleIndex} /> </div> diff --git a/src/components/Logs.tsx b/src/components/Logs.tsx index 0dbb8f0..59abced 100644 --- a/src/components/Logs.tsx +++ b/src/components/Logs.tsx @@ -1,5 +1,5 @@ import * as React from 'react'; -import { ArrowDown, Pause, Play, Trash2 } from 'react-feather'; +import { ArrowDown, Pause, Play, Trash2 } from '~/components/shared/FeatherIcons'; import { useTranslation } from 'react-i18next'; import ContentHeader from '~/components/ContentHeader'; diff --git a/src/components/Modal.tsx b/src/components/Modal.tsx index e91523c..dfb9683 100644 --- a/src/components/Modal.tsx +++ b/src/components/Modal.tsx @@ -1,6 +1,6 @@ import cx from 'clsx'; import * as React from 'react'; -import Modal, { Props as ReactModalProps } from 'react-modal'; +import ReactModalBase, { Props as ReactModalProps } from 'react-modal'; import s0 from './Modal.module.scss'; @@ -8,10 +8,28 @@ type Props = ReactModalProps & { isOpen: boolean; onRequestClose: (...args: any[]) => any; children: React.ReactNode; - className?: string; - overlayClassName?: string; }; +const ReactModal = ReactModalBase as unknown as React.ComponentType<ReactModalProps>; + +function withBaseClass( + className: ReactModalProps['className'], + baseClassName: string +): ReactModalProps['className'] { + if (!className) { + return baseClassName; + } + + if (typeof className === 'string') { + return cx(className, baseClassName); + } + + return { + ...className, + base: cx(className.base, baseClassName), + }; +} + function ModalAPIConfig({ isOpen, onRequestClose, @@ -20,10 +38,10 @@ function ModalAPIConfig({ children, ...otherProps }: Props) { - const contentCls = cx(className, s0.content); - const overlayCls = cx(overlayClassName, s0.overlay); + const contentCls = withBaseClass(className, s0.content); + const overlayCls = withBaseClass(overlayClassName, s0.overlay); return ( - <Modal + <ReactModal isOpen={isOpen} onRequestClose={onRequestClose} className={contentCls} @@ -31,7 +49,7 @@ function ModalAPIConfig({ {...otherProps} > {children} - </Modal> + </ReactModal> ); } diff --git a/src/components/ModalCloseAllConnections.tsx b/src/components/ModalCloseAllConnections.tsx index 77bcb59..72efc7c 100644 --- a/src/components/ModalCloseAllConnections.tsx +++ b/src/components/ModalCloseAllConnections.tsx @@ -1,7 +1,8 @@ import cx from 'clsx'; import React from 'react'; import { useTranslation } from 'react-i18next'; -import Modal from 'react-modal'; + +import Modal from './Modal'; import Button from './Button'; import modalStyle from './Modal.module.scss'; @@ -9,16 +10,25 @@ import s from './ModalCloseAllConnections.module.scss'; const { useRef, useCallback, useMemo } = React; +type Props = { + confirm?: string; + isOpen: boolean; + onRequestClose: () => void; + primaryButtonOnTap: (e: React.MouseEvent<HTMLButtonElement>) => unknown; +}; + export default function Comp({ confirm = 'close_all_confirm', isOpen, onRequestClose, primaryButtonOnTap, -}) { +}: Props) { const { t } = useTranslation(); - const primaryButtonRef = useRef(null); + const primaryButtonRef = useRef<HTMLButtonElement | null>(null); const onAfterOpen = useCallback(() => { - primaryButtonRef.current.focus(); + if (primaryButtonRef.current) { + primaryButtonRef.current.focus(); + } }, []); const className = useMemo( () => ({ diff --git a/src/components/ModalConnectionDetails.tsx b/src/components/ModalConnectionDetails.tsx index 3274b4e..4fb4c0c 100644 --- a/src/components/ModalConnectionDetails.tsx +++ b/src/components/ModalConnectionDetails.tsx @@ -3,7 +3,8 @@ import { formatDistance } from 'date-fns'; import { enUS, zhCN, zhTW } from 'date-fns/locale'; import React from 'react'; import { useTranslation } from 'react-i18next'; -import Modal from 'react-modal'; + +import Modal from './Modal'; import { FormattedConn } from '~/store/connections'; diff --git a/src/components/ModalManageConnectionColumns.tsx b/src/components/ModalManageConnectionColumns.tsx index 463f76b..6c687c4 100644 --- a/src/components/ModalManageConnectionColumns.tsx +++ b/src/components/ModalManageConnectionColumns.tsx @@ -1,6 +1,6 @@ import { DragDropContext, Draggable, Droppable } from '@hello-pangea/dnd'; import React from 'react'; -import { ChevronDown, ChevronUp, Menu } from 'react-feather'; +import { ChevronDown, ChevronUp, Menu } from '~/components/shared/FeatherIcons'; import { useTranslation } from 'react-i18next'; import BaseModal from '~/components/shared/BaseModal'; diff --git a/src/components/Rule.module.scss b/src/components/Rule.module.scss index 845fa5e..48a22e7 100644 --- a/src/components/Rule.module.scss +++ b/src/components/Rule.module.scss @@ -1,4 +1,4 @@ -@import '~/styles/utils/custom-media'; +@use '~/styles/utils/custom-media' as *; .rule { display: flex; diff --git a/src/components/Rule.tsx b/src/components/Rule.tsx index 24e2e57..ee3a7ab 100644 --- a/src/components/Rule.tsx +++ b/src/components/Rule.tsx @@ -1,5 +1,5 @@ import React from 'react'; -import { FileText, Globe, Hash, Link, Shield, Zap } from 'react-feather'; +import { FileText, Globe, Hash, Link, Shield, Zap } from '~/components/shared/FeatherIcons'; import s0 from './Rule.module.scss'; diff --git a/src/components/Rules.module.scss b/src/components/Rules.module.scss index c2a4f39..f764d09 100644 --- a/src/components/Rules.module.scss +++ b/src/components/Rules.module.scss @@ -1,4 +1,4 @@ -@import '~/styles/utils/custom-media'; +@use '~/styles/utils/custom-media' as *; .container { display: flex; diff --git a/src/components/Rules.tsx b/src/components/Rules.tsx index c4ecd90..fb63028 100644 --- a/src/components/Rules.tsx +++ b/src/components/Rules.tsx @@ -1,14 +1,14 @@ import cx from 'clsx'; import React from 'react'; import { useTranslation } from 'react-i18next'; -import { areEqual, VariableSizeList } from 'react-window'; +import { List as VirtualList, RowComponentProps } from 'react-window'; import ContentHeader from '~/components/ContentHeader'; import { RuleProviderItem } from '~/components/rules/RuleProviderItem'; import { RulesPageFab } from '~/components/rules/RulesPageFab'; import { TextFilter } from '~/components/shared/TextFitler'; import { useRulesPage } from '~/modules/rules/hooks'; -import { formatQty, getItemSizeFactory, itemKey, RulesListItemData } from '~/modules/rules/utils'; +import { formatQty, getItemSizeFactory, RulesListItemData } from '~/modules/rules/utils'; import { ruleFilterText } from '~/store/rules'; import { ClashAPIConfig } from '~/types'; @@ -17,10 +17,11 @@ import useRemainingViewPortHeight from '../hooks/useRemainingViewPortHeight'; import Rule from './Rule'; import s from './Rules.module.scss'; -const { memo } = React; +type RulesRowProps = { + data: RulesListItemData; +}; -// @ts-expect-error ts-migrate(2339) FIXME: Property 'index' does not exist on type '{ childre... Remove this comment to see the full error message -const Row = memo(({ index, style, data }) => { +function Row({ index, style, data }: RowComponentProps<RulesRowProps>) { const { rules, provider, apiConfig } = data; if (!rules) { @@ -39,7 +40,7 @@ const Row = memo(({ index, style, data }) => { <Rule {...r} /> </div> ); -}, areEqual); +} type RulesProps = { apiConfig: ClashAPIConfig; @@ -86,16 +87,15 @@ export default function Rules({ apiConfig }: RulesProps) { </div> </ContentHeader> <div ref={refRulesContainer} className={s.listWrapper}> - <VariableSizeList - height={containerHeight} - width="100%" - itemCount={isRulesTab ? rules.length : provider.names.length} - itemSize={getItemSize} - itemData={{ rules: isRulesTab ? rules : null, provider, apiConfig } as RulesListItemData} - itemKey={itemKey} - > - {Row} - </VariableSizeList> + <VirtualList + style={{ height: containerHeight, width: '100%' }} + rowCount={isRulesTab ? rules.length : provider.names.length} + rowHeight={getItemSize} + rowComponent={Row} + rowProps={{ + data: { rules: isRulesTab ? rules : null, provider, apiConfig } as RulesListItemData, + }} + /> </div> {provider && provider.names && provider.names.length > 0 ? ( <RulesPageFab apiConfig={apiConfig} /> diff --git a/src/components/SideBar.module.scss b/src/components/SideBar.module.scss index 2d73535..74f7087 100644 --- a/src/components/SideBar.module.scss +++ b/src/components/SideBar.module.scss @@ -1,4 +1,4 @@ -@import '~/styles/utils/custom-media'; +@use '~/styles/utils/custom-media' as *; .root { background: var(--color-bg-sidebar); @@ -74,7 +74,6 @@ &:hover { background-color: rgba(176, 206, 255, 0.221); color: var(--color-focus-blue); - transform: translateX(2px); } @media (--breakpoint-not-small) { @@ -110,10 +109,6 @@ } } -.row:hover svg { - transform: scale(1.06); -} - .rowActive { background: linear-gradient(135deg, #60a5fa 0%, var(--color-focus-blue) 100%); color: #fff; diff --git a/src/components/SideBar.tsx b/src/components/SideBar.tsx index 307a362..a117533 100644 --- a/src/components/SideBar.tsx +++ b/src/components/SideBar.tsx @@ -1,7 +1,7 @@ import { Tooltip } from '@reach/tooltip'; import cx from 'clsx'; import * as React from 'react'; -import { Info } from 'react-feather'; +import { Info } from '~/components/shared/FeatherIcons'; import { useTranslation } from 'react-i18next'; import { FcAreaChart, FcDocument, FcGlobe, FcLink, FcRuler, FcSettings } from 'react-icons/fc'; import { useQuery } from 'react-query'; diff --git a/src/components/Sparkline.module.scss b/src/components/Sparkline.module.scss index bb52657..bc60060 100644 --- a/src/components/Sparkline.module.scss +++ b/src/components/Sparkline.module.scss @@ -1,5 +1,5 @@ .sparkline { width: 100%; - height: 40px; + height: 10vh; margin-top: auto; } diff --git a/src/components/Sparkline.tsx b/src/components/Sparkline.tsx index b7f35a9..79f8bf5 100644 --- a/src/components/Sparkline.tsx +++ b/src/components/Sparkline.tsx @@ -63,9 +63,19 @@ const extraChartOptions: any = { export default function Sparkline({ data: dataArray, labels, type, styleIndex = 0 }) { chartJSResource.read(); + const isMemory = type === 'inuse'; + const options = useMemo(() => { return { ...extraChartOptions, + scales: { + ...extraChartOptions.scales, + y: { + display: false, + // 内存值稳定,不从零开始,让 Y 轴自动适应数据范围以显示波动 + beginAtZero: !isMemory, + }, + }, plugins: { ...extraChartOptions.plugins, tooltip: { @@ -75,9 +85,9 @@ export default function Sparkline({ data: dataArray, labels, type, styleIndex = title: () => '', label(context) { if (context.parsed.y !== null) { - const suffix = type === 'inuse' ? '' : '/s'; - // 还原 log1p 变换后的真实值 - return prettyBytes(Math.expm1(context.parsed.y)) + suffix; + const suffix = isMemory ? '' : '/s'; + const raw = isMemory ? context.parsed.y : Math.expm1(context.parsed.y); + return prettyBytes(raw) + suffix; } return ''; }, @@ -85,7 +95,7 @@ export default function Sparkline({ data: dataArray, labels, type, styleIndex = }, }, }; - }, [type]); + }, [type, isMemory]); const data = useMemo( () => ({ @@ -93,13 +103,13 @@ export default function Sparkline({ data: dataArray, labels, type, styleIndex = { ...commonDataSetProps, ...chartStyles[styleIndex][type], - // log1p 变换:压缩大尖刺,让小流量也可见;log1p(0)=0 不会出现 -Infinity - data: dataArray.map((v, i) => ({ x: labels[i], y: Math.log1p(v) })), + // 内存用原始值(变化幅度小,不需要压缩);流量用 log1p 压缩尖刺 + data: dataArray.map((v, i) => ({ x: labels[i], y: isMemory ? v : Math.log1p(v) })), fill: true, }, ], }), - [dataArray, labels, type, styleIndex] + [dataArray, labels, type, styleIndex, isMemory], ); return ( diff --git a/src/components/StateProvider.tsx b/src/components/StateProvider.tsx index 1ef48d7..4b074ef 100644 --- a/src/components/StateProvider.tsx +++ b/src/components/StateProvider.tsx @@ -1,15 +1,15 @@ -import produce, * as immer from 'immer'; +import { produce, setAutoFreeze } from 'immer'; import React from 'react'; // in logs store we update logs in place // outside of immer produce // this is just workaround -immer.setAutoFreeze(false); +setAutoFreeze(false); const { createContext, memo, useMemo, useRef, useEffect, useCallback, useContext, useState } = React; -export { immer }; +export const immer = { produce, setAutoFreeze }; const StateContext = createContext(null); const DispatchContext = createContext(null); @@ -33,7 +33,7 @@ export default function Provider({ initialState, actions = {}, children }) { const [state, setState] = useState(initialState); const getState = useCallback(() => stateRef.current, []); useEffect(() => { - if (process.env.NODE_ENV === 'development') { + if (import.meta.env.DEV) { (window as any).getState2 = getState; } }, [getState]); @@ -43,7 +43,7 @@ export default function Provider({ initialState, actions = {}, children }) { const stateNext = produce(getState(), fn); if (stateNext !== stateRef.current) { - if (process.env.NODE_ENV === 'development') { + if (import.meta.env.DEV) { // eslint-disable-next-line no-console console.log(actionId, stateNext); } @@ -81,7 +81,7 @@ export function connect(mapStateToProps: any) { // steal from https://github.com/reduxjs/redux/blob/master/src/bindActionCreators.ts function bindAction(action: any, dispatch: any) { return function (...args: any[]) { - return dispatch(action.apply(this, args)); + return dispatch(action(...args)); }; } diff --git a/src/components/StyleGuide.tsx b/src/components/StyleGuide.tsx index 0e5d2a5..f743f89 100644 --- a/src/components/StyleGuide.tsx +++ b/src/components/StyleGuide.tsx @@ -1,5 +1,5 @@ import React from 'react'; -import { Zap } from 'react-feather'; +import { Zap } from '~/components/shared/FeatherIcons'; import Loading from '~/components/Loading'; diff --git a/src/components/TrafficNow.module.scss b/src/components/TrafficNow.module.scss index e0e3271..2b0bcdf 100644 --- a/src/components/TrafficNow.module.scss +++ b/src/components/TrafficNow.module.scss @@ -1,24 +1,63 @@ .TrafficNow { color: var(--color-text); - display: grid; - grid-template-columns: repeat(auto-fill, minmax(240px, 1fr)); + display: flex; + flex-direction: column; grid-gap: 20px; + gap: 20px; padding: 10px 0; + .overview { + display: grid; + grid-template-columns: 1fr 1fr; + gap: 20px; + + & > div:nth-child(3) { + grid-column: 1 / -1; + } + + @media (min-width: 768px) { + grid-template-columns: 1fr 1fr 1fr; + + & > div:nth-child(3) { + grid-column: auto; + } + } + } + + .chartsRow { + display: flex; + flex-direction: column; + gap: 20px; + + @media (min-width: 768px) { + flex-direction: row; + + & > .sec { + flex: 1; + min-width: 0; + } + } + } + .sec { padding: 20px; background-color: var(--color-bg-card); border-radius: 12px; - box-shadow: 0 4px 6px -1px rgba(0, 0, 0, 0.1), 0 2px 4px -1px rgba(0, 0, 0, 0.06); - transition: transform 0.2s ease, box-shadow 0.2s ease; + box-shadow: + 0 4px 6px -1px rgba(0, 0, 0, 0.1), + 0 2px 4px -1px rgba(0, 0, 0, 0.06); + transition: + transform 0.2s ease, + box-shadow 0.2s ease; display: flex; flex-direction: column; justify-content: space-between; min-height: 140px; &:hover { - transform: translateY(-2px); - box-shadow: 0 10px 15px -3px rgba(0, 0, 0, 0.1), 0 4px 6px -2px rgba(0, 0, 0, 0.05); + box-shadow: + 0 10px 15px -3px rgba(0, 0, 0, 0.1), + 0 4px 6px -2px rgba(0, 0, 0, 0.05); } .header { diff --git a/src/components/TrafficNow.tsx b/src/components/TrafficNow.tsx index 5d48aab..5dc601b 100644 --- a/src/components/TrafficNow.tsx +++ b/src/components/TrafficNow.tsx @@ -1,5 +1,12 @@ import * as React from 'react'; -import { Activity, ArrowDown, ArrowUp, Cpu, Link as LinkIcon, Zap } from 'react-feather'; +import { + Download, + ArrowDown, + ArrowUp, + Cpu, + Link as LinkIcon, + Upload, +} from '~/components/shared/FeatherIcons'; import { useTranslation } from 'react-i18next'; import useMemory from '../hooks/useMemory'; @@ -27,69 +34,70 @@ export default function TrafficNow({ apiConfig, selectedChartStyleIndex }: Props return ( <div className={s0.TrafficNow}> - <div className={s0.sec}> - <div className={s0.header}> - <Cpu size={16} /> - <span>{t('Memory Usage')}</span> + <div className={s0.overview}> + <div className={s0.sec}> + <div className={s0.header}> + <Download size={16} /> + <span>{t('Download Total')}</span> + </div> + <div className={s0.value}>{dlTotal}</div> </div> - <div className={s0.value}>{mUsage}</div> - <Sparkline - data={memory.inuse} - labels={memory.labels} - type="inuse" - styleIndex={selectedChartStyleIndex} - /> - </div> - - <div className={s0.sec}> - <div className={s0.header}> - <ArrowDown size={16} /> - <span>{t('Download')}</span> + <div className={s0.sec}> + <div className={s0.header}> + <Upload size={16} /> + <span>{t('Upload Total')}</span> + </div> + <div className={s0.value}>{upTotal}</div> </div> - <div className={s0.value}>{downStr}</div> - <Sparkline - data={traffic.down} - labels={traffic.labels} - type="down" - styleIndex={selectedChartStyleIndex} - /> - </div> - <div className={s0.sec}> - <div className={s0.header}> - <ArrowUp size={16} /> - <span>{t('Upload')}</span> + <div className={s0.sec}> + <div className={s0.header}> + <LinkIcon size={16} /> + <span>{t('Active Connections')}</span> + </div> + <div className={s0.value}>{connNumber}</div> </div> - <div className={s0.value}>{upStr}</div> - <Sparkline - data={traffic.up} - labels={traffic.labels} - type="up" - styleIndex={selectedChartStyleIndex} - /> </div> - <div className={s0.sec}> - <div className={s0.header}> - <Activity size={16} /> - <span>{t('Download Total')}</span> + <div className={s0.chartsRow}> + <div className={s0.sec}> + <div className={s0.header}> + <ArrowDown size={16} /> + <span>{t('Download')}</span> + </div> + <div className={s0.value}>{downStr}</div> + <Sparkline + data={traffic.down} + labels={traffic.labels} + type="down" + styleIndex={selectedChartStyleIndex} + /> </div> - <div className={s0.value}>{dlTotal}</div> - </div> - - <div className={s0.sec}> - <div className={s0.header}> - <Zap size={16} /> - <span>{t('Upload Total')}</span> + <div className={s0.sec}> + <div className={s0.header}> + <ArrowUp size={16} /> + <span>{t('Upload')}</span> + </div> + <div className={s0.value}>{upStr}</div> + <Sparkline + data={traffic.up} + labels={traffic.labels} + type="up" + styleIndex={selectedChartStyleIndex} + /> </div> - <div className={s0.value}>{upTotal}</div> - </div> - - <div className={s0.sec}> - <div className={s0.header}> - <LinkIcon size={16} /> - <span>{t('Active Connections')}</span> + <div className={s0.sec}> + <div className={s0.header}> + <Cpu size={16} /> + <span>{t('Memory Usage')}</span> + </div> + <div className={s0.value}>{mUsage}</div> + <Sparkline + data={memory.inuse} + labels={memory.labels} + type="inuse" + styleIndex={selectedChartStyleIndex} + /> </div> - <div className={s0.value}>{connNumber}</div> </div> </div> ); diff --git a/src/components/about/About.module.scss b/src/components/about/About.module.scss index 7ed1aa5..de20013 100644 --- a/src/components/about/About.module.scss +++ b/src/components/about/About.module.scss @@ -1,4 +1,4 @@ -@import '~/styles/utils/custom-media'; +@use '~/styles/utils/custom-media' as *; .root { padding: 6px 15px; diff --git a/src/components/about/About.tsx b/src/components/about/About.tsx index 8fa3129..cb08218 100644 --- a/src/components/about/About.tsx +++ b/src/components/about/About.tsx @@ -1,5 +1,5 @@ import * as React from 'react'; -import { GitHub } from 'react-feather'; +import { GitHub } from '~/components/shared/FeatherIcons'; import ContentHeader from '~/components/ContentHeader'; import { useAboutVersionQuery } from '~/modules/about/hooks'; @@ -34,7 +34,7 @@ export function About({ apiConfig }: Props) { return ( <> - <ContentHeader title="About" /> + <ContentHeader>About</ContentHeader> {coreVersionMeta && version?.version ? ( <Version name={coreVersionMeta.name} diff --git a/src/components/proxies/Proxies.module.scss b/src/components/proxies/Proxies.module.scss index 8990688..af961cf 100644 --- a/src/components/proxies/Proxies.module.scss +++ b/src/components/proxies/Proxies.module.scss @@ -1,4 +1,4 @@ -@import '~/styles/utils/custom-media'; +@use '~/styles/utils/custom-media' as *; .topBar { position: sticky; diff --git a/src/components/proxies/Proxy.module.scss b/src/components/proxies/Proxy.module.scss index 60c589f..066026f 100644 --- a/src/components/proxies/Proxy.module.scss +++ b/src/components/proxies/Proxy.module.scss @@ -1,4 +1,4 @@ -@import '~/styles/utils/custom-media'; +@use '~/styles/utils/custom-media' as *; .proxy { padding: 5px; @@ -20,7 +20,6 @@ } &:hover { - transform: translateY(-2px); box-shadow: 0 10px 15px -3px rgba(0, 0, 0, 0.1), 0 4px 6px -2px rgba(0, 0, 0, 0.05); z-index: 1; } diff --git a/src/components/proxies/ProxyGroup.module.scss b/src/components/proxies/ProxyGroup.module.scss index 06a061f..19529cf 100644 --- a/src/components/proxies/ProxyGroup.module.scss +++ b/src/components/proxies/ProxyGroup.module.scss @@ -9,10 +9,6 @@ border-radius: 12px; box-shadow: var(--shadow-card); transition: border-color 0.2s ease, box-shadow 0.2s ease; - - &:hover { - border-color: var(--color-focus-blue); - } } .zapWrapper { diff --git a/src/components/proxies/ProxyGroup.tsx b/src/components/proxies/ProxyGroup.tsx index 69f2551..7b751fb 100644 --- a/src/components/proxies/ProxyGroup.tsx +++ b/src/components/proxies/ProxyGroup.tsx @@ -1,6 +1,6 @@ import cx from 'clsx'; import * as React from 'react'; -import { ChevronDown, Zap } from 'react-feather'; +import { ChevronDown, Zap } from '~/components/shared/FeatherIcons'; import { useQuery } from 'react-query'; import * as proxiesAPI from '~/api/proxies'; diff --git a/src/components/proxies/ProxyLatency.module.scss b/src/components/proxies/ProxyLatency.module.scss index fce0a2e..ac39100 100644 --- a/src/components/proxies/ProxyLatency.module.scss +++ b/src/components/proxies/ProxyLatency.module.scss @@ -1,4 +1,4 @@ -@import '~/styles/utils/custom-media'; +@use '~/styles/utils/custom-media' as *; .proxyLatency { display: inline-flex; diff --git a/src/components/proxies/ProxyList.module.scss b/src/components/proxies/ProxyList.module.scss index a7e1956..f4c8d87 100644 --- a/src/components/proxies/ProxyList.module.scss +++ b/src/components/proxies/ProxyList.module.scss @@ -1,4 +1,4 @@ -@import '~/styles/utils/custom-media'; +@use '~/styles/utils/custom-media' as *; .list { margin: 8px 0; diff --git a/src/components/proxies/ProxyPageFab.tsx b/src/components/proxies/ProxyPageFab.tsx index ec78650..a8536e0 100644 --- a/src/components/proxies/ProxyPageFab.tsx +++ b/src/components/proxies/ProxyPageFab.tsx @@ -1,5 +1,5 @@ import * as React from 'react'; -import { Zap } from 'react-feather'; +import { Zap } from '~/components/shared/FeatherIcons'; import { useTranslation } from 'react-i18next'; import { Action, Fab, IsFetching, position as fabPosition } from '~/components/shared/Fab'; diff --git a/src/components/proxies/ProxyProvider.module.scss b/src/components/proxies/ProxyProvider.module.scss index d222bc5..d22e50b 100644 --- a/src/components/proxies/ProxyProvider.module.scss +++ b/src/components/proxies/ProxyProvider.module.scss @@ -1,4 +1,4 @@ -@import '~/styles/utils/custom-media'; +@use '~/styles/utils/custom-media' as *; .updatedAt { margin-bottom: 12px; diff --git a/src/components/proxies/ProxyProvider.tsx b/src/components/proxies/ProxyProvider.tsx index d1f257a..47ccb8b 100644 --- a/src/components/proxies/ProxyProvider.tsx +++ b/src/components/proxies/ProxyProvider.tsx @@ -1,14 +1,14 @@ import cx from 'clsx'; import { formatDistance } from 'date-fns'; import * as React from 'react'; -import { ChevronDown, RotateCw, Zap } from 'react-feather'; +import { ChevronDown, RotateCw, Zap } from '~/components/shared/FeatherIcons'; import Button from '~/components/Button'; import Collapsible from '~/components/Collapsible'; import CollapsibleSectionHeader from '~/components/CollapsibleSectionHeader'; import s0 from '~/components/proxies/ProxyGroup.module.scss'; import { useStoreActions } from '~/components/StateProvider'; -import { framerMotionResouce } from '~/misc/motion'; +import { LazyMotion, domAnimation, m } from 'framer-motion'; import { useFilteredAndSorted, useUpdateProviderItem } from '~/modules/proxies/hooks'; import { healthcheckProviderByName } from '~/store/proxies'; import { DelayMapping, DispatchFn, ProxiesMapping, SubscriptionInfo } from '~/store/types'; @@ -190,19 +190,19 @@ function formatBytes(bytes, decimals = 2) { return `${parseFloat((bytes / Math.pow(k, i)).toFixed(dm))} ${sizes[i]}`; } function Refresh() { - const module = framerMotionResouce.read(); - const motion = module.motion; return ( - <motion.div - className={s.refresh} - variants={button} - initial="rest" - whileHover="hover" - whileTap="pressed" - > - <motion.div className="flexCenter" variants={arrow}> - <RotateCw size={16} /> - </motion.div> - </motion.div> + <LazyMotion features={domAnimation}> + <m.div + className={s.refresh} + variants={button} + initial="rest" + whileHover="hover" + whileTap="pressed" + > + <m.div className="flexCenter" variants={arrow}> + <RotateCw size={16} /> + </m.div> + </m.div> + </LazyMotion> ); } diff --git a/src/components/rules/RuleProviderItem.tsx b/src/components/rules/RuleProviderItem.tsx index 7077ed1..5100f3d 100644 --- a/src/components/rules/RuleProviderItem.tsx +++ b/src/components/rules/RuleProviderItem.tsx @@ -1,6 +1,6 @@ import { formatDistance } from 'date-fns'; import * as React from 'react'; -import { Activity, Database, RefreshCw } from 'react-feather'; +import { Activity, Database, RefreshCw } from '~/components/shared/FeatherIcons'; import Button from '~/components/Button'; import { useUpdateRuleProviderItem } from '~/modules/rules/hooks'; diff --git a/src/components/shared/BaseModal.tsx b/src/components/shared/BaseModal.tsx index 72dcba4..f7841f8 100644 --- a/src/components/shared/BaseModal.tsx +++ b/src/components/shared/BaseModal.tsx @@ -1,6 +1,7 @@ import cx from 'clsx'; import * as React from 'react'; -import Modal from 'react-modal'; + +import Modal from '../Modal'; import modalStyle from '../Modal.module.scss'; @@ -8,7 +9,13 @@ import s from './BaseModal.module.scss'; const { useMemo } = React; -export default function BaseModal({ isOpen, onRequestClose, children }) { +type BaseModalProps = { + isOpen: boolean; + onRequestClose: (...args: any[]) => unknown; + children: React.ReactNode; +}; + +export default function BaseModal({ isOpen, onRequestClose, children }: BaseModalProps) { const className = useMemo( () => ({ base: cx(modalStyle.content, s.cnt), diff --git a/src/components/shared/Basic.module.scss b/src/components/shared/Basic.module.scss index 79b8a16..df412e5 100644 --- a/src/components/shared/Basic.module.scss +++ b/src/components/shared/Basic.module.scss @@ -1,4 +1,4 @@ -@import '~/styles/utils/custom-media'; +@use '~/styles/utils/custom-media' as *; h2.sectionNameType { margin: 0; diff --git a/src/components/shared/Fab.tsx b/src/components/shared/Fab.tsx index 8e72432..49c9a89 100644 --- a/src/components/shared/Fab.tsx +++ b/src/components/shared/Fab.tsx @@ -18,7 +18,7 @@ export const position = { interface ABProps extends React.HTMLAttributes<HTMLButtonElement> { text?: string; - onClick?: (e: React.FormEvent) => void; + onClick?: (e: React.MouseEvent<HTMLButtonElement>) => unknown; 'data-testid'?: string; } @@ -46,7 +46,7 @@ interface FabProps { alwaysShowTitle?: boolean; icon?: React.ReactNode; mainButtonStyles?: React.CSSProperties; - onClick?: (e: React.FormEvent) => void; + onClick?: (e: React.MouseEvent<HTMLButtonElement>) => unknown; text?: string; children?: React.ReactNode; } @@ -68,7 +68,7 @@ const Fab: React.FC<FabProps> = ({ const close = () => setIsOpen(false); const enter = () => event === 'hover' && open(); const leave = () => event === 'hover' && close(); - const toggle = (e: React.FormEvent) => { + const toggle = (e: React.MouseEvent<HTMLButtonElement>) => { if (onClick) { return onClick(e); } @@ -76,7 +76,10 @@ const Fab: React.FC<FabProps> = ({ return event === 'click' ? (isOpen ? close() : open()) : null; }; - const actionOnClick = (e: React.FormEvent, userFunc: (e: React.FormEvent) => void) => { + const actionOnClick = ( + e: React.MouseEvent<HTMLButtonElement>, + userFunc: (e: React.MouseEvent<HTMLButtonElement>) => unknown + ) => { e.persist(); setIsOpen(false); setTimeout(() => { @@ -95,7 +98,7 @@ const Fab: React.FC<FabProps> = ({ 'aria-hidden': ariaHidden, tabIndex: isOpen ? 0 : -1, ...ch.props, - onClick: (e: React.FormEvent) => { + onClick: (e: React.MouseEvent<HTMLButtonElement>) => { if (ch.props.onClick) actionOnClick(e, ch.props.onClick); }, })} diff --git a/src/components/shared/FeatherIcons.ts b/src/components/shared/FeatherIcons.ts new file mode 100644 index 0000000..ee1f410 --- /dev/null +++ b/src/components/shared/FeatherIcons.ts @@ -0,0 +1,38 @@ +export { + Activity, + ArrowDown, + ArrowDownCircle, + ArrowUp, + ChevronDown, + ChevronUp, + Cpu, + Database, + Download, + DownloadCloud, + Eye, + EyeOff, + FileText, + GitHub, + Globe, + Hash, + Info, + Link, + LogOut, + Menu, + Monitor, + Pause, + Play, + RefreshCcw, + RefreshCw, + RotateCw, + Settings, + Shield, + Sliders, + Tag, + Tool, + Trash2, + Upload, + X, + XCircle, + Zap, +} from 'react-feather'; diff --git a/src/components/shared/RotateIcon.tsx b/src/components/shared/RotateIcon.tsx index 7e3ceae..0a5a018 100644 --- a/src/components/shared/RotateIcon.tsx +++ b/src/components/shared/RotateIcon.tsx @@ -1,6 +1,6 @@ import cx from 'clsx'; import * as React from 'react'; -import { RotateCw } from 'react-feather'; +import { RotateCw } from '~/components/shared/FeatherIcons'; import s from './RotateIcon.module.scss'; diff --git a/src/components/shared/ThemeSwitcher.tsx b/src/components/shared/ThemeSwitcher.tsx index 363d422..dfe248d 100644 --- a/src/components/shared/ThemeSwitcher.tsx +++ b/src/components/shared/ThemeSwitcher.tsx @@ -3,7 +3,7 @@ import * as React from 'react'; import { useTranslation } from 'react-i18next'; import { connect } from '~/components/StateProvider'; -import { framerMotionResouce } from '~/misc/motion'; +import { LazyMotion, domAnimation, m } from 'framer-motion'; import { getTheme, switchTheme } from '~/store/app'; import { State } from '~/store/types'; @@ -28,7 +28,7 @@ export function ThemeSwitcherImpl({ theme, dispatch }) { const onChange = React.useCallback( (e: React.ChangeEvent<HTMLSelectElement>) => dispatch(switchTheme(e.target.value)), - [dispatch] + [dispatch], ); return ( @@ -46,91 +46,89 @@ export function ThemeSwitcherImpl({ theme, dispatch }) { } function MoonA() { - const module = framerMotionResouce.read(); - const motion = module.motion; return ( - <svg - xmlns="http://www.w3.org/2000/svg" - width="20" - height="20" - viewBox="0 0 24 24" - fill="none" - stroke="currentColor" - strokeWidth="2" - strokeLinecap="round" - strokeLinejoin="round" - > - <motion.path - d="M21 12.79A9 9 0 1 1 11.21 3 7 7 0 0 0 21 12.79z" - initial={{ rotate: -30 }} - animate={{ rotate: 0 }} - transition={{ duration: 0.7 }} - /> - </svg> + <LazyMotion features={domAnimation}> + <svg + xmlns="http://www.w3.org/2000/svg" + width="20" + height="20" + viewBox="0 0 24 24" + fill="none" + stroke="currentColor" + strokeWidth="2" + strokeLinecap="round" + strokeLinejoin="round" + > + <m.path + d="M21 12.79A9 9 0 1 1 11.21 3 7 7 0 0 0 21 12.79z" + initial={{ rotate: -30 }} + animate={{ rotate: 0 }} + transition={{ duration: 0.7 }} + /> + </svg> + </LazyMotion> ); } function Sun() { - const module = framerMotionResouce.read(); - const motion = module.motion; - return ( - <svg - xmlns="http://www.w3.org/2000/svg" - width="20" - height="20" - viewBox="0 0 24 24" - fill="none" - stroke="currentColor" - strokeWidth="2" - strokeLinecap="round" - strokeLinejoin="round" - > - <circle cx="12" cy="12" r="5"></circle> - <motion.g initial={{ scale: 0.7 }} animate={{ scale: 1 }} transition={{ duration: 0.5 }}> - <line x1="12" y1="1" x2="12" y2="3"></line> - <line x1="12" y1="21" x2="12" y2="23"></line> - <line x1="4.22" y1="4.22" x2="5.64" y2="5.64"></line> - <line x1="18.36" y1="18.36" x2="19.78" y2="19.78"></line> - <line x1="1" y1="12" x2="3" y2="12"></line> - <line x1="21" y1="12" x2="23" y2="12"></line> - <line x1="4.22" y1="19.78" x2="5.64" y2="18.36"></line> - <line x1="18.36" y1="5.64" x2="19.78" y2="4.22"></line> - </motion.g> - </svg> + <LazyMotion features={domAnimation}> + <svg + xmlns="http://www.w3.org/2000/svg" + width="20" + height="20" + viewBox="0 0 24 24" + fill="none" + stroke="currentColor" + strokeWidth="2" + strokeLinecap="round" + strokeLinejoin="round" + > + <circle cx="12" cy="12" r="5"></circle> + <m.g initial={{ scale: 0.7 }} animate={{ scale: 1 }} transition={{ duration: 0.5 }}> + <line x1="12" y1="1" x2="12" y2="3"></line> + <line x1="12" y1="21" x2="12" y2="23"></line> + <line x1="4.22" y1="4.22" x2="5.64" y2="5.64"></line> + <line x1="18.36" y1="18.36" x2="19.78" y2="19.78"></line> + <line x1="1" y1="12" x2="3" y2="12"></line> + <line x1="21" y1="12" x2="23" y2="12"></line> + <line x1="4.22" y1="19.78" x2="5.64" y2="18.36"></line> + <line x1="18.36" y1="5.64" x2="19.78" y2="4.22"></line> + </m.g> + </svg> + </LazyMotion> ); } function Auto() { - const module = framerMotionResouce.read(); - const motion = module.motion; - return ( - <svg - xmlns="http://www.w3.org/2000/svg" - width="20" - height="20" - viewBox="0 0 24 24" - fill="none" - stroke="currentColor" - strokeWidth="2" - strokeLinecap="round" - strokeLinejoin="round" - > - <circle cx="12" cy="12" r="11" /> - <clipPath id="cut-off-bottom"> - <motion.rect - x="12" - y="0" - width="12" - height="24" - initial={{ rotate: -30 }} - animate={{ rotate: 0 }} - transition={{ duration: 0.7 }} - /> - </clipPath> - <circle cx="12" cy="12" r="6" clipPath="url(#cut-off-bottom)" fill="currentColor" /> - </svg> + <LazyMotion features={domAnimation}> + <svg + xmlns="http://www.w3.org/2000/svg" + width="20" + height="20" + viewBox="0 0 24 24" + fill="none" + stroke="currentColor" + strokeWidth="2" + strokeLinecap="round" + strokeLinejoin="round" + > + <circle cx="12" cy="12" r="11" /> + <clipPath id="cut-off-bottom"> + <m.rect + x="12" + y="0" + width="12" + height="24" + initial={{ rotate: -30 }} + animate={{ rotate: 0 }} + transition={{ duration: 0.7 }} + /> + </clipPath> + <circle cx="12" cy="12" r="6" clipPath="url(#cut-off-bottom)" fill="currentColor" /> + </svg> + </LazyMotion> ); } |
