mirror of
https://github.com/router-for-me/Cli-Proxy-API-Management-Center.git
synced 2026-06-16 21:03:58 +08:00
Compare commits
5 Commits
@@ -11,6 +11,7 @@ import {
|
||||
} from 'react';
|
||||
import { createPortal } from 'react-dom';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { usePageTransitionLayer } from '@/components/common/PageTransitionLayer';
|
||||
import { Input } from '@/components/ui/Input';
|
||||
import { Select } from '@/components/ui/Select';
|
||||
import { ToggleSwitch } from '@/components/ui/ToggleSwitch';
|
||||
@@ -177,8 +178,11 @@ export function VisualConfigEditor({
|
||||
onChange,
|
||||
}: VisualConfigEditorProps) {
|
||||
const { t } = useTranslation();
|
||||
const pageTransitionLayer = usePageTransitionLayer();
|
||||
const isCurrentLayer = pageTransitionLayer ? pageTransitionLayer.status === 'current' : true;
|
||||
const isMobile = useMediaQuery('(max-width: 768px)');
|
||||
const isFloatingSidebar = useMediaQuery('(min-width: 1025px)');
|
||||
const shouldRenderFloatingSidebar = !isMobile && isFloatingSidebar && isCurrentLayer;
|
||||
const routingStrategyLabelId = useId();
|
||||
const routingStrategyHintId = `${routingStrategyLabelId}-hint`;
|
||||
const keepaliveInputId = useId();
|
||||
@@ -371,7 +375,7 @@ export function VisualConfigEditor({
|
||||
floatingElement.style.removeProperty('pointer-events');
|
||||
};
|
||||
|
||||
if (isMobile || !isFloatingSidebar || !anchorElement || !workspaceElement) {
|
||||
if (!shouldRenderFloatingSidebar || !anchorElement || !workspaceElement) {
|
||||
clearFloatingStyles();
|
||||
return undefined;
|
||||
}
|
||||
@@ -441,7 +445,7 @@ export function VisualConfigEditor({
|
||||
contentScroller?.removeEventListener('scroll', requestPositionUpdate);
|
||||
clearFloatingStyles();
|
||||
};
|
||||
}, [isFloatingSidebar, isMobile]);
|
||||
}, [shouldRenderFloatingSidebar]);
|
||||
|
||||
const navContent = (
|
||||
<div className={styles.navList}>
|
||||
@@ -1042,7 +1046,7 @@ export function VisualConfigEditor({
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{!isMobile && isFloatingSidebar && typeof document !== 'undefined'
|
||||
{shouldRenderFloatingSidebar && typeof document !== 'undefined'
|
||||
? createPortal(
|
||||
<div ref={floatingSidebarRef} className={styles.floatingSidebarContainer}>
|
||||
<div className={styles.floatingSidebarRail}>{navContent}</div>
|
||||
|
||||
@@ -67,11 +67,7 @@
|
||||
}
|
||||
|
||||
.dropdown {
|
||||
position: absolute;
|
||||
top: calc(100% + 6px);
|
||||
left: 0;
|
||||
right: 0;
|
||||
z-index: 1000;
|
||||
position: fixed;
|
||||
background: var(--bg-primary);
|
||||
border: 1px solid var(--border-color);
|
||||
border-radius: $radius-lg;
|
||||
@@ -83,6 +79,7 @@
|
||||
max-height: 240px;
|
||||
overflow-y: auto;
|
||||
overscroll-behavior: contain;
|
||||
scrollbar-gutter: stable;
|
||||
}
|
||||
|
||||
.option {
|
||||
|
||||
+192
-56
@@ -1,4 +1,14 @@
|
||||
import { useCallback, useEffect, useId, useMemo, useRef, useState } from 'react';
|
||||
import {
|
||||
useCallback,
|
||||
useEffect,
|
||||
useId,
|
||||
useLayoutEffect,
|
||||
useMemo,
|
||||
useRef,
|
||||
useState,
|
||||
type CSSProperties
|
||||
} from 'react';
|
||||
import { createPortal } from 'react-dom';
|
||||
import { IconChevronDown } from './icons';
|
||||
import styles from './Select.module.scss';
|
||||
|
||||
@@ -21,6 +31,51 @@ interface SelectProps {
|
||||
id?: string;
|
||||
}
|
||||
|
||||
const VIEWPORT_MARGIN = 8;
|
||||
const DROPDOWN_OFFSET = 6;
|
||||
const DROPDOWN_MAX_HEIGHT = 240;
|
||||
const DROPDOWN_Z_INDEX = 2010;
|
||||
|
||||
const clamp = (value: number, min: number, max: number) => Math.min(Math.max(value, min), max);
|
||||
|
||||
const resolveDropdownStyle = (element: HTMLElement): CSSProperties => {
|
||||
const rect = element.getBoundingClientRect();
|
||||
const viewportWidth = window.innerWidth;
|
||||
const viewportHeight = window.innerHeight;
|
||||
const width = Math.min(rect.width, Math.max(0, viewportWidth - VIEWPORT_MARGIN * 2));
|
||||
const left = clamp(
|
||||
rect.left,
|
||||
VIEWPORT_MARGIN,
|
||||
Math.max(VIEWPORT_MARGIN, viewportWidth - width - VIEWPORT_MARGIN)
|
||||
);
|
||||
const spaceBelow = viewportHeight - rect.bottom - VIEWPORT_MARGIN - DROPDOWN_OFFSET;
|
||||
const spaceAbove = rect.top - VIEWPORT_MARGIN - DROPDOWN_OFFSET;
|
||||
const direction =
|
||||
spaceBelow >= DROPDOWN_MAX_HEIGHT || spaceBelow >= spaceAbove ? 'down' : 'up';
|
||||
const maxHeight = Math.max(
|
||||
0,
|
||||
Math.min(DROPDOWN_MAX_HEIGHT, direction === 'down' ? spaceBelow : spaceAbove)
|
||||
);
|
||||
|
||||
return direction === 'down'
|
||||
? {
|
||||
position: 'fixed',
|
||||
top: rect.bottom + DROPDOWN_OFFSET,
|
||||
left,
|
||||
width,
|
||||
maxHeight,
|
||||
zIndex: DROPDOWN_Z_INDEX
|
||||
}
|
||||
: {
|
||||
position: 'fixed',
|
||||
bottom: viewportHeight - rect.top + DROPDOWN_OFFSET,
|
||||
left,
|
||||
width,
|
||||
maxHeight,
|
||||
zIndex: DROPDOWN_Z_INDEX
|
||||
};
|
||||
};
|
||||
|
||||
export function Select({
|
||||
value,
|
||||
options,
|
||||
@@ -40,17 +95,78 @@ export function Select({
|
||||
const [open, setOpen] = useState(false);
|
||||
const [highlightedIndex, setHighlightedIndex] = useState(-1);
|
||||
const wrapRef = useRef<HTMLDivElement | null>(null);
|
||||
const dropdownRef = useRef<HTMLDivElement | null>(null);
|
||||
const rafRef = useRef<number | null>(null);
|
||||
const [dropdownStyle, setDropdownStyle] = useState<CSSProperties | null>(null);
|
||||
const isOpen = open && !disabled;
|
||||
|
||||
useEffect(() => {
|
||||
if (!open || disabled) return;
|
||||
const handleClickOutside = (event: MouseEvent) => {
|
||||
if (!wrapRef.current?.contains(event.target as Node)) setOpen(false);
|
||||
const target = event.target as Node;
|
||||
if (wrapRef.current?.contains(target) || dropdownRef.current?.contains(target)) return;
|
||||
setOpen(false);
|
||||
};
|
||||
document.addEventListener('mousedown', handleClickOutside);
|
||||
return () => document.removeEventListener('mousedown', handleClickOutside);
|
||||
}, [disabled, open]);
|
||||
|
||||
const isOpen = open && !disabled;
|
||||
const updateDropdownStyle = useCallback(() => {
|
||||
if (!wrapRef.current) return;
|
||||
setDropdownStyle(resolveDropdownStyle(wrapRef.current));
|
||||
}, []);
|
||||
|
||||
const scheduleDropdownStyleUpdate = useCallback(() => {
|
||||
if (typeof window === 'undefined') return;
|
||||
if (rafRef.current !== null) {
|
||||
window.cancelAnimationFrame(rafRef.current);
|
||||
}
|
||||
rafRef.current = window.requestAnimationFrame(() => {
|
||||
rafRef.current = null;
|
||||
updateDropdownStyle();
|
||||
});
|
||||
}, [updateDropdownStyle]);
|
||||
|
||||
useLayoutEffect(() => {
|
||||
if (!isOpen) {
|
||||
if (rafRef.current !== null && typeof window !== 'undefined') {
|
||||
window.cancelAnimationFrame(rafRef.current);
|
||||
rafRef.current = null;
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
updateDropdownStyle();
|
||||
|
||||
const handleViewportChange = () => {
|
||||
scheduleDropdownStyleUpdate();
|
||||
};
|
||||
|
||||
const resizeObserver =
|
||||
typeof ResizeObserver !== 'undefined' && wrapRef.current
|
||||
? new ResizeObserver(() => {
|
||||
scheduleDropdownStyleUpdate();
|
||||
})
|
||||
: null;
|
||||
|
||||
if (resizeObserver && wrapRef.current) {
|
||||
resizeObserver.observe(wrapRef.current);
|
||||
}
|
||||
|
||||
window.addEventListener('resize', handleViewportChange);
|
||||
window.addEventListener('scroll', handleViewportChange, true);
|
||||
|
||||
return () => {
|
||||
window.removeEventListener('resize', handleViewportChange);
|
||||
window.removeEventListener('scroll', handleViewportChange, true);
|
||||
resizeObserver?.disconnect();
|
||||
if (rafRef.current !== null) {
|
||||
window.cancelAnimationFrame(rafRef.current);
|
||||
rafRef.current = null;
|
||||
}
|
||||
};
|
||||
}, [isOpen, scheduleDropdownStyleUpdate, updateDropdownStyle]);
|
||||
|
||||
const selectedIndex = useMemo(() => options.findIndex((option) => option.value === value), [options, value]);
|
||||
const resolvedHighlightedIndex =
|
||||
highlightedIndex >= 0 ? highlightedIndex : selectedIndex >= 0 ? selectedIndex : options.length > 0 ? 0 : -1;
|
||||
@@ -136,60 +252,80 @@ export function Select({
|
||||
[commitSelection, disabled, isOpen, moveHighlight, options.length, resolvedHighlightedIndex]
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
if (!isOpen || resolvedHighlightedIndex < 0) return;
|
||||
const highlightedOption = document.getElementById(`${selectId}-option-${resolvedHighlightedIndex}`);
|
||||
highlightedOption?.scrollIntoView({ block: 'nearest' });
|
||||
}, [isOpen, resolvedHighlightedIndex, selectId]);
|
||||
|
||||
const dropdown =
|
||||
isOpen && dropdownStyle
|
||||
? (
|
||||
<div
|
||||
ref={dropdownRef}
|
||||
className={styles.dropdown}
|
||||
id={listboxId}
|
||||
role="listbox"
|
||||
aria-label={ariaLabel}
|
||||
style={dropdownStyle}
|
||||
>
|
||||
{options.map((opt, index) => {
|
||||
const active = opt.value === value;
|
||||
const highlighted = index === resolvedHighlightedIndex;
|
||||
return (
|
||||
<button
|
||||
key={opt.value}
|
||||
id={`${selectId}-option-${index}`}
|
||||
type="button"
|
||||
role="option"
|
||||
aria-selected={active}
|
||||
className={`${styles.option} ${active ? styles.optionActive : ''} ${highlighted ? styles.optionHighlighted : ''}`.trim()}
|
||||
onMouseEnter={() => setHighlightedIndex(index)}
|
||||
onKeyDown={handleKeyDown}
|
||||
onClick={() => commitSelection(index)}
|
||||
>
|
||||
{opt.label}
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)
|
||||
: null;
|
||||
|
||||
return (
|
||||
<div
|
||||
className={`${styles.wrap} ${fullWidth ? styles.wrapFullWidth : ''} ${className ?? ''}`}
|
||||
ref={wrapRef}
|
||||
>
|
||||
<button
|
||||
id={selectId}
|
||||
type="button"
|
||||
className={styles.trigger}
|
||||
onClick={disabled ? undefined : () => setOpen((prev) => !prev)}
|
||||
onKeyDown={handleKeyDown}
|
||||
aria-haspopup="listbox"
|
||||
aria-expanded={isOpen}
|
||||
aria-controls={isOpen ? listboxId : undefined}
|
||||
aria-activedescendant={
|
||||
isOpen && resolvedHighlightedIndex >= 0
|
||||
? `${selectId}-option-${resolvedHighlightedIndex}`
|
||||
: undefined
|
||||
}
|
||||
aria-label={ariaLabel}
|
||||
aria-labelledby={ariaLabelledBy}
|
||||
aria-describedby={ariaDescribedBy}
|
||||
disabled={disabled}
|
||||
<>
|
||||
<div
|
||||
className={`${styles.wrap} ${fullWidth ? styles.wrapFullWidth : ''} ${className ?? ''}`}
|
||||
ref={wrapRef}
|
||||
>
|
||||
<span className={`${styles.triggerText} ${isPlaceholder ? styles.placeholder : ''}`}>
|
||||
{displayText}
|
||||
</span>
|
||||
<span className={styles.triggerIcon} aria-hidden="true">
|
||||
<IconChevronDown size={14} />
|
||||
</span>
|
||||
</button>
|
||||
{isOpen && (
|
||||
<div className={styles.dropdown} id={listboxId} role="listbox" aria-label={ariaLabel}>
|
||||
{options.map((opt, index) => {
|
||||
const active = opt.value === value;
|
||||
const highlighted = index === resolvedHighlightedIndex;
|
||||
return (
|
||||
<button
|
||||
key={opt.value}
|
||||
id={`${selectId}-option-${index}`}
|
||||
type="button"
|
||||
role="option"
|
||||
aria-selected={active}
|
||||
className={`${styles.option} ${active ? styles.optionActive : ''} ${highlighted ? styles.optionHighlighted : ''}`.trim()}
|
||||
onMouseEnter={() => setHighlightedIndex(index)}
|
||||
onKeyDown={handleKeyDown}
|
||||
onClick={() => commitSelection(index)}
|
||||
>
|
||||
{opt.label}
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<button
|
||||
id={selectId}
|
||||
type="button"
|
||||
className={styles.trigger}
|
||||
onClick={disabled ? undefined : () => setOpen((prev) => !prev)}
|
||||
onKeyDown={handleKeyDown}
|
||||
aria-haspopup="listbox"
|
||||
aria-expanded={isOpen}
|
||||
aria-controls={isOpen ? listboxId : undefined}
|
||||
aria-activedescendant={
|
||||
isOpen && resolvedHighlightedIndex >= 0
|
||||
? `${selectId}-option-${resolvedHighlightedIndex}`
|
||||
: undefined
|
||||
}
|
||||
aria-label={ariaLabel}
|
||||
aria-labelledby={ariaLabelledBy}
|
||||
aria-describedby={ariaDescribedBy}
|
||||
disabled={disabled}
|
||||
>
|
||||
<span className={`${styles.triggerText} ${isPlaceholder ? styles.placeholder : ''}`}>
|
||||
{displayText}
|
||||
</span>
|
||||
<span className={styles.triggerIcon} aria-hidden="true">
|
||||
<IconChevronDown size={14} />
|
||||
</span>
|
||||
</button>
|
||||
</div>
|
||||
{dropdown && (typeof document === 'undefined' ? dropdown : createPortal(dropdown, document.body))}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -44,7 +44,7 @@ export function SelectionCheckbox({
|
||||
disabled={disabled}
|
||||
/>
|
||||
<span className={boxClassName}>{checked ? <IconCheck size={12} /> : null}</span>
|
||||
{label ? <span className={textClassName}>{label}</span> : null}
|
||||
{label ? <div className={textClassName}>{label}</div> : null}
|
||||
</label>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -15,6 +15,7 @@ export type AuthFilesUiState = {
|
||||
};
|
||||
|
||||
const AUTH_FILES_UI_STATE_KEY = 'authFilesPage.uiState';
|
||||
const AUTH_FILES_COMPACT_MODE_KEY = 'authFilesPage.compactMode';
|
||||
const AUTH_FILES_SORT_MODE_SET = new Set<AuthFilesSortMode>(AUTH_FILES_SORT_MODES);
|
||||
|
||||
export const isAuthFilesSortMode = (value: unknown): value is AuthFilesSortMode =>
|
||||
@@ -40,3 +41,23 @@ export const writeAuthFilesUiState = (state: AuthFilesUiState) => {
|
||||
// ignore
|
||||
}
|
||||
};
|
||||
|
||||
export const readPersistedAuthFilesCompactMode = (): boolean | null => {
|
||||
if (typeof window === 'undefined') return null;
|
||||
try {
|
||||
const raw = window.localStorage.getItem(AUTH_FILES_COMPACT_MODE_KEY);
|
||||
if (raw === null) return null;
|
||||
return JSON.parse(raw) === true;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
};
|
||||
|
||||
export const writePersistedAuthFilesCompactMode = (compactMode: boolean) => {
|
||||
if (typeof window === 'undefined') return;
|
||||
try {
|
||||
window.localStorage.setItem(AUTH_FILES_COMPACT_MODE_KEY, JSON.stringify(compactMode));
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
};
|
||||
|
||||
@@ -218,6 +218,9 @@
|
||||
"gemini_models_fetch_empty": "No models returned. Please check Base URL, API key, or headers.",
|
||||
"gemini_models_fetch_error": "Failed to fetch Gemini models",
|
||||
"gemini_models_fetch_apply": "Add selected models",
|
||||
"model_discovery_select_visible": "Select current list",
|
||||
"model_discovery_clear_selection": "Clear selection",
|
||||
"model_discovery_selected_count": "{{count}} selected",
|
||||
"gemini_models_search_label": "Search models",
|
||||
"gemini_models_search_placeholder": "Filter by name, alias, or description",
|
||||
"gemini_models_search_empty": "No models match your search. Try a different keyword.",
|
||||
|
||||
@@ -218,6 +218,9 @@
|
||||
"gemini_models_fetch_empty": "Модели не получены. Проверьте Base URL, API-ключ или заголовки.",
|
||||
"gemini_models_fetch_error": "Не удалось получить модели Gemini",
|
||||
"gemini_models_fetch_apply": "Добавить выбранные модели",
|
||||
"model_discovery_select_visible": "Выбрать текущий список",
|
||||
"model_discovery_clear_selection": "Очистить выбор",
|
||||
"model_discovery_selected_count": "Выбрано: {{count}}",
|
||||
"gemini_models_search_label": "Поиск моделей",
|
||||
"gemini_models_search_placeholder": "Фильтр по имени, псевдониму или описанию",
|
||||
"gemini_models_search_empty": "Нет подходящих моделей. Попробуйте другой запрос.",
|
||||
|
||||
@@ -218,6 +218,9 @@
|
||||
"gemini_models_fetch_empty": "未获取到模型,请检查 Base URL、API Key 或请求头。",
|
||||
"gemini_models_fetch_error": "获取 Gemini 模型失败",
|
||||
"gemini_models_fetch_apply": "添加所选模型",
|
||||
"model_discovery_select_visible": "全选当前列表",
|
||||
"model_discovery_clear_selection": "清空已选",
|
||||
"model_discovery_selected_count": "已选 {{count}} 项",
|
||||
"gemini_models_search_label": "搜索模型",
|
||||
"gemini_models_search_placeholder": "按名称、别名或描述筛选",
|
||||
"gemini_models_search_empty": "没有匹配的模型,请更换关键字试试。",
|
||||
|
||||
@@ -4,6 +4,7 @@ import { useTranslation } from 'react-i18next';
|
||||
import { Button } from '@/components/ui/Button';
|
||||
import { Card } from '@/components/ui/Card';
|
||||
import { Input } from '@/components/ui/Input';
|
||||
import { SelectionCheckbox } from '@/components/ui/SelectionCheckbox';
|
||||
import { SecondaryScreenShell } from '@/components/common/SecondaryScreenShell';
|
||||
import { useEdgeSwipeBack } from '@/hooks/useEdgeSwipeBack';
|
||||
import { modelsApi } from '@/services/api';
|
||||
@@ -48,6 +49,14 @@ export function AiProvidersClaudeModelsPage() {
|
||||
return name.includes(filter) || alias.includes(filter) || desc.includes(filter);
|
||||
});
|
||||
}, [models, search]);
|
||||
const visibleModelNames = useMemo(
|
||||
() => filteredModels.map((model) => model.name),
|
||||
[filteredModels]
|
||||
);
|
||||
const allVisibleSelected = useMemo(
|
||||
() => visibleModelNames.length > 0 && visibleModelNames.every((name) => selected.has(name)),
|
||||
[selected, visibleModelNames]
|
||||
);
|
||||
|
||||
const fetchClaudeModelDiscovery = useCallback(async () => {
|
||||
setFetching(true);
|
||||
@@ -117,6 +126,22 @@ export function AiProvidersClaudeModelsPage() {
|
||||
void fetchClaudeModelDiscovery();
|
||||
}, [fetchClaudeModelDiscovery, form.apiKey, form.baseUrl, form.headers, initialLoading]);
|
||||
|
||||
useEffect(() => {
|
||||
const availableNames = new Set(models.map((model) => model.name));
|
||||
setSelected((prev) => {
|
||||
let changed = false;
|
||||
const next = new Set<string>();
|
||||
prev.forEach((name) => {
|
||||
if (availableNames.has(name)) {
|
||||
next.add(name);
|
||||
} else {
|
||||
changed = true;
|
||||
}
|
||||
});
|
||||
return changed ? next : prev;
|
||||
});
|
||||
}, [models]);
|
||||
|
||||
const handleBack = useCallback(() => {
|
||||
navigate(-1);
|
||||
}, [navigate]);
|
||||
@@ -145,6 +170,18 @@ export function AiProvidersClaudeModelsPage() {
|
||||
});
|
||||
};
|
||||
|
||||
const handleSelectVisible = useCallback(() => {
|
||||
setSelected((prev) => {
|
||||
const next = new Set(prev);
|
||||
visibleModelNames.forEach((name) => next.add(name));
|
||||
return next;
|
||||
});
|
||||
}, [visibleModelNames]);
|
||||
|
||||
const handleClearSelection = useCallback(() => {
|
||||
setSelected(new Set());
|
||||
}, []);
|
||||
|
||||
const handleApply = () => {
|
||||
const selectedModels = models.filter((model) => selected.has(model.name));
|
||||
if (selectedModels.length) {
|
||||
@@ -153,7 +190,7 @@ export function AiProvidersClaudeModelsPage() {
|
||||
handleBack();
|
||||
};
|
||||
|
||||
const canApply = !disableControls && !saving && !fetching;
|
||||
const canApply = !disableControls && !saving && !fetching && selected.size > 0;
|
||||
|
||||
return (
|
||||
<SecondaryScreenShell
|
||||
@@ -219,9 +256,42 @@ export function AiProvidersClaudeModelsPage() {
|
||||
onChange={(e) => setSearch(e.target.value)}
|
||||
disabled={fetching}
|
||||
/>
|
||||
{models.length > 0 && (
|
||||
<div className={styles.modelDiscoveryToolbar}>
|
||||
<div className={styles.modelDiscoveryToolbarActions}>
|
||||
<Button
|
||||
variant="secondary"
|
||||
size="sm"
|
||||
onClick={handleSelectVisible}
|
||||
disabled={
|
||||
disableControls ||
|
||||
saving ||
|
||||
fetching ||
|
||||
filteredModels.length === 0 ||
|
||||
allVisibleSelected
|
||||
}
|
||||
>
|
||||
{t('ai_providers.model_discovery_select_visible')}
|
||||
</Button>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={handleClearSelection}
|
||||
disabled={disableControls || saving || fetching || selected.size === 0}
|
||||
>
|
||||
{t('ai_providers.model_discovery_clear_selection')}
|
||||
</Button>
|
||||
</div>
|
||||
<div className={styles.modelDiscoverySelectionSummary}>
|
||||
{t('ai_providers.model_discovery_selected_count', { count: selected.size })}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
{error && <div className="error-box">{error}</div>}
|
||||
{fetching ? (
|
||||
<div className={styles.sectionHint}>{t('ai_providers.claude_models_fetch_loading')}</div>
|
||||
<div className={styles.sectionHint}>
|
||||
{t('ai_providers.claude_models_fetch_loading')}
|
||||
</div>
|
||||
) : models.length === 0 ? (
|
||||
<div className={styles.sectionHint}>{t('ai_providers.claude_models_fetch_empty')}</div>
|
||||
) : filteredModels.length === 0 ? (
|
||||
@@ -231,29 +301,30 @@ export function AiProvidersClaudeModelsPage() {
|
||||
{filteredModels.map((model) => {
|
||||
const checked = selected.has(model.name);
|
||||
return (
|
||||
<label
|
||||
<SelectionCheckbox
|
||||
key={model.name}
|
||||
checked={checked}
|
||||
onChange={() => toggleSelection(model.name)}
|
||||
disabled={disableControls || saving || fetching}
|
||||
ariaLabel={model.name}
|
||||
className={`${styles.modelDiscoveryRow} ${
|
||||
checked ? styles.modelDiscoveryRowSelected : ''
|
||||
}`}
|
||||
>
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={checked}
|
||||
onChange={() => toggleSelection(model.name)}
|
||||
/>
|
||||
<div className={styles.modelDiscoveryMeta}>
|
||||
<div className={styles.modelDiscoveryName}>
|
||||
{model.name}
|
||||
{model.alias && (
|
||||
<span className={styles.modelDiscoveryAlias}>{model.alias}</span>
|
||||
labelClassName={styles.modelDiscoverySelectionLabel}
|
||||
label={
|
||||
<div className={styles.modelDiscoveryMeta}>
|
||||
<div className={styles.modelDiscoveryName}>
|
||||
{model.name}
|
||||
{model.alias && (
|
||||
<span className={styles.modelDiscoveryAlias}>{model.alias}</span>
|
||||
)}
|
||||
</div>
|
||||
{model.description && (
|
||||
<div className={styles.modelDiscoveryDesc}>{model.description}</div>
|
||||
)}
|
||||
</div>
|
||||
{model.description && (
|
||||
<div className={styles.modelDiscoveryDesc}>{model.description}</div>
|
||||
)}
|
||||
</div>
|
||||
</label>
|
||||
}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
|
||||
@@ -7,6 +7,7 @@ import { Input } from '@/components/ui/Input';
|
||||
import { HeaderInputList } from '@/components/ui/HeaderInputList';
|
||||
import { ModelInputList } from '@/components/ui/ModelInputList';
|
||||
import { Modal } from '@/components/ui/Modal';
|
||||
import { SelectionCheckbox } from '@/components/ui/SelectionCheckbox';
|
||||
import { ToggleSwitch } from '@/components/ui/ToggleSwitch';
|
||||
import { useEdgeSwipeBack } from '@/hooks/useEdgeSwipeBack';
|
||||
import { useUnsavedChangesGuard } from '@/hooks/useUnsavedChangesGuard';
|
||||
@@ -66,7 +67,9 @@ const buildCodexSignature = (form: ProviderFormState) =>
|
||||
JSON.stringify({
|
||||
apiKey: String(form.apiKey ?? '').trim(),
|
||||
priority:
|
||||
form.priority !== undefined && Number.isFinite(form.priority) ? Math.trunc(form.priority) : null,
|
||||
form.priority !== undefined && Number.isFinite(form.priority)
|
||||
? Math.trunc(form.priority)
|
||||
: null,
|
||||
prefix: String(form.prefix ?? '').trim(),
|
||||
baseUrl: String(form.baseUrl ?? '').trim(),
|
||||
websockets: Boolean(form.websockets),
|
||||
@@ -95,7 +98,9 @@ export function AiProvidersCodexEditPage() {
|
||||
const [saving, setSaving] = useState(false);
|
||||
const [error, setError] = useState('');
|
||||
const [form, setForm] = useState<ProviderFormState>(() => buildEmptyForm());
|
||||
const [baselineSignature, setBaselineSignature] = useState(() => buildCodexSignature(buildEmptyForm()));
|
||||
const [baselineSignature, setBaselineSignature] = useState(() =>
|
||||
buildCodexSignature(buildEmptyForm())
|
||||
);
|
||||
|
||||
const [modelDiscoveryOpen, setModelDiscoveryOpen] = useState(false);
|
||||
const [modelDiscoveryEndpoint, setModelDiscoveryEndpoint] = useState('');
|
||||
@@ -119,7 +124,9 @@ export function AiProvidersCodexEditPage() {
|
||||
const invalidIndex = editIndex !== null && !initialData;
|
||||
|
||||
const title =
|
||||
editIndex !== null ? t('ai_providers.codex_edit_modal_title') : t('ai_providers.codex_add_modal_title');
|
||||
editIndex !== null
|
||||
? t('ai_providers.codex_edit_modal_title')
|
||||
: t('ai_providers.codex_add_modal_title');
|
||||
|
||||
const handleBack = useCallback(() => {
|
||||
const state = location.state as LocationState;
|
||||
@@ -216,6 +223,16 @@ export function AiProvidersCodexEditPage() {
|
||||
return name.includes(filter) || alias.includes(filter) || description.includes(filter);
|
||||
});
|
||||
}, [discoveredModels, modelDiscoverySearch]);
|
||||
const visibleDiscoveredModelNames = useMemo(
|
||||
() => discoveredModelsFiltered.map((model) => model.name),
|
||||
[discoveredModelsFiltered]
|
||||
);
|
||||
const allVisibleDiscoveredSelected = useMemo(
|
||||
() =>
|
||||
visibleDiscoveredModelNames.length > 0 &&
|
||||
visibleDiscoveredModelNames.every((name) => modelDiscoverySelected.has(name)),
|
||||
[modelDiscoverySelected, visibleDiscoveredModelNames]
|
||||
);
|
||||
|
||||
const mergeDiscoveredModels = useCallback(
|
||||
(selectedModels: ModelInfo[]) => {
|
||||
@@ -247,7 +264,10 @@ export function AiProvidersCodexEditPage() {
|
||||
});
|
||||
|
||||
if (addedCount > 0) {
|
||||
showNotification(t('ai_providers.codex_models_fetch_added', { count: addedCount }), 'success');
|
||||
showNotification(
|
||||
t('ai_providers.codex_models_fetch_added', { count: addedCount }),
|
||||
'success'
|
||||
);
|
||||
}
|
||||
},
|
||||
[setForm, showNotification, t]
|
||||
@@ -320,6 +340,22 @@ export function AiProvidersCodexEditPage() {
|
||||
void fetchCodexModelDiscovery();
|
||||
}, [fetchCodexModelDiscovery, form.apiKey, form.baseUrl, form.headers, modelDiscoveryOpen]);
|
||||
|
||||
useEffect(() => {
|
||||
const availableNames = new Set(discoveredModels.map((model) => model.name));
|
||||
setModelDiscoverySelected((prev) => {
|
||||
let changed = false;
|
||||
const next = new Set<string>();
|
||||
prev.forEach((name) => {
|
||||
if (availableNames.has(name)) {
|
||||
next.add(name);
|
||||
} else {
|
||||
changed = true;
|
||||
}
|
||||
});
|
||||
return changed ? next : prev;
|
||||
});
|
||||
}, [discoveredModels]);
|
||||
|
||||
const toggleModelDiscoverySelection = (name: string) => {
|
||||
setModelDiscoverySelected((prev) => {
|
||||
const next = new Set(prev);
|
||||
@@ -332,8 +368,22 @@ export function AiProvidersCodexEditPage() {
|
||||
});
|
||||
};
|
||||
|
||||
const handleSelectVisibleDiscoveredModels = useCallback(() => {
|
||||
setModelDiscoverySelected((prev) => {
|
||||
const next = new Set(prev);
|
||||
visibleDiscoveredModelNames.forEach((name) => next.add(name));
|
||||
return next;
|
||||
});
|
||||
}, [visibleDiscoveredModelNames]);
|
||||
|
||||
const handleClearDiscoveredModelSelection = useCallback(() => {
|
||||
setModelDiscoverySelected(new Set());
|
||||
}, []);
|
||||
|
||||
const handleApplyDiscoveredModels = () => {
|
||||
const selectedModels = discoveredModels.filter((model) => modelDiscoverySelected.has(model.name));
|
||||
const selectedModels = discoveredModels.filter((model) =>
|
||||
modelDiscoverySelected.has(model.name)
|
||||
);
|
||||
if (selectedModels.length) {
|
||||
mergeDiscoveredModels(selectedModels);
|
||||
}
|
||||
@@ -374,7 +424,9 @@ export function AiProvidersCodexEditPage() {
|
||||
updateConfigValue('codex-api-key', nextList);
|
||||
clearCache('codex-api-key');
|
||||
showNotification(
|
||||
editIndex !== null ? t('notification.codex_config_updated') : t('notification.codex_config_added'),
|
||||
editIndex !== null
|
||||
? t('notification.codex_config_updated')
|
||||
: t('notification.codex_config_added'),
|
||||
'success'
|
||||
);
|
||||
allowNextNavigation();
|
||||
@@ -407,7 +459,8 @@ export function AiProvidersCodexEditPage() {
|
||||
!invalidIndexParam &&
|
||||
!invalidIndex &&
|
||||
Boolean((form.baseUrl ?? '').trim());
|
||||
const canApplyModelDiscovery = !disableControls && !saving && !modelDiscoveryFetching;
|
||||
const canApplyModelDiscovery =
|
||||
!disableControls && !saving && !modelDiscoveryFetching && modelDiscoverySelected.size > 0;
|
||||
|
||||
return (
|
||||
<SecondaryScreenShell
|
||||
@@ -514,7 +567,9 @@ export function AiProvidersCodexEditPage() {
|
||||
|
||||
<div className={styles.modelConfigSection}>
|
||||
<div className={styles.modelConfigHeader}>
|
||||
<label className={styles.modelConfigTitle}>{t('ai_providers.codex_models_label')}</label>
|
||||
<label className={styles.modelConfigTitle}>
|
||||
{t('ai_providers.codex_models_label')}
|
||||
</label>
|
||||
<div className={styles.modelConfigToolbar}>
|
||||
<Button
|
||||
variant="secondary"
|
||||
@@ -595,7 +650,9 @@ export function AiProvidersCodexEditPage() {
|
||||
}
|
||||
>
|
||||
<div className={styles.openaiModelsContent}>
|
||||
<div className={styles.sectionHint}>{t('ai_providers.codex_models_fetch_hint')}</div>
|
||||
<div className={styles.sectionHint}>
|
||||
{t('ai_providers.codex_models_fetch_hint')}
|
||||
</div>
|
||||
<div className={styles.openaiModelsEndpointSection}>
|
||||
<label className={styles.openaiModelsEndpointLabel}>
|
||||
{t('ai_providers.codex_models_fetch_url_label')}
|
||||
@@ -624,41 +681,86 @@ export function AiProvidersCodexEditPage() {
|
||||
onChange={(e) => setModelDiscoverySearch(e.target.value)}
|
||||
disabled={modelDiscoveryFetching}
|
||||
/>
|
||||
{discoveredModels.length > 0 && (
|
||||
<div className={styles.modelDiscoveryToolbar}>
|
||||
<div className={styles.modelDiscoveryToolbarActions}>
|
||||
<Button
|
||||
variant="secondary"
|
||||
size="sm"
|
||||
onClick={handleSelectVisibleDiscoveredModels}
|
||||
disabled={
|
||||
disableControls ||
|
||||
saving ||
|
||||
modelDiscoveryFetching ||
|
||||
discoveredModelsFiltered.length === 0 ||
|
||||
allVisibleDiscoveredSelected
|
||||
}
|
||||
>
|
||||
{t('ai_providers.model_discovery_select_visible')}
|
||||
</Button>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={handleClearDiscoveredModelSelection}
|
||||
disabled={
|
||||
disableControls ||
|
||||
saving ||
|
||||
modelDiscoveryFetching ||
|
||||
modelDiscoverySelected.size === 0
|
||||
}
|
||||
>
|
||||
{t('ai_providers.model_discovery_clear_selection')}
|
||||
</Button>
|
||||
</div>
|
||||
<div className={styles.modelDiscoverySelectionSummary}>
|
||||
{t('ai_providers.model_discovery_selected_count', {
|
||||
count: modelDiscoverySelected.size,
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
{modelDiscoveryError && <div className="error-box">{modelDiscoveryError}</div>}
|
||||
{modelDiscoveryFetching ? (
|
||||
<div className={styles.sectionHint}>{t('ai_providers.codex_models_fetch_loading')}</div>
|
||||
<div className={styles.sectionHint}>
|
||||
{t('ai_providers.codex_models_fetch_loading')}
|
||||
</div>
|
||||
) : discoveredModels.length === 0 ? (
|
||||
<div className={styles.sectionHint}>{t('ai_providers.codex_models_fetch_empty')}</div>
|
||||
<div className={styles.sectionHint}>
|
||||
{t('ai_providers.codex_models_fetch_empty')}
|
||||
</div>
|
||||
) : discoveredModelsFiltered.length === 0 ? (
|
||||
<div className={styles.sectionHint}>{t('ai_providers.codex_models_search_empty')}</div>
|
||||
<div className={styles.sectionHint}>
|
||||
{t('ai_providers.codex_models_search_empty')}
|
||||
</div>
|
||||
) : (
|
||||
<div className={styles.modelDiscoveryList}>
|
||||
{discoveredModelsFiltered.map((model) => {
|
||||
const checked = modelDiscoverySelected.has(model.name);
|
||||
return (
|
||||
<label
|
||||
<SelectionCheckbox
|
||||
key={model.name}
|
||||
checked={checked}
|
||||
onChange={() => toggleModelDiscoverySelection(model.name)}
|
||||
disabled={disableControls || saving || modelDiscoveryFetching}
|
||||
ariaLabel={model.name}
|
||||
className={`${styles.modelDiscoveryRow} ${
|
||||
checked ? styles.modelDiscoveryRowSelected : ''
|
||||
}`}
|
||||
>
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={checked}
|
||||
onChange={() => toggleModelDiscoverySelection(model.name)}
|
||||
/>
|
||||
<div className={styles.modelDiscoveryMeta}>
|
||||
<div className={styles.modelDiscoveryName}>
|
||||
{model.name}
|
||||
{model.alias && (
|
||||
<span className={styles.modelDiscoveryAlias}>{model.alias}</span>
|
||||
labelClassName={styles.modelDiscoverySelectionLabel}
|
||||
label={
|
||||
<div className={styles.modelDiscoveryMeta}>
|
||||
<div className={styles.modelDiscoveryName}>
|
||||
{model.name}
|
||||
{model.alias && (
|
||||
<span className={styles.modelDiscoveryAlias}>{model.alias}</span>
|
||||
)}
|
||||
</div>
|
||||
{model.description && (
|
||||
<div className={styles.modelDiscoveryDesc}>{model.description}</div>
|
||||
)}
|
||||
</div>
|
||||
{model.description && (
|
||||
<div className={styles.modelDiscoveryDesc}>{model.description}</div>
|
||||
)}
|
||||
</div>
|
||||
</label>
|
||||
}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
|
||||
@@ -7,6 +7,7 @@ import { Input } from '@/components/ui/Input';
|
||||
import { HeaderInputList } from '@/components/ui/HeaderInputList';
|
||||
import { ModelInputList } from '@/components/ui/ModelInputList';
|
||||
import { Modal } from '@/components/ui/Modal';
|
||||
import { SelectionCheckbox } from '@/components/ui/SelectionCheckbox';
|
||||
import { useEdgeSwipeBack } from '@/hooks/useEdgeSwipeBack';
|
||||
import { useUnsavedChangesGuard } from '@/hooks/useUnsavedChangesGuard';
|
||||
import { SecondaryScreenShell } from '@/components/common/SecondaryScreenShell';
|
||||
@@ -42,7 +43,9 @@ const parseIndexParam = (value: string | undefined) => {
|
||||
};
|
||||
|
||||
const stripGeminiModelResourceName = (value: string) => {
|
||||
return String(value ?? '').trim().replace(/^\/?models\//i, '');
|
||||
return String(value ?? '')
|
||||
.trim()
|
||||
.replace(/^\/?models\//i, '');
|
||||
};
|
||||
|
||||
const normalizeModelEntries = (entries: Array<{ name: string; alias: string }>) =>
|
||||
@@ -61,7 +64,9 @@ const buildGeminiSignature = (form: GeminiFormState) =>
|
||||
JSON.stringify({
|
||||
apiKey: String(form.apiKey ?? '').trim(),
|
||||
priority:
|
||||
form.priority !== undefined && Number.isFinite(form.priority) ? Math.trunc(form.priority) : null,
|
||||
form.priority !== undefined && Number.isFinite(form.priority)
|
||||
? Math.trunc(form.priority)
|
||||
: null,
|
||||
prefix: String(form.prefix ?? '').trim(),
|
||||
baseUrl: String(form.baseUrl ?? '').trim(),
|
||||
proxyUrl: String(form.proxyUrl ?? '').trim(),
|
||||
@@ -89,7 +94,9 @@ export function AiProvidersGeminiEditPage() {
|
||||
const [saving, setSaving] = useState(false);
|
||||
const [error, setError] = useState('');
|
||||
const [form, setForm] = useState<GeminiFormState>(() => buildEmptyForm());
|
||||
const [baselineSignature, setBaselineSignature] = useState(() => buildGeminiSignature(buildEmptyForm()));
|
||||
const [baselineSignature, setBaselineSignature] = useState(() =>
|
||||
buildGeminiSignature(buildEmptyForm())
|
||||
);
|
||||
|
||||
const [modelDiscoveryOpen, setModelDiscoveryOpen] = useState(false);
|
||||
const [modelDiscoveryEndpoint, setModelDiscoveryEndpoint] = useState('');
|
||||
@@ -113,7 +120,9 @@ export function AiProvidersGeminiEditPage() {
|
||||
const invalidIndex = editIndex !== null && !initialData;
|
||||
|
||||
const title =
|
||||
editIndex !== null ? t('ai_providers.gemini_edit_modal_title') : t('ai_providers.gemini_add_modal_title');
|
||||
editIndex !== null
|
||||
? t('ai_providers.gemini_edit_modal_title')
|
||||
: t('ai_providers.gemini_add_modal_title');
|
||||
|
||||
const handleBack = useCallback(() => {
|
||||
const state = location.state as LocationState;
|
||||
@@ -196,6 +205,16 @@ export function AiProvidersGeminiEditPage() {
|
||||
return name.includes(filter) || alias.includes(filter) || description.includes(filter);
|
||||
});
|
||||
}, [discoveredModels, modelDiscoverySearch]);
|
||||
const visibleDiscoveredModelNames = useMemo(
|
||||
() => discoveredModelsFiltered.map((model) => model.name),
|
||||
[discoveredModelsFiltered]
|
||||
);
|
||||
const allVisibleDiscoveredSelected = useMemo(
|
||||
() =>
|
||||
visibleDiscoveredModelNames.length > 0 &&
|
||||
visibleDiscoveredModelNames.every((name) => modelDiscoverySelected.has(name)),
|
||||
[modelDiscoverySelected, visibleDiscoveredModelNames]
|
||||
);
|
||||
|
||||
const mergeDiscoveredModels = useCallback(
|
||||
(selectedModels: ModelInfo[]) => {
|
||||
@@ -225,7 +244,10 @@ export function AiProvidersGeminiEditPage() {
|
||||
});
|
||||
|
||||
if (addedCount > 0) {
|
||||
showNotification(t('ai_providers.gemini_models_fetch_added', { count: addedCount }), 'success');
|
||||
showNotification(
|
||||
t('ai_providers.gemini_models_fetch_added', { count: addedCount }),
|
||||
'success'
|
||||
);
|
||||
}
|
||||
},
|
||||
[setForm, showNotification, t]
|
||||
@@ -306,6 +328,22 @@ export function AiProvidersGeminiEditPage() {
|
||||
void fetchGeminiModelDiscovery();
|
||||
}, [fetchGeminiModelDiscovery, form.apiKey, form.baseUrl, form.headers, modelDiscoveryOpen]);
|
||||
|
||||
useEffect(() => {
|
||||
const availableNames = new Set(discoveredModels.map((model) => model.name));
|
||||
setModelDiscoverySelected((prev) => {
|
||||
let changed = false;
|
||||
const next = new Set<string>();
|
||||
prev.forEach((name) => {
|
||||
if (availableNames.has(name)) {
|
||||
next.add(name);
|
||||
} else {
|
||||
changed = true;
|
||||
}
|
||||
});
|
||||
return changed ? next : prev;
|
||||
});
|
||||
}, [discoveredModels]);
|
||||
|
||||
const toggleModelDiscoverySelection = (name: string) => {
|
||||
setModelDiscoverySelected((prev) => {
|
||||
const next = new Set(prev);
|
||||
@@ -318,8 +356,22 @@ export function AiProvidersGeminiEditPage() {
|
||||
});
|
||||
};
|
||||
|
||||
const handleSelectVisibleDiscoveredModels = useCallback(() => {
|
||||
setModelDiscoverySelected((prev) => {
|
||||
const next = new Set(prev);
|
||||
visibleDiscoveredModelNames.forEach((name) => next.add(name));
|
||||
return next;
|
||||
});
|
||||
}, [visibleDiscoveredModelNames]);
|
||||
|
||||
const handleClearDiscoveredModelSelection = useCallback(() => {
|
||||
setModelDiscoverySelected(new Set());
|
||||
}, []);
|
||||
|
||||
const handleApplyDiscoveredModels = () => {
|
||||
const selectedModels = discoveredModels.filter((model) => modelDiscoverySelected.has(model.name));
|
||||
const selectedModels = discoveredModels.filter((model) =>
|
||||
modelDiscoverySelected.has(model.name)
|
||||
);
|
||||
if (selectedModels.length) {
|
||||
mergeDiscoveredModels(selectedModels);
|
||||
}
|
||||
@@ -374,7 +426,9 @@ export function AiProvidersGeminiEditPage() {
|
||||
updateConfigValue('gemini-api-key', nextList);
|
||||
clearCache('gemini-api-key');
|
||||
showNotification(
|
||||
editIndex !== null ? t('notification.gemini_key_updated') : t('notification.gemini_key_added'),
|
||||
editIndex !== null
|
||||
? t('notification.gemini_key_updated')
|
||||
: t('notification.gemini_key_added'),
|
||||
'success'
|
||||
);
|
||||
allowNextNavigation();
|
||||
@@ -400,8 +454,10 @@ export function AiProvidersGeminiEditPage() {
|
||||
updateConfigValue,
|
||||
]);
|
||||
|
||||
const canOpenModelDiscovery = !disableControls && !saving && !loading && !invalidIndexParam && !invalidIndex;
|
||||
const canApplyModelDiscovery = !disableControls && !saving && !modelDiscoveryFetching;
|
||||
const canOpenModelDiscovery =
|
||||
!disableControls && !saving && !loading && !invalidIndexParam && !invalidIndex;
|
||||
const canApplyModelDiscovery =
|
||||
!disableControls && !saving && !modelDiscoveryFetching && modelDiscoverySelected.size > 0;
|
||||
|
||||
return (
|
||||
<SecondaryScreenShell
|
||||
@@ -501,7 +557,9 @@ export function AiProvidersGeminiEditPage() {
|
||||
|
||||
<div className={styles.modelConfigSection}>
|
||||
<div className={styles.modelConfigHeader}>
|
||||
<label className={styles.modelConfigTitle}>{t('ai_providers.gemini_models_label')}</label>
|
||||
<label className={styles.modelConfigTitle}>
|
||||
{t('ai_providers.gemini_models_label')}
|
||||
</label>
|
||||
<div className={styles.modelConfigToolbar}>
|
||||
<Button
|
||||
variant="secondary"
|
||||
@@ -572,14 +630,20 @@ export function AiProvidersGeminiEditPage() {
|
||||
>
|
||||
{t('common.cancel')}
|
||||
</Button>
|
||||
<Button size="sm" onClick={handleApplyDiscoveredModels} disabled={!canApplyModelDiscovery}>
|
||||
<Button
|
||||
size="sm"
|
||||
onClick={handleApplyDiscoveredModels}
|
||||
disabled={!canApplyModelDiscovery}
|
||||
>
|
||||
{t('ai_providers.gemini_models_fetch_apply')}
|
||||
</Button>
|
||||
</>
|
||||
}
|
||||
>
|
||||
<div className={styles.openaiModelsContent}>
|
||||
<div className={styles.sectionHint}>{t('ai_providers.gemini_models_fetch_hint')}</div>
|
||||
<div className={styles.sectionHint}>
|
||||
{t('ai_providers.gemini_models_fetch_hint')}
|
||||
</div>
|
||||
<div className={styles.openaiModelsEndpointSection}>
|
||||
<label className={styles.openaiModelsEndpointLabel}>
|
||||
{t('ai_providers.gemini_models_fetch_url_label')}
|
||||
@@ -608,41 +672,86 @@ export function AiProvidersGeminiEditPage() {
|
||||
onChange={(e) => setModelDiscoverySearch(e.target.value)}
|
||||
disabled={modelDiscoveryFetching}
|
||||
/>
|
||||
{discoveredModels.length > 0 && (
|
||||
<div className={styles.modelDiscoveryToolbar}>
|
||||
<div className={styles.modelDiscoveryToolbarActions}>
|
||||
<Button
|
||||
variant="secondary"
|
||||
size="sm"
|
||||
onClick={handleSelectVisibleDiscoveredModels}
|
||||
disabled={
|
||||
disableControls ||
|
||||
saving ||
|
||||
modelDiscoveryFetching ||
|
||||
discoveredModelsFiltered.length === 0 ||
|
||||
allVisibleDiscoveredSelected
|
||||
}
|
||||
>
|
||||
{t('ai_providers.model_discovery_select_visible')}
|
||||
</Button>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={handleClearDiscoveredModelSelection}
|
||||
disabled={
|
||||
disableControls ||
|
||||
saving ||
|
||||
modelDiscoveryFetching ||
|
||||
modelDiscoverySelected.size === 0
|
||||
}
|
||||
>
|
||||
{t('ai_providers.model_discovery_clear_selection')}
|
||||
</Button>
|
||||
</div>
|
||||
<div className={styles.modelDiscoverySelectionSummary}>
|
||||
{t('ai_providers.model_discovery_selected_count', {
|
||||
count: modelDiscoverySelected.size,
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
{modelDiscoveryError && <div className="error-box">{modelDiscoveryError}</div>}
|
||||
{modelDiscoveryFetching ? (
|
||||
<div className={styles.sectionHint}>{t('ai_providers.gemini_models_fetch_loading')}</div>
|
||||
<div className={styles.sectionHint}>
|
||||
{t('ai_providers.gemini_models_fetch_loading')}
|
||||
</div>
|
||||
) : discoveredModels.length === 0 ? (
|
||||
<div className={styles.sectionHint}>{t('ai_providers.gemini_models_fetch_empty')}</div>
|
||||
<div className={styles.sectionHint}>
|
||||
{t('ai_providers.gemini_models_fetch_empty')}
|
||||
</div>
|
||||
) : discoveredModelsFiltered.length === 0 ? (
|
||||
<div className={styles.sectionHint}>{t('ai_providers.gemini_models_search_empty')}</div>
|
||||
<div className={styles.sectionHint}>
|
||||
{t('ai_providers.gemini_models_search_empty')}
|
||||
</div>
|
||||
) : (
|
||||
<div className={styles.modelDiscoveryList}>
|
||||
{discoveredModelsFiltered.map((model) => {
|
||||
const checked = modelDiscoverySelected.has(model.name);
|
||||
return (
|
||||
<label
|
||||
<SelectionCheckbox
|
||||
key={model.name}
|
||||
checked={checked}
|
||||
onChange={() => toggleModelDiscoverySelection(model.name)}
|
||||
disabled={disableControls || saving || modelDiscoveryFetching}
|
||||
ariaLabel={model.name}
|
||||
className={`${styles.modelDiscoveryRow} ${
|
||||
checked ? styles.modelDiscoveryRowSelected : ''
|
||||
}`}
|
||||
>
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={checked}
|
||||
onChange={() => toggleModelDiscoverySelection(model.name)}
|
||||
/>
|
||||
<div className={styles.modelDiscoveryMeta}>
|
||||
<div className={styles.modelDiscoveryName}>
|
||||
{model.name}
|
||||
{model.alias && (
|
||||
<span className={styles.modelDiscoveryAlias}>{model.alias}</span>
|
||||
labelClassName={styles.modelDiscoverySelectionLabel}
|
||||
label={
|
||||
<div className={styles.modelDiscoveryMeta}>
|
||||
<div className={styles.modelDiscoveryName}>
|
||||
{model.name}
|
||||
{model.alias && (
|
||||
<span className={styles.modelDiscoveryAlias}>{model.alias}</span>
|
||||
)}
|
||||
</div>
|
||||
{model.description && (
|
||||
<div className={styles.modelDiscoveryDesc}>{model.description}</div>
|
||||
)}
|
||||
</div>
|
||||
{model.description && (
|
||||
<div className={styles.modelDiscoveryDesc}>{model.description}</div>
|
||||
)}
|
||||
</div>
|
||||
</label>
|
||||
}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
|
||||
@@ -4,6 +4,7 @@ import { useTranslation } from 'react-i18next';
|
||||
import { Button } from '@/components/ui/Button';
|
||||
import { Card } from '@/components/ui/Card';
|
||||
import { Input } from '@/components/ui/Input';
|
||||
import { SelectionCheckbox } from '@/components/ui/SelectionCheckbox';
|
||||
import { SecondaryScreenShell } from '@/components/common/SecondaryScreenShell';
|
||||
import { useEdgeSwipeBack } from '@/hooks/useEdgeSwipeBack';
|
||||
import { modelsApi } from '@/services/api';
|
||||
@@ -48,6 +49,14 @@ export function AiProvidersOpenAIModelsPage() {
|
||||
return name.includes(filter) || alias.includes(filter) || desc.includes(filter);
|
||||
});
|
||||
}, [models, search]);
|
||||
const visibleModelNames = useMemo(
|
||||
() => filteredModels.map((model) => model.name),
|
||||
[filteredModels]
|
||||
);
|
||||
const allVisibleSelected = useMemo(
|
||||
() => visibleModelNames.length > 0 && visibleModelNames.every((name) => selected.has(name)),
|
||||
[selected, visibleModelNames]
|
||||
);
|
||||
|
||||
const fetchOpenaiModelDiscovery = useCallback(
|
||||
async ({ allowFallback = true }: { allowFallback?: boolean } = {}) => {
|
||||
@@ -98,6 +107,22 @@ export function AiProvidersOpenAIModelsPage() {
|
||||
void fetchOpenaiModelDiscovery();
|
||||
}, [fetchOpenaiModelDiscovery, form.baseUrl, initialLoading]);
|
||||
|
||||
useEffect(() => {
|
||||
const availableNames = new Set(models.map((model) => model.name));
|
||||
setSelected((prev) => {
|
||||
let changed = false;
|
||||
const next = new Set<string>();
|
||||
prev.forEach((name) => {
|
||||
if (availableNames.has(name)) {
|
||||
next.add(name);
|
||||
} else {
|
||||
changed = true;
|
||||
}
|
||||
});
|
||||
return changed ? next : prev;
|
||||
});
|
||||
}, [models]);
|
||||
|
||||
const handleBack = useCallback(() => {
|
||||
navigate(-1);
|
||||
}, [navigate]);
|
||||
@@ -126,6 +151,18 @@ export function AiProvidersOpenAIModelsPage() {
|
||||
});
|
||||
};
|
||||
|
||||
const handleSelectVisible = useCallback(() => {
|
||||
setSelected((prev) => {
|
||||
const next = new Set(prev);
|
||||
visibleModelNames.forEach((name) => next.add(name));
|
||||
return next;
|
||||
});
|
||||
}, [visibleModelNames]);
|
||||
|
||||
const handleClearSelection = useCallback(() => {
|
||||
setSelected(new Set());
|
||||
}, []);
|
||||
|
||||
const handleApply = () => {
|
||||
const selectedModels = models.filter((model) => selected.has(model.name));
|
||||
if (selectedModels.length) {
|
||||
@@ -134,7 +171,7 @@ export function AiProvidersOpenAIModelsPage() {
|
||||
handleBack();
|
||||
};
|
||||
|
||||
const canApply = !disableControls && !saving && !fetching;
|
||||
const canApply = !disableControls && !saving && !fetching && selected.size > 0;
|
||||
|
||||
return (
|
||||
<SecondaryScreenShell
|
||||
@@ -200,9 +237,42 @@ export function AiProvidersOpenAIModelsPage() {
|
||||
onChange={(e) => setSearch(e.target.value)}
|
||||
disabled={fetching}
|
||||
/>
|
||||
{models.length > 0 && (
|
||||
<div className={styles.modelDiscoveryToolbar}>
|
||||
<div className={styles.modelDiscoveryToolbarActions}>
|
||||
<Button
|
||||
variant="secondary"
|
||||
size="sm"
|
||||
onClick={handleSelectVisible}
|
||||
disabled={
|
||||
disableControls ||
|
||||
saving ||
|
||||
fetching ||
|
||||
filteredModels.length === 0 ||
|
||||
allVisibleSelected
|
||||
}
|
||||
>
|
||||
{t('ai_providers.model_discovery_select_visible')}
|
||||
</Button>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={handleClearSelection}
|
||||
disabled={disableControls || saving || fetching || selected.size === 0}
|
||||
>
|
||||
{t('ai_providers.model_discovery_clear_selection')}
|
||||
</Button>
|
||||
</div>
|
||||
<div className={styles.modelDiscoverySelectionSummary}>
|
||||
{t('ai_providers.model_discovery_selected_count', { count: selected.size })}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
{error && <div className="error-box">{error}</div>}
|
||||
{fetching ? (
|
||||
<div className={styles.sectionHint}>{t('ai_providers.openai_models_fetch_loading')}</div>
|
||||
<div className={styles.sectionHint}>
|
||||
{t('ai_providers.openai_models_fetch_loading')}
|
||||
</div>
|
||||
) : models.length === 0 ? (
|
||||
<div className={styles.sectionHint}>{t('ai_providers.openai_models_fetch_empty')}</div>
|
||||
) : filteredModels.length === 0 ? (
|
||||
@@ -212,29 +282,30 @@ export function AiProvidersOpenAIModelsPage() {
|
||||
{filteredModels.map((model) => {
|
||||
const checked = selected.has(model.name);
|
||||
return (
|
||||
<label
|
||||
<SelectionCheckbox
|
||||
key={model.name}
|
||||
checked={checked}
|
||||
onChange={() => toggleSelection(model.name)}
|
||||
disabled={disableControls || saving || fetching}
|
||||
ariaLabel={model.name}
|
||||
className={`${styles.modelDiscoveryRow} ${
|
||||
checked ? styles.modelDiscoveryRowSelected : ''
|
||||
}`}
|
||||
>
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={checked}
|
||||
onChange={() => toggleSelection(model.name)}
|
||||
/>
|
||||
<div className={styles.modelDiscoveryMeta}>
|
||||
<div className={styles.modelDiscoveryName}>
|
||||
{model.name}
|
||||
{model.alias && (
|
||||
<span className={styles.modelDiscoveryAlias}>{model.alias}</span>
|
||||
labelClassName={styles.modelDiscoverySelectionLabel}
|
||||
label={
|
||||
<div className={styles.modelDiscoveryMeta}>
|
||||
<div className={styles.modelDiscoveryName}>
|
||||
{model.name}
|
||||
{model.alias && (
|
||||
<span className={styles.modelDiscoveryAlias}>{model.alias}</span>
|
||||
)}
|
||||
</div>
|
||||
{model.description && (
|
||||
<div className={styles.modelDiscoveryDesc}>{model.description}</div>
|
||||
)}
|
||||
</div>
|
||||
{model.description && (
|
||||
<div className={styles.modelDiscoveryDesc}>{model.description}</div>
|
||||
)}
|
||||
</div>
|
||||
</label>
|
||||
}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
|
||||
@@ -327,15 +327,18 @@
|
||||
}
|
||||
|
||||
.modelDiscoveryRow {
|
||||
display: flex;
|
||||
display: inline-flex;
|
||||
align-items: flex-start;
|
||||
gap: 8px;
|
||||
width: 100%;
|
||||
padding: 8px 10px;
|
||||
border: 1px solid var(--border-color);
|
||||
border-radius: 8px;
|
||||
background: var(--bg-primary);
|
||||
cursor: pointer;
|
||||
transition: background 0.15s ease, border-color 0.15s ease;
|
||||
transition:
|
||||
background 0.15s ease,
|
||||
border-color 0.15s ease;
|
||||
|
||||
input[type='checkbox'] {
|
||||
margin-top: 2px;
|
||||
@@ -348,6 +351,11 @@
|
||||
}
|
||||
}
|
||||
|
||||
.modelDiscoverySelectionLabel {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.modelDiscoveryRowSelected {
|
||||
border-color: var(--primary-color);
|
||||
background: var(--bg-tertiary);
|
||||
@@ -376,6 +384,27 @@
|
||||
line-height: 1.4;
|
||||
}
|
||||
|
||||
.modelDiscoveryToolbar {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: $spacing-sm;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.modelDiscoveryToolbarActions {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: $spacing-xs;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.modelDiscoverySelectionSummary {
|
||||
font-size: 12px;
|
||||
color: var(--text-tertiary);
|
||||
line-height: 1.4;
|
||||
}
|
||||
|
||||
.openaiTestButtonSuccess {
|
||||
background-color: var(--success-badge-bg, #d1fae5);
|
||||
border-color: var(--success-badge-border, #6ee7b7);
|
||||
@@ -417,7 +446,9 @@
|
||||
width: 100%;
|
||||
height: 8px;
|
||||
border-radius: 2px;
|
||||
transition: transform 0.15s ease, opacity 0.15s ease;
|
||||
transition:
|
||||
transform 0.15s ease,
|
||||
opacity 0.15s ease;
|
||||
|
||||
.statusBlockWrapper:hover &,
|
||||
.statusBlockWrapper.statusBlockActive & {
|
||||
|
||||
@@ -356,39 +356,15 @@
|
||||
.fileGrid {
|
||||
display: grid;
|
||||
gap: $spacing-md;
|
||||
grid-template-columns: repeat(3, minmax(0, 1fr));
|
||||
|
||||
@include tablet {
|
||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||
}
|
||||
|
||||
@include mobile {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
grid-template-columns: repeat(auto-fill, minmax(min(100%, 320px), 1fr));
|
||||
}
|
||||
|
||||
.fileGridQuotaManaged {
|
||||
grid-template-columns: repeat(3, minmax(0, 1fr));
|
||||
|
||||
@include tablet {
|
||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||
}
|
||||
|
||||
@include mobile {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
grid-template-columns: repeat(auto-fill, minmax(min(100%, 340px), 1fr));
|
||||
}
|
||||
|
||||
.fileGridCompact {
|
||||
grid-template-columns: repeat(4, minmax(0, 1fr));
|
||||
|
||||
@include tablet {
|
||||
grid-template-columns: repeat(3, minmax(0, 1fr));
|
||||
}
|
||||
|
||||
@include mobile {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
grid-template-columns: repeat(auto-fill, minmax(min(100%, 280px), 1fr));
|
||||
}
|
||||
|
||||
.antigravityGrid {
|
||||
|
||||
+62
-36
@@ -53,7 +53,9 @@ import { useAuthFilesStatusBarCache } from '@/features/authFiles/hooks/useAuthFi
|
||||
import {
|
||||
isAuthFilesSortMode,
|
||||
readAuthFilesUiState,
|
||||
readPersistedAuthFilesCompactMode,
|
||||
writeAuthFilesUiState,
|
||||
writePersistedAuthFilesCompactMode,
|
||||
type AuthFilesSortMode,
|
||||
} from '@/features/authFiles/uiState';
|
||||
import { useAuthStore, useNotificationStore, useThemeStore } from '@/stores';
|
||||
@@ -88,6 +90,7 @@ export function AuthFilesPage() {
|
||||
const [viewMode, setViewMode] = useState<'diagram' | 'list'>('list');
|
||||
const [sortMode, setSortMode] = useState<AuthFilesSortMode>('default');
|
||||
const [batchActionBarVisible, setBatchActionBarVisible] = useState(false);
|
||||
const [uiStateHydrated, setUiStateHydrated] = useState(false);
|
||||
const floatingBatchActionsRef = useRef<HTMLDivElement>(null);
|
||||
const batchActionAnimationRef = useRef<AnimationPlaybackControlsWithThen | null>(null);
|
||||
const previousSelectionCountRef = useRef(0);
|
||||
@@ -176,46 +179,58 @@ export function AuthFilesPage() {
|
||||
const pageSize = compactMode ? pageSizeByMode.compact : pageSizeByMode.regular;
|
||||
|
||||
useEffect(() => {
|
||||
const persisted = readAuthFilesUiState();
|
||||
if (!persisted) return;
|
||||
const persistedCompactMode = readPersistedAuthFilesCompactMode();
|
||||
if (typeof persistedCompactMode === 'boolean') {
|
||||
setCompactMode(persistedCompactMode);
|
||||
}
|
||||
|
||||
if (typeof persisted.filter === 'string' && persisted.filter.trim()) {
|
||||
setFilter(persisted.filter);
|
||||
}
|
||||
if (typeof persisted.problemOnly === 'boolean') {
|
||||
setProblemOnly(persisted.problemOnly);
|
||||
}
|
||||
if (typeof persisted.compactMode === 'boolean') {
|
||||
setCompactMode(persisted.compactMode);
|
||||
}
|
||||
if (typeof persisted.search === 'string') {
|
||||
setSearch(persisted.search);
|
||||
}
|
||||
if (typeof persisted.page === 'number' && Number.isFinite(persisted.page)) {
|
||||
setPage(Math.max(1, Math.round(persisted.page)));
|
||||
}
|
||||
const legacyPageSize =
|
||||
typeof persisted.pageSize === 'number' && Number.isFinite(persisted.pageSize)
|
||||
? clampCardPageSize(persisted.pageSize)
|
||||
: null;
|
||||
const regularPageSize =
|
||||
typeof persisted.regularPageSize === 'number' && Number.isFinite(persisted.regularPageSize)
|
||||
? clampCardPageSize(persisted.regularPageSize)
|
||||
: legacyPageSize ?? DEFAULT_REGULAR_PAGE_SIZE;
|
||||
const compactPageSize =
|
||||
typeof persisted.compactPageSize === 'number' && Number.isFinite(persisted.compactPageSize)
|
||||
? clampCardPageSize(persisted.compactPageSize)
|
||||
: legacyPageSize ?? DEFAULT_COMPACT_PAGE_SIZE;
|
||||
setPageSizeByMode({
|
||||
regular: regularPageSize,
|
||||
compact: compactPageSize,
|
||||
});
|
||||
if (isAuthFilesSortMode(persisted.sortMode)) {
|
||||
setSortMode(persisted.sortMode);
|
||||
const persisted = readAuthFilesUiState();
|
||||
if (persisted) {
|
||||
if (typeof persisted.filter === 'string' && persisted.filter.trim()) {
|
||||
setFilter(persisted.filter);
|
||||
}
|
||||
if (typeof persisted.problemOnly === 'boolean') {
|
||||
setProblemOnly(persisted.problemOnly);
|
||||
}
|
||||
if (
|
||||
typeof persistedCompactMode !== 'boolean' &&
|
||||
typeof persisted.compactMode === 'boolean'
|
||||
) {
|
||||
setCompactMode(persisted.compactMode);
|
||||
}
|
||||
if (typeof persisted.search === 'string') {
|
||||
setSearch(persisted.search);
|
||||
}
|
||||
if (typeof persisted.page === 'number' && Number.isFinite(persisted.page)) {
|
||||
setPage(Math.max(1, Math.round(persisted.page)));
|
||||
}
|
||||
const legacyPageSize =
|
||||
typeof persisted.pageSize === 'number' && Number.isFinite(persisted.pageSize)
|
||||
? clampCardPageSize(persisted.pageSize)
|
||||
: null;
|
||||
const regularPageSize =
|
||||
typeof persisted.regularPageSize === 'number' && Number.isFinite(persisted.regularPageSize)
|
||||
? clampCardPageSize(persisted.regularPageSize)
|
||||
: legacyPageSize ?? DEFAULT_REGULAR_PAGE_SIZE;
|
||||
const compactPageSize =
|
||||
typeof persisted.compactPageSize === 'number' && Number.isFinite(persisted.compactPageSize)
|
||||
? clampCardPageSize(persisted.compactPageSize)
|
||||
: legacyPageSize ?? DEFAULT_COMPACT_PAGE_SIZE;
|
||||
setPageSizeByMode({
|
||||
regular: regularPageSize,
|
||||
compact: compactPageSize,
|
||||
});
|
||||
if (isAuthFilesSortMode(persisted.sortMode)) {
|
||||
setSortMode(persisted.sortMode);
|
||||
}
|
||||
}
|
||||
|
||||
setUiStateHydrated(true);
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
if (!uiStateHydrated) return;
|
||||
|
||||
writeAuthFilesUiState({
|
||||
filter,
|
||||
problemOnly,
|
||||
@@ -227,7 +242,18 @@ export function AuthFilesPage() {
|
||||
compactPageSize: pageSizeByMode.compact,
|
||||
sortMode,
|
||||
});
|
||||
}, [filter, problemOnly, compactMode, search, page, pageSize, pageSizeByMode, sortMode]);
|
||||
writePersistedAuthFilesCompactMode(compactMode);
|
||||
}, [
|
||||
compactMode,
|
||||
filter,
|
||||
page,
|
||||
pageSize,
|
||||
pageSizeByMode,
|
||||
problemOnly,
|
||||
search,
|
||||
sortMode,
|
||||
uiStateHydrated,
|
||||
]);
|
||||
|
||||
useEffect(() => {
|
||||
setPageSizeInput(String(pageSize));
|
||||
|
||||
@@ -6,6 +6,7 @@ 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 { usePageTransitionLayer } from '@/components/common/PageTransitionLayer';
|
||||
import { Button } from '@/components/ui/Button';
|
||||
import { Input } from '@/components/ui/Input';
|
||||
import {
|
||||
@@ -36,6 +37,8 @@ function readCommercialModeFromYaml(yamlContent: string): boolean {
|
||||
|
||||
export function ConfigPage() {
|
||||
const { t } = useTranslation();
|
||||
const pageTransitionLayer = usePageTransitionLayer();
|
||||
const isCurrentLayer = pageTransitionLayer ? pageTransitionLayer.status === 'current' : true;
|
||||
const showNotification = useNotificationStore((state) => state.showNotification);
|
||||
const showConfirmation = useNotificationStore((state) => state.showConfirmation);
|
||||
const connectionStatus = useAuthStore((state) => state.connectionStatus);
|
||||
@@ -79,6 +82,7 @@ export function ConfigPage() {
|
||||
|
||||
const disableControls = connectionStatus !== 'connected';
|
||||
const isDirty = dirty || visualDirty;
|
||||
const shouldRenderFloatingActions = isCurrentLayer;
|
||||
const hasVisualModeError = !!visualParseError;
|
||||
const hasVisualValidationErrors =
|
||||
activeTab === 'visual' &&
|
||||
@@ -358,7 +362,7 @@ export function ConfigPage() {
|
||||
|
||||
// Keep bottom floating actions from covering page content by syncing its height to a CSS variable.
|
||||
useLayoutEffect(() => {
|
||||
if (typeof window === 'undefined') return;
|
||||
if (typeof window === 'undefined' || !shouldRenderFloatingActions) return;
|
||||
|
||||
const actionsEl = floatingActionsRef.current;
|
||||
if (!actionsEl) return;
|
||||
@@ -379,7 +383,7 @@ export function ConfigPage() {
|
||||
window.removeEventListener('resize', updatePadding);
|
||||
document.documentElement.style.removeProperty('--config-action-bar-height');
|
||||
};
|
||||
}, []);
|
||||
}, [shouldRenderFloatingActions]);
|
||||
|
||||
// CodeMirror extensions
|
||||
const extensions = useMemo(
|
||||
@@ -622,7 +626,9 @@ export function ConfigPage() {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{typeof document !== 'undefined' ? createPortal(floatingActions, document.body) : null}
|
||||
{shouldRenderFloatingActions && typeof document !== 'undefined'
|
||||
? createPortal(floatingActions, document.body)
|
||||
: null}
|
||||
<DiffModal
|
||||
open={diffModalOpen}
|
||||
original={serverYaml}
|
||||
|
||||
Reference in New Issue
Block a user