Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
33 changes: 26 additions & 7 deletions frontend/src/components/layout/Header.tsx
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import { useTranslation } from 'react-i18next'
import { Sun, Moon, Languages, Play, Pause } from 'lucide-react'
import { Sun, Moon, Languages, Play, Pause, Menu } from 'lucide-react'
import { useTheme } from '@/hooks/useTheme'
import { cn } from '@/lib/utils'
import logoIcon from '@/assets/icons/icons.png'
Expand All @@ -9,7 +9,7 @@ import { useSettingsStore } from '@/stores/settingsStore'
export function Header() {
const { t } = useTranslation()
const { toggleTheme, isDark } = useTheme()
const { language, setLanguage } = useSettingsStore()
const { language, setLanguage, toggleMobileSidebar } = useSettingsStore()
const [proxyEnabled, setProxyEnabled] = useState(false)
const [proxyLoading, setProxyLoading] = useState(false)
const [port, setPort] = useState(8080)
Expand Down Expand Up @@ -51,8 +51,17 @@ export function Header() {
}

return (
<header className="glass-topbar flex items-center justify-between px-4 drag-region h-12">
<div className="flex items-center gap-3 no-drag">
<header className="glass-topbar flex items-center justify-between px-3 sm:px-4 drag-region h-12">
<div className="flex items-center gap-2 sm:gap-3 no-drag">
{/* Mobile hamburger menu */}
<button
onClick={toggleMobileSidebar}
className="w-8 h-8 flex md:hidden items-center justify-center rounded-lg transition-all duration-300 group"
aria-label="Toggle menu"
>
<Menu className="h-5 w-5 text-[var(--text-primary)] group-hover:text-[var(--accent-primary)]" />
</button>

<div className="sidebar-logo-icon">
<img
src={logoIcon}
Expand All @@ -67,7 +76,7 @@ export function Header() {
</div>
</div>

<div className="flex items-center gap-4 no-drag">
<div className="flex items-center gap-2 sm:gap-4 no-drag">
<button
onClick={toggleTheme}
className="w-8 h-8 flex items-center justify-center rounded-lg transition-all duration-300 group"
Expand All @@ -91,7 +100,7 @@ export function Header() {
<div className="flex items-center">
<div
className={cn(
"flex items-center gap-1.5 pl-2.5 pr-1 py-1 rounded-full transition-all duration-300",
"flex items-center gap-1 sm:gap-1.5 pl-2 sm:pl-2.5 pr-1 py-1 rounded-full transition-all duration-300",
"border",
proxyEnabled
? "proxy-toggle-active"
Expand All @@ -110,14 +119,24 @@ export function Header() {
/>
<span
className={cn(
"text-xs font-medium transition-colors duration-300",
"text-xs font-medium transition-colors duration-300 hidden sm:inline",
proxyEnabled
? "text-[var(--accent-primary)]"
: "text-[var(--text-muted)]"
)}
>
127.0.0.1:{port}
</span>
<span
className={cn(
"text-xs font-medium transition-colors duration-300 sm:hidden",
proxyEnabled
? "text-[var(--accent-primary)]"
: "text-[var(--text-muted)]"
)}
>
:{port}
</span>
<button
onClick={handleToggleProxy}
disabled={proxyLoading}
Expand Down
10 changes: 8 additions & 2 deletions frontend/src/components/layout/MainLayout.tsx
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import { Outlet } from 'react-router-dom'
import { Sidebar } from './Sidebar'
import { Header } from './Header'
import { MobileSidebar } from './MobileSidebar'

export function MainLayout() {
return (
Expand All @@ -11,8 +12,13 @@ export function MainLayout() {
</div>
<Header />
<div className="flex flex-1 overflow-hidden">
<Sidebar />
<main className="flex-1 overflow-auto p-6">
{/* Desktop sidebar - hidden on mobile */}
<div className="hidden md:block">
<Sidebar />
</div>
{/* Mobile sidebar overlay */}
<MobileSidebar />
<main className="flex-1 overflow-auto p-3 sm:p-4 md:p-6">
<Outlet />
</main>
</div>
Expand Down
117 changes: 117 additions & 0 deletions frontend/src/components/layout/MobileSidebar.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,117 @@
import { NavLink, useNavigate, useLocation } from 'react-router-dom'
import { useTranslation } from 'react-i18next'
import {
LayoutDashboard,
Server,
Settings2,
FileText,
Settings,
Key,
Cpu,
Info,
MessageSquare,
X,
} from 'lucide-react'
import { cn } from '@/lib/utils'
import { useSettingsStore } from '@/stores/settingsStore'
import { useNavigationStore } from '@/stores/navigationStore'

interface NavItem {
titleKey: string
href: string
icon: React.ComponentType<{ className?: string }>
}

const navItems: NavItem[] = [
{ titleKey: 'nav.dashboard', href: '/', icon: LayoutDashboard },
{ titleKey: 'nav.providers', href: '/providers', icon: Server },
{ titleKey: 'nav.proxy', href: '/proxy', icon: Settings2 },
{ titleKey: 'nav.models', href: '/models', icon: Cpu },
{ titleKey: 'nav.session', href: '/session', icon: MessageSquare },
{ titleKey: 'nav.apiKeys', href: '/api-keys', icon: Key },
{ titleKey: 'nav.logs', href: '/logs', icon: FileText },
{ titleKey: 'nav.settings', href: '/settings', icon: Settings },
{ titleKey: 'nav.about', href: '/about', icon: Info },
]

export function MobileSidebar() {
const { t } = useTranslation()
const { mobileSidebarOpen, setMobileSidebarOpen } = useSettingsStore()
const { blockers } = useNavigationStore()
const navigate = useNavigate()
const location = useLocation()

const hasBlockers = blockers.length > 0

const handleNavigation = (href: string) => {
if (hasBlockers && location.pathname !== href) {
// Let the navigation blocker dialog handle this
return
}
navigate(href)
setMobileSidebarOpen(false)
}

if (!mobileSidebarOpen) return null

return (
<>
{/* Backdrop overlay */}
<div
className="fixed inset-0 z-40 bg-black/60 backdrop-blur-sm md:hidden"
onClick={() => setMobileSidebarOpen(false)}
/>
{/* Sidebar panel */}
<aside
className={cn(
'fixed inset-y-0 left-0 z-50 w-64 flex flex-col md:hidden',
'glass-sidebar !m-0 !rounded-none !rounded-r-2xl',
'animate-in slide-in-from-left duration-300'
)}
>
{/* Close button */}
<div className="flex items-center justify-between p-4 border-b border-[var(--glass-border)]">
<span className="text-base font-semibold text-[var(--text-primary)]">
{t('nav.menu', 'Menu')}
</span>
<button
onClick={() => setMobileSidebarOpen(false)}
className="w-8 h-8 flex items-center justify-center rounded-lg text-[var(--text-muted)] hover:text-[var(--text-primary)] hover:bg-[var(--glass-bg-hover)] transition-all"
>
<X className="h-5 w-5" />
</button>
</div>

{/* Navigation items */}
<nav className="flex-1 p-3 space-y-1 overflow-y-auto">
{navItems.map((item) => {
const title = t(item.titleKey)
return (
<div
key={item.href}
onClick={() => handleNavigation(item.href)}
className="block cursor-pointer"
>
<NavLink
to={item.href}
onClick={(e) => {
e.preventDefault()
}}
className={({ isActive }) =>
cn(
'sidebar-nav-item expanded',
isActive ? 'active' : 'inactive'
)
}
>
<item.icon className="h-5 w-5 flex-shrink-0" />
<span className="whitespace-nowrap">{title}</span>
</NavLink>
</div>
)
})}
</nav>
</aside>
</>
)
}
10 changes: 5 additions & 5 deletions frontend/src/components/logs/RequestLogList.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -133,7 +133,7 @@ export function RequestLogList() {
return (
<div className="space-y-4">
<div className="flex flex-wrap items-center gap-2">
<div className="relative min-w-[220px] flex-1">
<div className="relative min-w-[160px] sm:min-w-[220px] flex-1">
<Search className="pointer-events-none absolute left-2 top-1/2 h-4 w-4 -translate-y-1/2 text-muted-foreground" />
<Input
value={search}
Expand Down Expand Up @@ -180,8 +180,8 @@ export function RequestLogList() {
<TableHead className="w-[170px]">{t('logs.time')}</TableHead>
<TableHead className="w-[100px]">{t('logs.status')}</TableHead>
<TableHead>{t('logs.model')}</TableHead>
<TableHead>{t('logs.provider')}</TableHead>
<TableHead>{t('logs.account')}</TableHead>
<TableHead className="hidden sm:table-cell">{t('logs.provider')}</TableHead>
<TableHead className="hidden md:table-cell">{t('logs.account')}</TableHead>
<TableHead className="w-[100px] text-right">{t('logs.latency')}</TableHead>
</TableRow>
</TableHeader>
Expand Down Expand Up @@ -220,8 +220,8 @@ export function RequestLogList() {
</span>
) : null}
</TableCell>
<TableCell className="text-sm">{log.providerName || '-'}</TableCell>
<TableCell className="text-sm">{log.accountName || '-'}</TableCell>
<TableCell className="hidden sm:table-cell text-sm">{log.providerName || '-'}</TableCell>
<TableCell className="hidden md:table-cell text-sm">{log.accountName || '-'}</TableCell>
<TableCell className="text-right text-sm">
{formatLatency(log.latency)}
</TableCell>
Expand Down
2 changes: 1 addition & 1 deletion frontend/src/components/ui/dialog.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -36,7 +36,7 @@ const DialogContent = React.forwardRef<
<DialogPrimitive.Content
ref={ref}
className={cn(
"fixed left-[50%] top-[50%] z-50 grid w-full max-w-lg translate-x-[-50%] translate-y-[-50%] gap-4 border bg-background p-6 shadow-lg duration-200 data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[state=closed]:slide-out-to-left-1/2 data-[state=closed]:slide-out-to-top-[48%] data-[state=open]:slide-in-from-left-1/2 data-[state=open]:slide-in-from-top-[48%] sm:rounded-lg",
"fixed left-[50%] top-[50%] z-50 grid w-[calc(100%-2rem)] max-w-lg translate-x-[-50%] translate-y-[-50%] gap-4 border bg-background p-4 sm:p-6 shadow-lg duration-200 data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[state=closed]:slide-out-to-left-1/2 data-[state=closed]:slide-out-to-top-[48%] data-[state=open]:slide-in-from-left-1/2 data-[state=open]:slide-in-from-top-[48%] rounded-lg sm:rounded-lg",
className
)}
{...props}
Expand Down
38 changes: 26 additions & 12 deletions frontend/src/i18n/index.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,5 @@
import i18n from 'i18next'
import { initReactI18next } from 'react-i18next'
import LanguageDetector from 'i18next-browser-languagedetector'

import zhCN from './locales/zh-CN.json'
import enUS from './locales/en-US.json'
Expand All @@ -14,26 +13,41 @@ const resources = {
},
}

/**
* Determine the initial language from persisted zustand store or browser navigator.
* We no longer use i18next-browser-languagedetector to avoid race conditions
* with zustand's persist middleware (both writing to localStorage independently).
*/
function getInitialLanguage(): string {
try {
const stored = localStorage.getItem('chat2api-settings')
if (stored) {
const parsed = JSON.parse(stored)
const lang = parsed?.state?.language
if (lang === 'zh-CN' || lang === 'en-US') {
return lang
}
}
} catch {
// ignore parse errors
}

// Fallback: detect from browser navigator
const navLang = navigator.language || ''
if (navLang.startsWith('zh')) return 'zh-CN'
return 'en-US'
}

i18n
.use(LanguageDetector)
.use(initReactI18next)
.init({
resources,
lng: getInitialLanguage(),
fallbackLng: 'en-US',
debug: false,
interpolation: {
escapeValue: false,
},
detection: {
order: ['localStorage', 'navigator'],
caches: ['localStorage'],
lookupLocalStorage: 'i18nextLng',
convertDetectedLanguage: (lng: string) => {
if (lng.includes('zh')) return 'zh-CN'
if (lng.includes('en')) return 'en-US'
return 'en-US'
},
},
})

export default i18n
3 changes: 2 additions & 1 deletion frontend/src/i18n/locales/en-US.json
Original file line number Diff line number Diff line change
Expand Up @@ -16,7 +16,8 @@
"logs": "Logs",
"session": "Session",
"settings": "Settings",
"about": "About"
"about": "About",
"menu": "Menu"
},
"dashboard": {
"title": "Dashboard",
Expand Down
3 changes: 2 additions & 1 deletion frontend/src/i18n/locales/zh-CN.json
Original file line number Diff line number Diff line change
Expand Up @@ -16,7 +16,8 @@
"logs": "日志",
"session": "会话管理",
"settings": "设置",
"about": "关于"
"about": "关于",
"menu": "菜单"
},
"dashboard": {
"title": "仪表盘",
Expand Down
Loading