diff --git a/src/components/config/ConfigSection.module.scss b/src/components/config/ConfigSection.module.scss new file mode 100644 index 0000000..4f7315f --- /dev/null +++ b/src/components/config/ConfigSection.module.scss @@ -0,0 +1,108 @@ +@use '../../styles/mixins' as *; +@use '../../styles/variables' as *; + +.section { + display: grid; + grid-template-columns: minmax(0, 220px) minmax(0, 1fr); + gap: 24px; + padding: 26px 0; + border-top: 1px solid color-mix(in srgb, var(--border-color) 76%, transparent); + scroll-margin-top: 112px; + + &:first-child { + border-top: none; + padding-top: 0; + } + + @include mobile { + grid-template-columns: minmax(0, 1fr); + gap: 18px; + padding: 22px 0; + scroll-margin-top: 88px; + } +} + +.header { + position: sticky; + top: 104px; + align-self: start; + display: flex; + flex-direction: column; + gap: 14px; + + @include mobile { + position: static; + } +} + +.titleRow { + display: flex; + align-items: center; + gap: 12px; + min-width: 0; +} + +.indexBadge { + display: inline-flex; + align-items: center; + justify-content: center; + min-width: 42px; + height: 42px; + padding: 0 12px; + border-radius: 14px; + background: + linear-gradient(180deg, color-mix(in srgb, var(--bg-primary) 92%, transparent), transparent), + color-mix(in srgb, var(--primary-color) 11%, var(--bg-secondary)); + border: 1px solid color-mix(in srgb, var(--primary-color) 18%, var(--border-color)); + color: var(--primary-hover); + font-size: 12px; + font-weight: 700; + letter-spacing: 0.08em; +} + +.iconBadge { + display: inline-flex; + align-items: center; + justify-content: center; + width: 38px; + height: 38px; + border-radius: 14px; + background: color-mix(in srgb, var(--bg-secondary) 88%, transparent); + border: 1px solid color-mix(in srgb, var(--border-color) 80%, transparent); + color: var(--text-secondary); + flex: 0 0 auto; +} + +.headingGroup { + display: flex; + flex-direction: column; + gap: 6px; + min-width: 0; +} + +.title { + margin: 0; + font-size: clamp(20px, 2vw, 25px); + line-height: 1.08; + font-weight: 700; + color: var(--text-primary); +} + +.description { + margin: 0; + max-width: 34ch; + color: var(--text-secondary); + font-size: 14px; + line-height: 1.7; + + @include mobile { + max-width: none; + } +} + +.content { + display: flex; + flex-direction: column; + gap: 16px; + min-width: 0; +} diff --git a/src/components/config/ConfigSection.tsx b/src/components/config/ConfigSection.tsx index 53e1da6..efc6dc4 100644 --- a/src/components/config/ConfigSection.tsx +++ b/src/components/config/ConfigSection.tsx @@ -1,22 +1,34 @@ -import type { PropsWithChildren, ReactNode } from 'react'; -import { Card } from '@/components/ui/Card'; +import { forwardRef, type HTMLAttributes, type PropsWithChildren, type ReactNode } from 'react'; +import styles from './ConfigSection.module.scss'; -interface ConfigSectionProps { +interface ConfigSectionProps extends Omit, 'title'> { title: ReactNode; description?: ReactNode; - className?: string; + indexLabel?: ReactNode; + icon?: ReactNode; } -export function ConfigSection({ title, description, className, children }: PropsWithChildren) { - return ( - - {description && ( -

- {description} -

- )} - {children} -
- ); -} +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/VisualConfigEditor.module.scss b/src/components/config/VisualConfigEditor.module.scss index 78f5ef8..2cc9de2 100644 --- a/src/components/config/VisualConfigEditor.module.scss +++ b/src/components/config/VisualConfigEditor.module.scss @@ -1,3 +1,624 @@ +@use '../../styles/mixins' as *; +@use '../../styles/variables' as *; + +.visualEditor { + display: flex; + flex-direction: column; + gap: $spacing-xl; + + :global(.form-group) { + gap: 8px; + margin-bottom: 0; + } + + :global(.form-group > label) { + color: var(--text-secondary); + font-size: 12px; + font-weight: 700; + letter-spacing: 0.04em; + } + + :global(.input) { + min-height: 48px; + border-radius: 16px; + background: color-mix(in srgb, var(--bg-primary) 92%, transparent); + border-color: color-mix(in srgb, var(--border-color) 84%, transparent); + box-shadow: inset 0 1px 0 rgba(255, 255, 255, 0.08); + } + + :global(.input:focus) { + background: var(--bg-primary); + border-color: var(--primary-color); + } + + :global(textarea.input) { + min-height: 120px; + } + + :global(.hint) { + color: var(--text-secondary); + font-size: 12px; + line-height: 1.6; + } + + :global(.error-box) { + border-radius: 14px; + } + + :global(.item-list) { + gap: 12px; + margin-top: 10px; + } + + :global(.item-row) { + border-radius: 18px; + padding: 14px 16px; + background: color-mix(in srgb, var(--bg-primary) 78%, transparent); + border-color: color-mix(in srgb, var(--border-color) 86%, transparent); + } + + :global(.item-row .item-meta) { + gap: 4px; + } + + :global(.item-row .item-actions) { + flex-wrap: wrap; + } + + :global(.pill) { + background: color-mix(in srgb, var(--bg-secondary) 84%, transparent); + border: 1px solid color-mix(in srgb, var(--border-color) 84%, transparent); + color: var(--text-secondary); + } +} + +.overview { + position: relative; + overflow: hidden; + border-radius: 30px; + border: 1px solid color-mix(in srgb, var(--border-color) 78%, transparent); + background: + radial-gradient( + circle at top right, + color-mix(in srgb, var(--primary-color) 12%, transparent), + transparent 36% + ), + linear-gradient( + 135deg, + color-mix(in srgb, var(--bg-primary) 94%, transparent), + color-mix(in srgb, var(--bg-secondary) 96%, transparent) + ); + padding: clamp(22px, 3vw, 30px); + + &::before { + content: ''; + position: absolute; + inset: 0 auto auto 0; + width: min(220px, 44vw); + height: min(220px, 44vw); + background: radial-gradient( + circle, + color-mix(in srgb, var(--primary-color) 16%, transparent), + transparent 72% + ); + opacity: 0.6; + pointer-events: none; + } +} + +.overviewGrid { + position: relative; + display: grid; + grid-template-columns: minmax(0, 1.05fr) minmax(260px, 0.95fr); + gap: 28px; + + @include mobile { + grid-template-columns: minmax(0, 1fr); + gap: 20px; + } +} + +.overviewLead { + display: flex; + flex-direction: column; + gap: 16px; + min-width: 0; +} + +.overviewMeta { + display: flex; + flex-wrap: wrap; + gap: 10px; +} + +.overviewPill { + display: inline-flex; + align-items: center; + gap: 6px; + min-height: 34px; + padding: 0 14px; + border-radius: 999px; + border: 1px solid color-mix(in srgb, var(--border-color) 84%, transparent); + background: color-mix(in srgb, var(--bg-primary) 82%, transparent); + color: var(--text-secondary); + font-size: 12px; + font-weight: 700; + letter-spacing: 0.04em; +} + +.overviewPillWarning { + color: var(--warning-text); + border-color: var(--warning-border); + background: var(--warning-bg); +} + +.overviewTitle { + margin: 0; + max-width: 18ch; + color: var(--text-primary); + font-size: clamp(28px, 4vw, 44px); + line-height: 0.98; + letter-spacing: -0.03em; +} + +.overviewNotice { + margin: 0; + max-width: 52ch; + color: var(--text-secondary); + font-size: 15px; + line-height: 1.7; +} + +.overviewFocusList { + display: flex; + flex-direction: column; + justify-content: center; + gap: 10px; +} + +.overviewFocusLink { + @include button-reset; + display: flex; + align-items: flex-start; + gap: 14px; + width: 100%; + padding: 14px 16px; + border-radius: 20px; + border: 1px solid color-mix(in srgb, var(--border-color) 70%, transparent); + background: color-mix(in srgb, var(--bg-primary) 74%, transparent); + text-align: left; + transition: + transform 0.18s ease, + border-color 0.18s ease, + background-color 0.18s ease; + + &:hover { + transform: translateY(-1px); + border-color: color-mix(in srgb, var(--primary-color) 26%, var(--border-color)); + background: color-mix(in srgb, var(--bg-primary) 92%, transparent); + } +} + +.overviewFocusLinkActive { + border-color: color-mix(in srgb, var(--primary-color) 28%, var(--border-color)); + background: color-mix(in srgb, var(--bg-primary) 92%, transparent); +} + +.focusIcon { + display: inline-flex; + align-items: center; + justify-content: center; + width: 40px; + height: 40px; + border-radius: 14px; + background: color-mix(in srgb, var(--primary-color) 10%, var(--bg-secondary)); + color: var(--primary-hover); + flex: 0 0 auto; +} + +.focusCopy { + display: flex; + flex-direction: column; + gap: 4px; + min-width: 0; +} + +.focusTitle { + color: var(--text-primary); + font-size: 15px; + font-weight: 700; + line-height: 1.2; +} + +.focusDescription { + color: var(--text-secondary); + font-size: 13px; + line-height: 1.55; +} + +.workspace { + display: grid; + grid-template-columns: minmax(0, 280px) minmax(0, 1fr); + gap: 24px; + align-items: start; + + @media (max-width: 1024px) { + grid-template-columns: minmax(0, 1fr); + } +} + +.sidebar { + position: sticky; + top: 88px; + align-self: start; + + @media (max-width: 1024px) { + position: static; + } +} + +.sidebarRail { + padding: 12px; + border-radius: 26px; + border: 1px solid color-mix(in srgb, var(--border-color) 84%, transparent); + background: color-mix(in srgb, var(--bg-primary) 70%, transparent); +} + +.navList { + display: flex; + flex-direction: column; + gap: 8px; + + @media (max-width: 1024px) { + flex-direction: row; + overflow-x: auto; + padding-bottom: 4px; + scrollbar-width: none; + + &::-webkit-scrollbar { + display: none; + } + } +} + +.navButton { + @include button-reset; + display: flex; + align-items: flex-start; + gap: 14px; + width: 100%; + padding: 14px; + border-radius: 20px; + border: 1px solid transparent; + background: transparent; + color: inherit; + text-align: left; + transition: + transform 0.18s ease, + background-color 0.18s ease, + border-color 0.18s ease; + + &:hover { + transform: translateX(2px); + background: color-mix(in srgb, var(--bg-secondary) 86%, transparent); + border-color: color-mix(in srgb, var(--border-color) 88%, transparent); + } + + @media (max-width: 1024px) { + min-width: 232px; + flex: 0 0 auto; + + &:hover { + transform: translateY(-1px); + } + } +} + +.navButtonActive { + background: color-mix(in srgb, var(--bg-primary) 92%, transparent); + border-color: color-mix(in srgb, var(--primary-color) 24%, var(--border-color)); + box-shadow: 0 18px 36px -30px rgba(0, 0, 0, 0.32); +} + +.navIndex { + display: inline-flex; + align-items: center; + justify-content: center; + min-width: 34px; + height: 34px; + border-radius: 12px; + background: color-mix(in srgb, var(--bg-secondary) 88%, transparent); + border: 1px solid color-mix(in srgb, var(--border-color) 84%, transparent); + color: var(--text-secondary); + font-size: 11px; + font-weight: 700; + letter-spacing: 0.08em; + flex: 0 0 auto; +} + +.navMain { + display: flex; + flex-direction: column; + gap: 6px; + min-width: 0; + flex: 1; +} + +.navHeadingRow { + display: flex; + align-items: center; + gap: 10px; + min-width: 0; +} + +.navLabelWrap { + display: inline-flex; + align-items: center; + gap: 8px; + min-width: 0; +} + +.navIcon { + display: inline-flex; + align-items: center; + justify-content: center; + color: var(--primary-hover); + flex: 0 0 auto; +} + +.navLabel { + color: var(--text-primary); + font-size: 15px; + font-weight: 700; + line-height: 1.2; +} + +.navDescription { + color: var(--text-secondary); + font-size: 12px; + line-height: 1.55; +} + +.navBadge { + display: inline-flex; + align-items: center; + justify-content: center; + min-width: 28px; + height: 28px; + padding: 0 10px; + border-radius: 999px; + background: var(--warning-bg); + border: 1px solid var(--warning-border); + color: var(--warning-text); + font-size: 12px; + font-weight: 700; + flex: 0 0 auto; +} + +.sections { + display: flex; + flex-direction: column; + min-width: 0; + padding: clamp(20px, 3vw, 30px); + border-radius: 32px; + border: 1px solid color-mix(in srgb, var(--border-color) 84%, transparent); + background: color-mix(in srgb, var(--bg-primary) 76%, transparent); +} + +.sectionGrid { + display: grid; + grid-template-columns: repeat(auto-fit, minmax(220px, 1fr)); + gap: 16px; +} + +.sectionStack { + display: flex; + flex-direction: column; + gap: 16px; +} + +.divider { + height: 1px; + background: color-mix(in srgb, var(--border-color) 84%, transparent); +} + +.toggleRow { + display: grid; + grid-template-columns: minmax(0, 1fr) auto; + gap: 16px; + align-items: center; + padding: 16px 18px; + border-radius: 18px; + border: 1px solid color-mix(in srgb, var(--border-color) 82%, transparent); + background: color-mix(in srgb, var(--bg-primary) 84%, transparent); + + @include mobile { + grid-template-columns: minmax(0, 1fr); + } +} + +.toggleCopy { + display: flex; + flex-direction: column; + gap: 6px; + min-width: 0; +} + +.toggleTitle { + color: var(--text-primary); + font-size: 15px; + font-weight: 700; + line-height: 1.2; +} + +.toggleDescription { + color: var(--text-secondary); + font-size: 13px; + line-height: 1.6; +} + +.fieldShell { + display: flex; + flex-direction: column; + gap: 8px; + min-width: 0; +} + +.fieldLabel { + color: var(--text-secondary); + font-size: 12px; + font-weight: 700; + letter-spacing: 0.04em; +} + +.fieldControl { + position: relative; +} + +.fieldHint { + color: var(--text-secondary); + font-size: 12px; + line-height: 1.6; +} + +.inlinePill { + position: absolute; + right: 10px; + top: 50%; + transform: translateY(-50%); + display: inline-flex; + align-items: center; + min-height: 26px; + padding: 0 10px; + border-radius: 999px; + border: 1px solid color-mix(in srgb, var(--border-color) 82%, transparent); + background: color-mix(in srgb, var(--bg-primary) 92%, transparent); + color: var(--text-secondary); + font-size: 11px; + font-weight: 700; + letter-spacing: 0.04em; +} + +.subsection { + display: flex; + flex-direction: column; + gap: 14px; + padding: 18px 20px; + border-radius: 24px; + border: 1px solid color-mix(in srgb, var(--border-color) 82%, transparent); + background: color-mix(in srgb, var(--bg-primary) 84%, transparent); +} + +.subsectionHeader { + display: flex; + flex-direction: column; + gap: 6px; +} + +.subsectionTitle { + margin: 0; + color: var(--text-primary); + font-size: 16px; + font-weight: 700; + line-height: 1.25; +} + +.subsectionDescription { + margin: 0; + color: var(--text-secondary); + font-size: 13px; + line-height: 1.65; +} + +.sectionIssueBadge { + display: inline-flex; + align-items: center; + justify-content: center; + min-width: 28px; + height: 28px; + padding: 0 10px; + border-radius: 999px; + background: var(--warning-bg); + border: 1px solid var(--warning-border); + color: var(--warning-text); + font-size: 12px; + font-weight: 700; +} + +.blockHeaderRow { + display: flex; + align-items: center; + justify-content: space-between; + gap: 12px; + flex-wrap: wrap; +} + +.blockStack { + display: flex; + flex-direction: column; + gap: 12px; +} + +.ruleCard { + display: flex; + flex-direction: column; + gap: 12px; + padding: 14px; + border-radius: 20px; + border: 1px solid color-mix(in srgb, var(--border-color) 84%, transparent); + background: color-mix(in srgb, var(--bg-primary) 88%, transparent); +} + +.ruleCardHeader { + display: flex; + align-items: center; + justify-content: space-between; + gap: 12px; + flex-wrap: wrap; +} + +.ruleCardTitle { + color: var(--text-primary); + font-size: 15px; + font-weight: 700; + line-height: 1.2; +} + +.blockLabel { + color: var(--text-secondary); + font-size: 12px; + font-weight: 700; + line-height: 1.4; + letter-spacing: 0.04em; +} + +.actionRow { + display: flex; + justify-content: flex-end; +} + +.emptyState { + border: 1px dashed color-mix(in srgb, var(--border-color) 84%, transparent); + border-radius: 18px; + padding: 18px 16px; + color: var(--text-secondary); + text-align: center; + background: color-mix(in srgb, var(--bg-secondary) 88%, 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; @@ -23,9 +644,11 @@ } .payloadJsonInput { - min-height: 96px; + min-height: 120px; resize: vertical; - font-family: 'Consolas', 'Monaco', 'Menlo', monospace; + font-family: + ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, 'Liberation Mono', 'Courier New', + monospace; } .payloadParamError { @@ -66,3 +689,19 @@ width: 100%; } } + +@include mobile { + .sections { + border-radius: 26px; + padding: 18px; + } + + .subsection { + padding: 16px; + border-radius: 20px; + } + + .stringListRow { + align-items: stretch; + } +} diff --git a/src/components/config/VisualConfigEditor.tsx b/src/components/config/VisualConfigEditor.tsx index 8f141de..6b0c1f5 100644 --- a/src/components/config/VisualConfigEditor.tsx +++ b/src/components/config/VisualConfigEditor.tsx @@ -1,13 +1,34 @@ -import { useCallback, useId, type ReactNode } from 'react'; +import { + useCallback, + useEffect, + useId, + useMemo, + useRef, + useState, + type ComponentType, + type ReactNode, +} from 'react'; import { useTranslation } from 'react-i18next'; import { Input } from '@/components/ui/Input'; import { Select } from '@/components/ui/Select'; import { ToggleSwitch } from '@/components/ui/ToggleSwitch'; +import { + IconCode, + IconDiamond, + IconKey, + IconSatellite, + IconSettings, + IconShield, + IconTimer, + IconTrendingUp, + type IconProps, +} from '@/components/ui/icons'; import { ConfigSection } from '@/components/config/ConfigSection'; import type { PayloadFilterRule, PayloadParamValidationErrorCode, PayloadRule, + VisualConfigFieldPath, VisualConfigValidationErrorCode, VisualConfigValidationErrors, VisualConfigValues, @@ -17,10 +38,31 @@ import { PayloadFilterRulesEditor, PayloadRulesEditor, } from './VisualConfigEditorBlocks'; +import styles from './VisualConfigEditor.module.scss'; + +type VisualSectionId = + | 'server' + | 'tls' + | 'remote' + | 'auth' + | 'system' + | 'network' + | 'quota' + | 'streaming' + | 'payload'; + +type VisualSection = { + id: VisualSectionId; + title: string; + description: string; + icon: ComponentType; + errorCount: number; +}; interface VisualConfigEditorProps { values: VisualConfigValues; validationErrors?: VisualConfigValidationErrors; + hasPayloadValidationErrors?: boolean; disabled?: boolean; onChange: (values: Partial) => void; } @@ -43,22 +85,10 @@ type ToggleRowProps = { function ToggleRow({ title, description, checked, disabled, onChange }: ToggleRowProps) { return ( -
-
-
{title}
- {description && ( -
- {description} -
- )} +
+
+
{title}
+ {description ?
{description}
: null}
@@ -66,24 +96,83 @@ function ToggleRow({ title, description, checked, disabled, onChange }: ToggleRo } function SectionGrid({ children }: { children: ReactNode }) { + return
{children}
; +} + +function SectionStack({ children }: { children: ReactNode }) { + return
{children}
; +} + +function Divider() { + return
; +} + +function SectionSubsection({ + title, + description, + children, +}: { + title: string; + description?: string; + children: ReactNode; +}) { return ( -
+
+
+

{title}

+ {description ?

{description}

: null} +
{children}
); } -function Divider() { - return
; +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, disabled = false, onChange }: VisualConfigEditorProps) { +export function VisualConfigEditor({ + values, + validationErrors, + hasPayloadValidationErrors = false, + disabled = false, + onChange, +}: VisualConfigEditorProps) { const { t } = useTranslation(); const routingStrategyLabelId = useId(); const routingStrategyHintId = `${routingStrategyLabelId}-hint`; @@ -93,22 +182,34 @@ export function VisualConfigEditor({ values, validationErrors, disabled = false, const nonstreamKeepaliveInputId = useId(); const nonstreamKeepaliveHintId = `${nonstreamKeepaliveInputId}-hint`; const nonstreamKeepaliveErrorId = `${nonstreamKeepaliveInputId}-error`; - const isKeepaliveDisabled = values.streaming.keepaliveSeconds === '' || values.streaming.keepaliveSeconds === '0'; + const [activeSectionId, setActiveSectionId] = useState('server'); + const sectionRefs = useRef>>({}); + + const isKeepaliveDisabled = + values.streaming.keepaliveSeconds === '' || values.streaming.keepaliveSeconds === '0'; const isNonstreamKeepaliveDisabled = - values.streaming.nonstreamKeepaliveInterval === '' || values.streaming.nonstreamKeepaliveInterval === '0'; + values.streaming.nonstreamKeepaliveInterval === '' || + values.streaming.nonstreamKeepaliveInterval === '0'; + const portError = getValidationMessage(t, validationErrors?.port); const logsMaxSizeError = getValidationMessage(t, validationErrors?.logsMaxTotalSizeMb); const requestRetryError = getValidationMessage(t, validationErrors?.requestRetry); const maxRetryCredentialsError = getValidationMessage(t, validationErrors?.maxRetryCredentials); const maxRetryIntervalError = getValidationMessage(t, validationErrors?.maxRetryInterval); const keepaliveError = getValidationMessage(t, validationErrors?.['streaming.keepaliveSeconds']); - const bootstrapRetriesError = getValidationMessage(t, validationErrors?.['streaming.bootstrapRetries']); + const bootstrapRetriesError = getValidationMessage( + t, + validationErrors?.['streaming.bootstrapRetries'] + ); const nonstreamKeepaliveError = getValidationMessage( t, validationErrors?.['streaming.nonstreamKeepaliveInterval'] ); - const handleApiKeysTextChange = useCallback((apiKeysText: string) => onChange({ apiKeysText }), [onChange]); + const handleApiKeysTextChange = useCallback( + (apiKeysText: string) => onChange({ apiKeysText }), + [onChange] + ); const handlePayloadDefaultRulesChange = useCallback( (payloadDefaultRules: PayloadRule[]) => onChange({ payloadDefaultRules }), [onChange] @@ -130,433 +231,719 @@ export function VisualConfigEditor({ values, validationErrors, disabled = false, [onChange] ); + const countErrors = useCallback( + (fields: VisualConfigFieldPath[]) => + fields.reduce((total, field) => total + (validationErrors?.[field] ? 1 : 0), 0), + [validationErrors] + ); + + const sections = useMemo( + () => [ + { + id: 'server', + title: t('config_management.visual.sections.server.title'), + description: t('config_management.visual.sections.server.description'), + icon: IconSettings, + errorCount: countErrors(['port']), + }, + { + id: 'tls', + title: t('config_management.visual.sections.tls.title'), + description: t('config_management.visual.sections.tls.description'), + icon: IconShield, + errorCount: 0, + }, + { + id: 'remote', + title: t('config_management.visual.sections.remote.title'), + description: t('config_management.visual.sections.remote.description'), + icon: IconSatellite, + errorCount: 0, + }, + { + id: 'auth', + title: t('config_management.visual.sections.auth.title'), + description: t('config_management.visual.sections.auth.description'), + icon: IconKey, + errorCount: 0, + }, + { + id: 'system', + title: t('config_management.visual.sections.system.title'), + description: t('config_management.visual.sections.system.description'), + icon: IconDiamond, + errorCount: countErrors(['logsMaxTotalSizeMb']), + }, + { + id: 'network', + title: t('config_management.visual.sections.network.title'), + description: t('config_management.visual.sections.network.description'), + icon: IconTrendingUp, + errorCount: countErrors(['requestRetry', 'maxRetryCredentials', 'maxRetryInterval']), + }, + { + id: 'quota', + title: t('config_management.visual.sections.quota.title'), + description: t('config_management.visual.sections.quota.description'), + icon: IconTimer, + errorCount: 0, + }, + { + id: 'streaming', + title: t('config_management.visual.sections.streaming.title'), + description: t('config_management.visual.sections.streaming.description'), + icon: IconSatellite, + errorCount: countErrors([ + 'streaming.keepaliveSeconds', + 'streaming.bootstrapRetries', + 'streaming.nonstreamKeepaliveInterval', + ]), + }, + { + id: 'payload', + title: t('config_management.visual.sections.payload.title'), + description: t('config_management.visual.sections.payload.description'), + icon: IconCode, + errorCount: hasPayloadValidationErrors ? 1 : 0, + }, + ], + [countErrors, hasPayloadValidationErrors, t] + ); + + const hasValidationIssues = + sections.some((section) => section.errorCount > 0) || hasPayloadValidationErrors; + const focusSections = useMemo( + () => sections.filter((section) => ['server', 'network', 'payload'].includes(section.id)), + [sections] + ); + + useEffect(() => { + 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(); + }, [sections]); + + const handleSectionJump = useCallback((sectionId: VisualSectionId) => { + setActiveSectionId(sectionId); + sectionRefs.current[sectionId]?.scrollIntoView({ behavior: 'smooth', block: 'start' }); + }, []); + return ( -
-
- {t('config_management.visual.notice')} +
+
+
+
+
+ + {t('config_management.tabs.visual', { defaultValue: '可视化编辑' })} + + {hasValidationIssues ? ( + + {t('config_management.visual.validation.validation_blocked')} + + ) : null} +
+

{t('config_management.editor_title')}

+

{t('config_management.visual.notice')}

+
+ +
+ {focusSections.map((section) => { + const Icon = section.icon; + + return ( + + ); + })} +
+
- - - onChange({ host: e.target.value })} - disabled={disabled} - /> - onChange({ port: e.target.value })} - disabled={disabled} - error={portError} - /> - - +
+ + +
+ { + sectionRefs.current.server = node; + }} + indexLabel="01" + icon={} + title={t('config_management.visual.sections.server.title')} + description={t('config_management.visual.sections.server.description')} + > + + onChange({ host: e.target.value })} + disabled={disabled} + /> + onChange({ port: e.target.value })} + disabled={disabled} + error={portError} + /> + + + + { + sectionRefs.current.tls = node; + }} + indexLabel="02" + icon={} + title={t('config_management.visual.sections.tls.title')} + description={t('config_management.visual.sections.tls.description')} + > + + onChange({ tlsEnable })} + /> + + {values.tlsEnable ? ( + <> + + + onChange({ tlsCert: e.target.value })} + disabled={disabled} + /> + onChange({ tlsKey: e.target.value })} + disabled={disabled} + /> + + + ) : null} + + + + { + sectionRefs.current.remote = node; + }} + indexLabel="03" + icon={} + title={t('config_management.visual.sections.remote.title')} + description={t('config_management.visual.sections.remote.description')} + > + + onChange({ rmAllowRemote })} + /> + onChange({ rmDisableControlPanel })} + /> onChange({ tlsCert: e.target.value })} + label={t('config_management.visual.sections.remote.secret_key')} + type="password" + placeholder={t('config_management.visual.sections.remote.secret_key_placeholder')} + value={values.rmSecretKey} + onChange={(e) => onChange({ rmSecretKey: e.target.value })} disabled={disabled} /> onChange({ tlsKey: e.target.value })} + label={t('config_management.visual.sections.remote.panel_repo')} + placeholder="https://github.com/router-for-me/Cli-Proxy-API-Management-Center" + value={values.rmPanelRepo} + onChange={(e) => onChange({ rmPanelRepo: e.target.value })} disabled={disabled} /> - - )} -
- + + - -
- onChange({ rmAllowRemote })} - /> - onChange({ rmDisableControlPanel })} - /> - - onChange({ rmSecretKey: e.target.value })} - disabled={disabled} - /> - onChange({ rmPanelRepo: e.target.value })} - disabled={disabled} - /> - -
-
- - -
- onChange({ authDir: e.target.value })} - disabled={disabled} - hint={t('config_management.visual.sections.auth.auth_dir_hint')} - /> - -
-
- - -
- - onChange({ debug })} - /> - onChange({ commercialMode })} - /> - onChange({ loggingToFile })} - /> - onChange({ usageStatisticsEnabled })} - /> - - - - onChange({ logsMaxTotalSizeMb: e.target.value })} - disabled={disabled} - error={logsMaxSizeError} - /> - -
-
- - -
- - onChange({ proxyUrl: e.target.value })} - disabled={disabled} - /> - 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({ authDir: e.target.value })} disabled={disabled} - ariaLabelledBy={routingStrategyLabelId} - ariaDescribedBy={routingStrategyHintId} - onChange={(nextValue) => - onChange({ routingStrategy: nextValue as VisualConfigValues['routingStrategy'] }) - } + hint={t('config_management.visual.sections.auth.auth_dir_hint')} /> -
{t('config_management.visual.sections.network.routing_strategy_hint')}
-
-
+
+ +
+ + - onChange({ forceModelPrefix })} - /> - onChange({ wsAuth })} - /> -
-
+ { + sectionRefs.current.system = node; + }} + indexLabel="05" + icon={} + title={t('config_management.visual.sections.system.title')} + description={t('config_management.visual.sections.system.description')} + > + + + onChange({ debug })} + /> + onChange({ commercialMode })} + /> + onChange({ loggingToFile })} + /> + onChange({ usageStatisticsEnabled })} + /> + - -
- onChange({ quotaSwitchProject })} - /> - onChange({ quotaSwitchPreviewModel })} - /> -
-
- - -
- -
- -
- + - onChange({ streaming: { ...values.streaming, keepaliveSeconds: e.target.value } }) - } + value={values.logsMaxTotalSizeMb} + onChange={(e) => onChange({ logsMaxTotalSizeMb: e.target.value })} + disabled={disabled} + error={logsMaxSizeError} + /> + + + + + { + sectionRefs.current.network = node; + }} + indexLabel="06" + icon={} + title={t('config_management.visual.sections.network.title')} + description={t('config_management.visual.sections.network.description')} + > + + + onChange({ proxyUrl: e.target.value })} disabled={disabled} /> - {isKeepaliveDisabled && ( - - {t('config_management.visual.sections.streaming.disabled')} - - )} -
- {keepaliveError &&
{keepaliveError}
} -
{t('config_management.visual.sections.streaming.keepalive_hint')}
-
- onChange({ streaming: { ...values.streaming, bootstrapRetries: e.target.value } })} - disabled={disabled} - hint={t('config_management.visual.sections.streaming.bootstrap_hint')} - error={bootstrapRetriesError} - /> -
- - -
- -
- 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({ + streaming: { + ...values.streaming, + keepaliveSeconds: e.target.value, + }, + }) + } + disabled={disabled} + /> + {isKeepaliveDisabled ? ( + + {t('config_management.visual.sections.streaming.disabled')} + + ) : null} +
+ + + onChange({ - streaming: { ...values.streaming, nonstreamKeepaliveInterval: e.target.value }, + streaming: { + ...values.streaming, + bootstrapRetries: e.target.value, + }, }) } disabled={disabled} + hint={t('config_management.visual.sections.streaming.bootstrap_hint')} + error={bootstrapRetriesError} /> - {isNonstreamKeepaliveDisabled && ( - - {t('config_management.visual.sections.streaming.disabled')} - - )} -
- {nonstreamKeepaliveError &&
{nonstreamKeepaliveError}
} -
- {t('config_management.visual.sections.streaming.nonstream_keepalive_hint')} -
-
- + + + + +
+ + onChange({ + streaming: { + ...values.streaming, + nonstreamKeepaliveInterval: e.target.value, + }, + }) + } + disabled={disabled} + /> + {isNonstreamKeepaliveDisabled ? ( + + {t('config_management.visual.sections.streaming.disabled')} + + ) : null} +
+
+
+
+
+ + { + sectionRefs.current.payload = node; + }} + indexLabel="09" + icon={} + title={t('config_management.visual.sections.payload.title')} + description={t('config_management.visual.sections.payload.description')} + > + + + + + + + + + + + + + + + + + + + + + +
- - - -
-
-
{t('config_management.visual.sections.payload.default_rules')}
-
- {t('config_management.visual.sections.payload.default_rules_desc')} -
- -
- -
-
{t('config_management.visual.sections.payload.default_raw_rules')}
-
- {t('config_management.visual.sections.payload.default_raw_rules_desc')} -
- -
- -
-
{t('config_management.visual.sections.payload.override_rules')}
-
- {t('config_management.visual.sections.payload.override_rules_desc')} -
- -
- -
-
{t('config_management.visual.sections.payload.override_raw_rules')}
-
- {t('config_management.visual.sections.payload.override_raw_rules_desc')} -
- -
- -
-
{t('config_management.visual.sections.payload.filter_rules')}
-
- {t('config_management.visual.sections.payload.filter_rules_desc')} -
- -
-
-
+
); } diff --git a/src/components/config/VisualConfigEditorBlocks.tsx b/src/components/config/VisualConfigEditorBlocks.tsx index 61d7c04..fdf1301 100644 --- a/src/components/config/VisualConfigEditorBlocks.tsx +++ b/src/components/config/VisualConfigEditorBlocks.tsx @@ -78,7 +78,10 @@ export const ApiKeysCardEditor = memo(function ApiKeysCardEditor({ const renderApiKeyIds = useMemo(() => { if (apiKeyIds.length === apiKeys.length) return apiKeyIds; if (apiKeyIds.length > apiKeys.length) return apiKeyIds.slice(0, apiKeys.length); - return [...apiKeyIds, ...Array.from({ length: apiKeys.length - apiKeyIds.length }, () => makeClientId())]; + return [ + ...apiKeyIds, + ...Array.from({ length: apiKeys.length - apiKeyIds.length }, () => makeClientId()), + ]; }, [apiKeyIds, apiKeys.length]); const apiKeyInputId = useId(); @@ -140,7 +143,9 @@ export const ApiKeysCardEditor = memo(function ApiKeysCardEditor({ return; } - const editingIndex = editingApiKeyId ? renderApiKeyIds.findIndex((id) => id === editingApiKeyId) : -1; + const editingIndex = editingApiKeyId + ? renderApiKeyIds.findIndex((id) => id === editingApiKeyId) + : -1; const nextKeys = editingApiKeyId === null ? [...apiKeys, trimmed] @@ -167,7 +172,7 @@ export const ApiKeysCardEditor = memo(function ApiKeysCardEditor({ return (
-
+
{apiKeys.length === 0 ? ( -
- {t('config_management.visual.api_keys.empty')} -
+
{t('config_management.visual.api_keys.empty')}
) : (
{apiKeys.map((key, index) => (
#{index + 1}
-
{t('config_management.visual.api_keys.input_label')}
+
+ {t('config_management.visual.api_keys.input_label')} +
{maskApiKey(String(key || ''))}
- } >
- +
-
{t('config_management.visual.api_keys.input_hint')}
- {formError &&
{formError}
} +
+ {t('config_management.visual.api_keys.input_hint')} +
+ {formError && ( +
+ {formError} +
+ )}
@@ -288,7 +304,10 @@ const StringListEditor = memo(function StringListEditor({ const renderItemIds = useMemo(() => { if (itemIds.length === items.length) return itemIds; if (itemIds.length > items.length) return itemIds.slice(0, items.length); - return [...itemIds, ...Array.from({ length: items.length - itemIds.length }, () => makeClientId())]; + return [ + ...itemIds, + ...Array.from({ length: items.length - itemIds.length }, () => makeClientId()), + ]; }, [itemIds, items.length]); const updateItem = (index: number, nextValue: string) => @@ -303,9 +322,9 @@ const StringListEditor = memo(function StringListEditor({ }; return ( -
+
{items.map((item, index) => ( -
+
))} -
+
@@ -378,7 +397,11 @@ export const PayloadRulesEditor = memo(function PayloadRulesEditor({ updateRule(ruleIndex, { models: rule.models.filter((_, i) => i !== modelIndex) }); }; - const updateModel = (ruleIndex: number, modelIndex: number, patch: Partial) => { + const updateModel = ( + ruleIndex: number, + modelIndex: number, + patch: Partial + ) => { const rule = rules[ruleIndex]; updateRule(ruleIndex, { models: rule.models.map((m, i) => (i === modelIndex ? { ...m, ...patch } : m)), @@ -401,7 +424,11 @@ export const PayloadRulesEditor = memo(function PayloadRulesEditor({ updateRule(ruleIndex, { params: rule.params.filter((_, i) => i !== paramIndex) }); }; - const updateParam = (ruleIndex: number, paramIndex: number, patch: Partial) => { + const updateParam = ( + ruleIndex: number, + paramIndex: number, + patch: Partial + ) => { const rule = rules[ruleIndex]; updateRule(ruleIndex, { params: rule.params.map((p, i) => (i === paramIndex ? { ...p, ...patch } : p)), @@ -442,7 +469,9 @@ export const PayloadRulesEditor = memo(function PayloadRulesEditor({ placeholder={t('config_management.visual.payload_rules.value_raw_json')} aria-label={t('config_management.visual.payload_rules.param_value')} value={param.value} - onChange={(e) => updateParam(ruleIndex, paramIndex, { value: e.target.value, valueType: 'json' })} + onChange={(e) => + updateParam(ruleIndex, paramIndex, { value: e.target.value, valueType: 'json' }) + } disabled={disabled} /> ); @@ -451,7 +480,11 @@ export const PayloadRulesEditor = memo(function PayloadRulesEditor({ if (param.valueType === 'boolean') { return (
))} -
-
-
-
{t('config_management.visual.payload_rules.remove_params')}
+
+
+ {t('config_management.visual.payload_rules.remove_params')} +
+
{t('config_management.visual.payload_rules.no_rules')}
)} -
+
diff --git a/src/pages/ConfigPage.module.scss b/src/pages/ConfigPage.module.scss index 07faf18..6ace1f6 100644 --- a/src/pages/ConfigPage.module.scss +++ b/src/pages/ConfigPage.module.scss @@ -1,27 +1,123 @@ @use '../styles/mixins' as *; +@use '../styles/variables' as *; .container { width: 100%; min-height: 100%; display: flex; flex-direction: column; + gap: $spacing-lg; overflow-y: auto; padding-bottom: calc( var(--config-action-bar-height, 0px) + 16px + env(safe-area-inset-bottom) + #{$spacing-md} ); } -.pageTitle { - font-size: 28px; +.pageHeader { + position: relative; + overflow: hidden; + display: grid; + grid-template-columns: minmax(0, 1.2fr) auto; + gap: 24px; + align-items: end; + padding: clamp(22px, 3vw, 30px); + border-radius: 30px; + border: 1px solid color-mix(in srgb, var(--border-color) 82%, transparent); + background: + radial-gradient( + circle at top right, + color-mix(in srgb, var(--primary-color) 14%, transparent), + transparent 34% + ), + linear-gradient( + 135deg, + color-mix(in srgb, var(--bg-primary) 94%, transparent), + color-mix(in srgb, var(--bg-secondary) 96%, transparent) + ); + + &::before { + content: ''; + position: absolute; + inset: 0 auto auto 0; + width: min(260px, 48vw); + height: min(260px, 48vw); + background: radial-gradient( + circle, + color-mix(in srgb, var(--primary-color) 12%, transparent), + transparent 70% + ); + pointer-events: none; + opacity: 0.7; + } + + @include mobile { + grid-template-columns: minmax(0, 1fr); + align-items: stretch; + } +} + +.pageHeaderCopy { + position: relative; + display: flex; + flex-direction: column; + gap: 12px; + min-width: 0; +} + +.pageEyebrow { + color: var(--text-secondary); + font-size: 12px; font-weight: 700; + letter-spacing: 0.08em; + text-transform: uppercase; +} + +.pageTitle { + margin: 0; color: var(--text-primary); - margin: 0 0 $spacing-md 0; + font-size: clamp(32px, 4vw, 52px); + line-height: 0.95; + letter-spacing: -0.04em; } .description { - font-size: 14px; + margin: 0; + max-width: 46rem; color: var(--text-secondary); - margin: 0 0 $spacing-xl 0; + font-size: 15px; + line-height: 1.7; +} + +.pageMeta { + position: relative; + display: flex; + flex-direction: column; + align-items: flex-end; + gap: 12px; + min-width: 0; + + @include mobile { + align-items: stretch; + } +} + +.statusBadge { + display: inline-flex; + align-items: center; + justify-content: center; + min-height: 38px; + padding: 0 14px; + border-radius: 999px; + border: 1px solid color-mix(in srgb, var(--border-color) 82%, transparent); + background: color-mix(in srgb, var(--bg-primary) 86%, transparent); + color: var(--text-primary); + font-size: 13px; + font-weight: 700; + text-align: center; + + @include mobile { + width: 100%; + } } .tabBar { @@ -29,10 +125,9 @@ align-items: center; gap: 4px; padding: 4px; - margin-bottom: $spacing-lg; - border: 1px solid var(--border-color); - background: var(--bg-secondary); - border-radius: $radius-full; + border-radius: 999px; + background: color-mix(in srgb, var(--bg-primary) 82%, transparent); + border: 1px solid color-mix(in srgb, var(--border-color) 82%, transparent); width: fit-content; max-width: 100%; overflow-x: auto; @@ -44,53 +139,57 @@ @include mobile { width: 100%; - - .tabItem { - flex: 1; - } } } .tabItem { @include button-reset; padding: 10px 16px; - font-size: 14px; - font-weight: 600; - color: var(--text-secondary); - background: transparent; + border-radius: 999px; border: 1px solid transparent; - border-radius: $radius-full; - cursor: pointer; + color: var(--text-secondary); + font-size: 14px; + font-weight: 700; + white-space: nowrap; transition: - background-color 0.15s ease, color 0.15s ease, + background-color 0.15s ease, border-color 0.15s ease, - box-shadow 0.15s ease; + transform 0.15s ease; &:hover:not(:disabled) { color: var(--text-primary); - background: var(--bg-tertiary); + background: color-mix(in srgb, var(--bg-secondary) 92%, transparent); + border-color: color-mix(in srgb, var(--border-color) 88%, transparent); } &:disabled { opacity: 0.6; cursor: not-allowed; + transform: none; } - &:focus { - outline: none; - } - - &:focus-visible { - outline: 2px solid var(--primary-color); - outline-offset: 2px; + @include mobile { + flex: 1; + text-align: center; } } .tabActive { color: var(--text-primary); background: var(--bg-primary); - border-color: var(--border-color); + border-color: color-mix(in srgb, var(--primary-color) 24%, var(--border-color)); + box-shadow: 0 16px 32px -28px rgba(0, 0, 0, 0.3); +} + +.workspaceShell { + display: flex; + flex-direction: column; + gap: $spacing-lg; + padding: clamp(18px, 3vw, 28px); + border-radius: 32px; + border: 1px solid color-mix(in srgb, var(--border-color) 82%, transparent); + background: color-mix(in srgb, var(--bg-primary) 74%, transparent); box-shadow: var(--shadow); } @@ -98,57 +197,84 @@ display: flex; flex-direction: column; gap: $spacing-lg; - flex: 1; min-height: 0; } +.sourceWorkspace { + display: flex; + flex-direction: column; + gap: 16px; + min-height: 0; +} + +.sourceToolbar { + display: flex; + align-items: center; + gap: $spacing-sm; + padding-bottom: 16px; + border-bottom: 1px solid color-mix(in srgb, var(--border-color) 80%, transparent); + + @include mobile { + flex-direction: column; + align-items: stretch; + } +} + .searchInputWrapper { flex: 1; + min-width: 0; position: relative; display: flex; align-items: center; - // The shared Input component adds a wrapper (.form-group) with margin-bottom. - // In the floating toolbar we want the input to be compact. :global(.form-group) { margin-bottom: 0; + width: 100%; } } .searchInput { flex: 1; - border-radius: $radius-full !important; + border-radius: 16px !important; padding-right: 132px !important; } -.searchCount { - font-size: 12px; - color: var(--text-secondary); - background: var(--bg-secondary); - padding: 2px 8px; - border-radius: $radius-full; - pointer-events: none; - white-space: nowrap; -} - .searchRight { display: inline-flex; align-items: center; gap: 8px; } +.searchCount { + display: inline-flex; + align-items: center; + min-height: 28px; + padding: 0 10px; + border-radius: 999px; + background: color-mix(in srgb, var(--bg-primary) 88%, transparent); + border: 1px solid color-mix(in srgb, var(--border-color) 84%, transparent); + color: var(--text-secondary); + font-size: 12px; + font-weight: 700; + pointer-events: none; + white-space: nowrap; +} + .searchButton { @include button-reset; display: inline-flex; align-items: center; justify-content: center; - width: 32px; - height: 32px; - border-radius: $radius-full; + width: 34px; + height: 34px; + border-radius: 999px; background: var(--primary-color); border: 1px solid var(--primary-color); color: #fff; - transition: background-color $transition-fast, border-color $transition-fast, opacity $transition-fast; + transition: + background-color $transition-fast, + border-color $transition-fast, + opacity $transition-fast; &:hover:not(:disabled) { background: var(--primary-hover); @@ -163,94 +289,58 @@ .searchActions { display: flex; - gap: 4px; + gap: 6px; flex-shrink: 0; button { - min-width: 32px; - width: 32px; - height: 32px; + min-width: 36px; + width: 36px; + height: 36px; padding: 0 !important; - border-radius: $radius-full; + border-radius: 999px; } -} - -.controls { - display: flex; - justify-content: space-between; - align-items: center; - gap: $spacing-md; @include mobile { - flex-direction: column; - align-items: stretch; - } -} + justify-content: stretch; -.status { - font-size: 14px; - color: var(--text-secondary); - - &.modified { - color: var(--warning-color); - } - - &.saved { - color: var(--success-color); - } - - &.error { - color: var(--danger-color); + button { + flex: 1; + width: auto; + } } } .editorWrapper { width: 100%; flex: 0 0 auto; - height: clamp(360px, 60vh, 920px); - border: 1px solid var(--border-color); - border-radius: $radius-lg; + height: clamp(420px, 64vh, 980px); + border: 1px solid color-mix(in srgb, var(--border-color) 84%, transparent); + border-radius: 28px; overflow: hidden; - position: relative; - --floating-controls-height: 0px; + background: color-mix(in srgb, var(--bg-primary) 90%, transparent); @supports (height: 100dvh) { - height: clamp(360px, 60dvh, 920px); + height: clamp(420px, 64dvh, 980px); } - // Floating search toolbar on top of the editor (but not covering content). - .floatingControls { - position: absolute; - top: 12px; - left: 12px; - right: 12px; - z-index: 10; - display: flex; - align-items: center; - gap: $spacing-sm; - flex-wrap: wrap; - pointer-events: auto; - } - - // CodeMirror theme overrides :global { .cm-editor { height: 100%; font-size: 14px; font-family: 'Consolas', 'Monaco', 'Menlo', monospace; + background: transparent; } .cm-scroller { overflow: auto; - padding-top: calc(var(--floating-controls-height, 0px) + #{$spacing-md}); -webkit-overflow-scrolling: touch; touch-action: pan-x pan-y; overscroll-behavior: contain; } .cm-gutters { - border-right: 1px solid var(--border-color); - background: var(--bg-secondary); + border-right: 1px solid color-mix(in srgb, var(--border-color) 84%, transparent); + background: color-mix(in srgb, var(--bg-secondary) 88%, transparent); } .cm-lineNumbers .cm-gutterElement { @@ -280,7 +370,6 @@ background: rgba(255, 152, 0, 0.5); } - // Dark theme adjustments [data-theme='dark'] & { .cm-gutters { background: var(--bg-tertiary); @@ -293,26 +382,22 @@ } } -.configCard { - display: flex; - flex-direction: column; - flex: 1; - min-height: 0; - overflow: visible; +.modified { + color: var(--warning-text); + background: var(--warning-bg); + border-color: var(--warning-border); } -.actions { - display: flex; - gap: $spacing-sm; - justify-content: flex-end; +.saved { + color: var(--success-color); + background: color-mix(in srgb, var(--success-color) 12%, var(--bg-primary)); + border-color: color-mix(in srgb, var(--success-color) 28%, var(--border-color)); +} - @include mobile { - justify-content: stretch; - - button { - flex: 1; - } - } +.error { + color: var(--warning-text); + background: var(--warning-bg); + border-color: var(--warning-border); } .floatingActionContainer { @@ -347,11 +432,16 @@ } .floatingStatus { - font-size: 11px; - font-weight: 600; - padding: 5px 8px; + display: inline-flex; + align-items: center; + min-height: 28px; + padding: 0 10px; border-radius: 999px; + border: 1px solid transparent; background: color-mix(in srgb, var(--text-primary) 6%, transparent); + color: var(--text-primary); + font-size: 11px; + font-weight: 700; text-align: center; max-width: min(280px, 46vw); white-space: nowrap; @@ -370,7 +460,9 @@ border-radius: 999px; cursor: pointer; color: var(--text-primary); - transition: background-color 0.2s ease, transform 0.15s ease; + transition: + background-color 0.2s ease, + transform 0.15s ease; &:hover:not(:disabled) { background: color-mix(in srgb, var(--text-primary) 10%, transparent); @@ -420,22 +512,3 @@ flex: 0 0 auto; } } - -@media (max-height: 820px) { - .pageTitle { - font-size: 24px; - margin-bottom: $spacing-sm; - } - - .description { - margin-bottom: $spacing-lg; - } - - .content { - gap: $spacing-md; - } - - .configCard { - padding: $spacing-md; - } -} diff --git a/src/pages/ConfigPage.tsx b/src/pages/ConfigPage.tsx index e6a64af..107bc0a 100644 --- a/src/pages/ConfigPage.tsx +++ b/src/pages/ConfigPage.tsx @@ -6,10 +6,15 @@ import { yaml } from '@codemirror/lang-yaml'; import { search, searchKeymap, highlightSelectionMatches } from '@codemirror/search'; import { keymap } from '@codemirror/view'; import { parse as parseYaml, parseDocument } from 'yaml'; -import { Card } from '@/components/ui/Card'; import { Button } from '@/components/ui/Button'; import { Input } from '@/components/ui/Input'; -import { IconCheck, IconChevronDown, IconChevronUp, IconRefreshCw, IconSearch } from '@/components/ui/icons'; +import { + IconCheck, + IconChevronDown, + IconChevronUp, + IconRefreshCw, + IconSearch, +} from '@/components/ui/icons'; import { VisualConfigEditor } from '@/components/config/VisualConfigEditor'; import { DiffModal } from '@/components/config/DiffModal'; import { useVisualConfig } from '@/hooks/useVisualConfig'; @@ -44,7 +49,7 @@ export function ConfigPage() { visualHasPayloadValidationErrors, loadVisualValuesFromYaml, applyVisualChangesToYaml, - setVisualValues + setVisualValues, } = useVisualConfig(); const [activeTab, setActiveTab] = useState(() => { @@ -64,11 +69,12 @@ export function ConfigPage() { // Search state const [searchQuery, setSearchQuery] = useState(''); - const [searchResults, setSearchResults] = useState<{ current: number; total: number }>({ current: 0, total: 0 }); + const [searchResults, setSearchResults] = useState<{ current: number; total: number }>({ + current: 0, + total: 0, + }); const [lastSearchedQuery, setLastSearchedQuery] = useState(''); const editorRef = useRef(null); - const floatingControlsRef = useRef(null); - const editorWrapperRef = useRef(null); const floatingActionsRef = useRef(null); const disableControls = connectionStatus !== 'connected'; @@ -154,7 +160,9 @@ export function ConfigPage() { if (latestDocument.errors.length > 0) { showNotification( t('config_management.visual_mode_latest_yaml_invalid', { - message: latestDocument.errors[0]?.message ?? t('config_management.visual_mode_save_blocked') + message: + latestDocument.errors[0]?.message ?? + t('config_management.visual_mode_save_blocked'), }), 'error' ); @@ -174,7 +182,9 @@ export function ConfigPage() { try { const doc = parseDocument(latestServerYaml); diffOriginal = doc.toString({ indent: 2, lineWidth: 120, minContentWidth: 0 }); - } catch { /* keep raw on parse failure */ } + } catch { + /* keep raw on parse failure */ + } } if (diffOriginal === nextMergedYaml) { @@ -203,32 +213,43 @@ export function ConfigPage() { setDirty(true); }, []); - const handleTabChange = useCallback((tab: ConfigEditorTab) => { - if (tab === activeTab) return; + const handleTabChange = useCallback( + (tab: ConfigEditorTab) => { + if (tab === activeTab) return; - if (tab === 'source') { - // Only rewrite YAML when there are pending visual changes; otherwise preserve raw YAML + comments. - if (visualDirty) { - const nextContent = applyVisualChangesToYaml(content); - if (nextContent !== content) { - setContent(nextContent); - setDirty(true); + if (tab === 'source') { + // Only rewrite YAML when there are pending visual changes; otherwise preserve raw YAML + comments. + if (visualDirty) { + const nextContent = applyVisualChangesToYaml(content); + if (nextContent !== content) { + setContent(nextContent); + setDirty(true); + } + } + } else { + const result = loadVisualValuesFromYaml(content); + if (!result.ok) { + showNotification( + t('config_management.visual_mode_unavailable_detail', { message: result.error }), + 'error' + ); + return; } } - } else { - const result = loadVisualValuesFromYaml(content); - if (!result.ok) { - showNotification( - t('config_management.visual_mode_unavailable_detail', { message: result.error }), - 'error' - ); - return; - } - } - setActiveTab(tab); - localStorage.setItem('config-management:tab', tab); - }, [activeTab, applyVisualChangesToYaml, content, loadVisualValuesFromYaml, showNotification, t, visualDirty]); + setActiveTab(tab); + localStorage.setItem('config-management:tab', tab); + }, + [ + activeTab, + applyVisualChangesToYaml, + content, + loadVisualValuesFromYaml, + showNotification, + t, + visualDirty, + ] + ); // Search functionality const performSearch = useCallback((query: string, direction: 'next' | 'prev' = 'next') => { @@ -290,7 +311,7 @@ export function ConfigPage() { // Scroll to and select the match view.dispatch({ selection: { anchor: matchPos, head: matchPos + query.length }, - scrollIntoView: true + scrollIntoView: true, }); view.focus(); }, []); @@ -306,18 +327,24 @@ export function ConfigPage() { } }, []); - const executeSearch = useCallback((direction: 'next' | 'prev' = 'next') => { - if (!searchQuery) return; - setLastSearchedQuery(searchQuery); - performSearch(searchQuery, direction); - }, [searchQuery, performSearch]); + const executeSearch = useCallback( + (direction: 'next' | 'prev' = 'next') => { + if (!searchQuery) return; + setLastSearchedQuery(searchQuery); + performSearch(searchQuery, direction); + }, + [searchQuery, performSearch] + ); - const handleSearchKeyDown = useCallback((e: React.KeyboardEvent) => { - if (e.key === 'Enter') { - e.preventDefault(); - executeSearch(e.shiftKey ? 'prev' : 'next'); - } - }, [executeSearch]); + const handleSearchKeyDown = useCallback( + (e: React.KeyboardEvent) => { + if (e.key === 'Enter') { + e.preventDefault(); + executeSearch(e.shiftKey ? 'prev' : 'next'); + } + }, + [executeSearch] + ); const handlePrevMatch = useCallback(() => { if (!lastSearchedQuery) return; @@ -329,31 +356,6 @@ export function ConfigPage() { performSearch(lastSearchedQuery, 'next'); }, [lastSearchedQuery, performSearch]); - // Keep floating controls from covering editor content by syncing its height to a CSS variable. - useLayoutEffect(() => { - if (activeTab !== 'source') return; - - const controlsEl = floatingControlsRef.current; - const wrapperEl = editorWrapperRef.current; - if (!controlsEl || !wrapperEl) return; - - const updatePadding = () => { - const height = controlsEl.getBoundingClientRect().height; - wrapperEl.style.setProperty('--floating-controls-height', `${height}px`); - }; - - updatePadding(); - window.addEventListener('resize', updatePadding); - - const ro = typeof ResizeObserver === 'undefined' ? null : new ResizeObserver(updatePadding); - ro?.observe(controlsEl); - - return () => { - ro?.disconnect(); - window.removeEventListener('resize', updatePadding); - }; - }, [activeTab]); - // Keep bottom floating actions from covering page content by syncing its height to a CSS variable. useLayoutEffect(() => { if (typeof window === 'undefined') return; @@ -380,12 +382,10 @@ export function ConfigPage() { }, []); // CodeMirror extensions - const extensions = useMemo(() => [ - yaml(), - search(), - highlightSelectionMatches(), - keymap.of(searchKeymap) - ], []); + const extensions = useMemo( + () => [yaml(), search(), highlightSelectionMatches(), keymap.of(searchKeymap)], + [] + ); // Status text const getStatusText = () => { @@ -393,21 +393,13 @@ export function ConfigPage() { if (loading) return t('config_management.status_loading'); if (error) return t('config_management.status_load_failed'); if (hasVisualModeError) return t('config_management.visual_mode_unavailable'); - if (hasVisualValidationErrors) return t('config_management.visual.validation.validation_blocked'); + if (hasVisualValidationErrors) + return t('config_management.visual.validation.validation_blocked'); if (saving) return t('config_management.status_saving'); if (isDirty) return t('config_management.status_dirty'); return t('config_management.status_loaded'); }; - const isLoadedStatus = - !disableControls && - !loading && - !error && - !saving && - !isDirty && - !hasVisualModeError && - !hasVisualValidationErrors; - const getStatusClass = () => { if (error || hasVisualModeError || hasVisualValidationErrors) return styles.error; if (isDirty) return styles.modified; @@ -436,7 +428,7 @@ export function ConfigPage() { const floatingActions = (
-
{getStatusText()}
+
{getStatusText()}
- +
+
{getStatusText()}
+
+ + +
+
- +
{error &&
{error}
} {!error && visualParseError && ( @@ -507,20 +507,20 @@ export function ConfigPage() { ) : ( -
- {/* Floating search controls */} -
+
+
handleSearchChange(e.target.value)} onKeyDown={handleSearchKeyDown} placeholder={t('config_management.search_placeholder', { - defaultValue: '搜索配置内容...' + defaultValue: '搜索配置内容...', })} disabled={disableControls || loading} className={styles.searchInput} @@ -530,7 +530,9 @@ export function ConfigPage() { {searchResults.total > 0 ? `${searchResults.current} / ${searchResults.total}` - : t('config_management.search_no_results', { defaultValue: '无结果' })} + : t('config_management.search_no_results', { + defaultValue: '无结果', + })} )}
+
- + +
+ +
)} - - {/* Controls */} -
- {!isLoadedStatus && ( - - {getStatusText()} - - )} -
- +
{typeof document !== 'undefined' ? createPortal(floatingActions, document.body) : null}