diff options
Diffstat (limited to 'src/components/backend')
| -rw-r--r-- | src/components/backend/APIConfig.module.scss | 266 | ||||
| -rw-r--r-- | src/components/backend/APIConfig.tsx | 210 | ||||
| -rw-r--r-- | src/components/backend/APIDiscovery.module.scss | 5 | ||||
| -rw-r--r-- | src/components/backend/APIDiscovery.tsx | 2 | ||||
| -rw-r--r-- | src/components/backend/BackendList.module.scss | 186 | ||||
| -rw-r--r-- | src/components/backend/BackendList.tsx | 152 |
6 files changed, 608 insertions, 213 deletions
diff --git a/src/components/backend/APIConfig.module.scss b/src/components/backend/APIConfig.module.scss index 6581d46..cfc740f 100644 --- a/src/components/backend/APIConfig.module.scss +++ b/src/components/backend/APIConfig.module.scss @@ -1,52 +1,268 @@ .root { + // 弹层里这块是 flex item,写死宽度才不会被内容撑成 shrink-to-fit + width: 520px; + max-width: 100%; + margin: 0 auto; + padding: 40px 0 48px; + display: flex; + flex-direction: column; + gap: 24px; + &:focus { outline: none; } } -.header { +/* 顶部品牌区 */ +.hero { display: flex; - justify-content: center; + flex-direction: column; align-items: center; + text-align: center; + gap: 8px; +} - .icon { - --stroke: #f3f3f3; - color: #20497e; - opacity: 0.7; - transition: opacity 400ms; - &:hover { - opacity: 1; - } +.logo { + --stroke: var(--color-background); + color: #20497e; + opacity: 0.85; + transition: opacity 400ms; + &:hover { + opacity: 1; } } -.body { - padding: 15px 0 0; +.title { + margin: 4px 0 0; + font-size: 1.25rem; + font-weight: 600; + color: var(--color-text-highlight); +} + +.desc { + margin: 0; + font-size: 0.85rem; + line-height: 1.5; + color: var(--color-text-secondary); +} + +/* 表单卡片 */ +.card { + padding: 20px; + border-radius: var(--border-radius); + background: var(--color-card); + border: 1px solid var(--color-card-border); + box-shadow: var(--shadow-card); + display: flex; + flex-direction: column; + gap: 16px; +} + +/* 协议 / 主机 / 端口一行,secret 独占一行 */ +.grid { + display: grid; + gap: 14px 12px; + grid-template-columns: minmax(140px, auto) minmax(0, 1fr) 88px; + grid-template-areas: + 'protocol host port' + 'secret secret secret'; + + @media screen and (max-width: 30em) { + grid-template-columns: minmax(0, 1fr) 88px; + grid-template-areas: + 'protocol protocol' + 'host port' + 'secret secret'; + } +} + +.protocol { + grid-area: protocol; +} +.host { + grid-area: host; +} +.port { + grid-area: port; +} +.secret { + grid-area: secret; +} + +.field { + min-width: 0; + display: flex; + flex-direction: column; + gap: 6px; } -.hostnamePort { +.label { display: flex; + align-items: center; + gap: 6px; + font-size: 0.75rem; + font-weight: 500; + color: var(--color-text-secondary); +} + +.optional { + padding: 1px 6px; + border-radius: 100px; + background: var(--color-badge-bg); + color: var(--color-badge-fg); + font-size: 0.65rem; + font-weight: 400; + text-transform: lowercase; +} + +.input { + -webkit-appearance: none; + width: 100%; + min-width: 0; + height: 38px; + box-sizing: border-box; + padding: 0 10px; + font-family: inherit; + font-size: 0.875rem; + color: var(--color-text); + background: var(--color-input-bg); + border: 1px solid var(--color-input-border); + border-radius: 10px; + outline: none; + transition: + border-color 0.2s ease, + box-shadow 0.2s ease; - div { - flex: 1 1 auto; + &::placeholder { + color: var(--color-text-secondary); + opacity: 0.6; } - div:nth-child(2) { - flex-grow: 0; - flex-basis: 120px; - margin-left: 10px; + &:focus { + border-color: var(--color-focus-blue); + box-shadow: 0 0 0 3px var(--color-accent-soft-bg); } } +/* 协议分段器与输入框对齐高度 */ +.protocolControl { + height: 38px; +} + +.inputWithAction { + position: relative; + display: flex; + align-items: center; + + .input { + padding-right: 40px; + } +} + +.iconBtn { + position: absolute; + right: 4px; + display: inline-flex; + align-items: center; + justify-content: center; + width: 30px; + height: 30px; + padding: 0; + appearance: none; + border: none; + border-radius: 8px; + background: transparent; + color: var(--color-icon); + cursor: pointer; + + &:hover { + color: var(--color-text); + background: var(--color-hover-soft); + } + + &:focus-visible { + outline: 2px solid var(--color-focus-blue); + outline-offset: -2px; + } +} + +.actions { + display: flex; + align-items: center; + justify-content: space-between; + gap: 12px; +} + +.status { + min-width: 0; + font-size: 0.75rem; + line-height: 1.4; +} + +.buttons { + flex-shrink: 0; + display: flex; + align-items: center; + gap: 8px; +} + +.preview { + display: block; + color: var(--color-text-secondary); + font-family: var(--font-mono); + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; +} + .error { - height: 20px; - font-size: 0.8em; - color: #ff8b8b; + display: inline-flex; + align-items: center; + gap: 6px; + color: var(--color-danger); + + svg { + flex-shrink: 0; + } +} + +/* 已保存后端 */ +.saved { + display: flex; + flex-direction: column; + gap: 10px; } -.footer { - padding: 5px 0 10px; +.savedHeader { display: flex; - justify-content: flex-end; align-items: center; + gap: 8px; + padding: 0 4px; +} + +.savedTitle { + margin: 0; + font-size: 0.8rem; + font-weight: 600; + letter-spacing: 0.02em; + text-transform: uppercase; + color: var(--color-text-secondary); +} + +.count { + padding: 1px 8px; + border-radius: 100px; + background: var(--color-badge-bg); + color: var(--color-badge-fg); + font-size: 0.7rem; +} + +.empty { + margin: 0; + padding: 20px; + text-align: center; + font-size: 0.8rem; + color: var(--color-text-secondary); + border: 1px dashed var(--color-card-border); + border-radius: var(--border-radius); } diff --git a/src/components/backend/APIConfig.tsx b/src/components/backend/APIConfig.tsx index 72c72ca..3f26690 100644 --- a/src/components/backend/APIConfig.tsx +++ b/src/components/backend/APIConfig.tsx @@ -1,13 +1,18 @@ +import cx from 'clsx'; import * as React from 'react'; +import { useTranslation } from 'react-i18next'; -import Button from '~/components/Button'; -import Field from '~/components/Field'; -import SvgYacd from '~/components/SvgYacd'; +import Button from '~/components/shared/Button'; +import { AlertCircle, Eye, EyeOff } from '~/components/shared/FeatherIcons'; +import { SegmentedControl } from '~/components/shared/SegmentedControl'; +import SvgYacd from '~/components/shared/SvgYacd'; +import { useToggle } from '~/hooks/basic'; import { useBackendConfigForm } from '~/modules/backend/hooks'; +import type { Protocol } from '~/modules/backend/utils'; import type { ClashAPIConfigWithAddedAt } from '~/store/types'; import type { ClashAPIConfig } from '~/types'; -import s0 from './APIConfig.module.scss'; +import s from './APIConfig.module.scss'; import { BackendList } from './BackendList'; type Props = { @@ -16,9 +21,13 @@ type Props = { onAddConfig: (config: ClashAPIConfig) => void; onRemoveConfig: (config: ClashAPIConfig) => void; onSelectConfig: (config: ClashAPIConfig) => void; + onUpdateConfig: (prev: ClashAPIConfig, next: ClashAPIConfig) => void; }; -const { useRef } = React; +const protocolOptions: { value: Protocol; label: string }[] = [ + { value: 'http', label: 'HTTP' }, + { value: 'https', label: 'HTTPS' }, +]; export default function APIConfig({ apiConfigs, @@ -26,51 +35,162 @@ export default function APIConfig({ onAddConfig, onRemoveConfig, onSelectConfig, + onUpdateConfig, }: Props) { - const contentEl = useRef(null); - const { baseURL, secret, errMsg, handleInputOnChange, handleContentOnKeyDown, onConfirm } = - useBackendConfigForm({ onAddConfig }); + const { t } = useTranslation(); + const [showSecret, toggleSecret] = useToggle(); + const { + protocol, + host, + port, + secret, + errMsg, + baseURLPreview, + isSubmitting, + editing, + startEdit, + cancelEdit, + handleProtocolOnChange, + handleInputOnChange, + handleContentOnKeyDown, + onConfirm, + } = useBackendConfigForm({ onAddConfig, onUpdateConfig }); + + const SecretIcon = showSecret ? EyeOff : Eye; return ( - // eslint-disable-next-line jsx-a11y/no-static-element-interactions - <div className={s0.root} ref={contentEl} onKeyDown={handleContentOnKeyDown}> - <div className={s0.header}> - <div className={s0.icon}> - <SvgYacd width={160} height={160} stroke="var(--stroke)" /> + // oxlint-disable-next-line jsx-a11y/no-static-element-interactions + <div className={s.root} onKeyDown={handleContentOnKeyDown}> + <header className={s.hero}> + <div className={s.logo}> + <SvgYacd width={96} height={96} stroke="var(--stroke)" /> </div> - </div> - <div className={s0.body}> - <div className={s0.hostnamePort}> - <Field - id="baseURL" - name="baseURL" - label="API Base URL" - type="text" - placeholder="http://127.0.0.1:9090" - value={baseURL} - onChange={handleInputOnChange} - /> - <Field - id="secret" - name="secret" - label="Secret(optional)" - value={secret} - type="text" - onChange={handleInputOnChange} - /> + <h1 className={s.title}> + {editing ? t('backend_form_title_edit') : t('backend_form_title')} + </h1> + <p className={s.desc}>{editing ? editing.baseURL : t('backend_form_desc')}</p> + </header> + + <section className={s.card}> + <div className={s.grid}> + <div className={cx(s.field, s.protocol)}> + <span className={s.label} id="backend-protocol-label"> + {t('protocol')} + </span> + <SegmentedControl + options={protocolOptions} + value={protocol} + onChange={handleProtocolOnChange} + label={t('protocol')} + className={s.protocolControl} + /> + </div> + + <div className={cx(s.field, s.host)}> + <label className={s.label} htmlFor="backend-host"> + {t('host')} + </label> + <input + className={s.input} + id="backend-host" + name="host" + type="text" + autoComplete="off" + spellCheck={false} + placeholder="127.0.0.1" + value={host} + onChange={handleInputOnChange} + /> + </div> + + <div className={cx(s.field, s.port)}> + <label className={s.label} htmlFor="backend-port"> + {t('port')} + </label> + <input + className={s.input} + id="backend-port" + name="port" + type="text" + inputMode="numeric" + autoComplete="off" + placeholder="9090" + value={port} + onChange={handleInputOnChange} + /> + </div> + + <div className={cx(s.field, s.secret)}> + <label className={s.label} htmlFor="backend-secret"> + {t('secret')} + <span className={s.optional}>{t('optional')}</span> + </label> + <div className={s.inputWithAction}> + <input + className={s.input} + id="backend-secret" + name="secret" + type={showSecret ? 'text' : 'password'} + autoComplete="off" + spellCheck={false} + value={secret} + onChange={handleInputOnChange} + /> + <button + type="button" + className={s.iconBtn} + onClick={toggleSecret} + title={showSecret ? t('hide_secret') : t('show_secret')} + aria-label={showSecret ? t('hide_secret') : t('show_secret')} + > + <SecretIcon size={16} /> + </button> + </div> + </div> + </div> + + <div className={s.actions}> + <div className={s.status}> + {errMsg ? ( + <span className={s.error}> + <AlertCircle size={14} /> + {errMsg} + </span> + ) : baseURLPreview ? ( + <span className={s.preview}>{baseURLPreview}</span> + ) : null} + </div> + <div className={s.buttons}> + {editing ? ( + <Button kind="minimal" label={t('cancel_edit')} onClick={cancelEdit} /> + ) : null} + <Button + label={editing ? t('save_backend') : t('add_backend')} + onClick={onConfirm} + isLoading={isSubmitting} + /> + </div> </div> - </div> - <div className={s0.error}>{errMsg ? errMsg : null}</div> - <div className={s0.footer}> - <Button label="Add" onClick={onConfirm} /> - </div> - <div style={{ height: 20 }} /> - <BackendList - apiConfigs={apiConfigs} - selectedClashAPIConfigIndex={selectedClashAPIConfigIndex} - onRemove={onRemoveConfig} - onSelect={onSelectConfig} - /> + </section> + + <section className={s.saved}> + <div className={s.savedHeader}> + <h2 className={s.savedTitle}>{t('saved_backends')}</h2> + {apiConfigs.length > 0 ? <span className={s.count}>{apiConfigs.length}</span> : null} + </div> + {apiConfigs.length > 0 ? ( + <BackendList + apiConfigs={apiConfigs} + selectedClashAPIConfigIndex={selectedClashAPIConfigIndex} + editing={editing} + onRemove={onRemoveConfig} + onSelect={onSelectConfig} + onEdit={startEdit} + /> + ) : ( + <p className={s.empty}>{t('no_saved_backends')}</p> + )} + </section> </div> ); } diff --git a/src/components/backend/APIDiscovery.module.scss b/src/components/backend/APIDiscovery.module.scss index e2161f8..630ec36 100644 --- a/src/components/backend/APIDiscovery.module.scss +++ b/src/components/backend/APIDiscovery.module.scss @@ -6,7 +6,10 @@ left: 0; right: 0; - transform: none; + // Modal 用独立的 translate 属性做居中,这里必须同样用 translate 抵消。 + // 注意别在同一条规则里再写 transform:postcss-preset-env 会把两者合并成 + // transform: translate(0,0),translate 声明被吞掉,弹层就会偏移半屏。 + translate: none; padding: 0; border-radius: 0; diff --git a/src/components/backend/APIDiscovery.tsx b/src/components/backend/APIDiscovery.tsx index f848623..aace980 100644 --- a/src/components/backend/APIDiscovery.tsx +++ b/src/components/backend/APIDiscovery.tsx @@ -1,6 +1,6 @@ import * as React from 'react'; -import Modal from '~/components/Modal'; +import Modal from '~/components/shared/Modal'; import { ThemeSwitcher } from '~/components/shared/ThemeSwitcher'; import { DOES_NOT_SUPPORT_FETCH, errors } from '~/misc/errors'; diff --git a/src/components/backend/BackendList.module.scss b/src/components/backend/BackendList.module.scss index f3be8da..94d27c7 100644 --- a/src/components/backend/BackendList.module.scss +++ b/src/components/backend/BackendList.module.scss @@ -1,102 +1,156 @@ .ul { - position: relative; margin: 0; padding: 0; list-style: none; - line-height: 1.8; - - --width-max-content: 230px; + display: flex; + flex-direction: column; + gap: 8px; } .li { - position: relative; - margin: 5px 0; - padding: 10px 0; + display: flex; + align-items: center; + gap: 8px; + padding: 8px 10px 8px 4px; border-radius: var(--border-radius); - display: grid; - place-content: center; - grid-template-columns: 40px 1fr 40px; - grid-template-rows: 30px; - grid-template-areas: 'close url .'; - column-gap: 10px; - border: 1px solid var(--bg-near-transparent); + background: var(--color-card); + border: 1px solid var(--color-card-border); + transition: + border-color 0.2s ease, + background-color 0.2s ease; + + &:hover { + border-color: var(--color-focus-blue); + } } -.li:hover { - background-color: var(--bg-near-transparent); +.selected { + border-color: var(--color-focus-blue); + background: var(--color-node-active-bg); } -.close { - opacity: 0; - grid-area: close; - place-self: center; - cursor: pointer; +/* 上方表单正绑着这一条 */ +.editing { + border-color: var(--color-focus-blue); + box-shadow: 0 0 0 3px var(--color-accent-soft-bg); } -.li:hover .close, -.li:hover .eye { - opacity: 1; +/* 整行左侧是切换后端的点击区 */ +.main { + flex: 1; + min-width: 0; + display: flex; + align-items: center; + gap: 10px; + padding: 6px 8px; + appearance: none; + border: none; + border-radius: 8px; + background: transparent; + color: inherit; + font-family: inherit; + text-align: left; + cursor: pointer; + + &:focus-visible { + outline: 2px solid var(--color-focus-blue); + outline-offset: -2px; + } } -.close:focus, -.eye:focus { - opacity: 1; + +.dot { + flex-shrink: 0; + width: 8px; + height: 8px; + border-radius: 50%; + background: var(--color-text-secondary); + opacity: 0.4; + + .selected & { + background: var(--color-success); + opacity: 1; + } } -.hasSecret { - grid-template-rows: repeat(2, 30px); - grid-template-areas: - 'close url .' - 'close secret eye'; +.body { + min-width: 0; + display: flex; + flex-direction: column; + gap: 2px; } .url { - grid-area: url; -} -.secret { - grid-area: secret; -} -.eye { - grid-area: eye; - opacity: 0; - place-self: center; - cursor: pointer; + font-size: 0.875rem; + color: var(--color-text-highlight); + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; } -.url, .secret { + font-size: 0.72rem; + color: var(--color-text-secondary); white-space: nowrap; overflow: hidden; text-overflow: ellipsis; } -.btn { - outline: none; - appearance: none; - border: 1px solid transparent; - background-color: transparent; - color: inherit; +.tools { + flex-shrink: 0; display: flex; align-items: center; - padding: 5px; - border-radius: 100px; -} -.btn:focus { - border-color: var(--color-focus-blue); -} -.btn:hover:enabled { - background-color: var(--color-focus-blue); - color: white; -} -.btn:active:enabled { - transform: scale(0.97); + gap: 4px; } -.btn:disabled { - color: var(--color-text-secondary); + +.badge { + padding: 2px 8px; + border-radius: 100px; + background: var(--color-accent-soft-bg); + color: var(--color-accent-soft-fg); + font-size: 0.68rem; + font-weight: 500; + white-space: nowrap; } -.url { +.iconBtn { + display: inline-flex; + align-items: center; + justify-content: center; + width: 30px; + height: 30px; + padding: 0; + appearance: none; + border: none; + border-radius: 8px; + background: transparent; + color: var(--color-icon); cursor: pointer; + transition: + color 0.2s ease, + background-color 0.2s ease; + + &:hover:enabled { + color: var(--color-text); + background: var(--color-hover-soft); + } + + &:focus-visible { + outline: 2px solid var(--color-focus-blue); + outline-offset: -2px; + } + + &:disabled { + opacity: 0.35; + cursor: default; + } } -.url:hover { - color: var(--color-text-highlight); + +.active { + color: var(--color-accent-soft-fg); + background: var(--color-accent-soft-bg); +} + +.remove:hover:enabled { + color: var(--color-danger); + background: var(--color-danger-soft-bg); } diff --git a/src/components/backend/BackendList.tsx b/src/components/backend/BackendList.tsx index 66fc319..1cbbe05 100644 --- a/src/components/backend/BackendList.tsx +++ b/src/components/backend/BackendList.tsx @@ -1,7 +1,8 @@ import cx from 'clsx'; import * as React from 'react'; +import { useTranslation } from 'react-i18next'; -import { Eye, EyeOff, X as Close } from '~/components/shared/FeatherIcons'; +import { Edit3, Eye, EyeOff, Trash2 } from '~/components/shared/FeatherIcons'; import { useToggle } from '~/hooks/basic'; import type { ClashAPIConfigWithAddedAt } from '~/store/types'; import type { ClashAPIConfig } from '~/types'; @@ -11,110 +12,111 @@ import s from './BackendList.module.scss'; type Props = { apiConfigs: ClashAPIConfigWithAddedAt[]; selectedClashAPIConfigIndex: number; + editing: ClashAPIConfig | null; onRemove: (x: ClashAPIConfig) => void; onSelect: (x: ClashAPIConfig) => void; + onEdit: (x: ClashAPIConfig) => void; }; export function BackendList({ apiConfigs, selectedClashAPIConfigIndex, + editing, onRemove, onSelect, + onEdit, }: Props) { return ( - <> - <ul className={s.ul}> - {apiConfigs.map((item, idx) => { - return ( - <li - className={cx(s.li, { - [s.hasSecret]: item.secret, - [s.isSelected]: idx === selectedClashAPIConfigIndex, - })} - key={item.baseURL + item.secret} - > - <Item - disableRemove={idx === selectedClashAPIConfigIndex} - baseURL={item.baseURL} - secret={item.secret} - onRemove={onRemove} - onSelect={onSelect} - /> - </li> - ); - })} - </ul> - </> + <ul className={s.ul}> + {apiConfigs.map((item, idx) => ( + <Item + key={item.baseURL + item.secret} + baseURL={item.baseURL} + secret={item.secret} + isSelected={idx === selectedClashAPIConfigIndex} + isEditing={ + editing != null && editing.baseURL === item.baseURL && editing.secret === item.secret + } + onRemove={onRemove} + onSelect={onSelect} + onEdit={onEdit} + /> + ))} + </ul> ); } function Item({ baseURL, secret, - disableRemove, + isSelected, + isEditing, onRemove, onSelect, + onEdit, }: { baseURL: string; secret?: string; - disableRemove: boolean; + isSelected: boolean; + isEditing: boolean; onRemove: (x: ClashAPIConfig) => void; onSelect: (x: ClashAPIConfig) => void; + onEdit: (x: ClashAPIConfig) => void; }) { + const { t } = useTranslation(); const [show, toggle] = useToggle(); - const Icon = show ? EyeOff : Eye; - - const handleTap = React.useCallback((e: React.KeyboardEvent) => { - e.stopPropagation(); - }, []); + const SecretIcon = show ? EyeOff : Eye; return ( - <> - <Button - disabled={disableRemove} - onClick={() => onRemove({ baseURL, secret })} - className={s.close} - > - <Close size={20} /> - </Button> - <span - className={s.url} - tabIndex={0} - role="button" + <li className={cx(s.li, { [s.selected]: isSelected, [s.editing]: isEditing })}> + <button + type="button" + className={s.main} + title={isSelected ? undefined : t('use_this_backend')} onClick={() => onSelect({ baseURL, secret })} - onKeyUp={handleTap} > - {baseURL} - </span> - <span /> - {secret ? ( - <> - <span className={s.secret}>{show ? secret : '***'}</span> - - <Button onClick={toggle} className={s.eye}> - <Icon size={20} /> - </Button> - </> - ) : null} - </> - ); -} + <span className={s.dot} /> + <span className={s.body}> + <span className={s.url}>{baseURL}</span> + <span className={s.secret}> + {secret ? (show ? secret : '••••••••') : t('backend_no_secret')} + </span> + </span> + </button> -function Button({ - children, - onClick, - className, - disabled, -}: { - children: React.ReactNode; - - onClick?: (e: React.MouseEvent<HTMLButtonElement>) => unknown; - className: string; - disabled?: boolean; -}) { - return ( - <button disabled={disabled} className={cx(className, s.btn)} onClick={onClick}> - {children} - </button> + <div className={s.tools}> + {isSelected ? <span className={s.badge}>{t('backend_in_use')}</span> : null} + {secret ? ( + <button + type="button" + className={s.iconBtn} + onClick={toggle} + title={show ? t('hide_secret') : t('show_secret')} + aria-label={show ? t('hide_secret') : t('show_secret')} + > + <SecretIcon size={16} /> + </button> + ) : null} + <button + type="button" + className={cx(s.iconBtn, { [s.active]: isEditing })} + onClick={() => onEdit({ baseURL, secret })} + title={t('edit_backend')} + aria-label={t('edit_backend')} + > + <Edit3 size={16} /> + </button> + <button + type="button" + className={cx(s.iconBtn, s.remove)} + disabled={isSelected} + onClick={() => onRemove({ baseURL, secret })} + title={t('remove_backend')} + aria-label={t('remove_backend')} + > + <Trash2 size={16} /> + </button> + </div> + </li> ); } |
