diff options
| author | Larvan2 <[email protected]> | 2026-01-01 15:52:18 +0800 |
|---|---|---|
| committer | Larvan2 <[email protected]> | 2026-01-01 15:52:18 +0800 |
| commit | a547b3c8dc48791c4f767da0314d907eba543cc1 (patch) | |
| tree | 70a0d500147dcb972574e9a9641da956e033a963 /src/components | |
| parent | 6768024fc9460f7f5a459de32de4cf771c75e19c (diff) | |
chore: adjust style
Diffstat (limited to 'src/components')
32 files changed, 1287 insertions, 647 deletions
diff --git a/src/components/Collapsible.tsx b/src/components/Collapsible.tsx index 47b882c..f43dbd6 100644 --- a/src/components/Collapsible.tsx +++ b/src/components/Collapsible.tsx @@ -1,38 +1,22 @@ import React from 'react'; -import ResizeObserver from 'resize-observer-polyfill'; import { framerMotionResouce } from '../misc/motion'; -const { memo, useState, useRef, useEffect } = React; - -function usePrevious(value) { - const ref = useRef(); - useEffect(() => void (ref.current = value), [value]); - return ref.current; -} - -function useMeasure() { - const ref = useRef(); - const [bounds, set] = useState({ height: 0 }); - useEffect(() => { - const ro = new ResizeObserver(([entry]) => set(entry.contentRect)); - if (ref.current) ro.observe(ref.current); - return () => ro.disconnect(); - }, []); - return [ref, bounds]; -} +const { memo } = React; const variantsCollpapsibleWrap = { initialOpen: { height: 'auto', + opacity: 1, + visibility: 'visible', transition: { duration: 0 }, }, - open: (height) => ({ - height, + open: { + height: 'auto', opacity: 1, visibility: 'visible', transition: { duration: 0.3 }, - }), + }, closed: { height: 0, opacity: 0, @@ -42,30 +26,18 @@ const variantsCollpapsibleWrap = { }, }; -const variantsCollpapsibleChildContainer = { - open: {}, - closed: {}, -}; - -// @ts-expect-error ts-migrate(2339) FIXME: Property 'isOpen' does not exist on type '{ childr... Remove this comment to see the full error message -const Collapsible = memo(({ children, isOpen }) => { +const Collapsible = memo(({ children, isOpen }: { children: React.ReactNode; isOpen: boolean }) => { const module = framerMotionResouce.read(); const motion = module.motion; - const previous = usePrevious(isOpen); - // @ts-expect-error ts-migrate(2339) FIXME: Property 'height' does not exist on type 'MutableR... Remove this comment to see the full error message - const [refToMeature, { height }] = useMeasure(); return ( - <div> - <motion.div - animate={isOpen && previous === isOpen ? 'initialOpen' : isOpen ? 'open' : 'closed'} - custom={height} - variants={variantsCollpapsibleWrap} - > - <motion.div variants={variantsCollpapsibleChildContainer} ref={refToMeature}> - {children} - </motion.div> - </motion.div> - </div> + <motion.div + initial={isOpen ? 'initialOpen' : 'closed'} + animate={isOpen ? 'open' : 'closed'} + variants={variantsCollpapsibleWrap} + style={{ overflow: 'hidden' }} + > + {children} + </motion.div> ); }); diff --git a/src/components/CollapsibleSectionHeader.module.scss b/src/components/CollapsibleSectionHeader.module.scss index a875719..f0797dd 100644 --- a/src/components/CollapsibleSectionHeader.module.scss +++ b/src/components/CollapsibleSectionHeader.module.scss @@ -2,6 +2,7 @@ display: flex; align-items: center; padding: 5px; + user-select: none; &:focus { outline: none; diff --git a/src/components/Config.module.scss b/src/components/Config.module.scss index 7d7083c..9bc3f08 100644 --- a/src/components/Config.module.scss +++ b/src/components/Config.module.scss @@ -7,6 +7,10 @@ display: flex; flex-direction: column; gap: 24px; + + @media (max-width: 768px) { + padding: 15px 10px; + } } .section { @@ -14,6 +18,10 @@ grid-template-columns: repeat(auto-fill, minmax(320px, 1fr)); gap: 24px; align-items: start; + + @media (max-width: 768px) { + grid-template-columns: 1fr; + } } .wrapSwitch { @@ -29,7 +37,7 @@ padding: 0 40px; } > div { - border-top: 1px dashed #373737; + border-top: 1px dashed var(--color-separator); } } @@ -49,20 +57,20 @@ display: flex; align-items: center; gap: 10px; - color: var(--color-text-main); + color: var(--color-text-highlight); } .card { - background: var(--bg-near-transparent, rgba(255, 255, 255, 0.03)); - border: 1px solid var(--border-color, rgba(255, 255, 255, 0.08)); - border-radius: 16px; + background: var(--bg-log-info-card); + border: 1px solid var(--color-separator); + border-radius: 12px; padding: 24px; margin-bottom: 24px; - backdrop-filter: blur(10px); - transition: transform 0.2s ease, box-shadow 0.2s ease; + box-shadow: var(--shadow-card); + transition: border-color 0.2s ease, box-shadow 0.2s ease; &:hover { - border-color: var(--border-color-hover, rgba(255, 255, 255, 0.15)); - box-shadow: 0 8px 16px rgba(0, 0, 0, 0.2); + border-color: var(--color-focus-blue); + box-shadow: 0 8px 16px rgba(0, 0, 0, 0.1); } } diff --git a/src/components/Config.tsx b/src/components/Config.tsx index 8319485..248f29c 100644 --- a/src/components/Config.tsx +++ b/src/components/Config.tsx @@ -256,7 +256,7 @@ function ConfigImpl({ return ( <div> - <ContentHeader title={t('Config')} /> + <ContentHeader /> <div className={s0.root}> <div className={s0.card}> <div className={s0.sectionTitle}> diff --git a/src/components/ConnectionCard.module.scss b/src/components/ConnectionCard.module.scss index 6007c45..e0c42c3 100644 --- a/src/components/ConnectionCard.module.scss +++ b/src/components/ConnectionCard.module.scss @@ -1,12 +1,12 @@ .card { background: var(--color-bg-card); border-radius: 12px; - padding: 12px 16px; - margin-bottom: 10px; + padding: 10px 14px; + margin-bottom: 8px; box-shadow: 0 2px 6px rgba(0, 0, 0, 0.05); display: flex; flex-direction: column; - gap: 6px; + gap: 4px; cursor: pointer; transition: transform 0.1s ease; @@ -25,7 +25,7 @@ .host { color: #40c4aa; // Similar to the image font-weight: 600; - font-size: 1rem; + font-size: 0.95rem; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; @@ -35,20 +35,20 @@ .time { color: var(--color-text-secondary); - font-size: 0.85rem; + font-size: 0.8rem; flex-shrink: 0; } .typeProtocol { color: var(--color-text); - font-size: 0.9rem; + font-size: 0.85rem; opacity: 0.8; } .totals { display: flex; - gap: 12px; - font-size: 0.85rem; + gap: 10px; + font-size: 0.8rem; color: var(--color-text-secondary); span { @@ -67,7 +67,7 @@ align-items: center; gap: 6px; color: var(--color-text-secondary); - font-size: 0.85rem; + font-size: 0.8rem; overflow: hidden; flex: 1; margin-right: 8px; @@ -80,7 +80,7 @@ .arrow { opacity: 0.5; - font-size: 0.75rem; + font-size: 0.7rem; } .chains { @@ -96,14 +96,14 @@ .speedAndAction { display: flex; align-items: center; - gap: 12px; + gap: 8px; } .speed { display: flex; align-items: center; - gap: 6px; - font-size: 0.9rem; + gap: 4px; + font-size: 0.8rem; color: var(--color-text-secondary); font-family: 'Roboto Mono', monospace; diff --git a/src/components/ConnectionCard.tsx b/src/components/ConnectionCard.tsx index 68ca63e..dc1bfb4 100644 --- a/src/components/ConnectionCard.tsx +++ b/src/components/ConnectionCard.tsx @@ -15,7 +15,7 @@ interface Props { onClick: () => void; } -export default function ConnectionCard({ conn, onDisconnect, onClick }: Props) { +const ConnectionCard = React.memo(function ConnectionCard({ conn, onDisconnect, onClick }: Props) { const { i18n } = useTranslation(); let locale: Locale; @@ -81,4 +81,6 @@ export default function ConnectionCard({ conn, onDisconnect, onClick }: Props) { </div> </div> ); -} +}); + +export default ConnectionCard; diff --git a/src/components/ConnectionTable.module.scss b/src/components/ConnectionTable.module.scss index 1a47c81..e8e3daf 100644 --- a/src/components/ConnectionTable.module.scss +++ b/src/components/ConnectionTable.module.scss @@ -1,6 +1,10 @@ .tr { transition: all 0.2s ease; cursor: pointer; + display: flex; + align-items: stretch; + width: 100%; + min-width: fit-content; &:hover { // hover 作用到每个 td,避免 odd 行背景覆盖 @@ -16,25 +20,49 @@ .th { height: 48px; - background: var(--color-background); + background: var(--bg-log-info-card); top: 0; - font-size: 0.85em; + font-size: 0.8em; font-weight: 600; user-select: none; - text-align: center; text-transform: uppercase; letter-spacing: 0.5px; color: var(--color-text-secondary); - border-bottom: 2px solid var(--bg-near-transparent); + border-bottom: 2px solid var(--color-separator); position: sticky; z-index: 20; - box-shadow: 0 1px 0 rgba(0, 0, 0, 0.06); + padding: 0 8px; + white-space: nowrap; + overflow: hidden; + flex-shrink: 0; // Ensure fixed width is respected &:hover { color: var(--color-text-highlight); } } +.headerText { + overflow: hidden; + text-overflow: ellipsis; +} + +.cellText { + overflow: hidden; + text-overflow: ellipsis; + width: 100%; +} + +.sortIconContainer { + margin-left: 4px; + flex-shrink: 0; + display: flex; + align-items: center; +} + +.rotate180 { + transform: rotate(180deg); +} + .btnSection { button { margin-right: 15px; @@ -49,15 +77,15 @@ } .td { - padding: 12px 8px; + padding: 0 8px; font-size: 0.875em; - min-width: 9em; cursor: pointer; - text-align: left; vertical-align: middle; white-space: nowrap; - border-bottom: 1px solid var(--bg-near-transparent); + border-bottom: 1px solid var(--color-separator); transition: color 0.15s ease; + box-sizing: border-box; + flex-shrink: 0; // Ensure fixed width is respected &:hover { color: var(--color-text-highlight); @@ -78,7 +106,7 @@ border-collapse: separate; border-spacing: 0; width: 100%; - background: var(--color-background); + background: transparent; @media (max-width: 768px) { display: none; @@ -86,11 +114,11 @@ } .tableWrapper { - margin-top: 6px; + margin-top: 0; border-radius: 12px; - overflow: visible; // sticky 需要避免中间层成为 scroll container - background: var(--color-background); - box-shadow: 0 2px 8px rgba(0, 0, 0, 0.08); + overflow: hidden; + background: transparent; + box-shadow: none; @media (max-width: 768px) { background: transparent; @@ -98,6 +126,14 @@ } } +.theadWrapper { + width: 100%; + overflow: hidden; + background: var(--bg-log-info-card); + border-top-left-radius: 12px; + border-top-right-radius: 12px; +} + .cardsView { display: none; @@ -112,7 +148,7 @@ align-items: center; gap: 12px; padding: 0; - margin-bottom: 20px; + margin-bottom: 12px; } .sortSelectWrapper { @@ -121,7 +157,7 @@ gap: 10px; flex: 1; background: var(--color-bg-card); - padding: 10px 16px; + padding: 8px 12px; border-radius: 12px; box-shadow: 0 2px 8px rgba(0, 0, 0, 0.06); color: var(--color-focus-blue); @@ -166,8 +202,8 @@ display: flex; align-items: center; justify-content: center; - width: 44px; - height: 44px; + width: 38px; + height: 38px; border-radius: 12px; cursor: pointer; box-shadow: 0 2px 8px rgba(0, 0, 0, 0.06); diff --git a/src/components/ConnectionTable.scss b/src/components/ConnectionTable.scss index 41e3b97..1559fb5 100644 --- a/src/components/ConnectionTable.scss +++ b/src/components/ConnectionTable.scss @@ -1,5 +1,5 @@ .connections-table { - td.ctrl { + .ctrl { min-width: 3.5em; text-align: center; display: flex; @@ -21,7 +21,7 @@ } } - td.type { + .type { min-width: 8em; text-align: center; @@ -31,49 +31,49 @@ } } - td.start, - td.downloadSpeedCurr, - td.uploadSpeedCurr, - td.download, - td.upload { + .start, + .downloadSpeedCurr, + .uploadSpeedCurr, + .download, + .upload { min-width: 7em; text-align: center; font-variant-numeric: tabular-nums; } - td.downloadSpeedCurr, - td.download { + .downloadSpeedCurr, + .download { color: #27ae60; } - td.uploadSpeedCurr, - td.upload { + .uploadSpeedCurr, + .upload { color: #3498db; } // 进程列 - td.process { + .process { max-width: 12em; overflow: hidden; text-overflow: ellipsis; } // 域名/Host列 - td.host { + .host { max-width: 20em; overflow: hidden; text-overflow: ellipsis; } // 规则列 - td.rule { + .rule { max-width: 15em; overflow: hidden; text-overflow: ellipsis; } // 节点链 - td.chains { + .chains { // 不截断:完整展示节点链(必要时允许换行) max-width: none; overflow: visible; diff --git a/src/components/ConnectionTable.tsx b/src/components/ConnectionTable.tsx index 630167a..9dcb7ae 100644 --- a/src/components/ConnectionTable.tsx +++ b/src/components/ConnectionTable.tsx @@ -3,11 +3,11 @@ import './ConnectionTable.scss'; import cx from 'clsx'; import { formatDistance, Locale } from 'date-fns'; import { enUS, zhCN, zhTW } from 'date-fns/locale'; -import React, { useEffect, useMemo, useState } from 'react'; -import { ArrowDown, ArrowUp, ChevronDown, Sliders } from 'react-feather'; -import { XCircle } from 'react-feather'; +import React, { useCallback, useEffect, useMemo, useState } from 'react'; +import { ArrowDown, ArrowUp, ChevronDown, Sliders, XCircle } from 'react-feather'; import { useTranslation } from 'react-i18next'; import { useSortBy, useTable } from 'react-table'; +import { FixedSizeList as List } from 'react-window'; import { FormattedConn } from '~/store/connections'; import { State } from '~/store/types'; @@ -23,19 +23,97 @@ import { connect } from './StateProvider'; const sortById = { id: 'id', desc: true }; -function Table({ data, columns, hiddenColumns, apiConfig }) { +const COLUMN_WIDTHS = { + ctrl: 50, + start: 100, + type: 120, + host: 300, + rule: 180, + chains: 250, + download: 100, + upload: 100, + downloadSpeedCurr: 100, + uploadSpeedCurr: 100, + source: 170, + destinationIP: 170, + process: 130, + sniffHost: 150, +}; + +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 = { + width, + minWidth: width, + flex: `0 0 ${width}px`, + flexShrink: 0, + }; + + if (['download', 'upload', 'downloadSpeedCurr', 'uploadSpeedCurr', 'start'].includes(columnId)) { + style.justifyContent = 'flex-end'; + } + + if (columnId === 'ctrl') { + style.justifyContent = 'center'; + } + + return style; +}; + +function Table({ data, columns, hiddenColumns, apiConfig, height }) { const { t, i18n } = useTranslation(); const [operationId, setOperationId] = useState(''); const [showModalDisconnect, setShowModalDisconnect] = useState(false); const [selectedConn, setSelectedConn] = useState<FormattedConn | null>(null); - // 从本地存储加载排序状态 - const savedSortBy = JSON.parse(localStorage.getItem('tableSortBy')) || [sortById]; + const [isMobile, setIsMobile] = useState(false); - const tableState = { - sortBy: savedSortBy, - hiddenColumns, - }; + 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)'); + setIsMobile(mql.matches); + const listener = (e) => setIsMobile(e.matches); + mql.addEventListener('change', listener); + return () => mql.removeEventListener('change', listener); + }, []); + + // 从本地存储加载排序状态 + const tableState = useMemo(() => { + const savedSortBy = JSON.parse(localStorage.getItem('tableSortBy')) || [sortById]; + return { + sortBy: savedSortBy, + hiddenColumns, + }; + }, [hiddenColumns]); const table = useTable( { @@ -47,7 +125,7 @@ function Table({ data, columns, hiddenColumns, apiConfig }) { useSortBy ); - const { getTableProps, setHiddenColumns, headerGroups, rows, prepareRow, toggleSortBy } = table; + const { setHiddenColumns, headerGroups, rows, prepareRow, toggleSortBy } = table; const state = table.state; const sortOptions = useMemo(() => { @@ -75,130 +153,214 @@ function Table({ data, columns, hiddenColumns, apiConfig }) { locale = enUS; } - const disconnectOperation = () => { + const disconnectOperation = useCallback(() => { connAPI.closeConnById(apiConfig, operationId); setShowModalDisconnect(false); - }; + }, [apiConfig, operationId]); - const handlerDisconnect = (id, e) => { + const handlerDisconnect = useCallback((id, e) => { e.stopPropagation(); setOperationId(id); setShowModalDisconnect(true); - }; + }, []); - const renderCell = (cell, locale) => { - switch (cell.column.id) { - case 'ctrl': - return ( - <XCircle - style={{ cursor: 'pointer' }} - onClick={(e) => handlerDisconnect(cell.row.original.id, e)} - ></XCircle> - ); - case 'start': - return formatDistance(cell.value, 0, { locale: locale }); - case 'download': - case 'upload': - return prettyBytes(cell.value); - case 'downloadSpeedCurr': - case 'uploadSpeedCurr': - return prettyBytes(cell.value) + '/s'; - default: - return cell.value; - } - }; + const renderCell = useCallback( + (cell, locale) => { + switch (cell.column.id) { + case 'ctrl': + return ( + <XCircle + style={{ cursor: 'pointer' }} + onClick={(e) => handlerDisconnect(cell.row.original.id, e)} + ></XCircle> + ); + case 'start': + return formatDistance(cell.value, 0, { locale: locale }); + case 'download': + case 'upload': + return prettyBytes(cell.value); + case 'downloadSpeedCurr': + case 'uploadSpeedCurr': + return prettyBytes(cell.value) + '/s'; + default: + return cell.value; + } + }, + [handlerDisconnect] + ); // 当排序状态改变时,将新状态保存到本地存储 useEffect(() => { localStorage.setItem('tableSortBy', JSON.stringify(state.sortBy)); }, [state.sortBy]); + const MobileRow = useCallback( + ({ index, style }) => { + const row = rows[index]; + const conn = row.original as FormattedConn; + return ( + <div style={style}> + <ConnectionCard + key={conn.id} + conn={conn} + onDisconnect={handlerDisconnect} + onClick={() => setSelectedConn(conn)} + /> + </div> + ); + }, + [rows, handlerDisconnect] + ); + + const DesktopRow = useCallback( + ({ index, style }) => { + const row = rows[index]; + prepareRow(row); + return ( + <div + {...(row as any).getRowProps({ + style: { + ...style, + display: 'flex', + width: TOTAL_WIDTH, + }, + })} + className={s.tr} + onClick={() => setSelectedConn((row as any).original)} + role="button" + tabIndex={0} + onKeyDown={(e) => { + if (e.key === 'Enter' || e.key === ' ') { + e.preventDefault(); + setSelectedConn((row as any).original); + } + }} + > + {row.cells.map((cell) => { + const columnStyle = getColumnStyle(cell.column.id); + return ( + <div + {...cell.getCellProps()} + className={cx(s.td, index % 2 === 0 ? s.odd : false, cell.column.id)} + style={{ + display: 'flex', + alignItems: 'center', + overflow: 'hidden', + textOverflow: 'ellipsis', + whiteSpace: 'nowrap', + ...columnStyle, + }} + > + <span className={s.cellText}>{renderCell(cell, locale)}</span> + </div> + ); + })} + </div> + ); + }, + [prepareRow, rows, renderCell, locale] + ); + return ( - <div className={s.tableWrapper}> - <div className={s.cardsView}> - <div className={s.mobileSortToolbar}> - <div className={s.sortSelectWrapper}> - <div className={s.selectedValue}> - <Sliders size={14} /> - <span> - {t('Sort')}: {sortOptions.find((opt) => opt.value === currentSort.id)?.label} - </span> + <div className={s.tableWrapper} style={{ height, overflow: 'hidden' }}> + {isMobile ? ( + <div className={s.cardsView}> + <div className={s.mobileSortToolbar}> + <div className={s.sortSelectWrapper}> + <div className={s.selectedValue}> + <Sliders size={14} /> + <span> + {t('Sort')}: {sortOptions.find((opt) => opt.value === currentSort.id)?.label} + </span> + </div> + <select + value={currentSort.id} + onChange={(e) => toggleSortBy(e.target.value, currentSort.desc)} + > + {sortOptions.map((opt) => ( + <option key={opt.value} value={opt.value}> + {opt.label} + </option> + ))} + </select> + <ChevronDown size={14} className={s.selectArrow} /> </div> - <select - value={currentSort.id} - onChange={(e) => toggleSortBy(e.target.value, currentSort.desc)} + <button + className={s.sortDirBtn} + onClick={() => toggleSortBy(currentSort.id, !currentSort.desc)} > - {sortOptions.map((opt) => ( - <option key={opt.value} value={opt.value}> - {opt.label} - </option> + {currentSort.desc ? <ArrowDown size={18} /> : <ArrowUp size={18} />} + </button> + </div> + <List height={height - 50} itemCount={rows.length} itemSize={120} width="100%"> + {MobileRow} + </List> + </div> + ) : ( + <div + className={cx(s.table, 'connections-table')} + style={{ + display: 'flex', + flexDirection: 'column', + height: '100%', + width: '100%', + }} + > + <div + className={s.theadWrapper} + ref={headerRef} + style={{ overflow: 'hidden', width: '100%' }} + > + <div className={s.thead} style={{ width: TOTAL_WIDTH }}> + {headerGroups.map((headerGroup, trindex) => ( + <div + {...headerGroup.getHeaderGroupProps()} + className={s.tr} + key={trindex} + style={{ display: 'flex' }} + > + {headerGroup.headers.map((column) => { + const columnStyle = getColumnStyle(column.id); + return ( + <div + {...column.getHeaderProps(column.getSortByToggleProps())} + className={s.th} + style={{ + display: 'flex', + alignItems: 'center', + ...columnStyle, + }} + > + <span className={s.headerText}>{t(column.render('Header'))}</span> + {column.id !== 'ctrl' ? ( + <span className={s.sortIconContainer}> + {column.isSorted ? ( + <ChevronDown + size={14} + className={column.isSortedDesc ? '' : s.rotate180} + /> + ) : null} + </span> + ) : null} + </div> + ); + })} + </div> ))} - </select> - <ChevronDown size={14} className={s.selectArrow} /> + </div> </div> - <button - className={s.sortDirBtn} - onClick={() => toggleSortBy(currentSort.id, !currentSort.desc)} + <List + height={height - 50} + itemCount={rows.length} + itemSize={44} + width="100%" + outerRef={outerRef} + innerElementType={InnerElement} > - {currentSort.desc ? <ArrowDown size={18} /> : <ArrowUp size={18} />} - </button> + {DesktopRow} + </List> </div> - {rows.map((row) => { - const conn = row.original as FormattedConn; - return ( - <ConnectionCard - key={conn.id} - conn={conn} - onDisconnect={handlerDisconnect} - onClick={() => setSelectedConn(conn)} - /> - ); - })} - </div> - <table {...getTableProps()} className={cx(s.table, 'connections-table')}> - <thead> - {headerGroups.map((headerGroup, trindex) => { - return ( - <tr {...headerGroup.getHeaderGroupProps()} className={s.tr} key={trindex}> - {headerGroup.headers.map((column) => ( - <th {...column.getHeaderProps(column.getSortByToggleProps())} className={s.th}> - <span>{t(column.render('Header'))}</span> - {column.id !== 'ctrl' ? ( - <span className={s.sortIconContainer}> - {column.isSorted ? ( - <ChevronDown - size={16} - className={column.isSortedDesc ? '' : s.rotate180} - /> - ) : null} - </span> - ) : null} - </th> - ))} - </tr> - ); - })} - </thead> - <tbody> - {rows.map((row, i) => { - prepareRow(row); - return ( - <tr className={s.tr} key={i} onClick={() => setSelectedConn((row as any).original)}> - {row.cells.map((cell) => { - return ( - <td - {...cell.getCellProps()} - className={cx(s.td, i % 2 === 0 ? s.odd : false, cell.column.id)} - > - {renderCell(cell, locale)} - </td> - ); - })} - </tr> - ); - })} - </tbody> - </table> + )} <MOdalCloseConnection confirm={'disconnect'} isOpen={showModalDisconnect} diff --git a/src/components/Connections.module.scss b/src/components/Connections.module.scss index d81922a..e355f2b 100644 --- a/src/components/Connections.module.scss +++ b/src/components/Connections.module.scss @@ -27,27 +27,16 @@ min-width: 20px; } -.header { - display: grid; - grid-template-columns: 1fr auto; - align-items: center; - padding: 20px 30px 10px; - gap: 20px; - - @media (max-width: 768px) { - grid-template-columns: 1fr; - padding: 15px 15px 5px; - gap: 12px; - } -} - .inputWrapper { width: 100%; - max-width: 320px; - justify-self: end; + max-width: 200px; + margin-left: auto; @media (max-width: 768px) { max-width: none; + grid-column: 1 / -1; + margin-left: 0; + order: 3; } } @@ -90,9 +79,12 @@ border-radius: 12px; @media (max-width: 768px) { - justify-content: center; - width: 100%; - padding: 8px; + grid-column: 2; + grid-row: 2; + justify-content: flex-end; + width: auto; + padding: 2px; + background: transparent; } } @@ -143,14 +135,14 @@ display: flex; justify-content: space-between; align-items: center; - padding: 0 30px; gap: 16px; + width: 100%; @media (max-width: 768px) { - flex-direction: column; - align-items: stretch; - padding: 0 15px; - gap: 12px; + display: grid; + grid-template-columns: 1fr auto; + gap: 8px; + padding: 4px 0; } } @@ -160,9 +152,7 @@ gap: 16px; @media (max-width: 768px) { - flex-direction: column; - align-items: stretch; - gap: 12px; + display: contents; } } @@ -174,12 +164,16 @@ gap: 4px; @media (max-width: 768px) { + grid-column: 1 / -1; justify-content: space-between; width: 100%; :global(.react-tabs__tab) { flex: 1; + text-align: center; justify-content: center; + padding: 6px 4px !important; + font-size: 0.9em; } } } @@ -190,6 +184,8 @@ @media (max-width: 768px) { width: 100% !important; + grid-column: 1; + height: 36px; } } @@ -200,3 +196,23 @@ opacity: 0.2; margin: 0 2px; } + +.contentWrapper { + margin: 0 45px 20px; + background-color: var(--bg-log-info-card); + border-radius: 12px; + box-shadow: var(--shadow-card); + border: 1px solid var(--color-separator); + overflow: hidden; + + @media (max-width: 768px) { + margin: 10px 15px 15px; + background-color: transparent; + border: none; + box-shadow: none; + } +} + +.scrollArea { + overflow: visible; +} diff --git a/src/components/Connections.tsx b/src/components/Connections.tsx index bff97e9..1e83702 100644 --- a/src/components/Connections.tsx +++ b/src/components/Connections.tsx @@ -30,7 +30,7 @@ import { Fab, position as fabPosition } from './shared/Fab'; import { connect } from './StateProvider'; import SvgYacd from './SvgYacd'; -const { useEffect, useState, useRef, useCallback } = React; +const { useEffect, useState, useRef, useCallback, useMemo } = React; const ALL_SOURCE_IP = 'ALL_SOURCE_IP'; const sourceMapInit = localStorage.getItem('sourceMap') @@ -110,11 +110,24 @@ function getNameFromSource( function formatConnectionDataItem( i: ConnectionItem, - prevKv: Record<string, { upload: number; download: number }>, + prevKv: Record<string, FormattedConn>, now: number, sourceMap: { reg: string; name: string }[] ): FormattedConn { - const { id, metadata, upload, download, start, chains, rule, rulePayload } = i; + const { id, upload, download, start, chains, rule, rulePayload, metadata } = i; + const prev = prevKv[id]; + + if (prev) { + return { + ...prev, + upload, + download, + start: now - prev.startTime, + downloadSpeedCurr: download - prev.download, + uploadSpeedCurr: upload - prev.upload, + }; + } + const { host, destinationPort, @@ -128,29 +141,28 @@ function formatConnectionDataItem( sniffHost, } = metadata; // host could be an empty string if it's direct IP connection - let host2 = host; - if (host2 === '') host2 = destinationIP; - const prev = prevKv[id]; + const host2 = host || destinationIP; const source = `${sourceIP}:${sourcePort}`; + const startTime = new Date(start).valueOf(); - const ret = { + return { id, upload, download, - start: now - new Date(start).valueOf(), + start: now - startTime, + startTime, chains: modifyChains(chains), rule: !rulePayload ? rule : `${rule} :: ${rulePayload}`, ...metadata, host: `${host2}:${destinationPort}`, - sniffHost: sniffHost ? sniffHost : '-', + sniffHost: sniffHost || '-', type: `${type}(${network})`, source: getNameFromSource(sourceIP, sourceMap, source), - downloadSpeedCurr: download - (prev ? prev.download : 0), - uploadSpeedCurr: upload - (prev ? prev.upload : 0), - process: process ? process : '-', + downloadSpeedCurr: 0, + uploadSpeedCurr: 0, + process: process || '-', destinationIP: remoteDestination || destinationIP || host, }; - return ret; } function modifyChains(chains: string[]): string { if (!Array.isArray(chains) || chains.length === 0) { @@ -161,19 +173,12 @@ function modifyChains(chains: string[]): string { return chains[0]; } - //倒序 - if (chains.length === 2) { - return `${chains[1]} -> ${chains[0]}`; - } - - const first = chains.pop(); - const last = chains.shift(); - return `${first} -> ${last}`; + return `${chains[chains.length - 1]} -> ${chains[0]}`; } -function renderTableOrPlaceholder(columns, hiddenColumns, conns: FormattedConn[]) { +function renderTableOrPlaceholder(columns, hiddenColumns, conns: FormattedConn[], height: number) { return conns.length > 0 ? ( - <ConnectionTable data={conns} columns={columns} hiddenColumns={hiddenColumns} /> + <ConnectionTable data={conns} columns={columns} hiddenColumns={hiddenColumns} height={height} /> ) : ( <div className={s.placeHolder}> <SvgYacd width={200} height={200} c1="var(--color-text)" /> @@ -254,10 +259,16 @@ function Conn({ apiConfig }) { const [filterKeyword, setFilterKeyword] = useState(''); const [filterSourceIpStr, setFilterSourceIpStr] = useState(ALL_SOURCE_IP); - const filteredConns = filterConns(conns, filterKeyword, filterSourceIpStr); - const filteredClosedConns = filterConns(closedConns, filterKeyword, filterSourceIpStr); + const filteredConns = useMemo( + () => filterConns(conns, filterKeyword, filterSourceIpStr), + [conns, filterKeyword, filterSourceIpStr] + ); + const filteredClosedConns = useMemo( + () => filterConns(closedConns, filterKeyword, filterSourceIpStr), + [closedConns, filterKeyword, filterSourceIpStr] + ); - const getConnIpList = (conns: FormattedConn[]) => { + const connIpSet = useMemo(() => { return [ [ALL_SOURCE_IP, t('All')], ...Array.from(new Set(conns.map((x) => x.sourceIP))) @@ -266,8 +277,7 @@ function Conn({ apiConfig }) { return [value, getNameFromSource(value, sourceMap).trim() || t('internel')]; }), ]; - }; - const connIpSet = getConnIpList(conns); + }, [conns, t, sourceMap]); // const ClosedConnIpSet = getConnIpList(closedConns); const [isCloseFilterModalOpen, setIsCloseFilterModalOpen] = useState(false); @@ -308,10 +318,12 @@ function Conn({ apiConfig }) { const idx = x.findIndex((conn: ConnectionItem) => conn.id === c.id); if (idx < 0) closed.push(c); } - setClosedConns((prev) => { - // keep max 100 entries - return [...closed, ...prev].slice(0, 101); - }); + if (closed.length > 0) { + setClosedConns((prev) => { + // keep max 100 entries + return [...closed, ...prev].slice(0, 101); + }); + } // if previous connections and current connections are both empty // arrays, we wont update state to avaoid rerender if (x && (x.length !== 0 || prevConnsRef.current.length !== 0) && !isRefreshPaused) { @@ -350,86 +362,91 @@ function Conn({ apiConfig }) { return ( <div> - <div className={s.header}> - <ContentHeader title={t('Connections')} /> - <div className={s.inputWrapper}> - <Input - type="text" - name="filter" - autoComplete="off" - className={s.input} - placeholder={t('Search')} - onChange={(e) => setFilterKeyword(e.target.value)} - /> - </div> - </div> <Tabs> - <div className={s.controls}> - <div className={s.tabGroup}> - <TabList className={s.tabList}> - <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> - </TabList> - <Select - options={connIpSet} - selected={filterSourceIpStr} - className={s.sourceSelect} - onChange={(e) => setFilterSourceIpStr(e.target.value)} - /> - </div> - <div className={s.toolbar}> - <button - className={s.toolbarBtn} - onClick={openCloseAllModal} - title={t('close_all_connections')} - > - <IconClose size={15} /> - </button> - <button - className={s.toolbarBtn} - onClick={openCloseFilterModal} - title={t('close_filter_connections')} - > - <IconClose size={13} /> - <span className={s.toolbarBtnBadge}>F</span> - </button> - <span className={s.toolbarDivider} /> - <button - className={s.toolbarBtn} - onClick={() => setModalColumn(true)} - title={t('manage_column')} - > - <Settings size={15} /> - </button> - <button className={s.toolbarBtn} onClick={resetColumns} title={t('reset_column')}> - <RefreshCcw size={15} /> - </button> - <button className={s.toolbarBtn} onClick={openModalSource} title={t('client_tag')}> - <Tag size={15} /> - </button> + <ContentHeader> + <div className={s.controls}> + <div className={s.tabGroup}> + <TabList className={s.tabList}> + <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> + </TabList> + <Select + options={connIpSet} + selected={filterSourceIpStr} + className={s.sourceSelect} + onChange={(e) => setFilterSourceIpStr(e.target.value)} + /> + </div> + <div style={{ flex: 1 }} /> + <div className={s.inputWrapper}> + <Input + type="text" + name="filter" + autoComplete="off" + className={s.input} + placeholder={t('Search')} + onChange={(e) => setFilterKeyword(e.target.value)} + /> + </div> + <div className={s.toolbar}> + <button + className={s.toolbarBtn} + onClick={openCloseAllModal} + title={t('close_all_connections')} + > + <IconClose size={15} /> + </button> + <button + className={s.toolbarBtn} + onClick={openCloseFilterModal} + title={t('close_filter_connections')} + > + <IconClose size={13} /> + <span className={s.toolbarBtnBadge}>F</span> + </button> + <span className={s.toolbarDivider} /> + <button + className={s.toolbarBtn} + onClick={() => setModalColumn(true)} + title={t('manage_column')} + > + <Settings size={15} /> + </button> + <button className={s.toolbarBtn} onClick={resetColumns} title={t('reset_column')}> + <RefreshCcw size={15} /> + </button> + <button className={s.toolbarBtn} onClick={openModalSource} title={t('client_tag')}> + <Tag size={15} /> + </button> + </div> </div> - </div> - <div ref={refContainer} style={{ padding: 30, paddingBottom: 10, paddingTop: 10 }}> + </ContentHeader> + <div ref={refContainer} className={s.contentWrapper}> <div + className={s.scrollArea} style={{ height: containerHeight - paddingBottom, - overflow: 'auto', }} > <TabPanel> - {renderTableOrPlaceholder(columns, hiddenColumns, filteredConns)} + {renderTableOrPlaceholder( + columns, + hiddenColumns, + filteredConns, + containerHeight - paddingBottom + )} <Fab icon={isRefreshPaused ? <Play size={16} /> : <Pause size={16} />} mainButtonStyles={isRefreshPaused ? { background: '#e74c3c' } : {}} @@ -439,7 +456,12 @@ function Conn({ apiConfig }) { /> </TabPanel> <TabPanel> - {renderTableOrPlaceholder(columns, hiddenColumns, filteredClosedConns)} + {renderTableOrPlaceholder( + columns, + hiddenColumns, + filteredClosedConns, + containerHeight - paddingBottom + )} </TabPanel> </div> </div> diff --git a/src/components/ContentHeader.module.scss b/src/components/ContentHeader.module.scss index 3af15c6..b71b37e 100644 --- a/src/components/ContentHeader.module.scss +++ b/src/components/ContentHeader.module.scss @@ -1,17 +1,32 @@ @import '~/styles/utils/custom-media'; .root { - height: 76px; + height: 60px; display: flex; align-items: center; + padding: 0 15px; + + @media (max-width: 768px) { + min-height: 48px; + height: auto; + padding: 5px 10px; + flex-wrap: wrap; + } + + @media (--breakpoint-not-small) { + padding: 0 40px; + } } .h1 { white-space: nowrap; - padding: 0 15px; font-size: 1.7em; + + @media (max-width: 768px) { + font-size: 1.4em; + } + @media (--breakpoint-not-small) { - padding: 0 40px; font-size: 2em; } text-align: left; diff --git a/src/components/ContentHeader.tsx b/src/components/ContentHeader.tsx index 473cd4c..4709037 100644 --- a/src/components/ContentHeader.tsx +++ b/src/components/ContentHeader.tsx @@ -3,15 +3,11 @@ import React from 'react'; import s0 from './ContentHeader.module.scss'; type Props = { - title: string; + children?: React.ReactNode; }; -function ContentHeader({ title }: Props) { - return ( - <div className={s0.root}> - <h1 className={s0.h1}>{title}</h1> - </div> - ); +function ContentHeader({ children }: Props) { + return <div className={s0.root}>{children}</div>; } export default React.memo(ContentHeader); diff --git a/src/components/Home.tsx b/src/components/Home.tsx index c783db4..8a98987 100644 --- a/src/components/Home.tsx +++ b/src/components/Home.tsx @@ -1,15 +1,13 @@ import React from 'react'; -import { useTranslation } from 'react-i18next'; import ContentHeader from './ContentHeader'; import s0 from './Home.module.scss'; import TrafficNow from './TrafficNow'; export default function Home() { - const { t } = useTranslation(); return ( <div> - <ContentHeader title={t('Overview')} /> + <ContentHeader /> <div className={s0.root}> <TrafficNow /> </div> diff --git a/src/components/Logs.module.scss b/src/components/Logs.module.scss index 2eb0022..ce3bc1e 100644 --- a/src/components/Logs.module.scss +++ b/src/components/Logs.module.scss @@ -1,75 +1,170 @@ -.logMeta { - font-size: 0.8em; - margin-bottom: 5px; - display: block; - line-height: 1.55em; +.headerControls { + display: flex; + align-items: center; + gap: 10px; + flex: 1; + justify-content: flex-end; + max-width: 400px; + + & > div { + flex: 1; + } } -.logType { - flex-shrink: 0; - text-align: center; - width: 66px; - border-radius: 100px; - padding: 3px 5px; - margin: 0 8px; +.searchWrapper { + flex: 1; } -.logTime { - flex-shrink: 0; - color: #fb923c; +.clearBtn { + background: none; + border: none; + color: var(--color-text-secondary); + cursor: pointer; + padding: 8px; + border-radius: 4px; + display: flex; + align-items: center; + justify-content: center; + transition: background-color 0.2s, color 0.2s; + + &:hover { + background-color: var(--bg-near-transparent); + color: var(--color-text); + } } -.logText { - flex-shrink: 0; - color: #888; +.logLine { + display: flex; + font-family: var(--font-normal); + font-size: 12px; + padding: 4px 8px; + border-bottom: 1px solid var(--bg-near-transparent); + word-break: break-all; + + &:hover { + background-color: var(--bg-near-transparent); + } + + @media (max-width: 768px) { + flex-direction: column; + } +} + +.logMeta { + display: flex; align-items: center; - line-height: 1.35em; - /* force wrap */ - width: 100%; + flex-shrink: 0; + margin-right: 12px; + min-width: 180px; + @media (max-width: 768px) { - display: inline-block; + margin-bottom: 4px; } } -/*******************/ +.logTime { + color: var(--color-text-secondary); + margin-right: 8px; + opacity: 0.8; +} + +.logType { + text-transform: uppercase; + font-weight: bold; + font-size: 10px; + padding: 1px 6px; + border-radius: 3px; + min-width: 50px; + text-align: center; + + &[data-type='debug'] { + color: #389d3d; + background-color: rgba(56, 157, 61, 0.1); + } + &[data-type='info'] { + color: #0ea5e9; + background-color: rgba(14, 165, 233, 0.1); + } + &[data-type='warning'] { + color: #f59e0b; + background-color: rgba(245, 158, 11, 0.1); + } + &[data-type='error'] { + color: #ef4444; + background-color: rgba(239, 68, 68, 0.1); + } +} + +.logText { + color: var(--color-text); + line-height: 1.5; + flex: 1; +} .logsWrapper { - margin: 45px; + position: relative; + margin: 20px 45px; padding: 10px; background-color: var(--bg-log-info-card); - border-radius: 4px; + border-radius: 8px; color: var(--color-text); overflow-y: auto; + box-shadow: 0 4px 6px -1px rgba(0, 0, 0, 0.1), 0 2px 4px -1px rgba(0, 0, 0, 0.06); + @media (max-width: 768px) { - margin: 25px; + margin: 15px 25px; } - :global { - .log { - margin-bottom: 10px; - //background: var(--color-background); - } - .log.even { - //background: var(--color-background); - } + + &::-webkit-scrollbar { + width: 6px; + } + &::-webkit-scrollbar-thumb { + background-color: var(--bg-near-transparent); + border-radius: 3px; } } -/*******************/ +.scrollToBottomBtn { + position: absolute; + bottom: 80px; + right: 20px; + background-color: var(--color-focus-blue); + color: white; + border: none; + border-radius: 50%; + width: 36px; + height: 36px; + display: flex; + align-items: center; + justify-content: center; + cursor: pointer; + box-shadow: 0 2px 8px rgba(0, 0, 0, 0.2); + z-index: 10; + transition: transform 0.2s; + + &:hover { + transform: scale(1.1); + } + + @media (max-width: 768px) { + right: 25px; + } +} .logPlaceholder { display: flex; flex-direction: column; align-items: center; justify-content: center; - color: #2d2d30; + color: var(--color-text-secondary); div:nth-child(2) { - color: var(--color-text-secondary); - font-size: 1.4em; + font-size: 1.2em; + margin-top: 20px; opacity: 0.6; } } .logPlaceholderIcon { - opacity: 0.3; + opacity: 0.2; } diff --git a/src/components/Logs.tsx b/src/components/Logs.tsx index 22f2878..ae7e2a9 100644 --- a/src/components/Logs.tsx +++ b/src/components/Logs.tsx @@ -1,5 +1,5 @@ import * as React from 'react'; -import { Pause, Play } from 'react-feather'; +import { ArrowDown, Pause, Play, Trash2 } from 'react-feather'; import { useTranslation } from 'react-i18next'; import { fetchLogs, reconnect as reconnectLogs, stop as stopLogs } from '~/api/logs'; @@ -10,20 +10,13 @@ import SvgYacd from '~/components/SvgYacd'; import useRemainingViewPortHeight from '~/hooks/useRemainingViewPortHeight'; import { getClashAPIConfig, getLogStreamingPaused } from '~/store/app'; import { getLogLevel } from '~/store/configs'; -import { appendLog, getLogsForDisplay } from '~/store/logs'; +import { appendLog, clearLogs, getLogsForDisplay } from '~/store/logs'; import { Log, State } from '~/store/types'; import s from './Logs.module.scss'; import { Fab, position as fabPosition } from './shared/Fab'; -const { useCallback, useEffect } = React; - -const colors = { - debug: '#389d3d', - info: '#58c3f2', - warning: '#cc5abb', - error: '#c11c1c', -}; +const { useCallback, useEffect, useRef, useState } = React; const logTypes = { debug: 'debug', @@ -36,12 +29,14 @@ type LogLineProps = Partial<Log>; function LogLine({ time, payload, type }: LogLineProps) { return ( - <div className={s.logMeta}> - <span className={s.logTime}>{time}</span> - <span className={s.logType} style={{ color: colors[type] }}> - [ {logTypes[type]} ] - </span> - <span className={s.logText}>{payload}</span> + <div className={s.logLine}> + <div className={s.logMeta}> + <span className={s.logTime}>{time}</span> + <span className={s.logType} data-type={type}> + {logTypes[type]} + </span> + </div> + <div className={s.logText}>{payload}</div> </div> ); } @@ -61,11 +56,39 @@ function Logs({ dispatch, logLevel, apiConfig, logs, logStreamingPaused }) { const [refLogsContainer, containerHeight] = useRemainingViewPortHeight(); const { t } = useTranslation(); + const scrollRef = useRef<HTMLDivElement>(null); + const [isAtBottom, setIsAtBottom] = useState(true); + + const scrollToBottom = useCallback(() => { + if (scrollRef.current) { + scrollRef.current.scrollTop = scrollRef.current.scrollHeight; + } + }, []); + + useEffect(() => { + if (isAtBottom) { + scrollToBottom(); + } + }, [logs, isAtBottom, scrollToBottom]); + + const onScroll = useCallback((e: React.UIEvent<HTMLDivElement>) => { + const { scrollTop, scrollHeight, clientHeight } = e.currentTarget; + const atBottom = scrollHeight - scrollTop - clientHeight < 50; + setIsAtBottom(atBottom); + }, []); + return ( <div> - <ContentHeader title={t('Logs')} /> - <LogSearch /> - <div ref={refLogsContainer}> + <ContentHeader> + <div style={{ flex: 1 }} /> + <div className={s.headerControls}> + <LogSearch className={s.searchWrapper} /> + <button className={s.clearBtn} onClick={() => dispatch(clearLogs())} title={t('Clear')}> + <Trash2 size={18} /> + </button> + </div> + </ContentHeader> + <div ref={refLogsContainer} style={{ position: 'relative' }}> {logs.length === 0 ? ( <div className={s.logPlaceholder} style={{ height: containerHeight * 0.9 }}> <div className={s.logPlaceholderIcon}> @@ -74,12 +97,23 @@ function Logs({ dispatch, logLevel, apiConfig, logs, logStreamingPaused }) { <div>{t('no_logs')}</div> </div> ) : ( - <div className={s.logsWrapper} style={{ height: containerHeight * 0.85 }}> - {logs.map((log, index) => ( - <div className="" key={index}> - <LogLine {...log} /> - </div> - ))} + <> + <div + className={s.logsWrapper} + style={{ height: containerHeight * 0.8 }} + ref={scrollRef} + onScroll={onScroll} + > + {logs.map((log, index) => ( + <LogLine {...log} key={log.id || index} /> + ))} + </div> + + {!isAtBottom && ( + <button className={s.scrollToBottomBtn} onClick={scrollToBottom}> + <ArrowDown size={16} /> + </button> + )} <Fab icon={logStreamingPaused ? <Play size={16} /> : <Pause size={16} />} @@ -88,7 +122,7 @@ function Logs({ dispatch, logLevel, apiConfig, logs, logStreamingPaused }) { text={logStreamingPaused ? t('Resume Refresh') : t('Pause Refresh')} onClick={toggleIsRefreshPaused} ></Fab> - </div> + </> )} </div> </div> diff --git a/src/components/Rule.module.scss b/src/components/Rule.module.scss index efee1f4..845fa5e 100644 --- a/src/components/Rule.module.scss +++ b/src/components/Rule.module.scss @@ -3,16 +3,12 @@ .rule { display: flex; align-items: center; - padding: 8px 15px; + padding: 12px 20px; transition: background-color 0.2s ease; - border-bottom: 1px solid var(--color-bg-card-border, rgba(0, 0, 0, 0.05)); + border-bottom: 1px solid var(--color-separator); &:hover { - background-color: var(--color-bg-hover, rgba(0, 0, 0, 0.02)); - } - - @media (--breakpoint-not-small) { - padding: 12px 40px; + background-color: var(--bg-near-transparent); } } @@ -20,9 +16,9 @@ width: 40px; flex-shrink: 0; color: var(--color-text-secondary); - font-size: 0.8rem; - opacity: 0.5; - font-family: 'Roboto Mono', monospace; + font-size: 11px; + opacity: 0.4; + font-family: var(--font-mono); } .right { @@ -38,8 +34,8 @@ } .payload { - font-family: 'Roboto Mono', Menlo, monospace; - font-size: 0.95rem; + font-family: var(--font-mono); + font-size: 13px; color: var(--color-text); word-break: break-all; line-height: 1.4; @@ -47,19 +43,20 @@ .size { margin-left: 12px; - font-size: 0.75rem; + font-size: 10px; color: var(--color-text-secondary); - background: var(--color-bg-secondary, rgba(0, 0, 0, 0.05)); + background: var(--bg-near-transparent); padding: 1px 6px; - border-radius: 4px; + border-radius: 3px; white-space: nowrap; + text-transform: uppercase; } .metaRow { display: flex; align-items: center; gap: 12px; - font-size: 0.8rem; + font-size: 11px; } .typeTag { @@ -67,12 +64,14 @@ align-items: center; gap: 4px; color: var(--color-text-secondary); - background: var(--color-bg-secondary, rgba(0, 0, 0, 0.05)); + background: var(--bg-near-transparent); padding: 2px 8px; border-radius: 4px; span { font-weight: 500; + text-transform: uppercase; + font-size: 10px; } } diff --git a/src/components/Rules.module.scss b/src/components/Rules.module.scss index ee49394..48eb8a0 100644 --- a/src/components/Rules.module.scss +++ b/src/components/Rules.module.scss @@ -6,40 +6,85 @@ height: 100%; } -.header { - display: flex; - justify-content: space-between; - align-items: center; - padding: 16px 20px; - margin-bottom: 8px; - flex-shrink: 0; +.filterWrapper { + width: 100%; + max-width: 300px; - @media (--breakpoint-not-small) { - padding: 12px 40px; + @media (max-width: 768px) { + max-width: none; + margin-top: 8px; } } -.title { - margin: 0; - font-size: 1.5rem; - font-weight: 700; - color: var(--color-text); - white-space: nowrap; +.listWrapper { + margin: 0 45px 20px; + border-radius: 12px; + box-shadow: var(--shadow-card); + border: 1px solid var(--color-separator); + overflow: hidden; - @media (--breakpoint-not-small) { - font-size: 1.75rem; + @media (max-width: 768px) { + margin: 0 15px 15px; } } -.filterWrapper { - width: 100%; - max-width: 300px; - margin-left: 20px; +.RuleProviderItemWrapper { + border-bottom: 1px solid var(--color-separator); } -.RuleProviderItemWrapper { - padding: 8px 15px; - @media (--breakpoint-not-small) { - padding: 10px 40px; +.tabsContainer { + display: flex; + align-items: center; + background-color: var(--color-bg-sidebar); + border-radius: 12px; + padding: 4px; + + @media (max-width: 768px) { + flex: 1; } } + +.tab { + display: flex; + align-items: center; + padding: 8px 16px; + border-radius: 8px; + cursor: pointer; + font-size: 1em; + font-weight: 500; + color: var(--color-text-secondary); + transition: all 0.2s ease; + user-select: none; + + @media (max-width: 768px) { + padding: 6px 10px; + font-size: 0.85em; + flex: 1; + justify-content: center; + } + + &:hover { + color: var(--color-text-primary); + background: var(--bg-near-transparent); + } + + &.active { + background-color: var(--color-focus-blue); + color: #fff; + box-shadow: 0 2px 8px rgba(66, 133, 244, 0.3); + } +} + +.tabCount { + font-family: var(--font-normal); + font-size: 0.7em; + margin-left: 6px; + padding: 2px 8px; + display: inline-flex; + justify-content: center; + align-items: center; + background-color: rgba(255, 255, 255, 0.15); + border-radius: 10px; + font-weight: 600; + min-width: 20px; +} diff --git a/src/components/Rules.tsx b/src/components/Rules.tsx index dabc4a1..0f5efa0 100644 --- a/src/components/Rules.tsx +++ b/src/components/Rules.tsx @@ -1,7 +1,9 @@ +import cx from 'clsx'; import React from 'react'; import { useTranslation } from 'react-i18next'; import { areEqual, VariableSizeList } from 'react-window'; +import ContentHeader from '~/components/ContentHeader'; import { RuleProviderItem } from '~/components/rules/RuleProviderItem'; import { useRuleAndProvider } from '~/components/rules/rules.hooks'; import { RulesPageFab } from '~/components/rules/RulesPageFab'; @@ -16,9 +18,7 @@ import Rule from './Rule'; import s from './Rules.module.scss'; import { connect } from './StateProvider'; -const { memo } = React; - -const paddingBottom = 30; +const { memo, useState, useCallback } = React; type ItemData = { rules: any[]; @@ -27,19 +27,15 @@ type ItemData = { }; function itemKey(index: number, { rules, provider }: ItemData) { - const providerQty = provider.names.length; - - if (index < providerQty) { + if (!rules) { return provider.names[index]; } - const item = rules[index - providerQty]; - return item.id; + return rules[index].id; } -function getItemSizeFactory({ provider }) { - return function getItemSize(idx: number) { - const providerQty = provider.names.length; - if (idx < providerQty) { +function getItemSizeFactory({ isRulesTab }) { + return function getItemSize() { + if (!isRulesTab) { // provider return 100; } @@ -51,9 +47,8 @@ function getItemSizeFactory({ provider }) { // @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 }) => { const { rules, provider, apiConfig } = data; - const providerQty = provider.names.length; - if (index < providerQty) { + if (!rules) { const name = provider.names[index]; const item = provider.byName[name]; return ( @@ -63,7 +58,7 @@ const Row = memo(({ index, style, data }) => { ); } - const r = rules[index - providerQty]; + const r = rules[index]; return ( <div style={style}> <Rule {...r} /> @@ -84,25 +79,63 @@ type RulesProps = { function Rules({ apiConfig }: RulesProps) { const [refRulesContainer, containerHeight] = useRemainingViewPortHeight(); const { rules, provider } = useRuleAndProvider(apiConfig); - const getItemSize = getItemSizeFactory({ provider }); + const [activeTab, setActiveTab] = useState('rules'); + + const formatQty = (qty: number) => (qty < 100 ? '' + qty : '99+'); + + const handleTabKeyDown = useCallback( + (tab: string) => (e: React.KeyboardEvent) => { + if (e.key === 'Enter' || e.key === ' ') { + setActiveTab(tab); + } + }, + [] + ); + + const isRulesTab = activeTab === 'rules'; + const getItemSize = getItemSizeFactory({ isRulesTab }); const { t } = useTranslation(); return ( <div className={s.container}> - <div className={s.header}> - <h1 className={s.title}>{t('Rules')}</h1> + <ContentHeader> + <div className={s.tabsContainer}> + <div + className={cx(s.tab, { [s.active]: activeTab === 'rules' })} + onClick={() => setActiveTab('rules')} + onKeyDown={handleTabKeyDown('rules')} + role="button" + tabIndex={0} + > + {t('Rules')} + <span className={s.tabCount}>{formatQty(rules.length)}</span> + </div> + {provider.names.length > 0 && ( + <div + className={cx(s.tab, { [s.active]: activeTab === 'providers' })} + onClick={() => setActiveTab('providers')} + onKeyDown={handleTabKeyDown('providers')} + role="button" + tabIndex={0} + > + {t('rule_provider')} + <span className={s.tabCount}>{formatQty(provider.names.length)}</span> + </div> + )} + </div> + <div style={{ flex: 1 }} /> <div className={s.filterWrapper}> <TextFilter textAtom={ruleFilterText} placeholder={t('Search')} /> </div> - </div> - <div ref={refRulesContainer} style={{ paddingBottom }}> + </ContentHeader> + <div ref={refRulesContainer} className={s.listWrapper}> <VariableSizeList - height={containerHeight - paddingBottom} + height={containerHeight} width="100%" - itemCount={rules.length + provider.names.length} + itemCount={isRulesTab ? rules.length : provider.names.length} itemSize={getItemSize} - itemData={{ rules, provider, apiConfig }} + itemData={{ rules: isRulesTab ? rules : null, provider, apiConfig }} itemKey={itemKey} > {Row} diff --git a/src/components/Search.tsx b/src/components/Search.tsx index 0e0269c..4ee4963 100644 --- a/src/components/Search.tsx +++ b/src/components/Search.tsx @@ -5,7 +5,7 @@ import { useTranslation } from 'react-i18next'; import s0 from './Search.module.scss'; -function RuleSearch({ dispatch, searchText, updateSearchText }) { +function RuleSearch({ dispatch, searchText, updateSearchText, className }) { const { t } = useTranslation(); const [text, setText] = useState(searchText); const updateSearchTextInternal = useCallback( @@ -24,7 +24,7 @@ function RuleSearch({ dispatch, searchText, updateSearchText }) { }; return ( - <div className={s0.RuleSearch}> + <div className={className || s0.RuleSearch}> <div className={s0.RuleSearchContainer}> <div className={s0.inputWrapper}> <input diff --git a/src/components/proxies/Proxies.module.scss b/src/components/proxies/Proxies.module.scss index d3295cd..2bcbc92 100644 --- a/src/components/proxies/Proxies.module.scss +++ b/src/components/proxies/Proxies.module.scss @@ -3,14 +3,13 @@ .topBar { position: sticky; top: 0; - - display: flex; - align-items: center; - justify-content: space-between; - flex-wrap: wrap; z-index: 1; background-color: var(--color-background2); backdrop-filter: blur(36px); + + & > div { + width: 100%; + } } .topBarRight { @@ -19,8 +18,6 @@ flex-wrap: wrap; flex: 1; justify-content: flex-end; - - margin-right: 20px; } .textFilterContainer { @@ -36,3 +33,79 @@ padding: 10px 40px; } } + +.groupsContainer { + &.doubleColumn { + display: flex; + flex-direction: column; + gap: 0; + + @media screen and (min-width: 1200px) { + flex-direction: row; + align-items: flex-start; + } + + .column { + flex: 1; + display: flex; + flex-direction: column; + + @media screen and (max-width: 1199px) { + display: contents; + } + } + + .group { + @media screen and (min-width: 1200px) { + padding: 10px 20px; + } + } + } +} + +.tabsContainer { + display: flex; + align-items: center; + background-color: var(--color-bg-sidebar); + border-radius: 12px; + padding: 4px; +} + +.tab { + display: flex; + align-items: center; + padding: 8px 16px; + border-radius: 8px; + cursor: pointer; + user-select: none; + font-size: 1em; + font-weight: 500; + color: var(--color-text-secondary); + transition: all 0.2s ease; + user-select: none; + + &:hover { + color: var(--color-text-primary); + background: var(--bg-near-transparent); + } + + &.active { + background-color: var(--color-focus-blue); + color: #fff; + box-shadow: 0 2px 8px rgba(66, 133, 244, 0.3); + } +} + +.tabCount { + font-family: var(--font-normal); + font-size: 0.7em; + margin-left: 6px; + padding: 2px 8px; + display: inline-flex; + justify-content: center; + align-items: center; + background-color: rgba(255, 255, 255, 0.15); + border-radius: 10px; + font-weight: 600; + min-width: 20px; +} diff --git a/src/components/proxies/Proxies.tsx b/src/components/proxies/Proxies.tsx index 7c6fd48..c04298c 100644 --- a/src/components/proxies/Proxies.tsx +++ b/src/components/proxies/Proxies.tsx @@ -1,4 +1,5 @@ import { Tooltip } from '@reach/tooltip'; +import cx from 'clsx'; import * as React from 'react'; import { useTranslation } from 'react-i18next'; @@ -7,13 +8,13 @@ import ContentHeader from '~/components/ContentHeader'; import { ClosePrevConns } from '~/components/proxies/ClosePrevConns'; import { ProxyGroup } from '~/components/proxies/ProxyGroup'; import { ProxyPageFab } from '~/components/proxies/ProxyPageFab'; -import { ProxyProviderList } from '~/components/proxies/ProxyProviderList'; +import { ProxyProvider } from '~/components/proxies/ProxyProvider'; import Settings from '~/components/proxies/Settings'; import BaseModal from '~/components/shared/BaseModal'; import { TextFilter } from '~/components/shared/TextFitler'; import { connect, useStoreActions } from '~/components/StateProvider'; import Equalizer from '~/components/svg/Equalizer'; -import { getClashAPIConfig } from '~/store/app'; +import { getClashAPIConfig, getProxiesLayout } from '~/store/app'; import { fetchProxies, getDelay, @@ -26,7 +27,7 @@ import type { State } from '~/store/types'; import s0 from './Proxies.module.scss'; -const { useState, useEffect, useCallback, useRef } = React; +const { useState, useEffect, useCallback, useRef, useMemo } = React; function Proxies({ dispatch, @@ -35,9 +36,12 @@ function Proxies({ proxyProviders, apiConfig, showModalClosePrevConns, + proxiesLayout, }) { const refFetchedTimestamp = useRef<{ startAt?: number; completeAt?: number }>({}); + const formatQty = (qty: number) => (qty < 100 ? '' + qty : '99+'); + const fetchProxiesHooked = useCallback(() => { refFetchedTimestamp.current.startAt = Date.now(); dispatch(fetchProxies(apiConfig)).then(() => { @@ -66,45 +70,123 @@ function Proxies({ setIsSettingsModalOpen(false); }, []); + const [activeTab, setActiveTab] = useState('proxies'); + + const handleTabKeyDown = useCallback( + (tab: string) => (e: React.KeyboardEvent) => { + if (e.key === 'Enter' || e.key === ' ') { + setActiveTab(tab); + } + }, + [] + ); + const { proxies: { closeModalClosePrevConns, closePrevConnsAndTheModal }, } = useStoreActions(); const { t } = useTranslation(); + const proxyGroups = useMemo(() => { + const formatted = groupNames.map((name, i) => ({ name, i })); + if (proxiesLayout !== 'double') return [formatted]; + const left = []; + const right = []; + formatted.forEach((item, i) => { + if (i % 2 === 0) left.push(item); + else right.push(item); + }); + return [left, right]; + }, [groupNames, proxiesLayout]); + + const providers = useMemo(() => { + const formatted = proxyProviders.map((item, i) => ({ item, i })); + if (proxiesLayout !== 'double') return [formatted]; + const left = []; + const right = []; + formatted.forEach((item, i) => { + if (i % 2 === 0) left.push(item); + else right.push(item); + }); + return [left, right]; + }, [proxyProviders, proxiesLayout]); + return ( <> <BaseModal isOpen={isSettingsModalOpen} onRequestClose={closeSettingsModal}> <Settings /> </BaseModal> <div className={s0.topBar}> - <ContentHeader title={t('Proxies')} /> - <div className={s0.topBarRight}> - <div className={s0.textFilterContainer}> - <TextFilter textAtom={proxyFilterText} placeholder={t('Search')} /> + <ContentHeader> + <div className={s0.tabsContainer}> + <div + className={cx(s0.tab, { [s0.active]: activeTab === 'proxies' })} + onClick={() => setActiveTab('proxies')} + onKeyDown={handleTabKeyDown('proxies')} + role="button" + tabIndex={0} + > + {t('Proxies')} + <span className={s0.tabCount}>{formatQty(groupNames.length)}</span> + </div> + {proxyProviders.length > 0 && ( + <div + className={cx(s0.tab, { [s0.active]: activeTab === 'providers' })} + onClick={() => setActiveTab('providers')} + onKeyDown={handleTabKeyDown('providers')} + role="button" + tabIndex={0} + > + {t('proxy_provider')} + <span className={s0.tabCount}>{formatQty(proxyProviders.length)}</span> + </div> + )} </div> - <Tooltip label={t('settings')}> - <Button kind="minimal" onClick={() => setIsSettingsModalOpen(true)}> - <Equalizer size={16} /> - </Button> - </Tooltip> - </div> - </div> - <div> - {groupNames.map((groupName: string) => { - return ( - <div className={s0.group} key={groupName}> - <ProxyGroup - name={groupName} - delay={delay} - apiConfig={apiConfig} - dispatch={dispatch} - /> + <div style={{ flex: 1 }} /> + <div className={s0.topBarRight}> + <div className={s0.textFilterContainer}> + <TextFilter textAtom={proxyFilterText} placeholder={t('Search')} /> </div> - ); - })} + <Tooltip label={t('settings')}> + <Button kind="minimal" onClick={() => setIsSettingsModalOpen(true)}> + <Equalizer size={16} /> + </Button> + </Tooltip> + </div> + </ContentHeader> </div> - <ProxyProviderList items={proxyProviders} /> + {activeTab === 'proxies' ? ( + <div className={cx(s0.groupsContainer, { [s0.doubleColumn]: proxiesLayout === 'double' })}> + {proxyGroups.map((column, i) => ( + <div key={i} className={s0.column}> + {column.map(({ name, i: originalIndex }) => ( + <div className={s0.group} key={name} style={{ order: originalIndex }}> + <ProxyGroup name={name} delay={delay} apiConfig={apiConfig} dispatch={dispatch} /> + </div> + ))} + </div> + ))} + </div> + ) : ( + <div className={cx(s0.groupsContainer, { [s0.doubleColumn]: proxiesLayout === 'double' })}> + {providers.map((column, i) => ( + <div key={i} className={s0.column}> + {column.map(({ item, i: originalIndex }) => ( + <div className={s0.group} key={item.name} style={{ order: originalIndex }}> + <ProxyProvider + name={item.name} + proxies={item.proxies} + type={item.type} + vehicleType={item.vehicleType} + updatedAt={item.updatedAt} + subscriptionInfo={item.subscriptionInfo} + /> + </div> + ))} + </div> + ))} + </div> + )} <div style={{ height: 60 }} /> <ProxyPageFab dispatch={dispatch} apiConfig={apiConfig} proxyProviders={proxyProviders} /> <BaseModal isOpen={showModalClosePrevConns} onRequestClose={closeModalClosePrevConns}> @@ -123,6 +205,7 @@ const mapState = (s: State) => ({ proxyProviders: getProxyProviders(s), delay: getDelay(s), showModalClosePrevConns: getShowModalClosePrevConns(s), + proxiesLayout: getProxiesLayout(s), }); export default connect(mapState)(Proxies); diff --git a/src/components/proxies/Proxy.module.scss b/src/components/proxies/Proxy.module.scss index b1520eb..60c589f 100644 --- a/src/components/proxies/Proxy.module.scss +++ b/src/components/proxies/Proxy.module.scss @@ -31,10 +31,12 @@ } background-color: var(--color-bg-proxy); + color: var(--color-text); &.now { - background-color: var(--color-focus-blue); - color: #ddd; + background-color: var(--color-sb-active-row-bg); + color: var(--color-sb-active-row-font); + border-color: var(--color-focus-blue); } &.error { @@ -55,14 +57,27 @@ @media (--breakpoint-not-small) { font-size: 0.7em; } + color: #f596aa; + opacity: 0.6; + + .now & { + color: inherit; + opacity: 0.8; + } } .udpType { font-family: var(--font-mono); font-size: 0.6em; - margin-right: 3px; @media (--breakpoint-not-small) { font-size: 0.7em; } + color: #51a8dd; + opacity: 0.6; + + .now & { + color: inherit; + opacity: 0.8; + } } .tfoType { padding: 2px; diff --git a/src/components/proxies/Proxy.tsx b/src/components/proxies/Proxy.tsx index 1ba966e..578b8c6 100644 --- a/src/components/proxies/Proxy.tsx +++ b/src/components/proxies/Proxy.tsx @@ -4,7 +4,7 @@ import * as React from 'react'; import { keyCodes } from '~/misc/keycode'; import { getClashAPIConfig, getLatencyTestUrl } from '~/store/app'; -import { DispatchFn, ProxyItem } from '~/store/types'; +import { DelayMapping, DispatchFn, ProxiesMapping, ProxyItem } from '~/store/types'; import { ClashAPIConfig } from '~/types'; import { getDelay, getProxies, healthcheckProxy } from '../../store/proxies'; @@ -237,17 +237,14 @@ function ProxyImpl({ <ProxyNameTooltip label={name} aria-label={`proxy name: ${name}`}> <span>{name}</span> </ProxyNameTooltip> - <span className={s0.proxyType} style={{ paddingLeft: 4, opacity: 0.6, color: '#51A8DD' }}> + <span className={s0.udpType} style={{ paddingLeft: 4 }}> {formatUdpType(proxy.udp, proxy.xudp)} </span> </div> <div className={s0.row}> <div className={s0.row}> - <span - className={s0.proxyType} - style={{ paddingRight: 4, opacity: 0.6, color: '#F596AA' }} - > + <span className={s0.proxyType} style={{ paddingRight: 4 }}> {formatProxyType(proxy.type)} </span> @@ -266,6 +263,33 @@ function ProxyImpl({ ); } +function getLatency( + proxies: ProxiesMapping, + delay: DelayMapping, + name: string, + visited = new Set<string>() +) { + if (visited.has(name)) return undefined; + visited.add(name); + + const latency = delay[name]; + if (latency && (latency.testing || typeof latency.number === 'number' || latency.error)) { + return latency; + } + + const proxy = proxies[name]; + if (proxy && proxy.now && proxies[proxy.now]) { + return getLatency(proxies, delay, proxy.now, visited); + } + + const delayFromHistory = proxy?.history?.[proxy.history.length - 1]?.delay; + if (typeof delayFromHistory === 'number' && delayFromHistory > 0) { + return { number: delayFromHistory }; + } + + return latency; +} + const mapState = (s: any, { name }) => { const proxies = getProxies(s); const delay = getDelay(s); @@ -273,7 +297,7 @@ const mapState = (s: any, { name }) => { const proxy = proxies[name] || { name, history: [] }; return { proxy: proxy, - latency: delay[name], + latency: getLatency(proxies, delay, name), httpsLatencyTest: latencyTestUrl.startsWith('https://'), apiConfig: getClashAPIConfig(s), }; diff --git a/src/components/proxies/ProxyGroup.module.scss b/src/components/proxies/ProxyGroup.module.scss index 16fcba6..e88d0c6 100644 --- a/src/components/proxies/ProxyGroup.module.scss +++ b/src/components/proxies/ProxyGroup.module.scss @@ -4,9 +4,15 @@ .group { padding: 10px; - background-color: var(--color-bg-card); - border-radius: 10px; - box-shadow: 0 1px 5px rgba(0, 0, 0, 0.1); + background-color: var(--bg-log-info-card); + border: 1px solid var(--color-separator); + 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 { @@ -30,3 +36,22 @@ outline: var(--color-focus-blue) solid 1px; } } + +.groupHeader { + display: flex; + align-items: center; + justify-content: space-between; + user-select: none; + + .btnGroup { + display: flex; + flex-direction: row-reverse; + } + + @media screen and (min-width: 768px) { + justify-content: flex-start; + .btnGroup { + flex-direction: row; + } + } +} diff --git a/src/components/proxies/ProxyGroup.tsx b/src/components/proxies/ProxyGroup.tsx index 14c2f61..a892943 100644 --- a/src/components/proxies/ProxyGroup.tsx +++ b/src/components/proxies/ProxyGroup.tsx @@ -14,13 +14,14 @@ import { import { fetchProxies, getProxies, switchProxy } from '~/store/proxies'; import Button from '../Button'; +import Collapsible from '../Collapsible'; import CollapsibleSectionHeader from '../CollapsibleSectionHeader'; import { connect, useStoreActions } from '../StateProvider'; import { useFilteredAndSorted } from './hooks'; import s0 from './ProxyGroup.module.scss'; import { ProxyList, ProxyListSummaryView } from './ProxyList'; -const { createElement, useCallback, useMemo, useState, useEffect } = React; +const { useCallback, useMemo, useState } = React; function ZapWrapper() { return ( @@ -86,79 +87,47 @@ function ProxyGroupImpl({ setIsTestingLatency(false); }, [all, apiConfig, dispatch, name, version.meta, latencyTestUrl, requestDelayForProxies]); - const [windowWidth, setWindowWidth] = useState(window.innerWidth); - - const updateWindowWidth = () => { - setWindowWidth(window.innerWidth); - }; - - useEffect(() => { - window.addEventListener('resize', updateWindowWidth); - return () => window.removeEventListener('resize', updateWindowWidth); - }, []); - return ( <div className={s0.group}> - <div - style={{ - display: 'flex', - alignItems: 'center', - justifyContent: windowWidth > 768 ? 'start' : 'space-between', - }} - > + <div className={s0.groupHeader}> <CollapsibleSectionHeader name={name} type={type} toggle={toggle} qty={all.length} /> - <div style={{ display: 'flex' }}> - {windowWidth > 768 ? ( - <> - <Button - kind="minimal" - onClick={toggle} - className={s0.btn} - title="Toggle collapsible section" - > - <span className={cx(s0.arrow, { [s0.isOpen]: isOpen })}> - <ChevronDown size={20} /> - </span> - </Button> - <Button - title="Test latency" - kind="minimal" - onClick={testLatency} - isLoading={isTestingLatency} - > - <ZapWrapper /> - </Button> - </> - ) : ( - <> - <Button - title="Test latency" - kind="minimal" - onClick={testLatency} - isLoading={isTestingLatency} - > - <ZapWrapper /> - </Button> - <Button - kind="minimal" - onClick={toggle} - className={s0.btn} - title="Toggle collapsible section" - > - <span className={cx(s0.arrow, { [s0.isOpen]: isOpen })}> - <ChevronDown size={20} /> - </span> - </Button> - </> - )} + <div className={s0.btnGroup}> + <Button + kind="minimal" + onClick={toggle} + className={s0.btn} + title="Toggle collapsible section" + > + <span className={cx(s0.arrow, { [s0.isOpen]: isOpen })}> + <ChevronDown size={20} /> + </span> + </Button> + <Button + title="Test latency" + kind="minimal" + onClick={testLatency} + isLoading={isTestingLatency} + > + <ZapWrapper /> + </Button> </div> </div> - {createElement(isOpen ? ProxyList : ProxyListSummaryView, { - all, - now, - isSelectable, - itemOnTapCallback, - })} + <Collapsible isOpen={isOpen}> + <ProxyList + all={all} + now={now} + isSelectable={isSelectable} + itemOnTapCallback={itemOnTapCallback} + /> + </Collapsible> + <Collapsible isOpen={!isOpen}> + <ProxyListSummaryView + all={all} + now={now} + isSelectable={isSelectable} + itemOnTapCallback={itemOnTapCallback} + /> + </Collapsible> </div> ); } diff --git a/src/components/proxies/ProxyLatency.module.scss b/src/components/proxies/ProxyLatency.module.scss index 5063338..fce0a2e 100644 --- a/src/components/proxies/ProxyLatency.module.scss +++ b/src/components/proxies/ProxyLatency.module.scss @@ -11,7 +11,7 @@ border: 1px solid var(--color-proxy-border); /* Use theme-aware latency background with sensible default */ background: var(--bg-latency, #ffffff); - color: var(--color-text); + color: inherit; font-size: 0.75em; transition: background-color 0.15s ease, border-color 0.15s ease, box-shadow 0.15s ease, color 0.15s ease, transform 0.15s ease; diff --git a/src/components/proxies/ProxyProvider.module.scss b/src/components/proxies/ProxyProvider.module.scss index 582c255..282fb72 100644 --- a/src/components/proxies/ProxyProvider.module.scss +++ b/src/components/proxies/ProxyProvider.module.scss @@ -14,9 +14,15 @@ margin: 10px 40px; } padding: 10px; - background-color: var(--color-bg-card); - border-radius: 10px; - box-shadow: 0 1px 5px rgba(0, 0, 0, 0.1); + background-color: var(--bg-log-info-card); + border: 1px solid var(--color-separator); + 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); + } } .actionFooter { diff --git a/src/components/proxies/ProxyProvider.tsx b/src/components/proxies/ProxyProvider.tsx index 99d3b3a..768214c 100644 --- a/src/components/proxies/ProxyProvider.tsx +++ b/src/components/proxies/ProxyProvider.tsx @@ -102,6 +102,7 @@ function ProxyProviderImpl({ alignItems: 'center', flexWrap: 'wrap', justifyContent: 'space-between', + userSelect: 'none', }} > <CollapsibleSectionHeader diff --git a/src/components/proxies/ProxyProviderList.tsx b/src/components/proxies/ProxyProviderList.tsx index 22786f2..177b4d8 100644 --- a/src/components/proxies/ProxyProviderList.tsx +++ b/src/components/proxies/ProxyProviderList.tsx @@ -1,29 +1,23 @@ import * as React from 'react'; -import { useTranslation } from 'react-i18next'; -import ContentHeader from '~/components/ContentHeader'; import { ProxyProvider } from '~/components/proxies/ProxyProvider'; import { FormattedProxyProvider } from '~/store/types'; export function ProxyProviderList({ items }: { items: FormattedProxyProvider[] }) { - const { t } = useTranslation(); if (items.length === 0) return null; return ( - <> - <ContentHeader title={t('proxy_provider')} /> - <div> - {items.map((item) => ( - <ProxyProvider - key={item.name} - name={item.name} - proxies={item.proxies} - type={item.type} - vehicleType={item.vehicleType} - updatedAt={item.updatedAt} - subscriptionInfo={item.subscriptionInfo} - /> - ))} - </div> - </> + <div> + {items.map((item) => ( + <ProxyProvider + key={item.name} + name={item.name} + proxies={item.proxies} + type={item.type} + vehicleType={item.vehicleType} + updatedAt={item.updatedAt} + subscriptionInfo={item.subscriptionInfo} + /> + ))} + </div> ); } diff --git a/src/components/proxies/Settings.tsx b/src/components/proxies/Settings.tsx index b2ec192..dd2dfca 100644 --- a/src/components/proxies/Settings.tsx +++ b/src/components/proxies/Settings.tsx @@ -3,7 +3,12 @@ import { useTranslation } from 'react-i18next'; import Select from '~/components/shared/Select'; -import { getAutoCloseOldConns, getHideUnavailableProxies, getProxySortBy } from '../../store/app'; +import { + getAutoCloseOldConns, + getHideUnavailableProxies, + getProxiesLayout, + getProxySortBy, +} from '../../store/app'; import { connect, useStoreActions } from '../StateProvider'; import Switch from '../SwitchThemed'; import s from './Settings.module.scss'; @@ -72,6 +77,16 @@ function Settings({ appConfig }) { /> </div> </div> + <div className={s.labeledInput}> + <span>{t('double_column_layout')}</span> + <div> + <Switch + name="proxiesLayout" + checked={appConfig.proxiesLayout === 'double'} + onChange={(v) => updateAppConfig('proxiesLayout', v ? 'double' : 'single')} + /> + </div> + </div> </> ); } @@ -80,12 +95,14 @@ const mapState = (s) => { const proxySortBy = getProxySortBy(s); const hideUnavailableProxies = getHideUnavailableProxies(s); const autoCloseOldConns = getAutoCloseOldConns(s); + const proxiesLayout = getProxiesLayout(s); return { appConfig: { proxySortBy, hideUnavailableProxies, autoCloseOldConns, + proxiesLayout, }, }; }; diff --git a/src/components/rules/RuleProviderItem.module.scss b/src/components/rules/RuleProviderItem.module.scss index 8c8c070..933fa08 100644 --- a/src/components/rules/RuleProviderItem.module.scss +++ b/src/components/rules/RuleProviderItem.module.scss @@ -2,14 +2,11 @@ display: flex; align-items: center; height: 100%; - padding: 12px 16px; - border-radius: 8px; - border: 1px solid var(--color-bg-card-border, rgba(0, 0, 0, 0.05)); + padding: 16px 20px; transition: all 0.2s ease; &:hover { - border-color: var(--color-focus-blue); - box-shadow: 0 4px 12px rgba(0, 0, 0, 0.05); + background: var(--bg-near-transparent); .refreshButton { opacity: 1; @@ -21,9 +18,9 @@ width: 32px; flex-shrink: 0; color: var(--color-text-secondary); - font-size: 0.8rem; - opacity: 0.5; - font-family: 'Roboto Mono', monospace; + font-size: 11px; + opacity: 0.4; + font-family: var(--font-mono); } .middle { @@ -42,9 +39,9 @@ } .name { - font-size: 1rem; + font-size: 14px; font-weight: 600; - color: var(--color-text); + color: var(--color-text-highlight); } .badgeGroup { @@ -56,9 +53,9 @@ display: flex; align-items: center; gap: 4px; - font-size: 0.7rem; + font-size: 10px; color: var(--color-text-secondary); - background: var(--color-bg-secondary, rgba(0, 0, 0, 0.05)); + background: var(--bg-near-transparent); padding: 2px 6px; border-radius: 4px; text-transform: uppercase; @@ -69,7 +66,7 @@ display: flex; align-items: center; gap: 8px; - font-size: 0.8rem; + font-size: 11px; color: var(--color-text-secondary); } @@ -86,10 +83,12 @@ transition: all 0.2s ease; padding: 8px !important; border-radius: 50% !important; + color: var(--color-text-secondary); &:hover { opacity: 1; - background: var(--color-bg-secondary, rgba(0, 0, 0, 0.1)) !important; + color: var(--color-focus-blue); + background: var(--bg-near-transparent) !important; } } |
