From 38ee01c0e0f2805a2a63e823efbe1e906ec9b69d Mon Sep 17 00:00:00 2001 From: Supra4E8C Date: Thu, 6 Aug 2026 02:58:13 +0800 Subject: [PATCH] feat(config)!: route /config to the redesigned page and retire the legacy editor --- .../config/ConfigSection.module.scss | 113 - src/components/config/ConfigSection.tsx | 34 - src/components/config/ConfigSourceEditor.tsx | 62 - src/components/config/DiffModal.module.scss | 299 --- src/components/config/DiffModal.tsx | 309 --- .../config/VisualConfigEditor.module.scss | 1093 ---------- src/components/config/VisualConfigEditor.tsx | 1824 ----------------- .../config/VisualConfigEditorBlocks.tsx | 1574 -------------- src/components/config/configSearchIndex.ts | 485 ----- src/i18n/locales/en.json | 20 - src/i18n/locales/ru.json | 20 - src/i18n/locales/zh-CN.json | 20 - src/i18n/locales/zh-TW.json | 20 - src/pages/ConfigPage.module.scss | 395 ---- src/pages/ConfigPage.tsx | 691 ------- src/router/MainRoutes.tsx | 2 +- 16 files changed, 1 insertion(+), 6960 deletions(-) delete mode 100644 src/components/config/ConfigSection.module.scss delete mode 100644 src/components/config/ConfigSection.tsx delete mode 100644 src/components/config/ConfigSourceEditor.tsx delete mode 100644 src/components/config/DiffModal.module.scss delete mode 100644 src/components/config/DiffModal.tsx delete mode 100644 src/components/config/VisualConfigEditor.module.scss delete mode 100644 src/components/config/VisualConfigEditor.tsx delete mode 100644 src/components/config/VisualConfigEditorBlocks.tsx delete mode 100644 src/components/config/configSearchIndex.ts delete mode 100644 src/pages/ConfigPage.module.scss delete mode 100644 src/pages/ConfigPage.tsx diff --git a/src/components/config/ConfigSection.module.scss b/src/components/config/ConfigSection.module.scss deleted file mode 100644 index 0a310632..00000000 --- a/src/components/config/ConfigSection.module.scss +++ /dev/null @@ -1,113 +0,0 @@ -@use '../../styles/mixins' as *; - -.section { - display: flex; - flex-direction: column; - gap: clamp(16px, 2vw, 22px); - height: clamp(520px, calc(100dvh - var(--header-height, 64px) - 250px), 780px); - min-width: 0; - box-sizing: border-box; - overflow-y: auto; - overscroll-behavior: auto; - padding: clamp(20px, 2.4vw, 28px); - border: 1px solid var(--border-color); - border-radius: 8px; - background: color-mix(in srgb, var(--bg-primary) 82%, transparent); - scroll-margin-top: 104px; - scroll-snap-align: start; - scroll-snap-stop: always; - scrollbar-width: thin; - - @include mobile { - gap: 14px; - height: clamp(420px, calc(100dvh - var(--header-height, 64px) - 260px), 680px); - padding: 16px; - scroll-margin-top: 92px; - } -} - -.header { - display: grid; - grid-template-columns: auto minmax(0, 1fr); - gap: 10px; - align-items: start; - padding-bottom: 14px; - border-bottom: 1px solid var(--border-color); - background: color-mix(in srgb, var(--bg-primary) 88%, transparent); - - @include mobile { - grid-template-columns: minmax(0, 1fr); - gap: 10px; - padding-bottom: 12px; - background: transparent; - } -} - -.titleRow { - display: flex; - align-items: center; - gap: 8px; - min-width: 0; -} - -.indexBadge { - display: inline-flex; - align-items: center; - justify-content: center; - min-width: 32px; - height: 28px; - padding: 0 8px; - border: 1px solid var(--border-color); - border-radius: 6px; - color: var(--text-secondary); - font-size: 11px; - font-weight: 750; - letter-spacing: 0.08em; -} - -.iconBadge { - display: inline-flex; - align-items: center; - justify-content: center; - width: 28px; - height: 28px; - border-radius: 6px; - color: var(--text-secondary); - flex: 0 0 auto; -} - -.headingGroup { - display: flex; - flex-direction: column; - gap: 6px; - min-width: 0; -} - -.title { - margin: 0; - color: var(--text-primary); - font-size: clamp(18px, 1.6vw, 22px); - font-weight: 680; - line-height: 1.18; - letter-spacing: 0; -} - -.description { - margin: 0; - max-width: 72ch; - color: var(--text-secondary); - font-size: 13px; - line-height: 1.65; - - @include mobile { - max-width: none; - } -} - -.content { - display: flex; - flex-direction: column; - gap: 16px; - width: 100%; - min-width: 0; -} diff --git a/src/components/config/ConfigSection.tsx b/src/components/config/ConfigSection.tsx deleted file mode 100644 index efc6dc4d..00000000 --- a/src/components/config/ConfigSection.tsx +++ /dev/null @@ -1,34 +0,0 @@ -import { forwardRef, type HTMLAttributes, type PropsWithChildren, type ReactNode } from 'react'; -import styles from './ConfigSection.module.scss'; - -interface ConfigSectionProps extends Omit, 'title'> { - title: ReactNode; - description?: ReactNode; - indexLabel?: ReactNode; - icon?: ReactNode; -} - -export const ConfigSection = forwardRef>( - function ConfigSection( - { title, description, indexLabel, icon, className, children, ...rest }, - ref - ) { - const sectionClassName = [styles.section, className].filter(Boolean).join(' '); - - return ( -
-
-
- {indexLabel ? {indexLabel} : null} - {icon ? {icon} : null} -
-
-

{title}

- {description ?

{description}

: null} -
-
-
{children}
-
- ); - } -); diff --git a/src/components/config/ConfigSourceEditor.tsx b/src/components/config/ConfigSourceEditor.tsx deleted file mode 100644 index 8f85edf5..00000000 --- a/src/components/config/ConfigSourceEditor.tsx +++ /dev/null @@ -1,62 +0,0 @@ -import { useMemo, type Ref } from 'react'; -import CodeMirror, { type ReactCodeMirrorRef } from '@uiw/react-codemirror'; -import { yaml } from '@codemirror/lang-yaml'; -import { search, searchKeymap, highlightSelectionMatches } from '@codemirror/search'; -import { keymap } from '@codemirror/view'; - -type ConfigSourceEditorProps = { - value: string; - onChange: (value: string) => void; - editorRef?: Ref; - theme: 'light' | 'dark'; - editable: boolean; - placeholder: string; -}; - -export default function ConfigSourceEditor({ - value, - onChange, - editorRef, - theme, - editable, - placeholder, -}: ConfigSourceEditorProps) { - const extensions = useMemo( - () => [yaml(), search(), highlightSelectionMatches(), keymap.of(searchKeymap)], - [] - ); - - return ( - - ); -} diff --git a/src/components/config/DiffModal.module.scss b/src/components/config/DiffModal.module.scss deleted file mode 100644 index d5b08642..00000000 --- a/src/components/config/DiffModal.module.scss +++ /dev/null @@ -1,299 +0,0 @@ -@use '../../styles/variables' as *; -@use '../../styles/mixins' as *; - -$diff-mono: 'Consolas', 'Monaco', 'Menlo', 'SF Mono', monospace; -$diff-font-size: 12px; -$diff-line-height: 20px; -$diff-gutter-width: 50px; -$diff-prefix-width: 20px; - -// GitHub-inspired diff colors (theme-adaptive via color-mix) -$diff-add-color: #3fb950; -$diff-del-color: #f85149; -$diff-hunk-color: #388bfd; - -.diffModal { - :global(.modal-body) { - padding: 0; - max-height: none; - overflow: hidden; - } -} - -.content { - display: flex; - flex-direction: column; - height: 70vh; - min-height: 420px; -} - -.emptyState { - flex: 1; - border: 1px dashed var(--border-color); - border-radius: $radius-md; - background: var(--bg-secondary); - color: var(--text-secondary); - font-size: 14px; - display: grid; - place-items: center; - margin: $spacing-md; -} - -// ── File container ────────────────────────────────────── - -.diffContainer { - flex: 1; - min-height: 0; - display: flex; - flex-direction: column; - overflow: hidden; -} - -// ── File header ───────────────────────────────────────── - -.fileHeader { - display: flex; - align-items: center; - gap: $spacing-sm; - padding: 10px $spacing-md; - background: var(--bg-secondary); - border-bottom: 1px solid var(--border-color); - flex-shrink: 0; -} - -.fileIcon { - flex-shrink: 0; - color: var(--text-tertiary); -} - -.fileName { - font-size: 13px; - font-weight: 600; - font-family: $diff-mono; - color: var(--text-primary); -} - -.fileStats { - margin-left: auto; - display: flex; - align-items: center; - gap: $spacing-sm; - font-size: 12px; - font-weight: 700; - font-family: $diff-mono; -} - -.statAdditions { - color: $diff-add-color; -} - -.statDeletions { - color: $diff-del-color; -} - -.statBar { - display: inline-flex; - gap: 2px; - margin-left: 2px; -} - -.statBlock { - width: 8px; - height: 8px; - border-radius: 2px; -} - -.statBlockAdd { - background: $diff-add-color; -} - -.statBlockDel { - background: $diff-del-color; -} - -// ── Diff body (scrollable) ────────────────────────────── - -.diffBody { - flex: 1; - min-height: 0; - overflow: auto; - font-family: $diff-mono; - font-size: $diff-font-size; - line-height: $diff-line-height; -} - -// ── Hunk ──────────────────────────────────────────────── - -.hunk + .hunk { - border-top: 1px solid var(--border-color); -} - -.hunkHeader { - display: flex; - align-items: center; - background: color-mix(in srgb, $diff-hunk-color 8%, var(--bg-primary)); - border-bottom: 1px solid color-mix(in srgb, $diff-hunk-color 12%, var(--border-color)); - color: color-mix(in srgb, $diff-hunk-color 75%, var(--text-secondary)); - min-height: $diff-line-height; -} - -.hunkGutter { - width: $diff-gutter-width; - flex-shrink: 0; - display: flex; - align-items: center; - justify-content: center; - align-self: stretch; - border-right: 1px solid color-mix(in srgb, $diff-hunk-color 12%, var(--border-color)); -} - -.hunkExpandIcon { - color: color-mix(in srgb, $diff-hunk-color 70%, var(--text-tertiary)); - opacity: 0.7; -} - -.hunkText { - padding: 4px $spacing-sm 4px ($diff-prefix-width + $spacing-sm); - font-size: $diff-font-size; - white-space: nowrap; -} - -// ── Diff line ─────────────────────────────────────────── - -.diffLine { - display: flex; - min-height: $diff-line-height; -} - -// ── Line number gutters ───────────────────────────────── - -.lineNum { - width: $diff-gutter-width; - flex-shrink: 0; - padding: 0 8px; - text-align: right; - color: var(--text-tertiary); - user-select: none; - font-variant-numeric: tabular-nums; - border-right: 1px solid color-mix(in srgb, var(--border-color) 60%, transparent); - box-sizing: border-box; -} - -.lineNumEmpty { - color: transparent; -} - -// ── Prefix column (+/-/space) ─────────────────────────── - -.linePrefix { - width: $diff-prefix-width; - flex-shrink: 0; - text-align: center; - user-select: none; - font-weight: 700; -} - -// ── Code text ─────────────────────────────────────────── - -.lineText { - flex: 1; - min-width: 0; - padding-right: 12px; - white-space: pre-wrap; - word-break: break-word; -} - -// ── Context lines ─────────────────────────────────────── - -.context { - background: var(--bg-primary); - - .linePrefix { - color: var(--text-tertiary); - } - - .lineText { - color: var(--text-primary); - } -} - -// ── Deletion lines ────────────────────────────────────── - -.deletion { - background: color-mix(in srgb, $diff-del-color 8%, var(--bg-primary)); - - .lineNum { - background: color-mix(in srgb, $diff-del-color 12%, var(--bg-primary)); - border-right-color: color-mix(in srgb, $diff-del-color 18%, var(--border-color)); - color: color-mix(in srgb, $diff-del-color 60%, var(--text-tertiary)); - } - - .linePrefix { - color: $diff-del-color; - } - - .lineText { - color: var(--text-primary); - } -} - -// ── Addition lines ────────────────────────────────────── - -.addition { - background: color-mix(in srgb, $diff-add-color 8%, var(--bg-primary)); - - .lineNum { - background: color-mix(in srgb, $diff-add-color 12%, var(--bg-primary)); - border-right-color: color-mix(in srgb, $diff-add-color 18%, var(--border-color)); - color: color-mix(in srgb, $diff-add-color 60%, var(--text-tertiary)); - } - - .linePrefix { - color: $diff-add-color; - } - - .lineText { - color: var(--text-primary); - } -} - -// ── Mobile responsive ─────────────────────────────────── - -@include mobile { - .content { - height: 65vh; - min-height: 360px; - } - - .lineNum { - width: 36px; - padding: 0 4px; - font-size: 10px; - } - - .linePrefix { - width: 16px; - font-size: 11px; - } - - .hunkGutter { - width: 36px; - } - - .hunkText { - padding-left: 20px; - } - - .diffBody { - font-size: 11px; - line-height: 18px; - } - - .fileName { - font-size: 12px; - } - - .fileStats { - font-size: 11px; - } -} diff --git a/src/components/config/DiffModal.tsx b/src/components/config/DiffModal.tsx deleted file mode 100644 index b1f7fab2..00000000 --- a/src/components/config/DiffModal.tsx +++ /dev/null @@ -1,309 +0,0 @@ -import { useMemo } from 'react'; -import { useTranslation } from 'react-i18next'; -import { Text } from '@codemirror/state'; -import { Chunk } from '@codemirror/merge'; -import { Modal } from '@/components/ui/Modal'; -import { Button } from '@/components/ui/Button'; -import styles from './DiffModal.module.scss'; - -type DiffModalProps = { - open: boolean; - original: string; - modified: string; - onConfirm: () => void; - onCancel: () => void; - loading?: boolean; -}; - -type UnifiedLineType = 'context' | 'addition' | 'deletion'; - -type UnifiedLine = { - type: UnifiedLineType; - oldNum: number | null; - newNum: number | null; - text: string; -}; - -type Hunk = { - oldStart: number; - oldCount: number; - newStart: number; - newCount: number; - lines: UnifiedLine[]; -}; - -type DiffResult = { - hunks: Hunk[]; - additions: number; - deletions: number; -}; - -const DIFF_CONTEXT_LINES = 3; - -const clampPos = (doc: Text, pos: number) => Math.max(0, Math.min(pos, doc.length)); - -function computeUnifiedDiff(original: string, modified: string): DiffResult { - const oldDoc = Text.of(original.split('\n')); - const newDoc = Text.of(modified.split('\n')); - const chunks = Chunk.build(oldDoc, newDoc); - - let totalAdditions = 0; - let totalDeletions = 0; - - const hunks: Hunk[] = chunks.map((chunk: Chunk) => { - const lines: UnifiedLine[] = []; - - const hasDel = chunk.fromA < chunk.toA; - const hasAdd = chunk.fromB < chunk.toB; - - // Collect deleted lines from old doc - const delLines: { num: number; text: string }[] = []; - if (hasDel) { - const startLine = oldDoc.lineAt(chunk.fromA).number; - const endLine = oldDoc.lineAt(chunk.toA - 1).number; - for (let i = startLine; i <= endLine; i++) { - delLines.push({ num: i, text: oldDoc.line(i).text }); - } - } - - // Collect added lines from new doc - const addLines: { num: number; text: string }[] = []; - if (hasAdd) { - const startLine = newDoc.lineAt(chunk.fromB).number; - const endLine = newDoc.lineAt(chunk.toB - 1).number; - for (let i = startLine; i <= endLine; i++) { - addLines.push({ num: i, text: newDoc.line(i).text }); - } - } - - totalDeletions += delLines.length; - totalAdditions += addLines.length; - - // Compute context boundaries - let ctxBeforeEndOld: number; - let ctxAfterStartOld: number; - let ctxBeforeEndNew: number; - let ctxAfterStartNew: number; - - if (hasDel) { - ctxBeforeEndOld = delLines[0].num - 1; - ctxAfterStartOld = delLines[delLines.length - 1].num + 1; - } else { - const anchorPos = clampPos(oldDoc, chunk.fromA); - const lineInfo = oldDoc.lineAt(anchorPos); - if (chunk.fromA === lineInfo.from) { - ctxBeforeEndOld = lineInfo.number - 1; - ctxAfterStartOld = lineInfo.number; - } else { - ctxBeforeEndOld = lineInfo.number; - ctxAfterStartOld = lineInfo.number + 1; - } - } - - if (hasAdd) { - ctxBeforeEndNew = addLines[0].num - 1; - ctxAfterStartNew = addLines[addLines.length - 1].num + 1; - } else { - const anchorPos = clampPos(newDoc, chunk.fromB); - const lineInfo = newDoc.lineAt(anchorPos); - if (chunk.fromB === lineInfo.from) { - ctxBeforeEndNew = lineInfo.number - 1; - ctxAfterStartNew = lineInfo.number; - } else { - ctxBeforeEndNew = lineInfo.number; - ctxAfterStartNew = lineInfo.number + 1; - } - } - - // Context before - const ctxBeforeCount = Math.min( - DIFF_CONTEXT_LINES, - Math.max(0, ctxBeforeEndOld), - Math.max(0, ctxBeforeEndNew) - ); - - for (let i = ctxBeforeCount; i > 0; i--) { - const oldNum = ctxBeforeEndOld - i + 1; - const newNum = ctxBeforeEndNew - i + 1; - if (oldNum >= 1 && newNum >= 1 && oldNum <= oldDoc.lines) { - lines.push({ - type: 'context', - oldNum, - newNum, - text: oldDoc.line(oldNum).text, - }); - } - } - - // Deletions - for (const del of delLines) { - lines.push({ type: 'deletion', oldNum: del.num, newNum: null, text: del.text }); - } - - // Additions - for (const add of addLines) { - lines.push({ type: 'addition', oldNum: null, newNum: add.num, text: add.text }); - } - - // Context after - const ctxAfterCountOld = Math.max( - 0, - Math.min(DIFF_CONTEXT_LINES, oldDoc.lines - ctxAfterStartOld + 1) - ); - const ctxAfterCountNew = Math.max( - 0, - Math.min(DIFF_CONTEXT_LINES, newDoc.lines - ctxAfterStartNew + 1) - ); - const ctxAfterCount = Math.min(ctxAfterCountOld, ctxAfterCountNew); - - for (let i = 0; i < ctxAfterCount; i++) { - const oldNum = ctxAfterStartOld + i; - const newNum = ctxAfterStartNew + i; - if (oldNum >= 1 && oldNum <= oldDoc.lines && newNum >= 1 && newNum <= newDoc.lines) { - lines.push({ - type: 'context', - oldNum, - newNum, - text: oldDoc.line(oldNum).text, - }); - } - } - - // Compute hunk header values - const firstOld = lines.find((l) => l.oldNum !== null)?.oldNum ?? 1; - const firstNew = lines.find((l) => l.newNum !== null)?.newNum ?? 1; - const oldCount = lines.filter((l) => l.type !== 'addition').length; - const newCount = lines.filter((l) => l.type !== 'deletion').length; - - return { oldStart: firstOld, oldCount, newStart: firstNew, newCount, lines }; - }); - - return { hunks, additions: totalAdditions, deletions: totalDeletions }; -} - -const STAT_BLOCKS = 5; - -function StatBar({ additions, deletions }: { additions: number; deletions: number }) { - const total = additions + deletions; - if (total === 0) return null; - const addBlocks = Math.round((additions / total) * STAT_BLOCKS); - return ( - - {Array.from({ length: STAT_BLOCKS }, (_, i) => ( - - ))} - - ); -} - -export function DiffModal({ - open, - original, - modified, - onConfirm, - onCancel, - loading = false, -}: DiffModalProps) { - const { t } = useTranslation(); - - const diff = useMemo( - () => computeUnifiedDiff(original, modified), - [original, modified] - ); - - return ( - - - - - } - > -
- {diff.hunks.length === 0 ? ( -
{t('config_management.diff.no_changes')}
- ) : ( -
-
- - - - config.yaml - - +{diff.additions} - -{diff.deletions} - - -
- -
- {diff.hunks.map((hunk, hunkIdx) => ( -
-
- - - - - - - - @@ -{hunk.oldStart},{hunk.oldCount} +{hunk.newStart},{hunk.newCount} @@ - -
- - {hunk.lines.map((line, lineIdx) => ( -
- - {line.oldNum ?? ''} - - - {line.newNum ?? ''} - - - {line.type === 'deletion' ? '-' : line.type === 'addition' ? '+' : ' '} - - {line.text || ' '} -
- ))} -
- ))} -
-
- )} -
-
- ); -} diff --git a/src/components/config/VisualConfigEditor.module.scss b/src/components/config/VisualConfigEditor.module.scss deleted file mode 100644 index 0e8c59e2..00000000 --- a/src/components/config/VisualConfigEditor.module.scss +++ /dev/null @@ -1,1093 +0,0 @@ -@use '../../styles/mixins' as *; -@use '../../styles/variables' as *; - -.visualEditor { - display: flex; - flex-direction: column; - gap: 18px; - - :global(.form-group) { - gap: 7px; - margin-bottom: 0; - } - - :global(.form-group > label) { - color: var(--text-secondary); - font-size: 12px; - font-weight: 700; - letter-spacing: 0.02em; - } - - :global(.input) { - min-height: 42px; - border-radius: 8px; - background: var(--bg-secondary); - border-color: var(--border-color); - box-shadow: none; - } - - :global(.input:focus) { - background: var(--bg-primary); - border-color: var(--text-primary); - box-shadow: 0 0 0 2px color-mix(in srgb, var(--text-primary) 12%, transparent); - } - - :global(textarea.input) { - min-height: 112px; - } - - :global(.hint) { - color: var(--text-secondary); - font-size: 12px; - line-height: 1.55; - } - - :global(.error-box) { - border-radius: 8px; - } - - :global(.item-list) { - gap: 8px; - margin-top: 8px; - } - - :global(.item-row) { - border-radius: 8px; - padding: 12px; - background: transparent; - border-color: var(--border-color); - } - - :global(.item-row .item-meta) { - gap: 4px; - } - - :global(.item-row .item-actions) { - flex-wrap: wrap; - } - - :global(.pill) { - border: 1px solid var(--border-color); - border-radius: 6px; - background: transparent; - color: var(--text-secondary); - } -} - -.expandableInputWrapper { - position: relative; - display: flex; - align-items: flex-start; - min-width: 0; - flex: 1; -} - -.expandableInputWrapper > .expandableTextarea, -.expandableInputWrapper > :global(.input) { - flex: 1; - min-width: 0; - padding-right: 28px; -} - -.expandableTextarea { - resize: none; - min-height: 60px; - overflow: hidden; - line-height: 1.5; - padding-right: 32px; -} - -.expandableToggle { - position: absolute; - right: 7px; - top: 50%; - z-index: 1; - transform: translateY(-50%); - padding: 2px; - border: 0; - background: none; - color: var(--text-secondary); - font-size: 10px; - line-height: 1; - cursor: pointer; - opacity: 0.58; - transition: opacity 0.15s ease; - - &:hover { - opacity: 1; - } - - &:disabled { - cursor: default; - opacity: 0.35; - } -} - -.expandableInputExpanded .expandableToggle { - top: 9px; - right: 12px; - transform: none; -} - -.overview { - display: grid; - grid-template-columns: minmax(0, 1fr); - gap: 10px; - padding: 0 0 18px; - border-bottom: 1px solid var(--border-color); -} - -.overviewHeader { - display: flex; - align-items: center; - justify-content: space-between; - flex-wrap: wrap; - gap: 10px; - min-width: 0; - - @include mobile { - align-items: stretch; - } -} - -.overviewMeta { - display: flex; - flex-wrap: wrap; - gap: 6px; -} - -.overviewPill { - display: inline-flex; - align-items: center; - min-height: 28px; - padding: 0 9px; - border: 1px solid var(--border-color); - border-radius: 6px; - color: var(--text-secondary); - font-size: 12px; - font-weight: 700; - line-height: 1.2; -} - -.overviewPillWarning { - color: var(--warning-text); - border-color: var(--warning-border); - background: var(--warning-bg); -} - -.modeSwitch { - display: inline-flex; - gap: 4px; - padding: 3px; - border: 1px solid var(--border-color); - border-radius: 8px; - background: var(--bg-secondary); -} - -.modeButton { - @include button-reset; - display: inline-flex; - align-items: center; - gap: 6px; - min-height: 30px; - padding: 0 12px; - border-radius: 6px; - color: var(--text-secondary); - font-size: 13px; - font-weight: 700; - line-height: 1.2; - transition: - background-color 0.15s ease, - color 0.15s ease; - - &:hover { - color: var(--text-primary); - } -} - -.modeButtonActive { - background: var(--bg-primary); - color: var(--text-primary); - box-shadow: 0 1px 2px color-mix(in srgb, var(--text-primary) 10%, transparent); -} - -.storeAuthEditor { - display: flex; - flex-direction: column; - gap: 10px; -} - -.storeAuthEmpty { - margin: 0; - color: var(--text-secondary); - font-size: 13px; - line-height: 1.5; -} - -.storeAuthRule { - display: flex; - flex-direction: column; - gap: 12px; - padding: 14px; - border: 1px solid var(--border-color); - border-radius: 8px; - background: color-mix(in srgb, var(--bg-secondary) 42%, transparent); -} - -.storeAuthRuleHeader { - display: flex; - align-items: center; - justify-content: space-between; - gap: 12px; - min-width: 0; - - strong { - min-width: 0; - overflow: hidden; - color: var(--text-primary); - font-size: 13px; - font-weight: 700; - text-overflow: ellipsis; - white-space: nowrap; - } -} - -.storeAuthGrid { - display: grid; - grid-template-columns: repeat(2, minmax(0, 1fr)); - gap: 10px; - - @include mobile { - grid-template-columns: 1fr; - } -} - -.storeAuthField { - display: flex; - min-width: 0; - flex-direction: column; - gap: 6px; - - > span { - color: var(--text-secondary); - font-size: 12px; - font-weight: 700; - } -} - -.storeAuthApplyTo { - display: flex; - flex-direction: column; - gap: 7px; - - > span { - color: var(--text-secondary); - font-size: 12px; - font-weight: 700; - } - - small { - color: var(--text-tertiary); - font-size: 12px; - line-height: 1.45; - } -} - -.storeAuthCheckboxes { - display: flex; - flex-wrap: wrap; - gap: 8px; -} - -.storeAuthCheckbox { - display: inline-flex; - align-items: center; - gap: 7px; - min-height: 28px; - color: var(--text-secondary); - font-size: 12px; - font-weight: 650; - line-height: 1.4; - - input { - width: 14px; - height: 14px; - accent-color: var(--primary-color); - } -} - -// ── Global field search (P1) ──────────────────────────────────────────────── -.searchBox { - position: relative; - min-width: 0; -} - -.searchControl { - padding-right: 38px !important; -} - -.searchIcon { - display: inline-flex; - align-items: center; - color: var(--text-tertiary); - pointer-events: none; -} - -.searchResults { - position: absolute; - z-index: 20; - top: calc(100% + 6px); - left: 0; - right: 0; - display: flex; - flex-direction: column; - gap: 2px; - max-height: 320px; - overflow-y: auto; - padding: 6px; - border: 1px solid var(--border-color); - border-radius: 8px; - background: var(--bg-primary); - box-shadow: 0 14px 32px color-mix(in srgb, var(--text-primary) 16%, transparent); -} - -.searchResultItem { - @include button-reset; - display: flex; - align-items: center; - justify-content: space-between; - gap: 12px; - width: 100%; - padding: 9px 10px; - border-radius: 6px; - text-align: left; - transition: background-color 0.12s ease; - - &:hover { - background: color-mix(in srgb, var(--text-primary) 6%, transparent); - } -} - -// Keyboard/pointer "active" option — the aria-activedescendant target. -.searchResultItemActive { - background: color-mix(in srgb, var(--text-primary) 6%, transparent); -} - -.searchResultLabel { - display: inline-flex; - align-items: baseline; - gap: 7px; - min-width: 0; - color: var(--text-primary); - font-size: 13px; - font-weight: 650; - line-height: 1.3; -} - -.searchResultQualifier { - color: var(--text-tertiary); - font-size: 11px; - font-weight: 600; - white-space: nowrap; -} - -.searchResultSection { - flex: 0 0 auto; - color: var(--text-secondary); - font-size: 11px; - font-weight: 650; - white-space: nowrap; -} - -.searchEmpty { - padding: 12px 10px; - color: var(--text-secondary); - font-size: 13px; - text-align: center; -} - -// Stateless wrapper around each searchable field; the search jump scrolls to and -// pulse-highlights it. -.fieldAnchor { - display: block; - min-width: 0; - scroll-margin-top: calc(var(--header-height, 64px) + 16px); -} - -.fieldHighlightActive { - border-radius: 10px; - animation: cfgFieldHighlight 1.8s ease-out; -} - -@keyframes cfgFieldHighlight { - 0% { - box-shadow: 0 0 0 3px color-mix(in srgb, var(--text-primary) 55%, transparent); - } - 55% { - box-shadow: 0 0 0 3px color-mix(in srgb, var(--text-primary) 32%, transparent); - } - 100% { - box-shadow: 0 0 0 3px transparent; - } -} - -.simpleView { - display: flex; - flex-direction: column; - gap: 16px; - width: 100%; - max-width: 760px; - min-width: 0; -} - -.simpleForm { - display: flex; - flex-direction: column; - gap: 14px; - min-width: 0; -} - -// Wrap bare inputs so they read as cards consistent with toggleRow / API key blocks. -.simpleField { - padding: 14px; - border: 1px solid var(--border-color); - border-radius: 8px; - background: transparent; -} - -.simpleBanner { - display: flex; - align-items: center; - justify-content: space-between; - gap: 12px; - flex-wrap: wrap; - padding: 12px 14px; - border: 1px solid var(--warning-border); - border-radius: 8px; - background: var(--warning-bg); - color: var(--warning-text); - font-size: 13px; - font-weight: 600; - line-height: 1.5; -} - -.simpleBannerAction { - @include button-reset; - flex: 0 0 auto; - padding: 6px 12px; - border: 1px solid var(--warning-border); - border-radius: 6px; - color: var(--warning-text); - font-size: 12px; - font-weight: 700; - transition: background-color 0.15s ease; - - &:hover { - background: color-mix(in srgb, var(--warning-text) 10%, transparent); - } -} - -.simpleMore { - @include button-reset; - display: flex; - align-items: center; - justify-content: center; - gap: 8px; - width: 100%; - min-height: 46px; - padding: 12px 16px; - border: 1px dashed var(--border-color); - border-radius: 8px; - color: var(--text-secondary); - font-size: 13px; - font-weight: 700; - text-align: center; - transition: - background-color 0.15s ease, - border-color 0.15s ease, - color 0.15s ease; - - &:hover { - border-color: var(--text-primary); - color: var(--text-primary); - background: color-mix(in srgb, var(--text-primary) 4%, transparent); - } -} - -.workspace { - display: flex; - flex-direction: column; - gap: 14px; - min-width: 0; - - @include mobile { - gap: 12px; - } -} - -.mobileSectionNav { - display: none; - - @include mobile { - position: sticky; - top: calc(var(--header-height, 64px) + 10px); - z-index: 4; - display: block; - margin-bottom: 4px; - background: color-mix(in srgb, var(--bg-secondary) 92%, transparent); - } -} - -.mobileSectionNavScroller { - display: grid; - grid-template-columns: repeat(2, minmax(0, 1fr)); - gap: 6px; - overflow: visible; - padding: 2px 0 8px; -} - -.mobileSectionNavButton { - @include button-reset; - display: inline-flex; - align-items: center; - gap: 7px; - min-width: 0; - width: 100%; - padding: 9px 10px; - border: 1px solid var(--border-color); - border-radius: 8px; - background: var(--bg-primary); - text-align: left; -} - -.mobileSectionNavButtonActive { - border-color: var(--text-primary); - background: color-mix(in srgb, var(--text-primary) 6%, transparent); -} - -.mobileSectionNavIndex { - color: var(--text-tertiary); - font-size: 11px; - font-weight: 750; - letter-spacing: 0.08em; -} - -.mobileSectionNavLabel { - color: var(--text-primary); - font-size: 13px; - font-weight: 700; - line-height: 1.25; -} - -.mobileSectionNavBadge { - display: inline-flex; - align-items: center; - justify-content: center; - min-width: 20px; - height: 20px; - padding: 0 6px; - border-radius: 6px; - background: var(--warning-bg); - border: 1px solid var(--warning-border); - color: var(--warning-text); - font-size: 11px; - font-weight: 700; -} - -.sidebar { - position: sticky; - top: calc(var(--header-height, 64px) + 12px); - z-index: 5; - align-self: stretch; - min-width: 0; - - @include mobile { - display: none; - } -} - -.sidebarRail { - padding: 0 0 12px; - border-bottom: 1px solid var(--border-color); - background: color-mix(in srgb, var(--bg-secondary) 88%, transparent); -} - -.navList { - display: grid; - grid-template-columns: repeat(3, minmax(0, 1fr)); - gap: 8px; - min-width: 0; - - @include tablet { - grid-template-columns: repeat(2, minmax(0, 1fr)); - } -} - -.navButton { - @include button-reset; - display: flex; - align-items: center; - gap: 10px; - width: 100%; - min-height: 48px; - padding: 9px 11px; - border: 1px solid var(--border-color); - border-radius: 8px; - background: transparent; - color: inherit; - text-align: left; - transition: - background-color 0.15s ease, - border-color 0.15s ease, - color 0.15s ease; - - &:hover { - background: color-mix(in srgb, var(--text-primary) 5%, transparent); - } -} - -.navButtonActive { - border-color: var(--text-primary); - background: color-mix(in srgb, var(--text-primary) 6%, transparent); -} - -.navIndex { - min-width: 24px; - padding-top: 2px; - color: var(--text-tertiary); - font-size: 11px; - font-weight: 750; - letter-spacing: 0.08em; - flex: 0 0 auto; -} - -.navMain { - display: flex; - flex-direction: column; - min-width: 0; - flex: 1; -} - -.navHeadingRow { - display: flex; - align-items: center; - gap: 8px; - min-width: 0; -} - -.navLabelWrap { - display: inline-flex; - align-items: center; - gap: 7px; - min-width: 0; -} - -.navIcon { - display: inline-flex; - align-items: center; - justify-content: center; - width: 16px; - color: var(--text-secondary); - flex: 0 0 auto; -} - -.navLabel { - color: var(--text-primary); - font-size: 13px; - font-weight: 700; - line-height: 1.25; -} - -.navBadge { - display: inline-flex; - align-items: center; - justify-content: center; - min-width: 22px; - height: 22px; - padding: 0 7px; - border-radius: 6px; - background: var(--warning-bg); - border: 1px solid var(--warning-border); - color: var(--warning-text); - font-size: 11px; - font-weight: 700; - flex: 0 0 auto; -} - -.sections { - display: flex; - gap: 0; - width: 100%; - max-width: 100%; - min-width: 0; - overflow-x: auto; - overflow-y: hidden; - align-items: stretch; - padding: 0 0 12px; - scroll-padding-left: 0; - scroll-snap-type: x mandatory; - scrollbar-gutter: stable; - scrollbar-width: thin; - - @include mobile { - padding-bottom: 10px; - } - - > * { - flex: 0 0 100%; - width: 100%; - max-width: 100%; - } -} - -.sectionGrid { - display: grid; - grid-template-columns: repeat(auto-fit, minmax(240px, 1fr)); - gap: 14px; - - @include mobile { - grid-template-columns: minmax(0, 1fr); - } -} - -.sectionStack { - display: flex; - flex-direction: column; - gap: 14px; -} - -.divider { - height: 1px; - background: var(--border-color); -} - -.toggleRow { - display: grid; - grid-template-columns: minmax(0, 1fr) auto; - gap: 14px; - align-items: center; - min-height: 74px; - padding: 14px; - border: 1px solid var(--border-color); - border-radius: 8px; - background: transparent; - - @include mobile { - grid-template-columns: minmax(0, 1fr); - } -} - -.toggleCopy { - display: flex; - flex-direction: column; - gap: 5px; - min-width: 0; -} - -.toggleTitle { - color: var(--text-primary); - font-size: 14px; - font-weight: 700; - line-height: 1.25; -} - -.toggleDescription { - color: var(--text-secondary); - font-size: 12px; - line-height: 1.55; -} - -.fieldShell { - display: flex; - flex-direction: column; - gap: 7px; - min-width: 0; -} - -.fieldLabel { - color: var(--text-secondary); - font-size: 12px; - font-weight: 700; - letter-spacing: 0.02em; -} - -.fieldControl { - position: relative; -} - -.fieldHint { - color: var(--text-secondary); - font-size: 12px; - line-height: 1.55; -} - -.inlinePill { - position: absolute; - right: 8px; - top: 50%; - transform: translateY(-50%); - display: inline-flex; - align-items: center; - min-height: 24px; - padding: 0 8px; - border: 1px solid var(--border-color); - border-radius: 6px; - background: var(--bg-primary); - color: var(--text-secondary); - font-size: 11px; - font-weight: 700; -} - -.subsection { - display: flex; - flex-direction: column; - gap: 12px; - padding: 16px; - border: 1px solid var(--border-color); - border-radius: 8px; - background: transparent; -} - -.subsectionHeader { - display: flex; - flex-direction: column; - gap: 5px; -} - -.subsectionTitle { - margin: 0; - color: var(--text-primary); - font-size: 15px; - font-weight: 700; - line-height: 1.25; -} - -.subsectionDescription { - margin: 0; - color: var(--text-secondary); - font-size: 12px; - line-height: 1.6; -} - -.blockHeaderRow { - display: flex; - align-items: center; - justify-content: space-between; - gap: 10px; - flex-wrap: wrap; -} - -.blockStack { - display: flex; - flex-direction: column; - gap: 10px; -} - -.ruleCard { - display: flex; - flex-direction: column; - gap: 12px; - padding: 12px; - border: 1px solid var(--border-color); - border-radius: 8px; - background: color-mix(in srgb, var(--bg-secondary) 64%, transparent); -} - -.ruleCardHeader { - display: flex; - align-items: center; - justify-content: space-between; - gap: 10px; - flex-wrap: wrap; -} - -.ruleCardTitle { - color: var(--text-primary); - font-size: 14px; - font-weight: 700; - line-height: 1.25; -} - -.blockLabel { - color: var(--text-secondary); - font-size: 12px; - font-weight: 700; - line-height: 1.4; -} - -.actionRow { - display: flex; - justify-content: flex-end; -} - -.emptyState { - border: 1px dashed var(--border-color); - border-radius: 8px; - padding: 16px; - color: var(--text-secondary); - text-align: center; - background: transparent; -} - -.stringList { - display: flex; - flex-direction: column; - gap: 8px; -} - -.stringListRow { - display: flex; - align-items: center; - gap: 8px; - flex-wrap: wrap; -} - -.payloadRuleModelRow { - display: grid; - grid-template-columns: 1fr 160px auto auto; - gap: 8px; - align-items: center; -} - -.payloadRuleModelRowProtocolFirst { - grid-template-columns: 160px 1fr auto auto; -} - -.payloadModelGroup { - display: flex; - flex-direction: column; - gap: 8px; -} - -.payloadModelAdvanced { - display: flex; - flex-direction: column; - gap: 12px; - margin-left: 10px; - padding-left: 12px; - border-left: 2px solid var(--border-color); -} - -.payloadAdvancedGrid { - display: grid; - grid-template-columns: repeat(auto-fit, minmax(220px, 1fr)); - gap: 10px; -} - -.payloadHeaderRow { - display: grid; - grid-template-columns: minmax(0, 1fr) minmax(0, 1fr) auto; - gap: 8px; - align-items: center; -} - -.payloadRuleParamRow { - display: grid; - grid-template-columns: 1fr 140px 1fr auto; - gap: 8px; - align-items: start; -} - -.payloadRuleRawParamRow { - grid-template-columns: minmax(240px, 1fr) minmax(320px, 1fr) auto; -} - -.payloadRuleParamGroup { - display: flex; - flex-direction: column; - gap: 6px; -} - -.payloadJsonInput { - min-height: 112px; - resize: vertical; - font-family: - ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, 'Liberation Mono', 'Courier New', - monospace; -} - -.payloadParamError { - margin: 0; -} - -.payloadFilterModelRow { - display: grid; - grid-template-columns: 1fr 160px auto; - gap: 8px; - align-items: center; -} - -.payloadRowActionButton { - flex: 0 0 auto; - justify-self: start; -} - -.apiKeyModalInputRow { - display: flex; - gap: 8px; - align-items: center; - - :global(.input) { - flex: 1; - } -} - -@media (max-width: 900px) { - .payloadRuleModelRow, - .payloadRuleModelRowProtocolFirst, - .payloadHeaderRow, - .payloadRuleParamRow, - .payloadFilterModelRow { - grid-template-columns: minmax(0, 1fr); - } - - .apiKeyModalInputRow { - flex-direction: column; - align-items: stretch; - } - - .payloadRowActionButton { - width: 100%; - } -} - -@include mobile { - .overview { - padding-bottom: 14px; - } - - .subsection, - .ruleCard, - .toggleRow { - padding: 14px; - } - - .blockHeaderRow, - .ruleCardHeader { - align-items: stretch; - } - - .blockHeaderRow :global(.btn), - .ruleCardHeader :global(.btn), - .actionRow :global(.btn), - .stringListRow :global(.btn) { - width: 100%; - justify-content: center; - } - - .actionRow { - justify-content: stretch; - } - - .stringListRow { - align-items: stretch; - } -} - -@media (max-width: 380px) { - .subsection, - .ruleCard, - .toggleRow { - padding: 12px; - } -} diff --git a/src/components/config/VisualConfigEditor.tsx b/src/components/config/VisualConfigEditor.tsx deleted file mode 100644 index ebd1689f..00000000 --- a/src/components/config/VisualConfigEditor.tsx +++ /dev/null @@ -1,1824 +0,0 @@ -import { - useCallback, - useEffect, - useId, - useMemo, - useRef, - useState, - type ComponentType, - type ReactNode, -} from 'react'; -import { useTranslation } from 'react-i18next'; -import { usePageTransitionLayer } from '@/components/common/PageTransitionLayer'; -import { Collapsible } from '@/components/ui/Collapsible'; -import { Input } from '@/components/ui/Input'; -import { Select } from '@/components/ui/Select'; -import { ToggleSwitch } from '@/components/ui/ToggleSwitch'; -import { - IconCode, - IconKey, - IconNetwork, - IconSatellite, - IconScrollText, - IconSearch, - IconShield, - IconSlidersHorizontal, - IconTimer, - type IconProps, -} from '@/components/ui/icons'; -import { ConfigSection } from '@/components/config/ConfigSection'; -import { useMediaQuery } from '@/hooks/useMediaQuery'; -import type { - PayloadFilterRule, - PayloadParamValidationErrorCode, - PayloadRule, - PluginStoreAuthRule, - VisualConfigFieldPath, - VisualConfigValidationErrorCode, - VisualConfigValidationErrors, - VisualConfigValues, -} from '@/types/visualConfig'; -import { - ApiKeysCardEditor, - PayloadFilterRulesEditor, - PayloadRulesEditor, - PluginStoreAuthEditor, - StringListEditor, -} from './VisualConfigEditorBlocks'; -import { - configFieldDomId, - searchConfigFields, - type ConfigFieldSearchEntry, - type VisualSectionId, -} from './configSearchIndex'; -import styles from './VisualConfigEditor.module.scss'; - -type EditorMode = 'simple' | 'full'; - -const EDITOR_MODE_STORAGE_KEY = 'config-management:editor-mode'; - -type VisualSection = { - id: VisualSectionId; - title: string; - icon: ComponentType; - errorCount: number; -}; - -interface VisualConfigEditorProps { - values: VisualConfigValues; - validationErrors?: VisualConfigValidationErrors; - hasPayloadValidationErrors?: boolean; - disabled?: boolean; - onChange: (values: Partial) => void; -} - -function getValidationMessage( - t: ReturnType['t'], - errorCode?: VisualConfigValidationErrorCode | PayloadParamValidationErrorCode -) { - if (!errorCode) return undefined; - return t(`config_management.visual.validation.${errorCode}`); -} - -type ToggleRowProps = { - title: string; - description?: string; - checked: boolean; - disabled?: boolean; - onChange: (value: boolean) => void; -}; - -function ToggleRow({ title, description, checked, disabled, onChange }: ToggleRowProps) { - return ( -
-
-
{title}
- {description ?
{description}
: null} -
- -
- ); -} - -function SectionGrid({ children }: { children: ReactNode }) { - return
{children}
; -} - -function SectionStack({ children }: { children: ReactNode }) { - return
{children}
; -} - -function Divider() { - return
; -} - -// Stable, stateless anchor around a searchable field. Search jumps target its DOM id -// (see configSearchIndex.ts) and the highlight pulse is applied to it imperatively. -function FieldAnchor({ fieldId, children }: { fieldId: string; children: ReactNode }) { - return ( -
- {children} -
- ); -} - -function SectionSubsection({ - title, - description, - children, -}: { - title: string; - description?: string; - children: ReactNode; -}) { - return ( -
-
-

{title}

- {description ?

{description}

: null} -
- {children} -
- ); -} - -function FieldShell({ - label, - labelId, - htmlFor, - hint, - hintId, - error, - errorId, - children, -}: { - label: string; - labelId?: string; - htmlFor?: string; - hint?: string; - hintId?: string; - error?: string; - errorId?: string; - children: ReactNode; -}) { - return ( -
- - {children} - {error ? ( -
- {error} -
- ) : null} - {hint ? ( -
- {hint} -
- ) : null} -
- ); -} - -export function VisualConfigEditor({ - values, - validationErrors, - hasPayloadValidationErrors = false, - disabled = false, - onChange, -}: VisualConfigEditorProps) { - const { t } = useTranslation(); - const pageTransitionLayer = usePageTransitionLayer(); - const isCurrentLayer = pageTransitionLayer ? pageTransitionLayer.isCurrentLayer : true; - const isMobile = useMediaQuery('(max-width: 768px)'); - const routingStrategyLabelId = useId(); - const routingStrategyHintId = `${routingStrategyLabelId}-hint`; - const disableImageGenerationLabelId = useId(); - const disableImageGenerationHintId = `${disableImageGenerationLabelId}-hint`; - const keepaliveInputId = useId(); - const keepaliveHintId = `${keepaliveInputId}-hint`; - const keepaliveErrorId = `${keepaliveInputId}-error`; - const nonstreamKeepaliveInputId = useId(); - const nonstreamKeepaliveHintId = `${nonstreamKeepaliveInputId}-hint`; - const nonstreamKeepaliveErrorId = `${nonstreamKeepaliveInputId}-error`; - const [mode, setMode] = useState(() => { - const saved = localStorage.getItem(EDITOR_MODE_STORAGE_KEY); - return saved === 'full' ? 'full' : 'simple'; - }); - const [activeSectionId, setActiveSectionId] = useState('connectivity'); - const sectionRefs = useRef>>({}); - const mobileNavScrollerRef = useRef(null); - const mobileNavButtonRefs = useRef>>( - {} - ); - const [searchQuery, setSearchQuery] = useState(''); - // Dropdown visibility is tracked separately from the query text so a jump can close the - // results while leaving the typed text in the box for further editing. - const [searchOpen, setSearchOpen] = useState(false); - // Highlighted option for keyboard navigation of the results listbox (-1 = none). - const [activeResultIndex, setActiveResultIndex] = useState(0); - const searchListboxId = useId(); - const searchResultsRef = useRef(null); - // A fresh object per jump; the effect handles it once (guarded by handledJumpRef) so it - // never needs to clear state from inside the effect. - const [jumpRequest, setJumpRequest] = useState<{ - fieldId: string; - sectionId: VisualSectionId; - } | null>(null); - const handledJumpRef = useRef<{ fieldId: string; sectionId: VisualSectionId } | null>(null); - const searchBoxRef = useRef(null); - const highlightTimerRef = useRef(null); - const highlightedElRef = useRef(null); - - const handleModeChange = useCallback((next: EditorMode) => { - setMode(next); - localStorage.setItem(EDITOR_MODE_STORAGE_KEY, next); - }, []); - - const searchResults = useMemo(() => searchConfigFields(searchQuery, t), [searchQuery, t]); - // The results popup is visible only when the box is open AND there's a (trimmed) query. - const isResultsOpen = searchOpen && Boolean(searchQuery.trim()); - // Clamp the highlighted index to the current result set so a stale index (e.g. after the - // query narrows the list) never points past the end or at an option that no longer exists. - const effectiveActiveIndex = - searchResults.length > 0 - ? Math.min(Math.max(activeResultIndex, 0), searchResults.length - 1) - : -1; - - const handleResultJump = useCallback( - (entry: ConfigFieldSearchEntry) => { - // Keep the query text so the user can tweak it; just close the results dropdown. - setSearchOpen(false); - handleModeChange('full'); - setActiveSectionId(entry.sectionId); - // A new object instance defers scroll/highlight to the effect below, giving a - // simple→full switch time to mount the DOM before we query for the field. - setJumpRequest({ fieldId: entry.fieldId, sectionId: entry.sectionId }); - }, - [handleModeChange] - ); - - // Imperatively scroll to and pulse-highlight the jumped-to field once full mode is mounted. - useEffect(() => { - if (mode !== 'full' || !jumpRequest || handledJumpRef.current === jumpRequest) return; - handledJumpRef.current = jumpRequest; // handle each request once, even if deps re-fire - const { fieldId, sectionId } = jumpRequest; - const targetFieldId = - (fieldId === 'tlsCert' || fieldId === 'tlsKey') && !values.tlsEnable ? 'tlsEnable' : fieldId; - - const el = document.getElementById(configFieldDomId(targetFieldId)); - if (!el) { - // Field not rendered right now (e.g. TLS cert while TLS is disabled) — fall back to - // bringing its section into view horizontally. - sectionRefs.current[sectionId]?.scrollIntoView({ block: 'nearest', inline: 'start' }); - return; - } - - // Expand the collapsed
group this field belongs to: an ancestor when the - // anchor sits inside the group (TLS / remote / advanced fields), or a descendant when - // the anchor wraps the whole group (payload rule groups). - const details = el.closest('details') ?? el.querySelector('details'); - if (details && !details.open) details.open = true; - - // Clear any in-flight highlight before starting a new one. - if (highlightTimerRef.current !== null) { - clearTimeout(highlightTimerRef.current); - highlightedElRef.current?.classList.remove(styles.fieldHighlightActive); - } - - // Full-mode sections live in a horizontal scroll-snap container (`scroll-snap-type: x - // mandatory`). A single field-level scrollIntoView() tries to do the horizontal section - // switch AND the vertical field scroll at once, which the snap pulls back / lands wrong. - // So: (1) switch to the target section horizontally and instantly (no smooth → no snap - // fight), then (2) next frame, scroll the field vertically with inline:'nearest' so it - // can't re-trigger horizontal snapping. - sectionRefs.current[sectionId]?.scrollIntoView({ block: 'nearest', inline: 'start' }); - requestAnimationFrame(() => { - el.scrollIntoView({ behavior: 'smooth', block: 'center', inline: 'nearest' }); - el.classList.add(styles.fieldHighlightActive); - }); - highlightedElRef.current = el; - highlightTimerRef.current = window.setTimeout(() => { - el.classList.remove(styles.fieldHighlightActive); - highlightTimerRef.current = null; - highlightedElRef.current = null; - }, 1800); - }, [mode, jumpRequest, values.tlsEnable]); - - // Clear the highlight timer on unmount. - useEffect( - () => () => { - if (highlightTimerRef.current !== null) clearTimeout(highlightTimerRef.current); - }, - [] - ); - - // Close the results dropdown (keeping the query) when clicking outside the search box. - useEffect(() => { - if (!searchOpen) return; - const handlePointerDown = (event: MouseEvent) => { - if (searchBoxRef.current && !searchBoxRef.current.contains(event.target as Node)) { - setSearchOpen(false); - } - }; - document.addEventListener('mousedown', handlePointerDown); - return () => document.removeEventListener('mousedown', handlePointerDown); - }, [searchOpen]); - - // Keep the highlighted option scrolled into view during keyboard navigation. - useEffect(() => { - if (!isResultsOpen || effectiveActiveIndex < 0) return; - const node = searchResultsRef.current?.querySelector( - `[data-result-index="${effectiveActiveIndex}"]` - ); - node?.scrollIntoView({ block: 'nearest' }); - }, [effectiveActiveIndex, isResultsOpen]); - - const isKeepaliveDisabled = - values.streaming.keepaliveSeconds === '' || values.streaming.keepaliveSeconds === '0'; - const isNonstreamKeepaliveDisabled = - values.streaming.nonstreamKeepaliveInterval === '' || - values.streaming.nonstreamKeepaliveInterval === '0'; - - const portError = getValidationMessage(t, validationErrors?.port); - const logsMaxSizeError = getValidationMessage(t, validationErrors?.logsMaxTotalSizeMb); - const errorLogsMaxFilesError = getValidationMessage(t, validationErrors?.errorLogsMaxFiles); - const redisUsageQueueRetentionError = getValidationMessage( - t, - validationErrors?.redisUsageQueueRetentionSeconds - ); - const requestRetryError = getValidationMessage(t, validationErrors?.requestRetry); - const maxRetryCredentialsError = getValidationMessage(t, validationErrors?.maxRetryCredentials); - const maxRetryIntervalError = getValidationMessage(t, validationErrors?.maxRetryInterval); - const authAutoRefreshWorkersError = getValidationMessage( - t, - validationErrors?.authAutoRefreshWorkers - ); - const keepaliveError = getValidationMessage(t, validationErrors?.['streaming.keepaliveSeconds']); - const bootstrapRetriesError = getValidationMessage( - t, - validationErrors?.['streaming.bootstrapRetries'] - ); - const nonstreamKeepaliveError = getValidationMessage( - t, - validationErrors?.['streaming.nonstreamKeepaliveInterval'] - ); - - const handleApiKeysTextChange = useCallback( - (apiKeysText: string) => onChange({ apiKeysText }), - [onChange] - ); - const handlePluginStoreSourcesChange = useCallback( - (pluginStoreSources: string[]) => onChange({ pluginStoreSources }), - [onChange] - ); - const handlePluginStoreAuthChange = useCallback( - (pluginStoreAuth: PluginStoreAuthRule[]) => onChange({ pluginStoreAuth }), - [onChange] - ); - const handlePayloadDefaultRulesChange = useCallback( - (payloadDefaultRules: PayloadRule[]) => onChange({ payloadDefaultRules }), - [onChange] - ); - const handlePayloadDefaultRawRulesChange = useCallback( - (payloadDefaultRawRules: PayloadRule[]) => onChange({ payloadDefaultRawRules }), - [onChange] - ); - const handlePayloadOverrideRulesChange = useCallback( - (payloadOverrideRules: PayloadRule[]) => onChange({ payloadOverrideRules }), - [onChange] - ); - const handlePayloadOverrideRawRulesChange = useCallback( - (payloadOverrideRawRules: PayloadRule[]) => onChange({ payloadOverrideRawRules }), - [onChange] - ); - const handlePayloadFilterRulesChange = useCallback( - (payloadFilterRules: PayloadFilterRule[]) => onChange({ payloadFilterRules }), - [onChange] - ); - const disableImageGenerationOptions = useMemo( - () => [ - { - value: 'false', - label: t('config_management.visual.sections.network.disable_image_generation_false'), - }, - { - value: 'true', - label: t('config_management.visual.sections.network.disable_image_generation_true'), - }, - { - value: 'chat', - label: t('config_management.visual.sections.network.disable_image_generation_chat'), - }, - { - value: 'passthrough', - label: t('config_management.visual.sections.network.disable_image_generation_passthrough'), - }, - ], - [t] - ); - - const countErrors = useCallback( - (fields: VisualConfigFieldPath[]) => - fields.reduce((total, field) => total + (validationErrors?.[field] ? 1 : 0), 0), - [validationErrors] - ); - - const sections = useMemo( - () => [ - { - id: 'connectivity', - title: t('config_management.visual.sections.connectivity.title'), - icon: IconKey, - errorCount: countErrors(['port']), - }, - { - id: 'network', - title: t('config_management.visual.sections.network.title'), - icon: IconNetwork, - errorCount: countErrors([ - 'requestRetry', - 'maxRetryCredentials', - 'maxRetryInterval', - 'authAutoRefreshWorkers', - ]), - }, - { - id: 'logging', - title: t('config_management.visual.sections.logging.title'), - icon: IconScrollText, - errorCount: countErrors([ - 'errorLogsMaxFiles', - 'logsMaxTotalSizeMb', - 'redisUsageQueueRetentionSeconds', - ]), - }, - { - id: 'quota', - title: t('config_management.visual.sections.quota.title'), - icon: IconTimer, - errorCount: 0, - }, - { - id: 'streaming', - title: t('config_management.visual.sections.streaming.title'), - icon: IconSatellite, - errorCount: countErrors([ - 'streaming.keepaliveSeconds', - 'streaming.bootstrapRetries', - 'streaming.nonstreamKeepaliveInterval', - ]), - }, - { - id: 'advanced', - title: t('config_management.visual.sections.advanced.title'), - icon: IconShield, - errorCount: 0, - }, - { - id: 'payload', - title: t('config_management.visual.sections.payload.title'), - icon: IconCode, - errorCount: hasPayloadValidationErrors ? 1 : 0, - }, - ], - [countErrors, hasPayloadValidationErrors, t] - ); - - const hasValidationIssues = - sections.some((section) => section.errorCount > 0) || hasPayloadValidationErrors; - // Validation errors that live in fields not surfaced by simple mode (everything except `port`). - const hasHiddenValidationIssues = - (Object.keys(validationErrors ?? {}) as VisualConfigFieldPath[]).some( - (field) => field !== 'port' && Boolean(validationErrors?.[field]) - ) || hasPayloadValidationErrors; - const payloadValidationKey = hasPayloadValidationErrors ? 'payload-errors' : 'payload-ok'; - const activeSection = sections.find((section) => section.id === activeSectionId) ?? sections[0]; - - useEffect(() => { - if (mode !== 'full') return undefined; - if (!isCurrentLayer) return undefined; - if (typeof IntersectionObserver === 'undefined') return undefined; - - const observer = new IntersectionObserver( - (entries) => { - const visibleEntries = entries - .filter((entry) => entry.isIntersecting) - .sort((left, right) => right.intersectionRatio - left.intersectionRatio); - - if (visibleEntries.length === 0) return; - setActiveSectionId(visibleEntries[0].target.id as VisualSectionId); - }, - { - rootMargin: '-18% 0px -58% 0px', - threshold: [0.12, 0.3, 0.55], - } - ); - - for (const section of sections) { - const element = sectionRefs.current[section.id]; - if (element) observer.observe(element); - } - - return () => observer.disconnect(); - }, [isCurrentLayer, mode, sections]); - - useEffect(() => { - if (mode !== 'full' || !isCurrentLayer || !isMobile) return; - const scroller = mobileNavScrollerRef.current; - const button = mobileNavButtonRefs.current[activeSectionId]; - if (!scroller || !button) return; - - const scrollerRect = scroller.getBoundingClientRect(); - const buttonRect = button.getBoundingClientRect(); - const centeredLeft = - scroller.scrollLeft + - (buttonRect.left - scrollerRect.left) - - (scroller.clientWidth - buttonRect.width) / 2; - const maxScrollLeft = Math.max(scroller.scrollWidth - scroller.clientWidth, 0); - const targetLeft = Math.min(Math.max(centeredLeft, 0), maxScrollLeft); - - scroller.scrollTo({ - left: targetLeft, - behavior: 'smooth', - }); - }, [activeSectionId, isCurrentLayer, isMobile, mode]); - - const handleSectionJump = useCallback((sectionId: VisualSectionId) => { - setActiveSectionId(sectionId); - sectionRefs.current[sectionId]?.scrollIntoView({ - behavior: 'smooth', - block: 'nearest', - inline: 'start', - }); - }, []); - - // Shared high-frequency field blocks — reused verbatim by both the simple view and full mode - // so the two layouts never drift apart. - const hostField = ( - - onChange({ host: e.target.value })} - disabled={disabled} - /> - - ); - - const portField = ( - - onChange({ port: e.target.value })} - disabled={disabled} - error={portError} - /> - - ); - - const proxyUrlField = ( - - onChange({ proxyUrl: e.target.value })} - disabled={disabled} - /> - - ); - - const apiKeysField = ( - -
- -
-
- ); - - const debugToggle = ( - - onChange({ debug })} - /> - - ); - - const loggingToFileToggle = ( - - onChange({ loggingToFile })} - /> - - ); - - const quotaSwitchProjectToggle = ( - - onChange({ quotaSwitchProject })} - /> - - ); - - const quotaSwitchPreviewModelToggle = ( - - onChange({ quotaSwitchPreviewModel })} - /> - - ); - - const navContent = ( -
- {sections.map((section, index) => { - const Icon = section.icon; - - return ( - - ); - })} -
- ); - - return ( -
-
-
-
- - -
-
- {mode === 'full' && activeSection ? ( - {activeSection.title} - ) : null} - {hasValidationIssues ? ( - - {t('config_management.visual.validation.validation_blocked')} - - ) : null} -
-
- -
- = 0 - ? `${searchListboxId}-opt-${effectiveActiveIndex}` - : undefined - } - value={searchQuery} - onChange={(e) => { - setSearchQuery(e.target.value); - setSearchOpen(true); - setActiveResultIndex(0); - }} - onFocus={() => setSearchOpen(true)} - onKeyDown={(e) => { - // Ignore keys fired while an IME is composing (e.g. picking a Chinese - // candidate) — otherwise candidate selection triggers navigation/jump. - if (e.nativeEvent.isComposing) return; - if (e.key === 'Escape') { - setSearchOpen(false); - return; - } - if (e.key === 'ArrowDown') { - e.preventDefault(); - if (!isResultsOpen) { - setSearchOpen(true); - return; - } - if (searchResults.length === 0) return; - setActiveResultIndex((effectiveActiveIndex + 1) % searchResults.length); - return; - } - if (e.key === 'ArrowUp') { - e.preventDefault(); - if (!isResultsOpen) { - setSearchOpen(true); - return; - } - if (searchResults.length === 0) return; - setActiveResultIndex( - effectiveActiveIndex <= 0 ? searchResults.length - 1 : effectiveActiveIndex - 1 - ); - return; - } - if (e.key === 'Enter' && isResultsOpen && searchResults.length > 0) { - e.preventDefault(); - handleResultJump(searchResults[effectiveActiveIndex] ?? searchResults[0]); - } - }} - rightElement={ - - } - /> - {isResultsOpen ? ( -
- {searchResults.length > 0 ? ( - searchResults.map((entry, index) => ( - - )) - ) : ( -
- {t('config_management.visual.search.no_results')} -
- )} -
- ) : null} -
-
- - {mode === 'simple' ? ( -
- {hasHiddenValidationIssues ? ( -
- {t('config_management.visual.mode.validation_banner')} - -
- ) : null} - -
-
{hostField}
-
{portField}
- {apiKeysField} -
{proxyUrlField}
- {debugToggle} - {loggingToFileToggle} - {quotaSwitchProjectToggle} - {quotaSwitchPreviewModelToggle} -
- - -
- ) : ( -
- {isMobile ? ( -
-
- {sections.map((section, index) => ( - - ))} -
-
- ) : null} - - - -
- { - sectionRefs.current.connectivity = node; - }} - indexLabel="01" - icon={} - title={t('config_management.visual.sections.connectivity.title')} - description={t('config_management.visual.sections.connectivity.description')} - > - - - {hostField} - {portField} - - - - onChange({ authDir: e.target.value })} - disabled={disabled} - hint={t('config_management.visual.sections.auth.auth_dir_hint')} - /> - - - {apiKeysField} - - - - - onChange({ tlsEnable })} - /> - - - {values.tlsEnable ? ( - <> - - - - onChange({ tlsCert: e.target.value })} - disabled={disabled} - /> - - - onChange({ tlsKey: e.target.value })} - disabled={disabled} - /> - - - - ) : null} - - - - - - - - onChange({ rmAllowRemote })} - /> - - - onChange({ rmDisableControlPanel })} - /> - - - - onChange({ rmDisableAutoUpdatePanel }) - } - /> - - - - - onChange({ rmSecretKey: e.target.value })} - disabled={disabled} - /> - - - onChange({ rmPanelRepo: e.target.value })} - disabled={disabled} - /> - - - - - - - - { - sectionRefs.current.network = node; - }} - indexLabel="02" - icon={} - title={t('config_management.visual.sections.network.title')} - description={t('config_management.visual.sections.network.description')} - > - - - {proxyUrlField} - - onChange({ requestRetry: e.target.value })} - disabled={disabled} - error={requestRetryError} - /> - - - onChange({ maxRetryCredentials: e.target.value })} - disabled={disabled} - hint={t( - 'config_management.visual.sections.network.max_retry_credentials_hint' - )} - error={maxRetryCredentialsError} - /> - - - onChange({ maxRetryInterval: e.target.value })} - disabled={disabled} - error={maxRetryIntervalError} - /> - - - onChange({ authAutoRefreshWorkers: e.target.value })} - disabled={disabled} - hint={t( - 'config_management.visual.sections.network.auth_auto_refresh_workers_hint' - )} - error={authAutoRefreshWorkersError} - /> - - - - - onChange({ - disableImageGeneration: - nextValue as VisualConfigValues['disableImageGeneration'], - }) - } - /> - - - - onChange({ gptImage2BaseModel: e.target.value })} - disabled={disabled} - hint={t( - 'config_management.visual.sections.network.gpt_image_2_base_model_hint' - )} - /> - - - onChange({ routingSessionAffinityTTL: e.target.value })} - disabled={disabled} - /> - - - - - - onChange({ forceModelPrefix })} - /> - - - onChange({ passthroughHeaders })} - /> - - - onChange({ disableCooling })} - /> - - - onChange({ routingSessionAffinity })} - /> - - - onChange({ wsAuth })} - /> - - - - - - { - sectionRefs.current.logging = node; - }} - indexLabel="03" - icon={} - title={t('config_management.visual.sections.logging.title')} - description={t('config_management.visual.sections.logging.description')} - > - - - {debugToggle} - - onChange({ commercialMode })} - /> - - {loggingToFileToggle} - - - - - onChange({ logsMaxTotalSizeMb: e.target.value })} - disabled={disabled} - error={logsMaxSizeError} - /> - - - onChange({ errorLogsMaxFiles: e.target.value })} - disabled={disabled} - error={errorLogsMaxFilesError} - /> - - - - onChange({ redisUsageQueueRetentionSeconds: e.target.value }) - } - disabled={disabled} - hint={t( - 'config_management.visual.sections.system.redis_usage_retention_hint' - )} - error={redisUsageQueueRetentionError} - /> - - - - - - onChange({ usageStatisticsEnabled })} - /> - - - - - - { - sectionRefs.current.quota = node; - }} - indexLabel="04" - icon={} - title={t('config_management.visual.sections.quota.title')} - description={t('config_management.visual.sections.quota.description')} - > - - {quotaSwitchProjectToggle} - {quotaSwitchPreviewModelToggle} - - onChange({ quotaAntigravityCredits })} - /> - - - - - { - sectionRefs.current.streaming = node; - }} - indexLabel="05" - icon={} - title={t('config_management.visual.sections.streaming.title')} - description={t('config_management.visual.sections.streaming.description')} - > - - - - -
- - onChange({ - streaming: { - ...values.streaming, - keepaliveSeconds: e.target.value, - }, - }) - } - disabled={disabled} - /> - {isKeepaliveDisabled ? ( - - {t('config_management.visual.sections.streaming.disabled')} - - ) : null} -
-
-
- - - - onChange({ - streaming: { - ...values.streaming, - bootstrapRetries: e.target.value, - }, - }) - } - disabled={disabled} - hint={t('config_management.visual.sections.streaming.bootstrap_hint')} - error={bootstrapRetriesError} - /> - -
- - - - -
- - onChange({ - streaming: { - ...values.streaming, - nonstreamKeepaliveInterval: e.target.value, - }, - }) - } - disabled={disabled} - /> - {isNonstreamKeepaliveDisabled ? ( - - {t('config_management.visual.sections.streaming.disabled')} - - ) : null} -
-
-
-
-
-
- - { - sectionRefs.current.advanced = node; - }} - indexLabel="06" - icon={} - title={t('config_management.visual.sections.advanced.title')} - description={t('config_management.visual.sections.advanced.description')} - > - - - - - - onChange({ pluginsEnabled })} - /> - - - - - -
- - -
- {t( - 'config_management.visual.sections.system.plugin_store_sources_hint' - )} -
-
-
-
- - - -
-
- {t('config_management.visual.sections.system.plugin_store_auth_hint')} -
- -
-
-
-
-
- - - - - - onChange({ antigravitySignatureCacheEnabled }) - } - /> - - - - onChange({ antigravitySignatureBypassStrict }) - } - /> - - - - - - -
-

- {t('config_management.visual.sections.headers.claude_title')} -

-
- - - onChange({ claudeHeaderUserAgent: e.target.value })} - disabled={disabled} - /> - - - onChange({ claudeHeaderPackageVersion: e.target.value })} - disabled={disabled} - /> - - - onChange({ claudeHeaderRuntimeVersion: e.target.value })} - disabled={disabled} - /> - - - onChange({ claudeHeaderOs: e.target.value })} - disabled={disabled} - /> - - - onChange({ claudeHeaderArch: e.target.value })} - disabled={disabled} - /> - - - onChange({ claudeHeaderTimeout: e.target.value })} - disabled={disabled} - /> - - - - - - onChange({ claudeHeaderStabilizeDeviceProfile }) - } - /> - - - -
-

- {t('config_management.visual.sections.headers.codex_title')} -

-
- - - onChange({ codexHeaderUserAgent: e.target.value })} - disabled={disabled} - /> - - - onChange({ codexHeaderBetaFeatures: e.target.value })} - disabled={disabled} - /> - - -
-
-
-
- - { - sectionRefs.current.payload = node; - }} - indexLabel="07" - icon={} - title={t('config_management.visual.sections.payload.title')} - description={t('config_management.visual.sections.payload.description')} - > - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - -
-
- )} -
- ); -} diff --git a/src/components/config/VisualConfigEditorBlocks.tsx b/src/components/config/VisualConfigEditorBlocks.tsx deleted file mode 100644 index f9673cf0..00000000 --- a/src/components/config/VisualConfigEditorBlocks.tsx +++ /dev/null @@ -1,1574 +0,0 @@ -import { memo, useCallback, useId, useLayoutEffect, useMemo, useRef, useState } from 'react'; -import { useTranslation } from 'react-i18next'; -import { Button } from '@/components/ui/Button'; -import { Modal } from '@/components/ui/Modal'; -import { Select } from '@/components/ui/Select'; -import { useNotificationStore } from '@/stores'; -import styles from './VisualConfigEditor.module.scss'; -import { copyToClipboard } from '@/utils/clipboard'; -import type { - PayloadFilterRule, - PayloadHeaderEntry, - PayloadModelEntry, - PayloadParamEntry, - PayloadParamValidationErrorCode, - PayloadParamValueType, - PayloadRule, - PluginStoreAuthApplyTo, - PluginStoreAuthRule, - PluginStoreAuthType, -} from '@/types/visualConfig'; -import { makeClientId } from '@/types/visualConfig'; -import { - getPayloadParamValidationError, - VISUAL_CONFIG_PAYLOAD_VALUE_TYPE_OPTIONS, - VISUAL_CONFIG_PROTOCOL_OPTIONS, -} from '@/hooks/useVisualConfig'; -import { maskApiKey } from '@/utils/format'; -import { isValidApiKeyCharset } from '@/utils/validation'; - -/** Minimum character count before the expand/collapse toggle appears. */ -const EXPAND_THRESHOLD = 30; - -/** Auto-expanding textarea that collapses back to a single-line input on demand. */ -function ExpandableInput({ - value, - placeholder, - ariaLabel, - disabled, - className, - onChange, -}: { - value: string; - placeholder?: string; - ariaLabel?: string; - disabled?: boolean; - className?: string; - onChange: (nextValue: string) => void; -}) { - const { t } = useTranslation(); - const [collapsed, setCollapsed] = useState(true); - const textareaRef = useRef(null); - - const autoResize = useCallback((el: HTMLTextAreaElement) => { - el.style.height = 'auto'; - el.style.height = `${el.scrollHeight}px`; - }, []); - - const handleChange = (e: React.ChangeEvent) => { - // Strip newlines — these fields are single-line identifiers/paths that - // would break YAML serialization if they contained line breaks. - const sanitized = e.target.value.replace(/[\r\n]/g, ''); - onChange(sanitized); - // autoResize is handled by useLayoutEffect after React syncs the - // sanitized value back to the DOM — calling it here would measure - // stale content. - }; - - // Resize synchronously before paint to avoid visual flicker. - useLayoutEffect(() => { - if (!collapsed && textareaRef.current) { - autoResize(textareaRef.current); - } - }, [collapsed, value, autoResize]); - - if (collapsed) { - return ( -
- onChange(e.target.value.replace(/[\r\n]/g, ''))} - disabled={disabled} - /> - {value.length > EXPAND_THRESHOLD && ( - - )} -
- ); - } - - return ( -
-