mirror of
https://github.com/router-for-me/Cli-Proxy-API-Management-Center.git
synced 2026-02-18 02:30:51 +08:00
288 lines
10 KiB
TypeScript
288 lines
10 KiB
TypeScript
import { CSSProperties, useCallback, useEffect, useLayoutEffect, useRef, useState } from 'react';
|
|
import { createPortal } from 'react-dom';
|
|
import { useLocation } from 'react-router-dom';
|
|
import { usePageTransitionLayer } from '@/components/common/PageTransitionLayer';
|
|
import { useThemeStore } from '@/stores';
|
|
import iconGemini from '@/assets/icons/gemini.svg';
|
|
import iconOpenaiLight from '@/assets/icons/openai-light.svg';
|
|
import iconOpenaiDark from '@/assets/icons/openai-dark.svg';
|
|
import iconCodexLight from '@/assets/icons/codex_light.svg';
|
|
import iconCodexDark from '@/assets/icons/codex_drak.svg';
|
|
import iconClaude from '@/assets/icons/claude.svg';
|
|
import iconVertex from '@/assets/icons/vertex.svg';
|
|
import iconAmp from '@/assets/icons/amp.svg';
|
|
import styles from './ProviderNav.module.scss';
|
|
|
|
export type ProviderId = 'gemini' | 'codex' | 'claude' | 'vertex' | 'ampcode' | 'openai';
|
|
|
|
interface ProviderNavItem {
|
|
id: ProviderId;
|
|
label: string;
|
|
getIcon: (theme: string) => string;
|
|
}
|
|
|
|
const PROVIDERS: ProviderNavItem[] = [
|
|
{ id: 'gemini', label: 'Gemini', getIcon: () => iconGemini },
|
|
{ id: 'codex', label: 'Codex', getIcon: (theme) => (theme === 'dark' ? iconCodexDark : iconCodexLight) },
|
|
{ id: 'claude', label: 'Claude', getIcon: () => iconClaude },
|
|
{ id: 'vertex', label: 'Vertex', getIcon: () => iconVertex },
|
|
{ id: 'ampcode', label: 'Ampcode', getIcon: () => iconAmp },
|
|
{ id: 'openai', label: 'OpenAI', getIcon: (theme) => (theme === 'dark' ? iconOpenaiDark : iconOpenaiLight) },
|
|
];
|
|
|
|
const HEADER_OFFSET = 24;
|
|
type ScrollContainer = HTMLElement | (Window & typeof globalThis);
|
|
|
|
export function ProviderNav() {
|
|
const location = useLocation();
|
|
const pageTransitionLayer = usePageTransitionLayer();
|
|
const isCurrentLayer = pageTransitionLayer ? pageTransitionLayer.status === 'current' : true;
|
|
const resolvedTheme = useThemeStore((state) => state.resolvedTheme);
|
|
const [activeProvider, setActiveProvider] = useState<ProviderId | null>(null);
|
|
const contentScrollerRef = useRef<HTMLElement | null>(null);
|
|
const navListRef = useRef<HTMLDivElement | null>(null);
|
|
const navContainerRef = useRef<HTMLDivElement | null>(null);
|
|
const itemRefs = useRef<Record<ProviderId, HTMLButtonElement | null>>({
|
|
gemini: null,
|
|
codex: null,
|
|
claude: null,
|
|
vertex: null,
|
|
ampcode: null,
|
|
openai: null,
|
|
});
|
|
const [indicatorRect, setIndicatorRect] = useState<{
|
|
x: number;
|
|
y: number;
|
|
width: number;
|
|
height: number;
|
|
} | null>(null);
|
|
const [indicatorTransitionsEnabled, setIndicatorTransitionsEnabled] = useState(false);
|
|
const indicatorHasEnabledTransitionsRef = useRef(false);
|
|
|
|
// Only show this quick-switch overlay on the AI Providers list page.
|
|
// Note: The app uses iOS-style stacked page transitions inside `/ai-providers/*`,
|
|
// so this component can stay mounted while the user is on an edit route.
|
|
const normalizedPathname =
|
|
location.pathname.length > 1 && location.pathname.endsWith('/')
|
|
? location.pathname.slice(0, -1)
|
|
: location.pathname;
|
|
const shouldShow = isCurrentLayer && normalizedPathname === '/ai-providers';
|
|
|
|
const getHeaderHeight = useCallback(() => {
|
|
const header = document.querySelector('.main-header') as HTMLElement | null;
|
|
if (header) return header.getBoundingClientRect().height;
|
|
|
|
const raw = getComputedStyle(document.documentElement).getPropertyValue('--header-height');
|
|
const value = Number.parseFloat(raw);
|
|
return Number.isFinite(value) ? value : 0;
|
|
}, []);
|
|
|
|
const getContentScroller = useCallback(() => {
|
|
if (contentScrollerRef.current && document.contains(contentScrollerRef.current)) {
|
|
return contentScrollerRef.current;
|
|
}
|
|
|
|
const container = document.querySelector('.content') as HTMLElement | null;
|
|
contentScrollerRef.current = container;
|
|
return container;
|
|
}, []);
|
|
|
|
const getScrollContainer = useCallback((): ScrollContainer => {
|
|
// Mobile layout uses document scroll (layout switches at 768px); desktop uses the `.content` scroller.
|
|
const isMobile = window.matchMedia('(max-width: 768px)').matches;
|
|
if (isMobile) return window;
|
|
return getContentScroller() ?? window;
|
|
}, [getContentScroller]);
|
|
|
|
const handleScroll = useCallback(() => {
|
|
const container = getScrollContainer();
|
|
if (!container) return;
|
|
|
|
const isElementScroller = container instanceof HTMLElement;
|
|
const headerHeight = isElementScroller ? 0 : getHeaderHeight();
|
|
const containerTop = isElementScroller ? container.getBoundingClientRect().top : 0;
|
|
const activationLine = containerTop + headerHeight + HEADER_OFFSET + 1;
|
|
let currentActive: ProviderId | null = null;
|
|
|
|
for (const provider of PROVIDERS) {
|
|
const element = document.getElementById(`provider-${provider.id}`);
|
|
if (!element) continue;
|
|
|
|
const rect = element.getBoundingClientRect();
|
|
if (rect.top <= activationLine) {
|
|
currentActive = provider.id;
|
|
continue;
|
|
}
|
|
|
|
if (currentActive) break;
|
|
}
|
|
|
|
if (!currentActive) {
|
|
const firstVisible = PROVIDERS.find((provider) =>
|
|
document.getElementById(`provider-${provider.id}`)
|
|
);
|
|
currentActive = firstVisible?.id ?? null;
|
|
}
|
|
|
|
setActiveProvider(currentActive);
|
|
}, [getHeaderHeight, getScrollContainer]);
|
|
|
|
useEffect(() => {
|
|
if (!shouldShow) return;
|
|
const contentScroller = getContentScroller();
|
|
|
|
// Listen to both: desktop scroll happens on `.content`; mobile uses `window`.
|
|
window.addEventListener('scroll', handleScroll, { passive: true });
|
|
contentScroller?.addEventListener('scroll', handleScroll, { passive: true });
|
|
window.addEventListener('resize', handleScroll);
|
|
const raf = requestAnimationFrame(handleScroll);
|
|
return () => {
|
|
cancelAnimationFrame(raf);
|
|
window.removeEventListener('scroll', handleScroll);
|
|
window.removeEventListener('resize', handleScroll);
|
|
contentScroller?.removeEventListener('scroll', handleScroll);
|
|
};
|
|
}, [getContentScroller, handleScroll, shouldShow]);
|
|
|
|
const updateIndicator = useCallback((providerId: ProviderId | null) => {
|
|
if (!providerId) {
|
|
setIndicatorRect(null);
|
|
return;
|
|
}
|
|
|
|
const itemEl = itemRefs.current[providerId];
|
|
if (!itemEl) return;
|
|
|
|
setIndicatorRect({
|
|
x: itemEl.offsetLeft,
|
|
y: itemEl.offsetTop,
|
|
width: itemEl.offsetWidth,
|
|
height: itemEl.offsetHeight,
|
|
});
|
|
|
|
// Avoid animating from an initial (0,0) state on first paint.
|
|
if (!indicatorHasEnabledTransitionsRef.current) {
|
|
indicatorHasEnabledTransitionsRef.current = true;
|
|
requestAnimationFrame(() => setIndicatorTransitionsEnabled(true));
|
|
}
|
|
}, []);
|
|
|
|
useLayoutEffect(() => {
|
|
if (!shouldShow) return;
|
|
const raf = requestAnimationFrame(() => updateIndicator(activeProvider));
|
|
return () => cancelAnimationFrame(raf);
|
|
}, [activeProvider, shouldShow, updateIndicator]);
|
|
|
|
// Expose overlay height to the page, so it can reserve bottom padding and avoid being covered.
|
|
useLayoutEffect(() => {
|
|
if (!shouldShow) return;
|
|
|
|
const el = navContainerRef.current;
|
|
if (!el) return;
|
|
|
|
const updateHeight = () => {
|
|
const height = el.getBoundingClientRect().height;
|
|
document.documentElement.style.setProperty('--provider-nav-height', `${height}px`);
|
|
};
|
|
|
|
updateHeight();
|
|
window.addEventListener('resize', updateHeight);
|
|
|
|
const ro = typeof ResizeObserver === 'undefined' ? null : new ResizeObserver(updateHeight);
|
|
ro?.observe(el);
|
|
|
|
return () => {
|
|
ro?.disconnect();
|
|
window.removeEventListener('resize', updateHeight);
|
|
document.documentElement.style.removeProperty('--provider-nav-height');
|
|
};
|
|
}, [shouldShow]);
|
|
|
|
const scrollToProvider = (providerId: ProviderId) => {
|
|
const container = getScrollContainer();
|
|
const element = document.getElementById(`provider-${providerId}`);
|
|
if (!element || !container) return;
|
|
|
|
setActiveProvider(providerId);
|
|
updateIndicator(providerId);
|
|
|
|
// Mobile: scroll the document (header is fixed, so offset by header height).
|
|
if (!(container instanceof HTMLElement)) {
|
|
const headerHeight = getHeaderHeight();
|
|
const elementTop = element.getBoundingClientRect().top + window.scrollY;
|
|
const target = Math.max(0, elementTop - headerHeight - HEADER_OFFSET);
|
|
window.scrollTo({ top: target, behavior: 'smooth' });
|
|
return;
|
|
}
|
|
|
|
const containerRect = container.getBoundingClientRect();
|
|
const elementRect = element.getBoundingClientRect();
|
|
const scrollTop = container.scrollTop + (elementRect.top - containerRect.top) - HEADER_OFFSET;
|
|
|
|
container.scrollTo({ top: scrollTop, behavior: 'smooth' });
|
|
};
|
|
|
|
useEffect(() => {
|
|
if (!shouldShow) return;
|
|
const handleResize = () => updateIndicator(activeProvider);
|
|
window.addEventListener('resize', handleResize);
|
|
return () => {
|
|
window.removeEventListener('resize', handleResize);
|
|
};
|
|
}, [activeProvider, shouldShow, updateIndicator]);
|
|
|
|
const navContent = (
|
|
<div className={styles.navContainer} ref={navContainerRef}>
|
|
<div className={styles.navList} ref={navListRef}>
|
|
<div
|
|
className={[
|
|
styles.indicator,
|
|
indicatorRect ? styles.indicatorVisible : '',
|
|
indicatorTransitionsEnabled ? '' : styles.indicatorNoTransition,
|
|
]
|
|
.filter(Boolean)
|
|
.join(' ')}
|
|
style={
|
|
(indicatorRect
|
|
? ({
|
|
transform: `translate3d(${indicatorRect.x}px, ${indicatorRect.y}px, 0)`,
|
|
width: indicatorRect.width,
|
|
height: indicatorRect.height,
|
|
} satisfies CSSProperties)
|
|
: undefined) as CSSProperties | undefined
|
|
}
|
|
/>
|
|
{PROVIDERS.map((provider) => {
|
|
const isActive = activeProvider === provider.id;
|
|
return (
|
|
<button
|
|
key={provider.id}
|
|
className={`${styles.navItem} ${isActive ? styles.active : ''}`}
|
|
ref={(node) => {
|
|
itemRefs.current[provider.id] = node;
|
|
}}
|
|
onClick={() => scrollToProvider(provider.id)}
|
|
title={provider.label}
|
|
type="button"
|
|
aria-label={provider.label}
|
|
aria-pressed={isActive}
|
|
>
|
|
<img
|
|
src={provider.getIcon(resolvedTheme)}
|
|
alt={provider.label}
|
|
className={styles.icon}
|
|
/>
|
|
</button>
|
|
);
|
|
})}
|
|
</div>
|
|
</div>
|
|
);
|
|
|
|
if (typeof document === 'undefined') return null;
|
|
|
|
if (!shouldShow) return null;
|
|
|
|
return createPortal(navContent, document.body);
|
|
}
|