Compare commits

...

1 Commits

Author SHA1 Message Date
nanyuantingfeng
a4cb0b460f feat(web): add theme system with CSS variables and settings modal
- Add ThemeContext with light/dark/system theme support
- Migrate all hardcoded colors to CSS variables
- Add SettingsModal for theme customization
- Add font loader for dynamic font selection
- Add i18n support for Chinese and Turkish locales
- Fix accessibility: add aria-live to pairing error message
2026-03-21 05:57:29 -04:00
23 changed files with 2031 additions and 986 deletions

View File

@ -1,6 +1,6 @@
import { Routes, Route, Navigate } from 'react-router-dom'; import { Routes, Route, Navigate } from 'react-router-dom';
import { useState, useEffect, createContext, useContext, Component } from 'react'; import { useState, useEffect, createContext, useContext, Component, type ReactNode, type ErrorInfo } from 'react';
import type { ReactNode, ErrorInfo } from 'react'; import { ThemeProvider } from './contexts/ThemeContext';
import Layout from './components/layout/Layout'; import Layout from './components/layout/Layout';
import Dashboard from './pages/Dashboard'; import Dashboard from './pages/Dashboard';
import AgentChat from './pages/AgentChat'; import AgentChat from './pages/AgentChat';
@ -61,19 +61,19 @@ export class ErrorBoundary extends Component<
if (this.state.error) { if (this.state.error) {
return ( return (
<div className="p-6"> <div className="p-6">
<div className="bg-gray-900 border border-red-700 rounded-xl p-6 w-full max-w-lg"> <div className="card p-6 w-full max-w-lg" style={{ borderColor: 'rgba(239, 68, 68, 0.3)' }}>
<h2 className="text-lg font-semibold text-red-400 mb-2"> <h2 className="text-lg font-semibold mb-2" style={{ color: 'var(--color-status-error)' }}>
Something went wrong Something went wrong
</h2> </h2>
<p className="text-gray-400 text-sm mb-4"> <p className="text-sm mb-4" style={{ color: 'var(--pc-text-muted)' }}>
A render error occurred. Check the browser console for details. A render error occurred. Check the browser console for details.
</p> </p>
<pre className="text-xs text-red-300 bg-gray-800 rounded p-3 overflow-x-auto whitespace-pre-wrap break-all"> <pre className="text-xs rounded-lg p-3 overflow-x-auto whitespace-pre-wrap break-all font-mono" style={{ background: 'var(--pc-bg-base)', color: 'var(--color-status-error)' }}>
{this.state.error.message} {this.state.error.message}
</pre> </pre>
<button <button
onClick={() => this.setState({ error: null })} onClick={() => this.setState({ error: null })}
className="mt-6 px-4 py-2 bg-blue-600 hover:bg-blue-700 text-white text-sm font-medium rounded-lg transition-colors" className="btn-electric mt-6 px-4 py-2 text-sm font-medium"
> >
Try again Try again
</button> </button>
@ -125,36 +125,30 @@ function PairingDialog({ onPair }: { onPair: (code: string) => Promise<void> })
}; };
return ( return (
<div className="min-h-screen flex items-center justify-center" style={{ background: 'radial-gradient(ellipse at center, #0a0a20 0%, #050510 70%)' }}> <div className="min-h-screen flex items-center justify-center" style={{ background: 'var(--pc-bg-base)' }}>
{/* Ambient glow */} {/* Ambient glow */}
<div className="fixed top-1/2 left-1/2 -translate-x-1/2 -translate-y-1/2 w-[500px] h-[500px] rounded-full opacity-20 pointer-events-none" style={{ background: 'radial-gradient(circle, #0080ff 0%, transparent 70%)' }} /> <div className="relative surface-panel p-8 w-full max-w-md animate-fade-in-scale">
<div className="relative glass-card p-8 w-full max-w-md animate-fade-in-scale">
{/* Top glow accent */}
<div className="absolute -top-px left-1/4 right-1/4 h-px" style={{ background: 'linear-gradient(90deg, transparent, #0080ff, transparent)' }} />
<div className="text-center mb-8"> <div className="text-center mb-8">
<img <img
src="/_app/zeroclaw-trans.png" src="/_app/zeroclaw-trans.png"
alt="ZeroClaw" alt="ZeroClaw"
className="h-20 w-20 rounded-2xl object-cover mx-auto mb-4 animate-float" className="h-20 w-20 rounded-2xl object-cover mx-auto mb-4 animate-float"
style={{ boxShadow: '0 0 30px rgba(0,128,255,0.3)' }} onError={(e) => { e.currentTarget.style.display = 'none'; }}
/> />
<h1 className="text-2xl font-bold text-gradient-blue mb-2">ZeroClaw</h1> <h1 className="text-2xl font-bold mb-2 text-gradient-blue">ZeroClaw</h1>
{displayCode ? ( <p className="text-sm" style={{ color: 'var(--pc-text-muted)' }}>
<p className="text-[#556080] text-sm">Your pairing code</p> {displayCode ? 'Your pairing code' : 'Enter the pairing code from your terminal'}
) : ( </p>
<p className="text-[#556080] text-sm">Enter the pairing code from your terminal</p>
)}
</div> </div>
{/* Show the pairing code if available (localhost) */} {/* Show the pairing code if available (localhost) */}
{!codeLoading && displayCode && ( {!codeLoading && displayCode && (
<div className="mb-6 p-4 rounded-xl text-center" style={{ background: 'rgba(0,128,255,0.08)', border: '1px solid rgba(0,128,255,0.2)' }}> <div className="mb-6 p-4 rounded-2xl text-center border" style={{ background: 'var(--pc-accent-glow)', borderColor: 'var(--pc-accent-dim)' }}>
<div className="text-4xl font-mono font-bold tracking-[0.4em] text-white py-2"> <div className="text-4xl font-mono font-bold tracking-[0.4em] py-2" style={{ color: 'var(--pc-text-primary)' }}>
{displayCode} {displayCode}
</div> </div>
<p className="text-[#556080] text-xs mt-2">Enter this code below or on another device</p> <p className="text-xs mt-2" style={{ color: 'var(--pc-text-muted)' }}>Enter this code below or on another device</p>
</div> </div>
)} )}
@ -169,7 +163,7 @@ function PairingDialog({ onPair }: { onPair: (code: string) => Promise<void> })
autoFocus autoFocus
/> />
{error && ( {error && (
<p className="text-[#ff4466] text-sm mb-4 text-center animate-fade-in" aria-live="polite">{error}</p> <p aria-live="polite" className="text-sm mb-4 text-center animate-fade-in" style={{ color: 'var(--color-status-error)' }}>{error}</p>
)} )}
<button <button
type="submit" type="submit"
@ -210,10 +204,10 @@ function AppContent() {
if (loading) { if (loading) {
return ( return (
<div className="min-h-screen flex items-center justify-center" style={{ background: 'radial-gradient(ellipse at center, #0a0a20 0%, #050510 70%)' }}> <div className="min-h-screen flex items-center justify-center" style={{ background: 'var(--pc-bg-base)' }}>
<div className="flex flex-col items-center gap-4 animate-fade-in"> <div className="flex flex-col items-center gap-4 animate-fade-in">
<div className="h-10 w-10 border-2 border-[#0080ff30] border-t-[#0080ff] rounded-full animate-spin" /> <div className="h-10 w-10 border-2 rounded-full animate-spin" style={{ borderColor: 'var(--pc-border)', borderTopColor: 'var(--pc-accent)' }} />
<p className="text-[#556080] text-sm">Connecting...</p> <p className="text-sm" style={{ color: 'var(--pc-text-muted)' }}>Connecting...</p>
</div> </div>
</div> </div>
); );
@ -250,7 +244,9 @@ function AppContent() {
export default function App() { export default function App() {
return ( return (
<AuthProvider> <AuthProvider>
<AppContent /> <ThemeProvider>
<AppContent />
</ThemeProvider>
</AuthProvider> </AuthProvider>
); );
} }

View File

@ -0,0 +1,327 @@
import { useEffect, useMemo, useState } from 'react';
import { X, Settings, Sun, Moon, Monitor, Laptop, Check, Type, CaseSensitive } from 'lucide-react';
import { useTheme } from '@/hooks/useTheme';
import { t } from '@/lib/i18n';
import type { ThemeName, AccentColor, UiFont, MonoFont } from '@/contexts/ThemeContextDef';
import { uiFontStacks, monoFontStacks } from '@/contexts/ThemeContextDef';
const themeOptions: { value: ThemeName; icon: typeof Sun; labelKey: string }[] = [
{ value: 'system', icon: Laptop, labelKey: 'theme.system' },
{ value: 'dark', icon: Moon, labelKey: 'theme.dark' },
{ value: 'light', icon: Sun, labelKey: 'theme.light' },
{ value: 'oled', icon: Monitor, labelKey: 'theme.oled' },
];
const accentOptions: { value: AccentColor; color: string }[] = [
{ value: 'cyan', color: '#22d3ee' },
{ value: 'violet', color: '#8b5cf6' },
{ value: 'emerald', color: '#10b981' },
{ value: 'amber', color: '#f59e0b' },
{ value: 'rose', color: '#f43f5e' },
{ value: 'blue', color: '#3b82f6' },
];
const uiFontOptions: { value: UiFont; label: string; sample: string }[] = [
{ value: 'system', label: 'System', sample: 'Segoe/UI' },
{ value: 'inter', label: 'Inter', sample: 'Inter' },
{ value: 'segoe', label: 'Segoe UI', sample: 'Segoe' },
{ value: 'sf', label: 'SF Pro', sample: 'SF' },
];
const monoFontOptions: { value: MonoFont; label: string; sample: string }[] = [
{ value: 'jetbrains', label: 'JetBrains Mono', sample: 'JetBrains' },
{ value: 'fira', label: 'Fira Code', sample: 'Fira' },
{ value: 'cascadia', label: 'Cascadia Code', sample: 'Cascadia' },
{ value: 'system-mono', label: 'System mono', sample: 'System' },
];
const uiSizes = [14, 15, 16, 17, 18];
const monoSizes = [13, 14, 15, 16, 17];
function SectionTitle({ children }: { children: React.ReactNode }) {
return (
<div
className="text-[10px] uppercase tracking-wider mb-2 mt-5 first:mt-0"
style={{ color: 'var(--pc-text-faint)', fontWeight: 600 }}
>
{children}
</div>
);
}
interface Props {
open: boolean;
onClose: () => void;
}
export function SettingsModal({ open, onClose }: Props) {
const {
theme, accent, uiFont, monoFont, uiFontSize, monoFontSize,
setTheme, setAccent, setUiFont, setMonoFont, setUiFontSize, setMonoFontSize,
} = useTheme();
type TabId = 'appearance' | 'typography';
const [tab, setTab] = useState<TabId>('appearance');
const tabs: { id: TabId; label: string }[] = useMemo(() => [
{ id: 'appearance', label: t('settings.tab.appearance') },
{ id: 'typography', label: t('settings.tab.typography') },
], []);
useEffect(() => {
if (!open) return;
const handler = (e: KeyboardEvent) => {
if (e.key === 'Escape') onClose();
};
window.addEventListener('keydown', handler);
return () => window.removeEventListener('keydown', handler);
}, [open, onClose]);
if (!open) return null;
return (
<div
role="dialog"
aria-modal="true"
aria-label={t('settings.title')}
className="fixed inset-0 z-50 flex items-center justify-center"
onClick={onClose}
>
<div className="absolute inset-0" style={{ background: 'rgba(0,0,0,0.6)', backdropFilter: 'blur(8px)' }} />
<div
className="relative w-full max-w-xl mx-4 rounded-3xl border shadow-2xl animate-fade-in"
style={{ background: 'var(--pc-bg-base)', borderColor: 'var(--pc-border)' }}
onClick={(e) => e.stopPropagation()}
>
{/* Header */}
<div
className="flex items-center justify-between px-6 py-4 border-b"
style={{ borderColor: 'var(--pc-border)' }}
>
<div className="flex items-center gap-2.5">
<Settings size={18} style={{ color: 'var(--pc-accent-light)' }} />
<h2 className="text-sm font-semibold" style={{ color: 'var(--pc-text-primary)' }}>{t('settings.title')}</h2>
</div>
<button
onClick={onClose}
className="h-8 w-8 rounded-xl flex items-center justify-center transition-colors"
style={{ color: 'var(--pc-text-muted)', background: 'transparent', border: 'none', cursor: 'pointer' }}
onMouseEnter={(e) => { e.currentTarget.style.color = 'var(--pc-text-primary)'; e.currentTarget.style.background = 'var(--pc-hover)'; }}
onMouseLeave={(e) => { e.currentTarget.style.color = 'var(--pc-text-muted)'; e.currentTarget.style.background = 'transparent'; }}
aria-label="Close"
>
<X size={16} />
</button>
</div>
{/* Body */}
<div className="px-6 py-4 max-h-[60vh] overflow-y-auto">
{/* Tabs */}
<div className="flex gap-2 mb-4">
{tabs.map(tTab => (
<button
key={tTab.id}
onClick={() => setTab(tTab.id)}
className="flex-1 rounded-xl border px-3 py-2 text-xs font-medium transition-colors"
style={tab === tTab.id
? { borderColor: 'var(--pc-accent-dim)', background: 'var(--pc-accent-glow)', color: 'var(--pc-accent-light)' }
: { borderColor: 'var(--pc-border)', color: 'var(--pc-text-muted)', background: 'transparent' }
}
onMouseEnter={(e) => { if (tab !== tTab.id) e.currentTarget.style.background = 'var(--pc-hover)'; }}
onMouseLeave={(e) => { if (tab !== tTab.id) e.currentTarget.style.background = 'transparent'; }}
>
{tTab.label}
</button>
))}
</div>
{/* Appearance Tab */}
{tab === 'appearance' && (
<>
<SectionTitle>{t('settings.appearance')}</SectionTitle>
{/* Theme Mode */}
<div className="mb-3">
<div className="text-xs mb-2" style={{ color: 'var(--pc-text-secondary)' }}>{t('theme.mode')}</div>
<div className="flex gap-1.5">
{themeOptions.map(opt => {
const Icon = opt.icon;
const active = theme === opt.value;
return (
<button
key={opt.value}
onClick={() => setTheme(opt.value)}
aria-pressed={active}
className="flex-1 flex flex-col items-center gap-1 py-2 rounded-xl border text-xs transition-all"
style={active
? { borderColor: 'var(--pc-accent-dim)', background: 'var(--pc-accent-glow)', color: 'var(--pc-accent-light)' }
: { borderColor: 'var(--pc-border)', color: 'var(--pc-text-muted)', background: 'transparent' }
}
onMouseEnter={(e) => { if (!active) e.currentTarget.style.background = 'var(--pc-hover)'; }}
onMouseLeave={(e) => { if (!active) e.currentTarget.style.background = 'transparent'; }}
>
<Icon size={16} />
<span>{t(opt.labelKey)}</span>
</button>
);
})}
</div>
</div>
{/* Accent Color */}
<div className="mb-4">
<div className="text-xs mb-2" style={{ color: 'var(--pc-text-secondary)' }}>{t('theme.accent')}</div>
<div className="flex gap-2">
{accentOptions.map(opt => (
<button
key={opt.value}
onClick={() => setAccent(opt.value)}
className="relative h-7 w-7 rounded-full transition-all flex items-center justify-center"
style={{
backgroundColor: opt.color,
border: accent === opt.value ? `2px solid ${opt.color}` : '2px solid transparent',
boxShadow: accent === opt.value ? `0 0 8px ${opt.color}40` : 'none',
}}
aria-pressed={accent === opt.value}
aria-label={`${opt.value} accent`}
>
{accent === opt.value && <Check size={14} style={{ color: 'white' }} />}
</button>
))}
</div>
</div>
</>
)}
{/* Typography Tab */}
{tab === 'typography' && (
<>
<SectionTitle>{t('settings.typography')}</SectionTitle>
{/* UI Font */}
<div className="mb-4">
<div className="flex items-center gap-2 text-xs mb-2" style={{ color: 'var(--pc-text-secondary)' }}>
<Type size={14} />
{t('settings.fontUi')}
</div>
<div className="flex flex-wrap gap-1.5">
{uiFontOptions.map(opt => (
<button
key={opt.value}
onClick={() => setUiFont(opt.value)}
className="flex items-center gap-2 px-3 py-2 rounded-xl border text-xs transition-all"
style={uiFont === opt.value
? { borderColor: 'var(--pc-accent-dim)', background: 'var(--pc-accent-glow)', color: 'var(--pc-accent-light)' }
: { borderColor: 'var(--pc-border)', color: 'var(--pc-text-muted)', background: 'transparent' }
}
onMouseEnter={(e) => { if (uiFont !== opt.value) e.currentTarget.style.background = 'var(--pc-hover)'; }}
onMouseLeave={(e) => { if (uiFont !== opt.value) e.currentTarget.style.background = 'transparent'; }}
>
<span style={{ fontSize: '14px', fontFamily: uiFontStacks[opt.value] }}>{opt.sample}</span>
<span style={{ fontSize: '11px', color: 'var(--pc-text-faint)' }}>{opt.label}</span>
</button>
))}
</div>
</div>
{/* Mono Font */}
<div className="mb-4">
<div className="flex items-center gap-2 text-xs mb-2" style={{ color: 'var(--pc-text-secondary)' }}>
<CaseSensitive size={14} />
{t('settings.fontMono')}
</div>
<div className="flex flex-wrap gap-1.5">
{monoFontOptions.map(opt => (
<button
key={opt.value}
onClick={() => setMonoFont(opt.value)}
className="flex items-center gap-2 px-3 py-2 rounded-xl border text-xs transition-all"
style={monoFont === opt.value
? { borderColor: 'var(--pc-accent-dim)', background: 'var(--pc-accent-glow)', color: 'var(--pc-accent-light)' }
: { borderColor: 'var(--pc-border)', color: 'var(--pc-text-muted)', background: 'transparent' }
}
onMouseEnter={(e) => { if (monoFont !== opt.value) e.currentTarget.style.background = 'var(--pc-hover)'; }}
onMouseLeave={(e) => { if (monoFont !== opt.value) e.currentTarget.style.background = 'transparent'; }}
>
<span style={{ fontSize: '14px', fontFamily: monoFontStacks[opt.value] }}>{opt.sample}</span>
<span style={{ fontSize: '11px', color: 'var(--pc-text-faint)' }}>{opt.label}</span>
</button>
))}
</div>
</div>
{/* UI Font Size */}
<div className="mb-4">
<div className="text-xs mb-2" style={{ color: 'var(--pc-text-secondary)' }}>{t('settings.fontSize')}</div>
<div className="flex gap-1.5 flex-wrap">
{uiSizes.map(size => (
<button
key={size}
onClick={() => setUiFontSize(size)}
className="px-3 py-1.5 rounded-lg border text-xs transition-all"
style={uiFontSize === size
? { borderColor: 'var(--pc-accent-dim)', background: 'var(--pc-accent-glow)', color: 'var(--pc-accent-light)' }
: { borderColor: 'var(--pc-border)', color: 'var(--pc-text-muted)', background: 'transparent' }
}
onMouseEnter={(e) => { if (uiFontSize !== size) e.currentTarget.style.background = 'var(--pc-hover)'; }}
onMouseLeave={(e) => { if (uiFontSize !== size) e.currentTarget.style.background = 'transparent'; }}
>
{size}px
</button>
))}
</div>
</div>
{/* Mono Font Size */}
<div className="mb-4">
<div className="text-xs mb-2" style={{ color: 'var(--pc-text-secondary)' }}>{t('settings.fontMonoSize')}</div>
<div className="flex gap-1.5 flex-wrap">
{monoSizes.map(size => (
<button
key={size}
onClick={() => setMonoFontSize(size)}
className="px-3 py-1.5 rounded-lg border text-xs transition-all"
style={monoFontSize === size
? { borderColor: 'var(--pc-accent-dim)', background: 'var(--pc-accent-glow)', color: 'var(--pc-accent-light)' }
: { borderColor: 'var(--pc-border)', color: 'var(--pc-text-muted)', background: 'transparent' }
}
onMouseEnter={(e) => { if (monoFontSize !== size) e.currentTarget.style.background = 'var(--pc-hover)'; }}
onMouseLeave={(e) => { if (monoFontSize !== size) e.currentTarget.style.background = 'transparent'; }}
>
{size}px
</button>
))}
</div>
</div>
{/* Preview */}
<div
className="rounded-2xl border p-3"
style={{ background: 'var(--pc-bg-surface)', borderColor: 'var(--pc-border)' }}
>
<div
className="text-[11px] uppercase tracking-wide mb-2"
style={{ color: 'var(--pc-text-faint)' }}
>
{t('settings.preview')}
</div>
<div
className="text-sm mb-2"
style={{ color: 'var(--pc-text-primary)', fontFamily: 'var(--pc-font-ui)', fontSize: 'var(--pc-font-size)' }}
>
{t('settings.previewText')}
</div>
<div
className="rounded-xl border p-2 text-[13px]"
style={{ fontFamily: 'var(--pc-font-mono)', fontSize: 'var(--pc-font-size-mono)', color: 'var(--pc-text-primary)', borderColor: 'var(--pc-border)', background: 'var(--pc-bg-code)' }}
>
const hello = 'ZeroClaw'; // typography preview
</div>
</div>
</>
)}
</div>
</div>
</div>
);
}

View File

@ -1,8 +1,10 @@
import { useState } from 'react';
import { useLocation } from 'react-router-dom'; import { useLocation } from 'react-router-dom';
import { LogOut } from 'lucide-react'; import { LogOut, Settings } from 'lucide-react';
import { t } from '@/lib/i18n'; import { t } from '@/lib/i18n';
import { useLocaleContext } from '@/App'; import { useLocaleContext } from '@/App';
import { useAuth } from '@/hooks/useAuth'; import { useAuth } from '@/hooks/useAuth';
import { SettingsModal } from '@/components/SettingsModal';
const routeTitles: Record<string, string> = { const routeTitles: Record<string, string> = {
'/': 'nav.dashboard', '/': 'nav.dashboard',
@ -21,6 +23,7 @@ export default function Header() {
const location = useLocation(); const location = useLocation();
const { logout } = useAuth(); const { logout } = useAuth();
const { locale, setAppLocale } = useLocaleContext(); const { locale, setAppLocale } = useLocaleContext();
const [settingsOpen, setSettingsOpen] = useState(false);
const titleKey = routeTitles[location.pathname] ?? 'nav.dashboard'; const titleKey = routeTitles[location.pathname] ?? 'nav.dashboard';
const pageTitle = t(titleKey); const pageTitle = t(titleKey);
@ -32,31 +35,70 @@ export default function Header() {
}; };
return ( return (
<header className="h-14 flex items-center justify-between px-6 border-b border-[#1a1a3e]/40 animate-fade-in" style={{ background: 'linear-gradient(90deg, rgba(8,8,24,0.9), rgba(5,5,16,0.9))', backdropFilter: 'blur(12px)' }}> <>
{/* Page title */} <header className="h-14 flex items-center justify-between px-6 border-b animate-fade-in" style={{ background: 'var(--pc-bg-surface)', borderColor: 'var(--pc-border)', backdropFilter: 'blur(12px)', }}>
<h1 className="text-lg font-semibold text-white tracking-tight">{pageTitle}</h1> {/* Page title */}
<h1 className="h-9 leading-9 text-lg font-semibold tracking-tight" style={{ color: 'var(--pc-text-primary)' }}>{pageTitle}</h1>
{/* Right-side controls */} {/* Right-side controls */}
<div className="flex items-center gap-3"> <div className="flex items-center gap-2 h-9">
{/* Language switcher */} {/* Settings */}
<button <button
type="button" type="button"
onClick={toggleLanguage} onClick={() => setSettingsOpen(true)}
className="px-3 py-1 rounded-lg text-xs font-semibold border border-[#1a1a3e] text-[#8892a8] hover:text-white hover:border-[#0080ff40] hover:bg-[#0080ff10] transition-all duration-300" className="h-9 w-9 flex items-center justify-center rounded-xl text-xs transition-all"
> style={{ color: 'var(--pc-text-muted)', background: 'transparent', border: 'none', cursor: 'pointer' }}
{locale === 'en' ? 'EN' : 'TR'} onMouseEnter={(e) => { e.currentTarget.style.color = 'var(--pc-text-primary)'; e.currentTarget.style.background = 'var(--pc-hover)'; }}
</button> onMouseLeave={(e) => { e.currentTarget.style.color = 'var(--pc-text-muted)'; e.currentTarget.style.background = 'transparent'; }}
aria-label={t('settings.title')}
>
<Settings className="h-3.5 w-3.5" />
</button>
{/* Logout */} {/* Language switcher */}
<button <button
type="button" type="button"
onClick={logout} onClick={toggleLanguage}
className="flex items-center gap-1.5 px-3 py-1.5 rounded-lg text-xs text-[#8892a8] hover:text-[#ff4466] hover:bg-[#ff446610] transition-all duration-300" className="h-9 px-3 rounded-xl text-xs font-semibold border transition-all flex items-center"
> style={{
<LogOut className="h-3.5 w-3.5" /> borderColor: 'var(--pc-border)',
<span>{t('auth.logout')}</span> color: 'var(--pc-text-secondary)',
</button> background: 'var(--pc-bg-elevated)',
</div> }}
</header> onMouseEnter={(e) => {
e.currentTarget.style.borderColor = 'var(--pc-accent-dim)';
e.currentTarget.style.color = 'var(--pc-text-primary)';
}}
onMouseLeave={(e) => {
e.currentTarget.style.borderColor = 'var(--pc-border)';
e.currentTarget.style.color = 'var(--pc-text-secondary)';
}}
>
{locale === 'en' ? 'EN' : locale === 'zh' ? 'ZH' : 'TR'}
</button>
{/* Logout */}
<button
type="button"
onClick={logout}
className="h-9 px-3 rounded-xl text-xs transition-all flex items-center gap-1.5"
style={{ color: 'var(--pc-text-muted)', background: 'transparent', border: 'none', cursor: 'pointer' }}
onMouseEnter={(e) => {
e.currentTarget.style.color = '#f87171';
e.currentTarget.style.background = 'rgba(239, 68, 68, 0.08)';
}}
onMouseLeave={(e) => {
e.currentTarget.style.color = 'var(--pc-text-muted)';
e.currentTarget.style.background = 'transparent';
}}
>
<LogOut className="h-3.5 w-3.5" />
<span>{t('auth.logout')}</span>
</button>
</div>
</header>
<SettingsModal open={settingsOpen} onClose={() => setSettingsOpen(false)} />
</>
); );
} }

View File

@ -7,7 +7,7 @@ export default function Layout() {
const { pathname } = useLocation(); const { pathname } = useLocation();
return ( return (
<div className="h-screen flex overflow-hidden text-white" style={{ background: 'linear-gradient(135deg, #050510 0%, #080818 50%, #050510 100%)' }}> <div className="min-h-screen text-white" style={{ background: 'var(--pc-bg-base)' }}>
{/* Fixed sidebar */} {/* Fixed sidebar */}
<Sidebar /> <Sidebar />

View File

@ -28,18 +28,22 @@ const navItems = [
export default function Sidebar() { export default function Sidebar() {
return ( return (
<aside className="fixed top-0 left-0 h-screen w-60 flex flex-col" style={{ background: 'linear-gradient(180deg, #080818 0%, #050510 100%)' }}> <aside className="fixed top-0 left-0 h-screen w-60 flex flex-col border-r" style={{ background: 'var(--pc-bg-base)', borderColor: 'var(--pc-border)' }}>
{/* Glow line on right edge */}
<div className="sidebar-glow-line" />
{/* Logo / Title */} {/* Logo / Title */}
<div className="flex items-center gap-3 px-4 py-4 border-b border-[#1a1a3e]/50"> <div className="flex items-center gap-3 px-4 py-4 border-b h-14" style={{ borderColor: 'var(--pc-border)' }}>
<img <div className="relative shrink-0">
src="/_app/zeroclaw-trans.png" <div className="absolute -inset-1.5 rounded-xl" style={{ background: 'linear-gradient(135deg, rgba(var(--pc-accent-rgb), 0.15), rgba(var(--pc-accent-rgb), 0.05))' }} />
alt="ZeroClaw" <img
className="h-10 w-10 rounded-xl object-cover animate-pulse-glow" src="/_app/zeroclaw-trans.png"
/> alt="ZeroClaw"
<span className="text-lg font-bold text-gradient-blue tracking-wide"> className="relative h-9 w-9 rounded-xl object-cover"
onError={(e) => {
const img = e.currentTarget;
img.style.display = 'none';
}}
/>
</div>
<span className="text-sm font-semibold tracking-wide" style={{ color: 'var(--pc-text-primary)' }}>
ZeroClaw ZeroClaw
</span> </span>
</div> </div>
@ -53,24 +57,24 @@ export default function Sidebar() {
end={to === '/'} end={to === '/'}
className={({ isActive }) => className={({ isActive }) =>
[ [
'flex items-center gap-3 px-3 py-2.5 rounded-xl text-sm font-medium transition-all duration-300 animate-slide-in-left group', 'flex items-center gap-3 px-3 py-2.5 rounded-2xl text-sm font-medium transition-all group',
isActive isActive
? 'text-white shadow-[0_0_15px_rgba(0,128,255,0.2)]' ? 'text-[var(--pc-accent-light)]'
: 'text-[#556080] hover:text-white hover:bg-[#0080ff08]', : 'text-[var(--pc-text-muted)] hover:text-[var(--pc-text-secondary)] hover:bg-[var(--pc-hover)]',
].join(' ') ].join(' ')
} }
style={({ isActive }) => ({ style={({ isActive }) => ({
animationDelay: `${idx * 40}ms`, animationDelay: `${idx * 40}ms`,
...(isActive ? { background: 'linear-gradient(135deg, rgba(0,128,255,0.15), rgba(0,128,255,0.05))' } : {}), ...(isActive ? {
background: 'var(--pc-accent-glow)',
border: '1px solid var(--pc-accent-dim)',
} : {}),
})} })}
> >
{({ isActive }) => ( {({ isActive }) => (
<> <>
<Icon className={`h-5 w-5 flex-shrink-0 transition-colors duration-300 ${isActive ? 'text-[#0080ff]' : 'group-hover:text-[#0080ff80]'}`} /> <Icon className={`h-5 w-5 flex-shrink-0 transition-colors ${isActive ? 'text-[var(--pc-accent)]' : 'group-hover:text-[var(--pc-accent)]'}`} />
<span>{t(labelKey)}</span> <span>{t(labelKey)}</span>
{isActive && (
<div className="ml-auto h-1.5 w-1.5 rounded-full bg-[#0080ff] glow-dot" />
)}
</> </>
)} )}
</NavLink> </NavLink>
@ -78,8 +82,8 @@ export default function Sidebar() {
</nav> </nav>
{/* Footer */} {/* Footer */}
<div className="px-5 py-4 border-t border-[#1a1a3e]/50"> <div className="px-5 py-4 border-t text-[10px] uppercase tracking-wider" style={{ borderColor: 'var(--pc-border)', color: 'var(--pc-text-faint)' }}>
<p className="text-[10px] text-[#334060] tracking-wider uppercase">ZeroClaw Runtime</p> ZeroClaw Runtime
</div> </div>
</aside> </aside>
); );

View File

@ -0,0 +1,253 @@
import { useState, useEffect, useCallback, type ReactNode } from 'react';
import { ThemeContext, type ThemeContextValue } from './ThemeContextDef';
import { loadStored, STORAGE_KEY } from './themeStorage';
import type { ThemeName, AccentColor, UiFont, MonoFont } from './ThemeContextDef';
import { uiFontStacks, monoFontStacks } from './ThemeContextDef';
import { loadUiFont, loadMonoFont } from './fontLoader';
type ConcreteTheme = 'dark' | 'light' | 'oled';
const themes: Record<ConcreteTheme, Record<string, string>> = {
dark: {
'--pc-bg-base': '#1e1e24',
'--color-scheme': 'dark',
'--pc-bg-surface': '#232329',
'--pc-bg-elevated': '#27272a',
'--pc-bg-input': '#1a1a20',
'--pc-bg-sidebar': 'rgba(30,30,36,0.95)',
'--pc-bg-code': '#1a1a20',
'--pc-border': 'rgba(255,255,255,0.08)',
'--pc-border-strong': 'rgba(255,255,255,0.1)',
'--pc-text-primary': '#d4d4d8',
'--pc-text-secondary': '#a1a1aa',
'--pc-text-muted': '#71717a',
'--pc-text-faint': '#52525b',
'--pc-scrollbar-thumb': '#52525b',
'--pc-scrollbar-track': '#27272a',
'--pc-scrollbar-thumb-hover': '#71717a',
'--pc-hover': 'rgba(255,255,255,0.05)',
'--pc-hover-strong': 'rgba(255,255,255,0.08)',
'--pc-separator': 'rgba(255,255,255,0.05)',
},
light: {
'--pc-bg-base': '#f4f4f5',
'--color-scheme': 'light',
'--pc-bg-surface': '#ffffff',
'--pc-bg-elevated': '#e4e4e7',
'--pc-bg-input': '#ffffff',
'--pc-bg-sidebar': 'rgba(255,255,255,0.95)',
'--pc-bg-code': '#f4f4f5',
'--pc-border': 'rgba(0,0,0,0.08)',
'--pc-border-strong': 'rgba(0,0,0,0.12)',
'--pc-text-primary': '#18181b',
'--pc-text-secondary': '#3f3f46',
'--pc-text-muted': '#71717a',
'--pc-text-faint': '#a1a1aa',
'--pc-scrollbar-thumb': '#a1a1aa',
'--pc-scrollbar-track': '#e4e4e7',
'--pc-scrollbar-thumb-hover': '#71717a',
'--pc-hover': 'rgba(0,0,0,0.05)',
'--pc-hover-strong': 'rgba(0,0,0,0.08)',
'--pc-separator': 'rgba(0,0,0,0.08)',
},
oled: {
'--pc-bg-base': '#000000',
'--color-scheme': 'dark',
'--pc-bg-surface': '#0a0a0a',
'--pc-bg-elevated': '#141414',
'--pc-bg-input': '#0a0a0a',
'--pc-bg-sidebar': 'rgba(0,0,0,0.95)',
'--pc-bg-code': '#0a0a0a',
'--pc-border': 'rgba(255,255,255,0.06)',
'--pc-border-strong': 'rgba(255,255,255,0.08)',
'--pc-text-primary': '#d4d4d8',
'--pc-text-secondary': '#a1a1aa',
'--pc-text-muted': '#71717a',
'--pc-text-faint': '#3f3f46',
'--pc-scrollbar-thumb': '#3f3f46',
'--pc-scrollbar-track': '#0a0a0a',
'--pc-scrollbar-thumb-hover': '#52525b',
'--pc-hover': 'rgba(255,255,255,0.04)',
'--pc-hover-strong': 'rgba(255,255,255,0.06)',
'--pc-separator': 'rgba(255,255,255,0.04)',
},
};
const accents: Record<AccentColor, Record<string, string>> = {
cyan: {
'--pc-accent': '#22d3ee',
'--pc-accent-light': '#67e8f9',
'--pc-accent-dim': 'rgba(34,211,238,0.3)',
'--pc-accent-glow': 'rgba(34,211,238,0.1)',
'--pc-accent-rgb': '34,211,238',
},
violet: {
'--pc-accent': '#8b5cf6',
'--pc-accent-light': '#a78bfa',
'--pc-accent-dim': 'rgba(139,92,246,0.3)',
'--pc-accent-glow': 'rgba(139,92,246,0.1)',
'--pc-accent-rgb': '139,92,246',
},
emerald: {
'--pc-accent': '#10b981',
'--pc-accent-light': '#34d399',
'--pc-accent-dim': 'rgba(16,185,129,0.3)',
'--pc-accent-glow': 'rgba(16,185,129,0.1)',
'--pc-accent-rgb': '16,185,129',
},
amber: {
'--pc-accent': '#f59e0b',
'--pc-accent-light': '#fbbf24',
'--pc-accent-dim': 'rgba(245,158,11,0.3)',
'--pc-accent-glow': 'rgba(245,158,11,0.1)',
'--pc-accent-rgb': '245,158,11',
},
rose: {
'--pc-accent': '#f43f5e',
'--pc-accent-light': '#fb7185',
'--pc-accent-dim': 'rgba(244,63,94,0.3)',
'--pc-accent-glow': 'rgba(244,63,94,0.1)',
'--pc-accent-rgb': '244,63,94',
},
blue: {
'--pc-accent': '#3b82f6',
'--pc-accent-light': '#60a5fa',
'--pc-accent-dim': 'rgba(59,130,246,0.3)',
'--pc-accent-glow': 'rgba(59,130,246,0.1)',
'--pc-accent-rgb': '59,130,246',
},
};
function applyVars(vars: Record<string, string>) {
const root = document.documentElement;
for (const [k, v] of Object.entries(vars)) {
if (k === '--color-scheme') {
root.style.colorScheme = v as 'light' | 'dark';
} else {
root.style.setProperty(k, v);
}
}
}
function resolveTheme(name: ThemeName): 'dark' | 'light' | 'oled' {
if (name === 'system') {
return window.matchMedia('(prefers-color-scheme: light)').matches ? 'light' : 'dark';
}
return name;
}
interface ThemeSettings {
theme: ThemeName;
accent: AccentColor;
uiFont: UiFont;
monoFont: MonoFont;
uiFontSize: number;
monoFontSize: number;
}
function fontVars(uiFont: UiFont, monoFont: MonoFont, uiFontSize: number, monoFontSize: number) {
return {
'--pc-font-ui': uiFontStacks[uiFont],
'--pc-font-mono': monoFontStacks[monoFont],
'--pc-font-size': `${uiFontSize}px`,
'--pc-font-size-mono': `${monoFontSize}px`,
};
}
export function ThemeProvider({ children }: { children: ReactNode }) {
const [stored] = useState(loadStored);
const [theme, setThemeState] = useState<ThemeName>(stored.theme);
const [accent, setAccentState] = useState<AccentColor>(stored.accent);
const [uiFont, setUiFontState] = useState<UiFont>(stored.uiFont);
const [monoFont, setMonoFontState] = useState<MonoFont>(stored.monoFont);
const [uiFontSize, setUiFontSizeState] = useState<number>(stored.uiFontSize);
const [monoFontSize, setMonoFontSizeState] = useState<number>(stored.monoFontSize);
const persist = useCallback((s: ThemeSettings) => {
localStorage.setItem(STORAGE_KEY, JSON.stringify({
theme: s.theme,
accent: s.accent,
uiFont: s.uiFont,
monoFont: s.monoFont,
uiFontSize: s.uiFontSize,
monoFontSize: s.monoFontSize,
}));
}, []);
const applyAll = useCallback((s: ThemeSettings) => {
applyVars({
...themes[resolveTheme(s.theme)],
...accents[s.accent],
...fontVars(s.uiFont, s.monoFont, s.uiFontSize, s.monoFontSize),
});
}, []);
const setTheme = useCallback((t: ThemeName) => {
setThemeState(t);
const next: ThemeSettings = { theme: t, accent, uiFont, monoFont, uiFontSize, monoFontSize };
applyAll(next);
persist(next);
}, [accent, applyAll, persist, uiFont, monoFont, uiFontSize, monoFontSize]);
const setAccent = useCallback((a: AccentColor) => {
setAccentState(a);
const next: ThemeSettings = { theme, accent: a, uiFont, monoFont, uiFontSize, monoFontSize };
applyAll(next);
persist(next);
}, [theme, applyAll, persist, uiFont, monoFont, uiFontSize, monoFontSize]);
const setUiFont = useCallback((f: UiFont) => {
setUiFontState(f);
loadUiFont(f);
const next: ThemeSettings = { theme, accent, uiFont: f, monoFont, uiFontSize, monoFontSize };
applyAll(next);
persist(next);
}, [theme, accent, applyAll, persist, monoFont, uiFontSize, monoFontSize]);
const setMonoFont = useCallback((f: MonoFont) => {
setMonoFontState(f);
loadMonoFont(f);
const next: ThemeSettings = { theme, accent, uiFont, monoFont: f, uiFontSize, monoFontSize };
applyAll(next);
persist(next);
}, [theme, accent, applyAll, persist, uiFont, uiFontSize, monoFontSize]);
const setUiFontSize = useCallback((size: number) => {
const clamped = Math.min(20, Math.max(12, size));
setUiFontSizeState(clamped);
const next: ThemeSettings = { theme, accent, uiFont, monoFont, uiFontSize: clamped, monoFontSize };
applyAll(next);
persist(next);
}, [theme, accent, applyAll, persist, uiFont, monoFont, monoFontSize]);
const setMonoFontSize = useCallback((size: number) => {
const clamped = Math.min(20, Math.max(12, size));
setMonoFontSizeState(clamped);
const next: ThemeSettings = { theme, accent, uiFont, monoFont, uiFontSize, monoFontSize: clamped };
applyAll(next);
persist(next);
}, [theme, accent, applyAll, persist, uiFont, monoFont, uiFontSize]);
useEffect(() => {
applyAll({ theme, accent, uiFont, monoFont, uiFontSize, monoFontSize });
loadUiFont(uiFont);
loadMonoFont(monoFont);
}, []); // eslint-disable-line react-hooks/exhaustive-deps
useEffect(() => {
if (theme !== 'system') return;
const mq = window.matchMedia('(prefers-color-scheme: light)');
const handler = () => applyAll({ theme: mq.matches ? 'light' : 'dark', accent, uiFont, monoFont, uiFontSize, monoFontSize });
mq.addEventListener('change', handler);
return () => mq.removeEventListener('change', handler);
}, [theme, accent, applyAll, uiFont, monoFont, uiFontSize, monoFontSize]);
const resolvedTheme = resolveTheme(theme);
const value: ThemeContextValue = {
theme, accent, uiFont, monoFont, uiFontSize, monoFontSize,
resolvedTheme, setTheme, setAccent, setUiFont, setMonoFont, setUiFontSize, setMonoFontSize,
};
return <ThemeContext.Provider value={value}>{children}</ThemeContext.Provider>;
}

View File

@ -0,0 +1,52 @@
import { createContext } from 'react';
export type ThemeName = 'system' | 'dark' | 'light' | 'oled';
export type AccentColor = 'cyan' | 'violet' | 'emerald' | 'amber' | 'rose' | 'blue';
export type UiFont = 'system' | 'inter' | 'segoe' | 'sf';
export type MonoFont = 'jetbrains' | 'fira' | 'cascadia' | 'system-mono';
export const uiFontStacks: Record<UiFont, string> = {
system: 'system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif',
inter: '"Inter", system-ui, sans-serif',
segoe: '"Segoe UI", system-ui, sans-serif',
sf: '-apple-system, BlinkMacSystemFont, "SF Pro Text", sans-serif',
};
export const monoFontStacks: Record<MonoFont, string> = {
jetbrains: '"JetBrains Mono", "Fira Code", "Cascadia Code", monospace',
fira: '"Fira Code", "JetBrains Mono", "Cascadia Code", monospace',
cascadia: '"Cascadia Code", "JetBrains Mono", "Fira Code", monospace',
'system-mono': 'ui-monospace, "SF Mono", "Cascadia Code", "Fira Code", monospace',
};
export interface ThemeContextValue {
theme: ThemeName;
accent: AccentColor;
uiFont: UiFont;
monoFont: MonoFont;
uiFontSize: number;
monoFontSize: number;
resolvedTheme: 'dark' | 'light' | 'oled';
setTheme: (t: ThemeName) => void;
setAccent: (a: AccentColor) => void;
setUiFont: (f: UiFont) => void;
setMonoFont: (f: MonoFont) => void;
setUiFontSize: (size: number) => void;
setMonoFontSize: (size: number) => void;
}
export const ThemeContext = createContext<ThemeContextValue>({
theme: 'dark',
accent: 'cyan',
uiFont: 'system',
monoFont: 'jetbrains',
uiFontSize: 15,
monoFontSize: 14,
resolvedTheme: 'dark',
setTheme: () => {},
setAccent: () => {},
setUiFont: () => {},
setMonoFont: () => {},
setUiFontSize: () => {},
setMonoFontSize: () => {},
});

View File

@ -0,0 +1,25 @@
const loaded: Set<string> = new Set();
export function loadGoogleFont(family: string, weights: string = '400;500;600') {
const id = `gfont-${family.replace(/\s+/g, '-').toLowerCase()}`;
if (loaded.has(id)) return;
loaded.add(id);
const link = document.createElement('link');
link.id = id;
link.rel = 'stylesheet';
link.href = `https://fonts.googleapis.com/css2?family=${encodeURIComponent(family)}:wght@${weights}&display=swap`;
document.head.appendChild(link);
}
export function loadUiFont(font: string) {
if (font === 'inter') loadGoogleFont('Inter');
if (font === 'segoe') loadGoogleFont('Segoe UI');
if (font === 'sf') loadGoogleFont('SF Pro Text');
}
export function loadMonoFont(font: string) {
if (font === 'jetbrains') loadGoogleFont('JetBrains Mono');
if (font === 'fira') loadGoogleFont('Fira Code');
if (font === 'cascadia') loadGoogleFont('Cascadia Code');
}

View File

@ -0,0 +1,44 @@
import type { ThemeName, AccentColor, UiFont, MonoFont } from './ThemeContextDef';
import { uiFontStacks, monoFontStacks } from './ThemeContextDef';
export const STORAGE_KEY = 'zeroclaw-theme';
export interface StoredTheme {
theme: ThemeName;
accent: AccentColor;
uiFont: UiFont;
monoFont: MonoFont;
uiFontSize: number;
monoFontSize: number;
}
const DEFAULTS: StoredTheme = {
theme: 'dark',
accent: 'cyan',
uiFont: 'system',
monoFont: 'jetbrains',
uiFontSize: 15,
monoFontSize: 14,
};
const validThemes: ThemeName[] = ['dark', 'light', 'oled', 'system'];
const validAccents: AccentColor[] = ['cyan', 'violet', 'emerald', 'amber', 'rose', 'blue'];
export function loadStored(): StoredTheme {
try {
const raw = localStorage.getItem(STORAGE_KEY);
if (raw) {
const parsed = JSON.parse(raw);
const themeValid = validThemes.includes(parsed.theme);
const accentValid = validAccents.includes(parsed.accent);
const uiFont: UiFont = uiFontStacks[parsed.uiFont as UiFont] ? parsed.uiFont as UiFont : DEFAULTS.uiFont;
const monoFont: MonoFont = monoFontStacks[parsed.monoFont as MonoFont] ? parsed.monoFont as MonoFont : DEFAULTS.monoFont;
const uiFontSize = Number.isFinite(parsed.uiFontSize) ? Math.min(20, Math.max(12, Number(parsed.uiFontSize))) : DEFAULTS.uiFontSize;
const monoFontSize = Number.isFinite(parsed.monoFontSize) ? Math.min(20, Math.max(12, Number(parsed.monoFontSize))) : DEFAULTS.monoFontSize;
if (themeValid && accentValid) {
return { theme: parsed.theme, accent: parsed.accent, uiFont, monoFont, uiFontSize, monoFontSize };
}
}
} catch { /* ignore */ }
return DEFAULTS;
}

View File

@ -0,0 +1,4 @@
import { useContext } from 'react';
import { ThemeContext } from '../contexts/ThemeContextDef';
export const useTheme = () => useContext(ThemeContext);

View File

@ -1,62 +1,113 @@
@import "tailwindcss"; @import "tailwindcss";
/*
* ZeroClaw Electric Blue Theme
* Dark-mode with electric blue accents, glassmorphism, and animations.
*/
@theme { @theme {
--color-bg-primary: #050510; /* Theme-aware colors mapped to CSS custom properties */
--color-bg-secondary: #0a0a1a; --color-pc-base: var(--pc-bg-base);
--color-bg-card: #0d0d20; --color-pc-surface: var(--pc-bg-surface);
--color-bg-card-hover: #141430; --color-pc-elevated: var(--pc-bg-elevated);
--color-bg-input: #0a0a18; --color-pc-input: var(--pc-bg-input);
--color-pc-code: var(--pc-bg-code);
--color-border-default: #1a1a3e; --color-pc-border: var(--pc-border);
--color-border-subtle: #12122a; --color-pc-border-strong: var(--pc-border-strong);
--color-pc-text: var(--pc-text-primary);
--color-accent-blue: #0080ff; --color-pc-text-secondary: var(--pc-text-secondary);
--color-accent-blue-hover: #0066cc; --color-pc-text-muted: var(--pc-text-muted);
--color-accent-cyan: #00d4ff; --color-pc-text-faint: var(--pc-text-faint);
--color-accent-green: #00e68a; --color-pc-accent: var(--pc-accent);
--color-accent-green-hover: #00cc7a; --color-pc-accent-light: var(--pc-accent-light);
--color-pc-accent-dim: var(--pc-accent-dim);
--color-text-primary: #e8edf5; --color-pc-accent-glow: var(--pc-accent-glow);
--color-text-secondary: #8892a8;
--color-text-muted: #556080;
/* Status colors (fixed across themes) */
--color-status-success: #00e68a; --color-status-success: #00e68a;
--color-status-warning: #ffaa00; --color-status-warning: #ffaa00;
--color-status-error: #ff4466; --color-status-error: #ff4466;
--color-status-info: #0080ff; --color-status-info: #0080ff;
--color-glow-blue: #0080ff40;
--color-glow-cyan: #00d4ff30;
} }
/* Base styles */ :root {
/* Status colors for reference */
--color-status-success: #00e68a;
--color-status-warning: #ffaa00;
--color-status-error: #ff4466;
--color-status-info: #0080ff;
/* Backgrounds */
--pc-bg-base: #1e1e24;
--pc-bg-surface: #232329;
--pc-bg-elevated: #27272a;
--pc-bg-input: #1a1a20;
--pc-bg-code: #1a1a20;
--pc-bg-sidebar: rgba(30, 30, 36, 0.95);
/* Borders */
--pc-border: rgba(255, 255, 255, 0.08);
--pc-border-strong: rgba(255, 255, 255, 0.1);
/* Text */
--pc-text-primary: #d4d4d8;
--pc-text-secondary: #a1a1aa;
--pc-text-muted: #71717a;
--pc-text-faint: #52525b;
/* Accent (cyan) */
--pc-accent: #22d3ee;
--pc-accent-light: #67e8f9;
--pc-accent-dim: rgba(34, 211, 238, 0.3);
--pc-accent-glow: rgba(34, 211, 238, 0.1);
--pc-accent-rgb: 34, 211, 238;
/* Hover */
--pc-hover: rgba(255, 255, 255, 0.05);
--pc-hover-strong: rgba(255, 255, 255, 0.08);
--pc-separator: rgba(255, 255, 255, 0.05);
/* Scrollbar */
--pc-scrollbar-thumb: #52525b;
--pc-scrollbar-track: #27272a;
--pc-scrollbar-thumb-hover: #71717a;
/* Fonts */
--pc-font-ui: 'Inter', -apple-system, BlinkMacSystemFont, 'Segoe UI', system-ui, sans-serif;
--pc-font-mono: 'JetBrains Mono', ui-monospace, SFMono-Regular, 'SF Mono', Menlo, Monaco, Consolas, monospace;
--pc-font-size: 15px;
--pc-font-size-mono: 14px;
}
html { html {
color-scheme: dark;
} }
body { body {
background-color: var(--color-bg-primary); background-color: var(--pc-bg-base);
color: var(--color-text-primary); color: var(--pc-text-primary);
font-family: font-family: var(--pc-font-ui);
"Inter", font-size: var(--pc-font-size);
ui-sans-serif,
system-ui,
-apple-system,
sans-serif;
-webkit-font-smoothing: antialiased; -webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale; -moz-osx-font-smoothing: grayscale;
margin: 0;
overflow-x: hidden;
max-width: 100vw;
} }
#root { #root {
min-height: 100vh; min-height: 100vh;
} }
/* Scrollbar styling */ /* Focus ring */
:focus-visible {
outline: 2px solid var(--pc-accent-dim);
outline-offset: 2px;
}
:focus:not(:focus-visible) {
outline: none;
}
/* Scrollbar */
* {
scrollbar-width: thin;
scrollbar-color: var(--pc-scrollbar-thumb) var(--pc-scrollbar-track);
}
::-webkit-scrollbar { ::-webkit-scrollbar {
width: 6px; width: 6px;
height: 6px; height: 6px;
@ -67,35 +118,48 @@ body {
} }
::-webkit-scrollbar-thumb { ::-webkit-scrollbar-thumb {
background: #1a1a3e; background: var(--pc-scrollbar-thumb);
border-radius: 3px; border-radius: 3px;
} }
::-webkit-scrollbar-thumb:hover { ::-webkit-scrollbar-thumb:hover {
background: #0080ff60; background: var(--pc-scrollbar-thumb-hover);
} }
/* Card utility */ textarea::-webkit-scrollbar {
.card { width: 4px;
background: linear-gradient(135deg, rgba(13, 13, 32, 0.8), rgba(10, 10, 26, 0.6)); height: 0;
border: 1px solid rgba(0, 128, 255, 0.1);
border-radius: 1rem;
backdrop-filter: blur(12px);
transition: all 0.3s ease;
} }
.card:hover { textarea::-webkit-scrollbar:horizontal {
border-color: rgba(0, 128, 255, 0.25); display: none;
box-shadow: 0 0 20px rgba(0, 128, 255, 0.08);
} }
/* Focus ring utility */ textarea::-webkit-scrollbar-thumb {
*:focus-visible { background: var(--pc-scrollbar-thumb);
outline: 2px solid var(--color-accent-blue); border-radius: 2px;
outline-offset: 2px;
} }
/* ========== ANIMATIONS ========== */ textarea::-webkit-scrollbar-thumb:hover {
background: var(--pc-scrollbar-thumb-hover);
}
textarea {
overflow-x: hidden;
overflow-y: auto;
overflow-wrap: break-word;
word-break: break-word;
}
code, kbd, pre {
font-family: var(--pc-font-mono);
}
/* ── Animations ── */
@keyframes fade-in {
from { opacity: 0; transform: translateY(8px); }
to { opacity: 1; transform: translateY(0); }
}
@keyframes fadeIn { @keyframes fadeIn {
from { opacity: 0; transform: translateY(8px); } from { opacity: 0; transform: translateY(8px); }
@ -122,9 +186,14 @@ body {
to { opacity: 1; transform: translateY(0); } to { opacity: 1; transform: translateY(0); }
} }
@keyframes pulse-glow { @keyframes pulse-dot {
0%, 100% { box-shadow: 0 0 8px rgba(0, 128, 255, 0.3); } 0%, 100% { opacity: 1; }
50% { box-shadow: 0 0 20px rgba(0, 128, 255, 0.6); } 50% { opacity: 0.4; }
}
@keyframes bounce-dot {
0%, 80%, 100% { transform: translateY(0); opacity: 0.45; }
40% { transform: translateY(-4px); opacity: 1; }
} }
@keyframes shimmer { @keyframes shimmer {
@ -137,19 +206,11 @@ body {
50% { transform: translateY(-4px); } 50% { transform: translateY(-4px); }
} }
@keyframes borderGlow {
0%, 100% { border-color: rgba(0, 128, 255, 0.15); }
50% { border-color: rgba(0, 128, 255, 0.35); }
}
@keyframes gradientShift {
0% { background-position: 0% 50%; }
50% { background-position: 100% 50%; }
100% { background-position: 0% 50%; }
}
/* Animation utility classes */
.animate-fade-in { .animate-fade-in {
animation: fade-in 0.3s ease-out;
}
.animate-fade-in-legacy {
animation: fadeIn 0.4s ease-out both; animation: fadeIn 0.4s ease-out both;
} }
@ -170,18 +231,25 @@ body {
} }
.animate-pulse-glow { .animate-pulse-glow {
animation: pulse-glow 2s ease-in-out infinite; animation: fadeIn 2s ease-in-out infinite;
}
.animate-border-glow {
animation: borderGlow 3s ease-in-out infinite;
} }
.animate-float { .animate-float {
animation: float 3s ease-in-out infinite; animation: float 3s ease-in-out infinite;
} }
/* Stagger delays for grid children */ .pulse-dot {
animation: pulse-dot 2s ease-in-out infinite;
}
.bounce-dot {
animation: bounce-dot 0.9s infinite ease-in-out;
}
.bounce-dot:nth-child(1) { animation-delay: 0s; }
.bounce-dot:nth-child(2) { animation-delay: 0.12s; }
.bounce-dot:nth-child(3) { animation-delay: 0.24s; }
/* Stagger delays */
.stagger-children > *:nth-child(1) { animation-delay: 0ms; } .stagger-children > *:nth-child(1) { animation-delay: 0ms; }
.stagger-children > *:nth-child(2) { animation-delay: 60ms; } .stagger-children > *:nth-child(2) { animation-delay: 60ms; }
.stagger-children > *:nth-child(3) { animation-delay: 120ms; } .stagger-children > *:nth-child(3) { animation-delay: 120ms; }
@ -193,24 +261,46 @@ body {
.stagger-children > *:nth-child(9) { animation-delay: 480ms; } .stagger-children > *:nth-child(9) { animation-delay: 480ms; }
.stagger-children > *:nth-child(10) { animation-delay: 540ms; } .stagger-children > *:nth-child(10) { animation-delay: 540ms; }
/* ── Utility classes ── */
/* Card */
.card {
background: var(--pc-bg-surface);
border: 1px solid var(--pc-border);
border-radius: 1rem;
transition: all 0.3s ease;
}
.card:hover {
background: var(--pc-bg-elevated);
border-color: var(--pc-border-strong);
}
/* Glass card */ /* Glass card */
.glass-card { .glass-card {
background: linear-gradient(135deg, rgba(13, 13, 32, 0.7), rgba(5, 5, 16, 0.5)); background: var(--pc-bg-surface);
border: 1px solid rgba(0, 128, 255, 0.12); border: 1px solid var(--pc-border);
border-radius: 1rem; border-radius: 1rem;
backdrop-filter: blur(16px); backdrop-filter: blur(16px);
transition: all 0.3s cubic-bezier(0.4, 0, 0.2, 1); transition: all 0.3s ease;
} }
.glass-card:hover { .glass-card:hover {
border-color: rgba(0, 128, 255, 0.3); border-color: var(--pc-border-strong);
box-shadow: 0 4px 30px rgba(0, 128, 255, 0.1), 0 0 0 1px rgba(0, 128, 255, 0.05); background: var(--pc-bg-elevated);
transform: translateY(-1px);
} }
/* Electric button */ /* Surface panel */
.surface-panel {
background: var(--pc-bg-surface);
border: 1px solid var(--pc-border);
border-radius: 1.25rem;
backdrop-filter: blur(16px);
}
/* Electric button (primary action) */
.btn-electric { .btn-electric {
background: linear-gradient(135deg, #0080ff, #0066cc); background: var(--pc-accent);
color: white; color: white;
border: none; border: none;
border-radius: 0.75rem; border-radius: 0.75rem;
@ -221,9 +311,8 @@ body {
} }
.btn-electric:hover:not(:disabled) { .btn-electric:hover:not(:disabled) {
background: linear-gradient(135deg, #0090ff, #0070dd); opacity: 0.9;
box-shadow: 0 0 20px rgba(0, 128, 255, 0.4); box-shadow: 0 8px 24px rgba(var(--pc-accent-rgb), 0.15);
transform: translateY(-1px);
} }
.btn-electric:active:not(:disabled) { .btn-electric:active:not(:disabled) {
@ -231,10 +320,186 @@ body {
} }
.btn-electric:disabled { .btn-electric:disabled {
opacity: 0.3;
cursor: not-allowed;
}
/* Electric input */
.input-electric {
background: var(--pc-bg-input);
border: 1px solid var(--pc-border);
border-radius: 0.75rem;
color: var(--pc-text-primary);
transition: all 0.3s ease;
}
.input-electric:focus {
outline: none;
border-color: var(--pc-accent-dim);
box-shadow: 0 0 0 3px var(--pc-accent-glow);
}
.input-electric::placeholder {
color: var(--pc-text-muted);
}
/* Primary action (pill) */
.btn-primary {
background: var(--pc-accent);
color: white;
border-radius: 1rem;
font-weight: 600;
font-size: 0.875rem;
padding: 0.5rem 1.25rem;
border: none;
cursor: pointer;
transition: all 0.2s ease;
box-shadow: 0 4px 12px rgba(var(--pc-accent-rgb), 0.2);
}
.btn-primary:hover:not(:disabled) {
opacity: 0.9;
}
.btn-primary:disabled {
opacity: 0.3;
cursor: not-allowed;
}
/* Secondary button */
.btn-secondary {
background: var(--pc-bg-elevated);
color: var(--pc-text-secondary);
border: 1px solid var(--pc-border);
border-radius: 0.75rem;
font-weight: 500;
font-size: 0.875rem;
padding: 0.5rem 1rem;
cursor: pointer;
transition: all 0.2s ease;
}
.btn-secondary:hover:not(:disabled) {
background: var(--pc-hover);
color: var(--pc-text-primary);
border-color: var(--pc-border-strong);
}
.btn-secondary:disabled {
opacity: 0.4; opacity: 0.4;
cursor: not-allowed; cursor: not-allowed;
} }
/* Icon button */
.btn-icon {
padding: 0.5rem;
border-radius: 0.75rem;
color: var(--pc-text-muted);
background: transparent;
border: none;
cursor: pointer;
transition: all 0.2s ease;
display: inline-flex;
align-items: center;
justify-content: center;
}
.btn-icon:hover {
background: var(--pc-hover);
color: var(--pc-text-secondary);
}
/* Danger button */
.btn-danger {
background: rgba(239, 68, 68, 0.1);
color: #f87171;
border: 1px solid rgba(239, 68, 68, 0.2);
border-radius: 0.75rem;
font-weight: 500;
font-size: 0.875rem;
padding: 0.5rem 1rem;
cursor: pointer;
transition: all 0.2s ease;
}
.btn-danger:hover:not(:disabled) {
background: rgba(239, 68, 68, 0.15);
}
.btn-danger:disabled {
opacity: 0.4;
cursor: not-allowed;
}
/* Status badge */
.badge {
display: inline-flex;
align-items: center;
gap: 0.375rem;
padding: 0.25rem 0.75rem;
border-radius: 9999px;
font-size: 0.75rem;
font-weight: 500;
border: 1px solid;
}
.badge-success {
background: rgba(0, 230, 138, 0.08);
color: #34d399;
border-color: rgba(0, 230, 138, 0.2);
}
.badge-warning {
background: rgba(255, 170, 0, 0.08);
color: #fbbf24;
border-color: rgba(255, 170, 0, 0.2);
}
.badge-error {
background: rgba(255, 68, 102, 0.08);
color: #f87171;
border-color: rgba(255, 68, 102, 0.2);
}
.badge-info {
background: rgba(0, 128, 255, 0.08);
color: #60a5fa;
border-color: rgba(0, 128, 255, 0.2);
}
/* Status dot */
.status-dot {
width: 0.5rem;
height: 0.5rem;
border-radius: 9999px;
flex-shrink: 0;
}
.status-dot-success {
background: var(--color-status-success);
box-shadow: 0 0 6px var(--color-status-success);
}
.status-dot-warning {
background: var(--color-status-warning);
box-shadow: 0 0 6px var(--color-status-warning);
}
.status-dot-error {
background: var(--color-status-error);
box-shadow: 0 0 6px var(--color-status-error);
}
.status-dot-info {
background: var(--color-status-info);
box-shadow: 0 0 6px var(--color-status-info);
}
/* Glow dot (legacy) */
.glow-dot {
box-shadow: 0 0 6px currentColor;
}
/* Gradient text */ /* Gradient text */
.text-gradient-blue { .text-gradient-blue {
background: linear-gradient(135deg, #0080ff, #00d4ff); background: linear-gradient(135deg, #0080ff, #00d4ff);
@ -243,31 +508,13 @@ body {
background-clip: text; background-clip: text;
} }
/* Glow dot */ /* Modal backdrop */
.glow-dot { .modal-backdrop {
box-shadow: 0 0 6px currentColor; background: rgba(0, 0, 0, 0.6);
backdrop-filter: blur(8px);
} }
/* Electric input */ /* Progress bar */
.input-electric {
background: rgba(10, 10, 26, 0.8);
border: 1px solid rgba(0, 128, 255, 0.15);
border-radius: 0.75rem;
color: var(--color-text-primary);
transition: all 0.3s ease;
}
.input-electric:focus {
outline: none;
border-color: rgba(0, 128, 255, 0.5);
box-shadow: 0 0 0 3px rgba(0, 128, 255, 0.15), 0 0 20px rgba(0, 128, 255, 0.1);
}
.input-electric::placeholder {
color: var(--color-text-muted);
}
/* Progress bar animation */
.progress-bar-animated { .progress-bar-animated {
position: relative; position: relative;
overflow: hidden; overflow: hidden;
@ -280,22 +527,22 @@ body {
left: 0; left: 0;
right: 0; right: 0;
bottom: 0; bottom: 0;
background: linear-gradient(90deg, transparent, rgba(255,255,255,0.15), transparent); background: linear-gradient(90deg, transparent, rgba(255, 255, 255, 0.15), transparent);
background-size: 200% 100%; background-size: 200% 100%;
animation: shimmer 2s infinite; animation: shimmer 2s infinite;
} }
/* Table styling */ /* Table */
.table-electric { .table-electric {
width: 100%; width: 100%;
} }
.table-electric thead tr { .table-electric thead tr {
border-bottom: 1px solid rgba(0, 128, 255, 0.1); border-bottom: 1px solid var(--pc-border);
} }
.table-electric thead th { .table-electric thead th {
color: var(--color-text-muted); color: var(--pc-text-muted);
font-weight: 500; font-weight: 500;
font-size: 0.75rem; font-size: 0.75rem;
text-transform: uppercase; text-transform: uppercase;
@ -310,26 +557,92 @@ body {
} }
.table-electric tbody tr { .table-electric tbody tr {
border-bottom: 1px solid rgba(26, 26, 62, 0.5); border-bottom: 1px solid var(--pc-separator);
transition: all 0.2s ease; transition: all 0.2s ease;
} }
.table-electric tbody tr:hover { .table-electric tbody tr:hover {
background: rgba(0, 128, 255, 0.04); background: var(--pc-hover);
} }
/* Modal backdrop */ .table-electric tbody td {
.modal-backdrop { padding: 0.75rem 1rem;
background: rgba(5, 5, 16, 0.8); font-size: 0.875rem;
backdrop-filter: blur(8px); color: var(--pc-text-primary);
} }
/* Sidebar glow line */ /* ── Markdown styles ── */
.sidebar-glow-line { .markdown-body pre {
position: absolute; background: var(--pc-bg-code) !important;
right: 0; border: 1px solid var(--pc-border);
top: 0; border-radius: 0.75rem;
bottom: 0; padding: 1rem;
width: 1px; overflow-x: auto;
background: linear-gradient(180deg, transparent, rgba(0, 128, 255, 0.3), transparent); margin: 0.5rem 0;
font-family: var(--pc-font-mono);
font-size: calc(var(--pc-font-size-mono) * 0.9);
line-height: 1.6;
max-width: 100%;
box-sizing: border-box;
}
.markdown-body pre code {
white-space: pre;
word-break: normal;
overflow-wrap: normal;
}
.markdown-body code {
background: var(--pc-accent-glow);
padding: 2px 6px;
border-radius: 6px;
font-size: calc(var(--pc-font-size-mono) * 0.95);
font-family: var(--pc-font-mono);
}
.markdown-body p { margin: 4px 0; }
.markdown-body ul, .markdown-body ol { margin: 4px 0; padding-left: 20px; }
.markdown-body ul { list-style-type: disc; }
.markdown-body ol { list-style-type: decimal; }
.markdown-body li { margin: 2px 0; }
.markdown-body li > ul, .markdown-body li > ol { margin: 2px 0; }
.markdown-body li > ul { list-style-type: circle; }
.markdown-body li > ul > li > ul { list-style-type: square; }
.markdown-body blockquote { border-left: 3px solid var(--pc-accent-dim); padding-left: 12px; margin: 8px 0; opacity: 0.8; }
.markdown-body h1, .markdown-body h2, .markdown-body h3 { margin: 12px 0 4px; }
.markdown-body a { color: var(--pc-accent-light); text-decoration: underline; }
.markdown-body table { border-collapse: collapse; margin: 8px 0; display: block; overflow-x: auto; max-width: 100%; }
.markdown-body th, .markdown-body td { border: 1px solid var(--pc-border); padding: 6px 12px; }
.markdown-body th { background: var(--pc-accent-glow); }
.markdown-body img { max-width: 100%; border-radius: 8px; }
/* ── Accessibility: reduced motion ── */
@media (prefers-reduced-motion: reduce) {
.animate-fade-in,
.animate-fade-in-legacy,
.animate-fade-in-scale,
.animate-slide-in-left,
.animate-slide-in-right,
.animate-slide-in-up,
.animate-pulse-glow,
.animate-float {
animation: none;
}
.pulse-dot {
animation: none;
}
.bounce-dot {
animation: none;
opacity: 0.7;
}
.progress-bar-animated::after {
animation: none;
}
*,
*::before,
*::after {
transition-duration: 0.01ms !important;
animation-duration: 0.01ms !important;
animation-iteration-count: 1 !important;
}
} }

View File

@ -313,6 +313,29 @@ const translations: Record<Locale, Record<string, string>> = {
'dashboard.inactive': '非活跃', 'dashboard.inactive': '非活跃',
'dashboard.no_components': '没有组件报告', 'dashboard.no_components': '没有组件报告',
'dashboard.restarts': '重启次数', 'dashboard.restarts': '重启次数',
// Settings
'settings.title': '设置',
'settings.tab.appearance': '外观',
'settings.tab.typography': '排版',
'settings.appearance': '外观设置',
'settings.typography': '字体设置',
'settings.fontUi': '界面字体',
'settings.fontMono': '代码字体',
'settings.fontSize': '界面字号',
'settings.fontMonoSize': '代码字号',
'settings.preview': '预览',
'settings.previewText': '界面字体预览文本',
'settings.fontNote': '字体设置需要刷新页面后生效',
'settings.language': '界面语言',
// Theme
'theme.mode': '主题模式',
'theme.accent': '强调色',
'theme.system': '跟随系统',
'theme.dark': '深色',
'theme.light': '浅色',
'theme.oled': '纯黑',
}, },
en: { en: {
@ -620,6 +643,29 @@ const translations: Record<Locale, Record<string, string>> = {
'dashboard.inactive': 'Inactive', 'dashboard.inactive': 'Inactive',
'dashboard.no_components': 'No components reporting', 'dashboard.no_components': 'No components reporting',
'dashboard.restarts': 'Restarts', 'dashboard.restarts': 'Restarts',
// Settings
'settings.title': 'Settings',
'settings.tab.appearance': 'Appearance',
'settings.tab.typography': 'Typography',
'settings.appearance': 'Appearance',
'settings.typography': 'Typography',
'settings.fontUi': 'UI Font',
'settings.fontMono': 'Code Font',
'settings.fontSize': 'UI Font Size',
'settings.fontMonoSize': 'Code Font Size',
'settings.preview': 'Preview',
'settings.previewText': 'The quick brown fox jumps over the lazy dog.',
'settings.fontNote': 'Font changes apply on page reload.',
'settings.language': 'Language',
// Theme
'theme.mode': 'Theme Mode',
'theme.accent': 'Accent Color',
'theme.system': 'System',
'theme.dark': 'Dark',
'theme.light': 'Light',
'theme.oled': 'OLED Black',
}, },
tr: { tr: {
@ -927,6 +973,29 @@ const translations: Record<Locale, Record<string, string>> = {
'health.pid': 'İşlem Kimliği', 'health.pid': 'İşlem Kimliği',
'health.uptime': 'Çalışma Süresi', 'health.uptime': 'Çalışma Süresi',
'health.updated_at': 'Son Güncelleme', 'health.updated_at': 'Son Güncelleme',
// Settings
'settings.title': 'Ayarlar',
'settings.tab.appearance': 'Görünüm',
'settings.tab.typography': 'Tipografi',
'settings.appearance': 'Görünüm',
'settings.typography': 'Tipografi',
'settings.fontUi': 'Arayüz Yazı Tipi',
'settings.fontMono': 'Kod Yazı Tipi',
'settings.fontSize': 'Arayüz Boyutu',
'settings.fontMonoSize': 'Kod Boyutu',
'settings.preview': 'Önizleme',
'settings.previewText': 'Tembel köpek üzerinde hızlı kahverengi tilki zıplar.',
'settings.fontNote': 'Yazı tipi değişiklikleri sayfa yeniden yüklendikten sonra geçerli olur.',
'settings.language': 'Dil',
// Theme
'theme.mode': 'Tema Modu',
'theme.accent': 'Vurgu Rengi',
'theme.system': 'Sistem',
'theme.dark': 'Koyu',
'theme.light': 'Açık',
'theme.oled': 'OLED Siyah',
}, },
}; };

View File

@ -212,8 +212,8 @@ export default function AgentChat() {
<div className="flex flex-col h-[calc(100vh-3.5rem)]"> <div className="flex flex-col h-[calc(100vh-3.5rem)]">
{/* Connection status bar */} {/* Connection status bar */}
{error && ( {error && (
<div className="px-4 py-2 bg-[#ff446615] border-b border-[#ff446630] flex items-center gap-2 text-sm text-[#ff6680] animate-fade-in"> <div className="px-4 py-2 border-b flex items-center gap-2 text-sm animate-fade-in" style={{ background: 'rgba(239, 68, 68, 0.08)', borderColor: 'rgba(239, 68, 68, 0.2)', color: '#f87171', }}>
<AlertCircle className="h-4 w-4 flex-shrink-0" /> <AlertCircle className="h-4 w-4 shrink-0" />
{error} {error}
</div> </div>
)} )}
@ -221,12 +221,12 @@ export default function AgentChat() {
{/* Messages area */} {/* Messages area */}
<div className="flex-1 overflow-y-auto p-4 space-y-4"> <div className="flex-1 overflow-y-auto p-4 space-y-4">
{messages.length === 0 && ( {messages.length === 0 && (
<div className="flex flex-col items-center justify-center h-full text-[#334060] animate-fade-in"> <div className="flex flex-col items-center justify-center h-full text-center animate-fade-in" style={{ color: 'var(--pc-text-muted)' }}>
<div className="h-16 w-16 rounded-2xl flex items-center justify-center mb-4 animate-float" style={{ background: 'linear-gradient(135deg, #0080ff15, #0080ff08)' }}> <div className="h-16 w-16 rounded-3xl flex items-center justify-center mb-4 animate-float" style={{ background: 'var(--pc-accent-glow)' }}>
<Bot className="h-8 w-8 text-[#0080ff]" /> <Bot className="h-8 w-8" style={{ color: 'var(--pc-accent)' }} />
</div> </div>
<p className="text-lg font-semibold text-white mb-1">ZeroClaw Agent</p> <p className="text-lg font-semibold mb-1" style={{ color: 'var(--pc-text-primary)' }}>ZeroClaw Agent</p>
<p className="text-sm text-[#556080]">{t('agent.start_conversation')}</p> <p className="text-sm" style={{ color: 'var(--pc-text-muted)' }}>{t('agent.start_conversation')}</p>
</div> </div>
)} )}
@ -239,52 +239,43 @@ export default function AgentChat() {
style={{ animationDelay: `${Math.min(idx * 30, 200)}ms` }} style={{ animationDelay: `${Math.min(idx * 30, 200)}ms` }}
> >
<div <div
className={`flex-shrink-0 w-8 h-8 rounded-xl flex items-center justify-center ${ className="flex-shrink-0 w-9 h-9 rounded-2xl flex items-center justify-center border"
msg.role === 'user'
? ''
: ''
}`}
style={{ style={{
background: msg.role === 'user' background: msg.role === 'user' ? 'var(--pc-accent)' : 'var(--pc-bg-elevated)',
? 'linear-gradient(135deg, #0080ff, #0060cc)' borderColor: msg.role === 'user' ? 'var(--pc-accent)' : 'var(--pc-border)',
: 'linear-gradient(135deg, #1a1a3e, #12122a)'
}} }}
> >
{msg.role === 'user' ? ( {msg.role === 'user' ? (
<User className="h-4 w-4 text-white" /> <User className="h-4 w-4 text-white" />
) : ( ) : (
<Bot className="h-4 w-4 text-[#0080ff]" /> <Bot className="h-4 w-4" style={{ color: 'var(--pc-accent)' }} />
)} )}
</div> </div>
<div className="relative max-w-[75%]"> <div className="relative max-w-[75%]">
<div <div
className={`rounded-2xl px-4 py-3 ${ className="rounded-2xl px-4 py-3 border"
style={
msg.role === 'user' msg.role === 'user'
? 'text-white' ? { background: 'var(--pc-accent-glow)', borderColor: 'var(--pc-accent-dim)', color: 'var(--pc-text-primary)', }
: 'text-[#e8edf5] border border-[#1a1a3e]' : { background: 'var(--pc-bg-elevated)', borderColor: 'var(--pc-border)', color: 'var(--pc-text-primary)', }
}`} }
style={{
background: msg.role === 'user'
? 'linear-gradient(135deg, #0080ff, #0066cc)'
: 'linear-gradient(135deg, rgba(13,13,32,0.8), rgba(10,10,26,0.6))'
}}
> >
<p className="text-sm whitespace-pre-wrap break-words">{msg.content}</p> <p className="text-sm whitespace-pre-wrap break-words leading-relaxed">{msg.content}</p>
<p <p
className={`text-[10px] mt-1.5 ${ className="text-[10px] mt-1.5" style={{ color: msg.role === 'user' ? 'var(--pc-accent-light)' : 'var(--pc-text-faint)' }}>
msg.role === 'user' ? 'text-white/50' : 'text-[#334060]'
}`}
>
{msg.timestamp.toLocaleTimeString()} {msg.timestamp.toLocaleTimeString()}
</p> </p>
</div> </div>
<button <button
onClick={() => handleCopy(msg.id, msg.content)} onClick={() => handleCopy(msg.id, msg.content)}
aria-label={t('agent.copy_message')} aria-label={t('agent.copy_message')}
className="absolute top-1 right-1 opacity-0 group-hover:opacity-100 transition-all duration-300 p-1.5 rounded-lg bg-[#0a0a18] border border-[#1a1a3e] text-[#556080] hover:text-white hover:border-[#0080ff40]" className="absolute top-1 right-1 opacity-0 group-hover:opacity-100 transition-all p-1.5 rounded-xl"
style={{ background: 'var(--pc-bg-elevated)', border: '1px solid var(--pc-border)', color: 'var(--pc-text-muted)', }}
onMouseEnter={(e) => { e.currentTarget.style.color = 'var(--pc-text-primary)'; e.currentTarget.style.borderColor = 'var(--pc-accent-dim)'; }}
onMouseLeave={(e) => { e.currentTarget.style.color = 'var(--pc-text-muted)'; e.currentTarget.style.borderColor = 'var(--pc-border)'; }}
> >
{copiedId === msg.id ? ( {copiedId === msg.id ? (
<Check className="h-3 w-3 text-[#00e68a]" /> <Check className="h-3 w-3" style={{ color: '#34d399' }} />
) : ( ) : (
<Copy className="h-3 w-3" /> <Copy className="h-3 w-3" />
)} )}
@ -295,15 +286,13 @@ export default function AgentChat() {
{typing && ( {typing && (
<div className="flex items-start gap-3 animate-fade-in"> <div className="flex items-start gap-3 animate-fade-in">
<div className="flex-shrink-0 w-8 h-8 rounded-xl flex items-center justify-center" style={{ background: 'linear-gradient(135deg, #1a1a3e, #12122a)' }}> <div className="flex-shrink-0 w-9 h-9 rounded-2xl flex items-center justify-center border" style={{ background: 'var(--pc-bg-elevated)', borderColor: 'var(--pc-border)' }}>
<Bot className="h-4 w-4 text-[#0080ff]" /> <Bot className="h-4 w-4" style={{ color: 'var(--pc-accent)' }} />
</div> </div>
<div className="rounded-2xl px-4 py-3 border border-[#1a1a3e]" style={{ background: 'linear-gradient(135deg, rgba(13,13,32,0.8), rgba(10,10,26,0.6))' }}> <div className="rounded-2xl px-4 py-3 border flex items-center gap-1.5" style={{ background: 'var(--pc-bg-elevated)', borderColor: 'var(--pc-border)' }}>
<div className="flex items-center gap-1.5"> <span className="bounce-dot w-1.5 h-1.5 rounded-full" style={{ background: 'var(--pc-accent)' }} />
<span className="w-1.5 h-1.5 bg-[#0080ff] rounded-full animate-bounce" style={{ animationDelay: '0ms' }} /> <span className="bounce-dot w-1.5 h-1.5 rounded-full" style={{ background: 'var(--pc-accent)' }} />
<span className="w-1.5 h-1.5 bg-[#0080ff] rounded-full animate-bounce" style={{ animationDelay: '150ms' }} /> <span className="bounce-dot w-1.5 h-1.5 rounded-full" style={{ background: 'var(--pc-accent)' }} />
<span className="w-1.5 h-1.5 bg-[#0080ff] rounded-full animate-bounce" style={{ animationDelay: '300ms' }} />
</div>
</div> </div>
</div> </div>
)} )}
@ -312,36 +301,38 @@ export default function AgentChat() {
</div> </div>
{/* Input area */} {/* Input area */}
<div className="border-t border-[#1a1a3e]/40 p-4" style={{ background: 'linear-gradient(180deg, rgba(8,8,24,0.9), rgba(5,5,16,0.95))' }}> <div className="border-t p-4" style={{ borderColor: 'var(--pc-border)', background: 'var(--pc-bg-surface)' }}>
<div className="flex items-end gap-3 max-w-4xl mx-auto"> <div className="flex items-center gap-3 max-w-4xl mx-auto">
<div className="flex-1"> <textarea
<textarea ref={inputRef}
ref={inputRef} rows={1}
rows={1} value={input}
value={input} onChange={handleTextareaChange}
onChange={handleTextareaChange} onKeyDown={handleKeyDown}
onKeyDown={handleKeyDown} placeholder={connected ? t('agent.type_message') : t('agent.connecting')}
placeholder={connected ? t('agent.type_message') : t('agent.connecting')} disabled={!connected}
disabled={!connected} className="input-electric flex-1 px-4 text-sm resize-none disabled:opacity-40"
className="input-electric w-full px-4 py-3 text-sm resize-none overflow-y-auto disabled:opacity-40" style={{ minHeight: '44px', maxHeight: '200px', paddingTop: '10px', paddingBottom: '10px' }}
style={{ minHeight: '44px', maxHeight: '200px' }} />
/>
</div>
<button <button
type='button'
onClick={handleSend} onClick={handleSend}
disabled={!connected || !input.trim()} disabled={!connected || !input.trim()}
className="btn-electric flex-shrink-0 p-3 rounded-xl" className="btn-electric flex-shrink-0 rounded-2xl flex items-center justify-center"
style={{ color: 'white', width: '40px', height: '40px' }}
> >
<Send className="h-5 w-5" /> <Send className="h-5 w-5" />
</button> </button>
</div> </div>
<div className="flex items-center justify-center mt-2 gap-2"> <div className="flex items-center justify-center mt-2 gap-2">
<span <span
className={`inline-block h-1.5 w-1.5 rounded-full glow-dot ${ className="status-dot"
connected ? 'text-[#00e68a] bg-[#00e68a]' : 'text-[#ff4466] bg-[#ff4466]' style={connected
}`} ? { background: 'var(--color-status-success)', boxShadow: '0 0 6px var(--color-status-success)' }
: { background: 'var(--color-status-error)', boxShadow: '0 0 6px var(--color-status-error)' }
}
/> />
<span className="text-[10px] text-[#334060]"> <span className="text-[10px]" style={{ color: 'var(--pc-text-faint)' }}>
{connected ? t('agent.connected_status') : t('agent.disconnected_status')} {connected ? t('agent.connected_status') : t('agent.disconnected_status')}
</span> </span>
</div> </div>

View File

@ -18,9 +18,7 @@ export default function Config() {
useEffect(() => { useEffect(() => {
getConfig() getConfig()
.then((data) => { .then((data) => { setConfig(typeof data === 'string' ? data : JSON.stringify(data, null, 2)); })
setConfig(typeof data === 'string' ? data : JSON.stringify(data, null, 2));
})
.catch((err) => setError(err.message)) .catch((err) => setError(err.message))
.finally(() => setLoading(false)); .finally(() => setLoading(false));
}, []); }, []);
@ -49,7 +47,7 @@ export default function Config() {
if (loading) { if (loading) {
return ( return (
<div className="flex items-center justify-center h-64"> <div className="flex items-center justify-center h-64">
<div className="h-8 w-8 border-2 border-[#0080ff30] border-t-[#0080ff] rounded-full animate-spin" /> <div className="h-8 w-8 border-2 rounded-full animate-spin" style={{ borderColor: 'var(--pc-border)', borderTopColor: 'var(--pc-accent)' }} />
</div> </div>
); );
} }
@ -59,27 +57,22 @@ export default function Config() {
{/* Header */} {/* Header */}
<div className="flex items-center justify-between"> <div className="flex items-center justify-between">
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
<Settings className="h-5 w-5 text-[#0080ff]" /> <Settings className="h-5 w-5" style={{ color: 'var(--pc-accent)' }} />
<h2 className="text-sm font-semibold text-white uppercase tracking-wider">{t('config.configuration_title')}</h2> <h2 className="text-sm font-semibold uppercase tracking-wider" style={{ color: 'var(--pc-text-primary)' }}>{t('config.configuration_title')}</h2>
</div> </div>
<button <button onClick={handleSave} disabled={saving} className="btn-electric flex items-center gap-2 text-sm px-4 py-2">
onClick={handleSave} <Save className="h-4 w-4" />{saving ? t('config.saving') : t('config.save')}
disabled={saving}
className="btn-electric flex items-center gap-2 text-sm px-4 py-2"
>
<Save className="h-4 w-4" />
{saving ? t('config.saving') : t('config.save')}
</button> </button>
</div> </div>
{/* Sensitive fields note */} {/* Sensitive fields note */}
<div className="flex items-start gap-3 rounded-xl p-4 border border-[#ffaa0020]" style={{ background: 'rgba(255,170,0,0.05)' }}> <div className="flex items-start gap-3 rounded-2xl p-4 border" style={{ borderColor: 'rgba(255, 170, 0, 0.2)', background: 'rgba(255, 170, 0, 0.05)' }}>
<ShieldAlert className="h-5 w-5 text-[#ffaa00] flex-shrink-0 mt-0.5" /> <ShieldAlert className="h-5 w-5 flex-shrink-0 mt-0.5" style={{ color: 'var(--color-status-warning)' }} />
<div> <div>
<p className="text-sm text-[#ffaa00] font-medium"> <p className="text-sm font-medium" style={{ color: 'var(--color-status-warning)' }}>
{t('config.sensitive_title')} {t('config.sensitive_title')}
</p> </p>
<p className="text-sm text-[#ffaa0080] mt-0.5"> <p className="text-sm mt-0.5" style={{ color: 'rgba(255, 170, 0, 0.7)' }}>
{t('config.sensitive_hint')} {t('config.sensitive_hint')}
</p> </p>
</div> </div>
@ -87,27 +80,27 @@ export default function Config() {
{/* Success message */} {/* Success message */}
{success && ( {success && (
<div className="flex items-center gap-2 rounded-xl p-3 border border-[#00e68a30] animate-fade-in" style={{ background: 'rgba(0,230,138,0.06)' }}> <div className="flex items-center gap-2 rounded-xl p-3 border animate-fade-in" style={{ borderColor: 'rgba(0, 230, 138, 0.2)', background: 'rgba(0, 230, 138, 0.06)' }}>
<CheckCircle className="h-4 w-4 text-[#00e68a] flex-shrink-0" /> <CheckCircle className="h-4 w-4 flex-shrink-0" style={{ color: 'var(--color-status-success)' }} />
<span className="text-sm text-[#00e68a]">{success}</span> <span className="text-sm" style={{ color: 'var(--color-status-success)' }}>{success}</span>
</div> </div>
)} )}
{/* Error message */} {/* Error message */}
{error && ( {error && (
<div className="flex items-center gap-2 rounded-xl p-3 border border-[#ff446630] animate-fade-in" style={{ background: 'rgba(255,68,102,0.06)' }}> <div className="flex items-center gap-2 rounded-xl p-3 border animate-fade-in" style={{ borderColor: 'rgba(239, 68, 68, 0.2)', background: 'rgba(239, 68, 68, 0.06)' }}>
<AlertTriangle className="h-4 w-4 text-[#ff4466] flex-shrink-0" /> <AlertTriangle className="h-4 w-4 flex-shrink-0" style={{ color: 'var(--color-status-error)' }} />
<span className="text-sm text-[#ff6680]">{error}</span> <span className="text-sm" style={{ color: 'var(--color-status-error)' }}>{error}</span>
</div> </div>
)} )}
{/* Config Editor */} {/* Config Editor */}
<div className="glass-card overflow-hidden"> <div className="card overflow-hidden rounded-2xl">
<div className="flex items-center justify-between px-4 py-2.5 border-b border-[#1a1a3e]" style={{ background: 'rgba(0,128,255,0.03)' }}> <div className="flex items-center justify-between px-4 py-2.5 border-b" style={{ borderColor: 'var(--pc-border)', background: 'var(--pc-accent-glow)' }}>
<span className="text-[10px] text-[#334060] font-semibold uppercase tracking-wider"> <span className="text-[10px] font-semibold uppercase tracking-wider" style={{ color: 'var(--pc-text-muted)' }}>
{t('config.toml_label')} {t('config.toml_label')}
</span> </span>
<span className="text-[10px] text-[#334060]"> <span className="text-[10px]" style={{ color: 'var(--pc-text-faint)' }}>
{config.split('\n').length} {t('config.lines')} {config.split('\n').length} {t('config.lines')}
</span> </span>
</div> </div>
@ -115,8 +108,8 @@ export default function Config() {
value={config} value={config}
onChange={(e) => setConfig(e.target.value)} onChange={(e) => setConfig(e.target.value)}
spellCheck={false} spellCheck={false}
className="w-full min-h-[500px] text-[#8892a8] font-mono text-sm p-4 resize-y focus:outline-none focus:ring-2 focus:ring-[#0080ff40] focus:ring-inset" className="w-full min-h-[500px] text-sm p-4 resize-y focus:outline-none font-mono"
style={{ background: 'rgba(5,5,16,0.8)', tabSize: 4 }} style={{ background: 'var(--pc-bg-base)', color: 'var(--pc-text-secondary)', tabSize: 4 }}
/> />
</div> </div>
</div> </div>

View File

@ -19,16 +19,13 @@ export default function Cost() {
const [error, setError] = useState<string | null>(null); const [error, setError] = useState<string | null>(null);
useEffect(() => { useEffect(() => {
getCost() getCost().then(setCost).catch((err) => setError(err.message)).finally(() => setLoading(false));
.then(setCost)
.catch((err) => setError(err.message))
.finally(() => setLoading(false));
}, []); }, []);
if (error) { if (error) {
return ( return (
<div className="p-6 animate-fade-in"> <div className="p-6 animate-fade-in">
<div className="rounded-xl bg-[#ff446615] border border-[#ff446630] p-4 text-[#ff6680]"> <div className="rounded-2xl border p-4" style={{ background: 'rgba(239, 68, 68, 0.08)', borderColor: 'rgba(239, 68, 68, 0.2)', color: '#f87171' }}>
{t('cost.load_error')}: {error} {t('cost.load_error')}: {error}
</div> </div>
</div> </div>
@ -38,7 +35,7 @@ export default function Cost() {
if (loading || !cost) { if (loading || !cost) {
return ( return (
<div className="flex items-center justify-center h-64"> <div className="flex items-center justify-center h-64">
<div className="h-8 w-8 border-2 border-[#0080ff30] border-t-[#0080ff] rounded-full animate-spin" /> <div className="h-8 w-8 border-2 rounded-full animate-spin" style={{ borderColor: 'var(--pc-border)', borderTopColor: 'var(--pc-accent)' }} />
</div> </div>
); );
} }
@ -50,26 +47,26 @@ export default function Cost() {
{/* Summary Cards */} {/* Summary Cards */}
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-4 gap-4 stagger-children"> <div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-4 gap-4 stagger-children">
{[ {[
{ icon: DollarSign, color: '#0080ff', bg: '#0080ff15', label: t('cost.session_cost'), value: formatUSD(cost.session_cost_usd) }, { icon: DollarSign, accent: 'var(--pc-accent)', bg: 'rgba(var(--pc-accent-rgb), 0.08)', label: t('cost.session_cost'), value: formatUSD(cost.session_cost_usd) },
{ icon: TrendingUp, color: '#00e68a', bg: '#00e68a15', label: t('cost.daily_cost'), value: formatUSD(cost.daily_cost_usd) }, { icon: TrendingUp, accent: 'var(--color-status-success)', bg: 'rgba(0, 230, 138, 0.08)', label: t('cost.daily_cost'), value: formatUSD(cost.daily_cost_usd) },
{ icon: Layers, color: '#a855f7', bg: '#a855f715', label: t('cost.monthly_cost'), value: formatUSD(cost.monthly_cost_usd) }, { icon: Layers, accent: '#a78bfa', bg: 'rgba(167, 139, 250, 0.08)', label: t('cost.monthly_cost'), value: formatUSD(cost.monthly_cost_usd) },
{ icon: Hash, color: '#ff8800', bg: '#ff880015', label: t('cost.total_requests'), value: cost.request_count.toLocaleString() }, { icon: Hash, accent: 'var(--color-status-warning)', bg: 'rgba(255, 170, 0, 0.08)', label: t('cost.total_requests'), value: cost.request_count.toLocaleString() },
].map(({ icon: Icon, color, bg, label, value }) => ( ].map(({ icon: Icon, accent, bg, label, value }) => (
<div key={label} className="glass-card p-5 animate-slide-in-up"> <div key={label} className="card p-5 animate-slide-in-up">
<div className="flex items-center gap-3 mb-3"> <div className="flex items-center gap-3 mb-3">
<div className="p-2 rounded-xl" style={{ background: bg }}> <div className="p-2 rounded-2xl" style={{ background: bg, color: accent }}>
<Icon className="h-5 w-5" style={{ color }} /> <Icon className="h-5 w-5" />
</div> </div>
<span className="text-xs text-[#556080] uppercase tracking-wider font-medium">{label}</span> <span className="text-xs uppercase tracking-wider font-medium" style={{ color: 'var(--pc-text-muted)' }}>{label}</span>
</div> </div>
<p className="text-2xl font-bold text-white font-mono">{value}</p> <p className="text-2xl font-bold font-mono" style={{ color: 'var(--pc-text-primary)' }}>{value}</p>
</div> </div>
))} ))}
</div> </div>
{/* Token Statistics */} {/* Token Statistics */}
<div className="glass-card p-5 animate-slide-in-up" style={{ animationDelay: '200ms' }}> <div className="card p-5 animate-slide-in-up" style={{ animationDelay: '200ms' }}>
<h3 className="text-sm font-semibold text-white mb-4 uppercase tracking-wider"> <h3 className="text-sm font-semibold mb-4 uppercase tracking-wider" style={{ color: 'var(--pc-text-primary)' }}>
{t('cost.token_statistics')} {t('cost.token_statistics')}
</h3> </h3>
<div className="grid grid-cols-1 sm:grid-cols-3 gap-4"> <div className="grid grid-cols-1 sm:grid-cols-3 gap-4">
@ -78,23 +75,23 @@ export default function Cost() {
{ label: t('cost.avg_tokens_per_request'), value: cost.request_count > 0 ? Math.round(cost.total_tokens / cost.request_count).toLocaleString() : '0' }, { label: t('cost.avg_tokens_per_request'), value: cost.request_count > 0 ? Math.round(cost.total_tokens / cost.request_count).toLocaleString() : '0' },
{ label: t('cost.cost_per_1k_tokens'), value: cost.total_tokens > 0 ? formatUSD((cost.monthly_cost_usd / cost.total_tokens) * 1000) : '$0.0000' }, { label: t('cost.cost_per_1k_tokens'), value: cost.total_tokens > 0 ? formatUSD((cost.monthly_cost_usd / cost.total_tokens) * 1000) : '$0.0000' },
].map(({ label, value }) => ( ].map(({ label, value }) => (
<div key={label} className="rounded-xl p-4" style={{ background: 'rgba(0,128,255,0.04)', border: '1px solid rgba(0,128,255,0.08)' }}> <div key={label} className="rounded-2xl p-4 border" style={{ background: 'var(--pc-accent-glow)', borderColor: 'var(--pc-border)' }}>
<p className="text-xs text-[#556080] uppercase tracking-wider">{label}</p> <p className="text-xs uppercase tracking-wider" style={{ color: 'var(--pc-text-muted)' }}>{label}</p>
<p className="text-xl font-bold text-white mt-1 font-mono">{value}</p> <p className="text-xl font-bold mt-1 font-mono" style={{ color: 'var(--pc-text-primary)' }}>{value}</p>
</div> </div>
))} ))}
</div> </div>
</div> </div>
{/* Model Breakdown Table */} {/* Model Breakdown Table */}
<div className="glass-card overflow-hidden animate-slide-in-up" style={{ animationDelay: '300ms' }}> <div className="card overflow-hidden animate-slide-in-up rounded-2xl" style={{ animationDelay: '300ms' }}>
<div className="px-5 py-4 border-b border-[#1a1a3e]"> <div className="px-5 py-4 border-b" style={{ borderColor: 'var(--pc-border)' }}>
<h3 className="text-sm font-semibold text-white uppercase tracking-wider"> <h3 className="text-sm font-semibold uppercase tracking-wider" style={{ color: 'var(--pc-text-primary)' }}>
{t('cost.model_breakdown')} {t('cost.model_breakdown')}
</h3> </h3>
</div> </div>
{models.length === 0 ? ( {models.length === 0 ? (
<div className="p-8 text-center text-[#334060]"> <div className="p-8 text-center" style={{ color: 'var(--pc-text-faint)' }}>
{t('cost.no_model_data')} {t('cost.no_model_data')}
</div> </div>
) : ( ) : (
@ -102,51 +99,46 @@ export default function Cost() {
<table className="table-electric"> <table className="table-electric">
<thead> <thead>
<tr> <tr>
<th className="text-left">{t('cost.model')}</th> <th>{t('cost.model')}</th>
<th className="text-right">{t('cost.cost')}</th> <th className="text-right">{t('cost.cost')}</th>
<th className="text-right">{t('cost.tokens')}</th> <th className="text-right">{t('cost.tokens')}</th>
<th className="text-right">{t('cost.requests')}</th> <th className="text-right">{t('cost.requests')}</th>
<th className="text-left">{t('cost.share')}</th> <th>{t('cost.share')}</th>
</tr> </tr>
</thead> </thead>
<tbody> <tbody>
{models {models.sort((a, b) => b.cost_usd - a.cost_usd).map((m) => {
.sort((a, b) => b.cost_usd - a.cost_usd) const share = cost.monthly_cost_usd > 0 ? (m.cost_usd / cost.monthly_cost_usd) * 100 : 0;
.map((m) => { return (
const share = <tr key={m.model}>
cost.monthly_cost_usd > 0 <td className="font-medium text-sm" style={{ color: 'var(--pc-text-primary)' }}>
? (m.cost_usd / cost.monthly_cost_usd) * 100 {m.model}
: 0; </td>
return ( <td className="text-right font-mono text-sm" style={{ color: 'var(--pc-text-secondary)' }}>
<tr key={m.model}> {formatUSD(m.cost_usd)}
<td className="px-5 py-3 text-white font-medium text-sm"> </td>
{m.model} <td className="text-right text-sm" style={{ color: 'var(--pc-text-secondary)' }}>
</td> {m.total_tokens.toLocaleString()}
<td className="px-5 py-3 text-[#8892a8] text-right font-mono text-sm"> </td>
{formatUSD(m.cost_usd)} <td className="text-right text-sm" style={{ color: 'var(--pc-text-secondary)' }}>
</td> {m.request_count.toLocaleString()}
<td className="px-5 py-3 text-[#8892a8] text-right text-sm"> </td>
{m.total_tokens.toLocaleString()} <td>
</td> <div className="flex items-center gap-2">
<td className="px-5 py-3 text-[#8892a8] text-right text-sm"> <div className="w-20 h-1.5 rounded-full overflow-hidden" style={{ background: 'var(--pc-hover)' }}>
{m.request_count.toLocaleString()} <div
</td> className="h-full rounded-full progress-bar-animated transition-all duration-700"
<td className="px-5 py-3"> style={{ width: `${Math.max(share, 2)}%`, background: 'var(--pc-accent)' }}
<div className="flex items-center gap-2"> />
<div className="w-20 h-1.5 bg-[#0a0a18] rounded-full overflow-hidden">
<div
className="h-full rounded-full progress-bar-animated transition-all duration-700"
style={{ width: `${Math.max(share, 2)}%`, background: '#0080ff' }}
/>
</div>
<span className="text-xs text-[#556080] w-10 text-right font-mono">
{share.toFixed(1)}%
</span>
</div> </div>
</td> <span className="text-xs font-mono w-10 text-right" style={{ color: 'var(--pc-text-muted)' }}>
</tr> {share.toFixed(1)}%
); </span>
})} </div>
</td>
</tr>
);
})}
</tbody> </tbody>
</table> </table>
</div> </div>

View File

@ -53,14 +53,12 @@ function RunHistoryPanel({ jobId }: { jobId: string }) {
.finally(() => setLoading(false)); .finally(() => setLoading(false));
}, [jobId]); }, [jobId]);
useEffect(() => { useEffect(() => { fetchRuns(); }, [fetchRuns]);
fetchRuns();
}, [fetchRuns]);
if (loading) { if (loading) {
return ( return (
<div className="flex items-center gap-2 px-4 py-3 text-[#556080] text-xs"> <div className="flex items-center gap-2 px-4 py-3 text-xs" style={{ color: 'var(--pc-text-muted)' }}>
<div className="animate-spin rounded-full h-4 w-4 border border-[#0080ff30] border-t-[#0080ff]" /> <div className="h-4 w-4 border-2 rounded-full animate-spin" style={{ borderColor: 'var(--pc-border)', borderTopColor: 'var(--pc-accent)' }} />
Loading run history... Loading run history...
</div> </div>
); );
@ -70,13 +68,12 @@ function RunHistoryPanel({ jobId }: { jobId: string }) {
return ( return (
<div className="px-4 py-3"> <div className="px-4 py-3">
<div className="flex items-center justify-between"> <div className="flex items-center justify-between">
<span className="text-xs text-[#ff6680]"> <span className="text-xs" style={{ color: 'var(--color-status-error)' }}>
{t('cron.load_run_history_error')}: {error} {t('cron.load_run_history_error')}: {error}
</span> </span>
<button <button
onClick={fetchRuns} onClick={fetchRuns}
className="text-[#556080] hover:text-white transition-colors duration-300" className="btn-icon">
>
<RefreshCw className="h-3.5 w-3.5" /> <RefreshCw className="h-3.5 w-3.5" />
</button> </button>
</div> </div>
@ -87,10 +84,10 @@ function RunHistoryPanel({ jobId }: { jobId: string }) {
if (runs.length === 0) { if (runs.length === 0) {
return ( return (
<div className="px-4 py-3 flex items-center justify-between"> <div className="px-4 py-3 flex items-center justify-between">
<span className="text-xs text-[#334060]">{t('cron.no_runs')}</span> <span className="text-xs" style={{ color: 'var(--pc-text-faint)' }}>{t('cron.no_runs')}</span>
<button <button
onClick={fetchRuns} onClick={fetchRuns}
className="text-[#556080] hover:text-white transition-colors duration-300" className="btn-icon"
> >
<RefreshCw className="h-3.5 w-3.5" /> <RefreshCw className="h-3.5 w-3.5" />
</button> </button>
@ -101,12 +98,12 @@ function RunHistoryPanel({ jobId }: { jobId: string }) {
return ( return (
<div className="px-4 py-3"> <div className="px-4 py-3">
<div className="flex items-center justify-between mb-2"> <div className="flex items-center justify-between mb-2">
<span className="text-xs font-medium text-[#8892a8]"> <span className="text-xs font-medium" style={{ color: 'var(--pc-text-secondary)' }}>
{t('cron.recent_runs')} ({runs.length}) {t('cron.recent_runs')} ({runs.length})
</span> </span>
<button <button
onClick={fetchRuns} onClick={fetchRuns}
className="text-[#556080] hover:text-white transition-colors duration-300" className="btn-icon"
title="Refresh runs" title="Refresh runs"
> >
<RefreshCw className="h-3.5 w-3.5" /> <RefreshCw className="h-3.5 w-3.5" />
@ -116,26 +113,26 @@ function RunHistoryPanel({ jobId }: { jobId: string }) {
{runs.map((run) => ( {runs.map((run) => (
<div <div
key={run.id} key={run.id}
className="bg-[#0a0a2060] rounded-lg px-3 py-2 text-xs border border-[#1a1a3e]/30" className="rounded-xl px-3 py-2 text-xs border" style={{ background: 'var(--pc-bg-elevated)', borderColor: 'var(--pc-border)' }}
> >
<div className="flex items-center justify-between mb-1"> <div className="flex items-center justify-between mb-1">
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
{run.status === 'ok' ? ( {run.status === 'ok' ? (
<CheckCircle className="h-3.5 w-3.5 text-[#00e68a]" /> <CheckCircle className="h-3.5 w-3.5" style={{ color: 'var(--color-status-success)' }} />
) : ( ) : (
<XCircle className="h-3.5 w-3.5 text-[#ff4466]" /> <XCircle className="h-3.5 w-3.5" style={{ color: 'var(--color-status-error)' }} />
)} )}
<span className="text-[#8892a8] capitalize">{run.status}</span> <span style={{ color: 'var(--pc-text-secondary)' }}>{run.status}</span>
</div> </div>
<span className="text-[#556080]"> <span style={{ color: 'var(--pc-text-muted)' }}>
{formatDuration(run.duration_ms)} {formatDuration(run.duration_ms)}
</span> </span>
</div> </div>
<div className="flex items-center gap-3 text-[#556080]"> <div className="flex items-center gap-3" style={{ color: 'var(--pc-text-muted)' }}>
<span>{formatDate(run.started_at)}</span> <span>{formatDate(run.started_at)}</span>
</div> </div>
{run.output && ( {run.output && (
<pre className="mt-1.5 bg-[#050510]/70 rounded p-2 text-[#8892a8] text-xs overflow-x-auto max-h-24 whitespace-pre-wrap break-words"> <pre className="mt-1.5 rounded-lg p-2 text-xs overflow-x-auto max-h-24 whitespace-pre-wrap break-words font-mono" style={{ background: 'var(--pc-bg-base)', color: 'var(--pc-text-secondary)' }}>
{run.output} {run.output}
</pre> </pre>
)} )}
@ -190,10 +187,7 @@ export default function Cron() {
const fetchJobs = () => { const fetchJobs = () => {
setLoading(true); setLoading(true);
getCronJobs() getCronJobs().then(setJobs).catch((err) => setError(err.message)).finally(() => setLoading(false));
.then(setJobs)
.catch((err) => setError(err.message))
.finally(() => setLoading(false));
}; };
const fetchSettings = () => { const fetchSettings = () => {
@ -270,20 +264,20 @@ export default function Cron() {
if (!status) return null; if (!status) return null;
switch (status.toLowerCase()) { switch (status.toLowerCase()) {
case 'ok': case 'ok':
case 'success': case 'success':
return <CheckCircle className="h-4 w-4 text-[#00e68a]" />; return <CheckCircle className="h-4 w-4" style={{ color: 'var(--color-status-success)' }} />;
case 'error': case 'error':
case 'failed': case 'failed':
return <XCircle className="h-4 w-4 text-[#ff4466]" />; return <XCircle className="h-4 w-4" style={{ color: 'var(--color-status-error)' }} />;
default: default:
return <AlertCircle className="h-4 w-4 text-[#ffaa00]" />; return <AlertCircle className="h-4 w-4" style={{ color: 'var(--color-status-warning)' }} />;
} }
}; };
if (error) { if (error) {
return ( return (
<div className="p-6 animate-fade-in"> <div className="p-6 animate-fade-in">
<div className="rounded-xl bg-[#ff446615] border border-[#ff446630] p-4 text-[#ff6680]"> <div className="rounded-2xl border p-4" style={{ background: 'rgba(239, 68, 68, 0.08)', borderColor: 'rgba(239, 68, 68, 0.2)', color: '#f87171' }}>
{t('cron.load_error')}: {error} {t('cron.load_error')}: {error}
</div> </div>
</div> </div>
@ -293,7 +287,7 @@ export default function Cron() {
if (loading) { if (loading) {
return ( return (
<div className="flex items-center justify-center h-64"> <div className="flex items-center justify-center h-64">
<div className="h-8 w-8 border-2 border-[#0080ff30] border-t-[#0080ff] rounded-full animate-spin" /> <div className="h-8 w-8 border-2 rounded-full animate-spin" style={{ borderColor: 'var(--pc-border)', borderTopColor: 'var(--pc-accent)' }} />
</div> </div>
); );
} }
@ -303,8 +297,8 @@ export default function Cron() {
{/* Header */} {/* Header */}
<div className="flex items-center justify-between"> <div className="flex items-center justify-between">
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
<Clock className="h-5 w-5 text-[#0080ff]" /> <Clock className="h-5 w-5" style={{ color: 'var(--pc-accent)' }} />
<h2 className="text-sm font-semibold text-white uppercase tracking-wider"> <h2 className="text-sm font-semibold uppercase tracking-wider" style={{ color: 'var(--pc-text-primary)' }}>
{t('cron.scheduled_tasks')} ({jobs.length}) {t('cron.scheduled_tasks')} ({jobs.length})
</h2> </h2>
</div> </div>
@ -312,8 +306,7 @@ export default function Cron() {
onClick={openAddModal} onClick={openAddModal}
className="btn-electric flex items-center gap-2 text-sm px-4 py-2" className="btn-electric flex items-center gap-2 text-sm px-4 py-2"
> >
<Plus className="h-4 w-4" /> <Plus className="h-4 w-4" />{t('cron.add_job')}
{t('cron.add_job')}
</button> </button>
</div> </div>
@ -321,10 +314,10 @@ export default function Cron() {
{settings && ( {settings && (
<div className="glass-card px-4 py-3 flex items-center justify-between"> <div className="glass-card px-4 py-3 flex items-center justify-between">
<div> <div>
<span className="text-sm font-medium text-white"> <span className="text-sm font-medium" style={{ color: 'var(--pc-text-primary)' }}>
Catch up missed jobs on startup Catch up missed jobs on startup
</span> </span>
<p className="text-xs text-[#556080] mt-0.5"> <p className="text-xs mt-0.5" style={{ color: 'var(--pc-text-muted)' }}>
Run all overdue jobs when ZeroClaw starts after downtime Run all overdue jobs when ZeroClaw starts after downtime
</p> </p>
</div> </div>
@ -351,53 +344,39 @@ export default function Cron() {
{/* Unified Add / Edit Modal */} {/* Unified Add / Edit Modal */}
{modalJob !== null && ( {modalJob !== null && (
<div className="fixed inset-0 modal-backdrop flex items-center justify-center z-50"> <div className="fixed inset-0 modal-backdrop flex items-center justify-center z-50">
<div className="glass-card p-6 w-full max-w-md mx-4 animate-fade-in-scale"> <div className="surface-panel p-6 w-full max-w-md mx-4 animate-fade-in-scale">
<div className="flex items-center justify-between mb-4"> <div className="flex items-center justify-between mb-4">
<h3 className="text-lg font-semibold text-white"> <h3 className="text-lg font-semibold" style={{ color: 'var(--pc-text-primary)' }}>
{isEditing ? t('cron.edit_modal_title') : t('cron.add_modal_title')} {isEditing ? t('cron.edit_modal_title') : t('cron.add_modal_title')}
</h3> </h3>
<button <button
onClick={closeModal} onClick={closeModal}
className="text-[#556080] hover:text-white transition-colors duration-300" className="btn-icon"
> >
<X className="h-5 w-5" /> <X className="h-5 w-5" />
</button> </button>
</div> </div>
{formError && ( {formError && (
<div className="mb-4 rounded-xl bg-[#ff446615] border border-[#ff446630] p-3 text-sm text-[#ff6680] animate-fade-in"> <div className="mb-4 rounded-xl border p-3 text-sm animate-fade-in" style={{ background: 'rgba(239, 68, 68, 0.08)', borderColor: 'rgba(239, 68, 68, 0.2)', color: '#f87171' }}>
{formError} {formError}
</div> </div>
)} )}
<div className="space-y-4"> <div className="space-y-4">
<div> <div>
<label className="block text-xs font-semibold text-[#8892a8] mb-1.5 uppercase tracking-wider"> <label className="block text-xs font-semibold mb-1.5 uppercase tracking-wider" style={{ color: 'var(--pc-text-secondary)' }}>
{t('cron.name_optional')} {t('cron.name_optional')}
</label> </label>
<input <input type="text" value={formName} onChange={(e) => setFormName(e.target.value)} placeholder="e.g. Daily cleanup" className="input-electric w-full px-3 py-2.5 text-sm" />
type="text"
value={formName}
onChange={(e) => setFormName(e.target.value)}
placeholder="e.g. Daily cleanup"
className="input-electric w-full px-3 py-2.5 text-sm"
/>
</div> </div>
<div> <div>
<label className="block text-xs font-semibold text-[#8892a8] mb-1.5 uppercase tracking-wider"> <label className="block text-xs font-semibold mb-1.5 uppercase tracking-wider" style={{ color: 'var(--pc-text-secondary)' }}>
{t('cron.schedule_required')} <span className="text-[#ff4466]">*</span> {t('cron.schedule_required')} <span style={{ color: 'var(--color-status-error)' }}>*</span>
</label> </label>
<input <input type="text" value={formSchedule} onChange={(e) => setFormSchedule(e.target.value)} placeholder="e.g. 0 0 * * * (cron expression)" className="input-electric w-full px-3 py-2.5 text-sm" />
type="text"
value={formSchedule}
onChange={(e) => setFormSchedule(e.target.value)}
placeholder="e.g. 0 0 * * * (cron expression)"
className="input-electric w-full px-3 py-2.5 text-sm"
/>
</div> </div>
<div> <div>
<label className="block text-xs font-semibold text-[#8892a8] mb-1.5 uppercase tracking-wider"> <label className="block text-xs font-semibold mb-1.5 uppercase tracking-wider" style={{ color: 'var(--pc-text-secondary)' }}>
{t('cron.command_required')} <span className="text-[#ff4466]">*</span> {t('cron.command_required')} <span style={{ color: 'var(--color-status-error)' }}>*</span>
</label> </label>
<textarea <textarea
value={formCommand} value={formCommand}
@ -408,11 +387,10 @@ export default function Cron() {
/> />
</div> </div>
</div> </div>
<div className="flex justify-end gap-3 mt-6"> <div className="flex justify-end gap-3 mt-6">
<button <button
onClick={closeModal} onClick={closeModal}
className="px-4 py-2 text-sm font-medium text-[#8892a8] hover:text-white border border-[#1a1a3e] rounded-xl hover:bg-[#0080ff08] transition-all duration-300" className="btn-secondary px-4 py-2 text-sm font-medium"
> >
{t('cron.cancel')} {t('cron.cancel')}
</button> </button>
@ -432,21 +410,21 @@ export default function Cron() {
{/* Jobs Table */} {/* Jobs Table */}
{jobs.length === 0 ? ( {jobs.length === 0 ? (
<div className="glass-card p-8 text-center"> <div className="card p-8 text-center">
<Clock className="h-10 w-10 text-[#1a1a3e] mx-auto mb-3" /> <Clock className="h-10 w-10 mx-auto mb-3" style={{ color: 'var(--pc-text-faint)' }} />
<p className="text-[#556080]">{t('cron.empty')}</p> <p style={{ color: 'var(--pc-text-muted)' }}>{t('cron.empty')}</p>
</div> </div>
) : ( ) : (
<div className="glass-card overflow-x-auto"> <div className="card overflow-x-auto rounded-2xl">
<table className="table-electric"> <table className="table-electric">
<thead> <thead>
<tr> <tr>
<th className="text-left">{t('cron.id')}</th> <th>{t('cron.id')}</th>
<th className="text-left">{t('cron.name')}</th> <th>{t('cron.name')}</th>
<th className="text-left">{t('cron.command')}</th> <th>{t('cron.command')}</th>
<th className="text-left">{t('cron.next_run')}</th> <th>{t('cron.next_run')}</th>
<th className="text-left">{t('cron.last_status')}</th> <th>{t('cron.last_status')}</th>
<th className="text-left">{t('cron.enabled')}</th> <th>{t('cron.enabled')}</th>
<th className="text-right">{t('cron.actions')}</th> <th className="text-right">{t('cron.actions')}</th>
</tr> </tr>
</thead> </thead>
@ -454,14 +432,14 @@ export default function Cron() {
{jobs.map((job) => ( {jobs.map((job) => (
<React.Fragment key={job.id}> <React.Fragment key={job.id}>
<tr> <tr>
<td className="px-4 py-3 text-[#556080] font-mono text-xs"> <td className="font-mono text-xs">
<button <button
onClick={() => onClick={() =>
setExpandedJob((prev) => setExpandedJob((prev) =>
prev === job.id ? null : job.id, prev === job.id ? null : job.id,
) )
} }
className="flex items-center gap-1 text-[#556080] hover:text-white transition-colors duration-300" className="flex items-center gap-1 btn-icon"
title="Toggle run history" title="Toggle run history"
> >
{expandedJob === job.id ? ( {expandedJob === job.id ? (
@ -472,56 +450,54 @@ export default function Cron() {
{job.id.slice(0, 8)} {job.id.slice(0, 8)}
</button> </button>
</td> </td>
<td className="px-4 py-3 text-white font-medium text-sm"> <td className="font-medium text-sm" style={{ color: 'var(--pc-text-primary)' }}>
{job.name ?? '-'} {job.name ?? '-'}
</td> </td>
<td className="px-4 py-3 text-[#8892a8] font-mono text-xs max-w-[200px] truncate"> <td className="font-mono text-xs max-w-[200px] truncate" style={{ color: 'var(--pc-text-secondary)' }}>
{job.prompt ?? job.command} {job.prompt ?? job.command}
</td> </td>
<td className="px-4 py-3 text-[#556080] text-xs"> <td className="text-xs" style={{ color: 'var(--pc-text-muted)' }}>
{formatDate(job.next_run)} {formatDate(job.next_run)}
</td> </td>
<td className="px-4 py-3"> <td>
<div className="flex items-center gap-1.5"> <div className="flex items-center gap-1.5">
{statusIcon(job.last_status)} {statusIcon(job.last_status)}
<span className="text-[#8892a8] text-xs capitalize"> <span className="text-xs capitalize" style={{ color: 'var(--pc-text-secondary)' }}>
{job.last_status ?? '-'} {job.last_status ?? '-'}
</span> </span>
</div> </div>
</td> </td>
<td className="px-4 py-3"> <td>
<span <span className="inline-flex items-center px-2.5 py-0.5 rounded-full text-[10px] font-semibold border"
className={`inline-flex items-center px-2.5 py-0.5 rounded-full text-[10px] font-semibold border ${ style={job.enabled ? { color: 'var(--color-status-success)', borderColor: 'rgba(0, 230, 138, 0.2)', background: 'rgba(0, 230, 138, 0.06)' } : { color: 'var(--pc-text-faint)', borderColor: 'var(--pc-border)', background: 'transparent' }}>
job.enabled
? 'text-[#00e68a] border-[#00e68a30]'
: 'text-[#334060] border-[#1a1a3e]'
}`}
style={{ background: job.enabled ? 'rgba(0,230,138,0.06)' : 'rgba(26,26,62,0.3)' }}
>
{job.enabled ? t('cron.enabled_status') : t('cron.disabled_status')} {job.enabled ? t('cron.enabled_status') : t('cron.disabled_status')}
</span> </span>
</td> </td>
<td className="px-4 py-3 text-right"> <td className="text-right">
<div className="flex items-center justify-end gap-2"> <div className="flex items-center justify-end gap-2">
<button <button
onClick={() => openEditModal(job)} onClick={() => openEditModal(job)}
className="text-[#334060] hover:text-[#0080ff] transition-all duration-300" className="btn-icon"
title={t('cron.edit')} title={t('cron.edit')}
> >
<Pencil className="h-4 w-4" /> <Pencil className="h-4 w-4" />
</button> </button>
{confirmDelete === job.id ? ( {confirmDelete === job.id ? (
<div className="flex items-center justify-end gap-2 animate-fade-in"> <div className="flex items-center justify-end gap-2 animate-fade-in">
<span className="text-xs text-[#ff4466]">{t('cron.confirm_delete')}</span> <span className="text-xs" style={{ color: 'var(--color-status-error)' }}>
{t('cron.confirm_delete')}
</span>
<button <button
onClick={() => handleDelete(job.id)} onClick={() => handleDelete(job.id)}
className="text-[#ff4466] hover:text-[#ff6680] text-xs font-medium" className="text-xs font-medium"
style={{ color: 'var(--color-status-error)' }}
> >
{t('cron.yes')} {t('cron.yes')}
</button> </button>
<button <button
onClick={() => setConfirmDelete(null)} onClick={() => setConfirmDelete(null)}
className="text-[#556080] hover:text-white text-xs font-medium" className="text-xs font-medium"
style={{ color: 'var(--pc-text-muted)' }}
> >
{t('cron.no')} {t('cron.no')}
</button> </button>
@ -529,7 +505,7 @@ export default function Cron() {
) : ( ) : (
<button <button
onClick={() => setConfirmDelete(job.id)} onClick={() => setConfirmDelete(job.id)}
className="text-[#334060] hover:text-[#ff4466] transition-all duration-300" className="btn-icon"
> >
<Trash2 className="h-4 w-4" /> <Trash2 className="h-4 w-4" />
</button> </button>
@ -538,8 +514,8 @@ export default function Cron() {
</td> </td>
</tr> </tr>
{expandedJob === job.id && ( {expandedJob === job.id && (
<tr className="bg-[#0a0a2080]"> <tr>
<td colSpan={7}> <td colSpan={7} style={{ background: 'var(--pc-bg-elevated)' }}>
<RunHistoryPanel jobId={job.id} /> <RunHistoryPanel jobId={job.id} />
</td> </td>
</tr> </tr>

View File

@ -1,4 +1,4 @@
import { useState, useEffect } from "react"; import { useState, useEffect } from 'react';
import { import {
Cpu, Cpu,
Clock, Clock,
@ -7,10 +7,10 @@ import {
Activity, Activity,
DollarSign, DollarSign,
Radio, Radio,
} from "lucide-react"; } from 'lucide-react';
import type { StatusResponse, CostSummary } from "@/types/api"; import type { StatusResponse, CostSummary } from '@/types/api';
import { getStatus, getCost } from "@/lib/api"; import { getStatus, getCost } from '@/lib/api';
import { t } from "@/lib/i18n"; import { t } from '@/lib/i18n';
function formatUptime(seconds: number): string { function formatUptime(seconds: number): string {
const d = Math.floor(seconds / 86400); const d = Math.floor(seconds / 86400);
@ -27,32 +27,78 @@ function formatUSD(value: number): string {
function healthColor(status: string): string { function healthColor(status: string): string {
switch (status.toLowerCase()) { switch (status.toLowerCase()) {
case "ok": case 'ok':
case "healthy": case 'healthy':
return "bg-[#00e68a]"; return 'var(--color-status-success)';
case "warn": case 'warn':
case "warning": case 'warning':
case "degraded": case 'degraded':
return "bg-[#ffaa00]"; return 'var(--color-status-warning)';
default: default:
return "bg-[#ff4466]"; return 'var(--color-status-error)';
} }
} }
function healthBorder(status: string): string { function healthBorder(status: string): string {
switch (status.toLowerCase()) { switch (status.toLowerCase()) {
case "ok": case 'ok':
case "healthy": case 'healthy':
return "border-[#00e68a30]"; return 'rgba(0, 230, 138, 0.2)';
case "warn": case 'warn':
case "warning": case 'warning':
case "degraded": case 'degraded':
return "border-[#ffaa0030]"; return 'rgba(255, 170, 0, 0.2)';
default: default:
return "border-[#ff446630]"; return 'rgba(255, 68, 102, 0.2)';
} }
} }
function healthBg(status: string): string {
switch (status.toLowerCase()) {
case 'ok':
case 'healthy':
return 'rgba(0, 230, 138, 0.05)';
case 'warn':
case 'warning':
case 'degraded':
return 'rgba(255, 170, 0, 0.05)';
default:
return 'rgba(255, 68, 102, 0.05)';
}
}
const STATUS_CARDS = [
{
icon: Cpu,
accent: "var(--pc-accent)",
labelKey: "dashboard.provider_model",
getValue: (s: StatusResponse) => s.provider ?? "Unknown",
getSub: (s: StatusResponse) => s.model ?? "",
},
{
icon: Clock,
accent: "#34d399",
labelKey: "dashboard.uptime",
getValue: (s: StatusResponse) => formatUptime(s.uptime_seconds),
getSub: () => t("dashboard.since_last_restart"),
},
{
icon: Globe,
accent: "#a78bfa",
labelKey: "dashboard.gateway_port",
getValue: (s: StatusResponse) => `:${s.gateway_port}`,
getSub: () => "",
},
{
icon: Database,
accent: "#fbbf24",
labelKey: "dashboard.memory_backend",
getValue: (s: StatusResponse) => s.memory_backend,
getSub: (s: StatusResponse) =>
`${t("dashboard.paired")}: ${s.paired ? t("dashboard.paired_yes") : t("dashboard.paired_no")}`,
},
];
export default function Dashboard() { export default function Dashboard() {
const [status, setStatus] = useState<StatusResponse | null>(null); const [status, setStatus] = useState<StatusResponse | null>(null);
const [cost, setCost] = useState<CostSummary | null>(null); const [cost, setCost] = useState<CostSummary | null>(null);
@ -71,7 +117,7 @@ export default function Dashboard() {
if (error) { if (error) {
return ( return (
<div className="p-6 animate-fade-in"> <div className="p-6 animate-fade-in">
<div className="rounded-xl bg-[#ff446615] border border-[#ff446630] p-4 text-[#ff6680]"> <div className="rounded-2xl border p-4" style={{ background: "rgba(239, 68, 68, 0.08)", borderColor: "rgba(239, 68, 68, 0.2)", color: "#f87171", }}>
{t("dashboard.load_error")}: {error} {t("dashboard.load_error")}: {error}
</div> </div>
</div> </div>
@ -81,7 +127,7 @@ export default function Dashboard() {
if (!status || !cost) { if (!status || !cost) {
return ( return (
<div className="flex items-center justify-center h-64"> <div className="flex items-center justify-center h-64">
<div className="h-8 w-8 border-2 border-[#0080ff30] border-t-[#0080ff] rounded-full animate-spin" /> <div className="h-8 w-8 border-2 rounded-full animate-spin" style={{ borderColor: "var(--pc-border)", borderTopColor: "var(--pc-accent)", }}/>
</div> </div>
); );
} }
@ -90,99 +136,66 @@ export default function Dashboard() {
cost.session_cost_usd, cost.session_cost_usd,
cost.daily_cost_usd, cost.daily_cost_usd,
cost.monthly_cost_usd, cost.monthly_cost_usd,
0.001, 0.001
); );
return ( return (
<div className="p-6 space-y-6 animate-fade-in"> <div className="p-6 space-y-6 animate-fade-in">
{/* Status Cards Grid */} {/* Status Cards Grid */}
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-4 gap-4 stagger-children"> <div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-4 gap-4 stagger-children">
{[ {STATUS_CARDS.map(({ icon: Icon, accent, labelKey, getValue, getSub }) => (
{ <div key={labelKey} className="card p-5 animate-slide-in-up">
icon: Cpu,
color: "#0080ff",
bg: "#0080ff15",
label: t("dashboard.provider_model"),
value: status.provider ?? "Unknown",
sub: status.model,
},
{
icon: Clock,
color: "#00e68a",
bg: "#00e68a15",
label: t("dashboard.uptime"),
value: formatUptime(status.uptime_seconds),
sub: t("dashboard.since_last_restart"),
},
{
icon: Globe,
color: "#a855f7",
bg: "#a855f715",
label: t("dashboard.gateway_port"),
value: `:${status.gateway_port}`,
sub: "",
},
{
icon: Database,
color: "#ff8800",
bg: "#ff880015",
label: t("dashboard.memory_backend"),
value: status.memory_backend,
sub: `${t("dashboard.paired")}: ${status.paired ? t("dashboard.paired_yes") : t("dashboard.paired_no")}`,
},
].map(({ icon: Icon, color, bg, label, value, sub }) => (
<div key={label} className="glass-card p-5 animate-slide-in-up">
<div className="flex items-center gap-3 mb-3"> <div className="flex items-center gap-3 mb-3">
<div className="p-2 rounded-xl" style={{ background: bg }}> <div className="p-2 rounded-2xl" style={{ background: `rgba(var(--pc-accent-rgb), 0.08)`, color: accent, }}>
<Icon className="h-5 w-5" style={{ color }} /> <Icon className="h-5 w-5" />
</div> </div>
<span className="text-xs text-[#556080] uppercase tracking-wider font-medium"> <span className="text-xs uppercase tracking-wider font-medium" style={{ color: "var(--pc-text-muted)" }}>{t(labelKey)}</span>
{label}
</span>
</div> </div>
<p className="text-lg font-semibold text-white truncate capitalize"> <p className="text-lg font-semibold truncate capitalize" style={{ color: "var(--pc-text-primary)" }}>{getValue(status)}</p>
{value} <p className="text-sm truncate" style={{ color: "var(--pc-text-muted)" }}>{getSub(status)}</p>
</p>
<p className="text-sm text-[#556080] truncate">{sub}</p>
</div> </div>
))} ))}
</div> </div>
<div className="grid grid-cols-1 lg:grid-cols-3 gap-6 stagger-children"> <div className="grid grid-cols-1 lg:grid-cols-3 gap-6 stagger-children">
{/* Cost Widget */} {/* Cost Widget */}
<div className="glass-card p-5 animate-slide-in-up"> <div className="card p-5 animate-slide-in-up">
<div className="flex items-center gap-2 mb-5"> <div className="flex items-center gap-2 mb-5">
<DollarSign className="h-5 w-5 text-[#0080ff]" /> <DollarSign className="h-5 w-5" style={{ color: "var(--pc-accent)" }} />
<h2 className="text-sm font-semibold text-white uppercase tracking-wider"> <h2 className="text-sm font-semibold uppercase tracking-wider" style={{ color: "var(--pc-text-primary)" }}>{t("dashboard.cost_overview")}</h2>
{t("dashboard.cost_overview")}
</h2>
</div> </div>
<div className="space-y-4"> <div className="space-y-4">
{[ {[
{ {
label: t("dashboard.session_label"), label: t("dashboard.session_label"),
value: cost.session_cost_usd, value: cost.session_cost_usd,
color: "#0080ff", color: "var(--pc-accent)",
}, },
{ {
label: t("dashboard.daily_label"), label: t("dashboard.daily_label"),
value: cost.daily_cost_usd, value: cost.daily_cost_usd,
color: "#00e68a", color: "#34d399",
}, },
{ {
label: t("dashboard.monthly_label"), label: t("dashboard.monthly_label"),
value: cost.monthly_cost_usd, value: cost.monthly_cost_usd,
color: "#a855f7", color: "#a78bfa",
}, },
].map(({ label, value, color }) => ( ].map(({ label, value, color }) => (
<div key={label}> <div key={label}>
<div className="flex justify-between text-sm mb-1.5"> <div className="flex justify-between text-sm mb-1.5">
<span className="text-[#556080]">{label}</span> <span style={{ color: "var(--pc-text-muted)" }}>{label}</span>
<span className="text-white font-medium font-mono"> <span
className="font-medium font-mono"
style={{ color: "var(--pc-text-primary)" }}
>
{formatUSD(value)} {formatUSD(value)}
</span> </span>
</div> </div>
<div className="w-full h-1.5 bg-[#0a0a18] rounded-full overflow-hidden"> <div
className="w-full h-1.5 rounded-full overflow-hidden"
style={{ background: "var(--pc-hover)" }}
>
<div <div
className="h-full rounded-full progress-bar-animated transition-all duration-700 ease-out" className="h-full rounded-full progress-bar-animated transition-all duration-700 ease-out"
style={{ style={{
@ -194,43 +207,53 @@ export default function Dashboard() {
</div> </div>
))} ))}
</div> </div>
<div className="mt-5 pt-4 border-t border-[#1a1a3e]/50 flex justify-between text-sm"> <div
<span className="text-[#556080]"> className="mt-5 pt-4 border-t flex justify-between text-sm"
style={{ borderColor: "var(--pc-border)" }}
>
<span style={{ color: "var(--pc-text-muted)" }}>
{t("dashboard.total_tokens_label")} {t("dashboard.total_tokens_label")}
</span> </span>
<span className="text-white font-mono"> <span className="font-mono" style={{ color: "var(--pc-text-primary)" }}>
{cost.total_tokens.toLocaleString()} {cost.total_tokens.toLocaleString()}
</span> </span>
</div> </div>
<div className="flex justify-between text-sm mt-1"> <div className="flex justify-between text-sm mt-1">
<span className="text-[#556080]"> <span style={{ color: "var(--pc-text-muted)" }}>
{t("dashboard.requests_label")} {t("dashboard.requests_label")}
</span> </span>
<span className="text-white font-mono"> <span className="font-mono" style={{ color: "var(--pc-text-primary)" }}>
{cost.request_count.toLocaleString()} {cost.request_count.toLocaleString()}
</span> </span>
</div> </div>
</div> </div>
{/* Channels */} {/* Active Channels */}
<div className="glass-card p-5 animate-slide-in-up"> <div className="card p-5 animate-slide-in-up">
<div className="flex items-center gap-2 mb-5"> <div className="flex items-center gap-2 mb-5">
<Radio className="h-5 w-5 text-[#0080ff]" /> <Radio className="h-5 w-5" style={{ color: "var(--pc-accent)" }} />
<h2 className="text-sm font-semibold text-white uppercase tracking-wider flex-1"> <h2
{t("dashboard.channels")} className="text-sm font-semibold uppercase tracking-wider"
style={{ color: "var(--pc-text-primary)" }}
>
{t("dashboard.active_channels")}
</h2> </h2>
<button <button
onClick={() => setShowAllChannels((v) => !v)} onClick={() => setShowAllChannels((v) => !v)}
className="flex items-center gap-1 rounded-full px-2.5 py-1 text-xs font-medium transition-all duration-200" className="ml-auto flex items-center gap-1 rounded-full px-2.5 py-1 text-[10px] font-medium border transition-all"
style={{ style={
background: showAllChannels showAllChannels
? "rgba(0,128,255,0.15)" ? {
: "rgba(0,230,138,0.12)", background: "rgba(var(--pc-accent-rgb), 0.1)",
color: showAllChannels ? "#0080ff" : "#00e68a", borderColor: "rgba(var(--pc-accent-rgb), 0.3)",
border: showAllChannels color: "var(--pc-accent-light)",
? "1px solid rgba(0,128,255,0.3)" }
: "1px solid rgba(0,230,138,0.3)", : {
}} background: "rgba(0, 230, 138, 0.08)",
borderColor: "rgba(0, 230, 138, 0.25)",
color: "#34d399",
}
}
aria-label={ aria-label={
showAllChannels showAllChannels
? t("dashboard.filter_active") ? t("dashboard.filter_active")
@ -244,84 +267,117 @@ export default function Dashboard() {
</div> </div>
<div className="space-y-2"> <div className="space-y-2">
{Object.entries(status.channels).length === 0 ? ( {Object.entries(status.channels).length === 0 ? (
<p className="text-sm text-[#334060]"> <p className="text-sm" style={{ color: "var(--pc-text-faint)" }}>
{t("dashboard.no_channels")} {t("dashboard.no_channels")}
</p> </p>
) : ( ) : (() => {
(() => { const entries = Object.entries(status.channels).filter(
const entries = Object.entries(status.channels).filter( ([, active]) => showAllChannels || active
([, active]) => showAllChannels || active, );
if (entries.length === 0) {
return (
<p className="text-sm" style={{ color: "var(--pc-text-faint)" }}>
{t("dashboard.no_active_channels")}
</p>
); );
if (entries.length === 0) { }
return ( return entries.map(([name, active]) => (
<p className="text-sm text-[#334060]"> <div
{t("dashboard.no_active_channels")} key={name}
</p> className="flex items-center justify-between py-2.5 px-3 rounded-xl transition-all"
); style={{ background: "var(--pc-bg-elevated)" }}
} onMouseEnter={(e) => {
return entries.map(([name, active]) => ( e.currentTarget.style.background = "var(--pc-hover)";
<div }}
key={name} onMouseLeave={(e) => {
className="flex items-center justify-between py-2.5 px-3 rounded-xl transition-all duration-300 hover:bg-[#0080ff08]" e.currentTarget.style.background = "var(--pc-bg-elevated)";
style={{ background: "rgba(10, 10, 26, 0.5)" }} }}
>
<span
className="text-sm font-medium capitalize"
style={{ color: "var(--pc-text-primary)" }}
> >
<span className="text-sm text-white capitalize font-medium"> {name}
{name} </span>
<div className="flex items-center gap-2">
<span
className="status-dot"
style={
active
? {
background: "var(--color-status-success)",
boxShadow: "0 0 6px var(--color-status-success)",
}
: { background: "var(--pc-text-faint)" }
}
/>
<span className="text-xs" style={{ color: "var(--pc-text-muted)" }}>
{active ? t("dashboard.active") : t("dashboard.inactive")}
</span> </span>
<div className="flex items-center gap-2">
<span
className={`inline-block h-2 w-2 rounded-full glow-dot ${
active
? "text-[#00e68a] bg-[#00e68a]"
: "text-[#334060] bg-[#334060]"
}`}
/>
<span className="text-xs text-[#556080]">
{active
? t("dashboard.active")
: t("dashboard.inactive")}
</span>
</div>
</div> </div>
)); </div>
})() ));
)} })()}
</div> </div>
</div> </div>
{/* Health Grid */} <div className="card p-5 animate-slide-in-up">
<div className="glass-card p-5 animate-slide-in-up">
<div className="flex items-center gap-2 mb-5"> <div className="flex items-center gap-2 mb-5">
<Activity className="h-5 w-5 text-[#0080ff]" /> <Activity className="h-5 w-5" style={{ color: "var(--pc-accent)" }} />
<h2 className="text-sm font-semibold text-white uppercase tracking-wider"> <h2
className="text-sm font-semibold uppercase tracking-wider"
style={{ color: "var(--pc-text-primary)" }}
>
{t("dashboard.component_health")} {t("dashboard.component_health")}
</h2> </h2>
</div> </div>
<div className="grid grid-cols-2 gap-3"> <div className="grid grid-cols-2 gap-3">
{Object.entries(status.health.components).length === 0 ? ( {Object.entries(status.health.components).length === 0 ? (
<p className="text-sm text-[#334060] col-span-2"> <p
className="text-sm col-span-2"
style={{ color: "var(--pc-text-faint)" }}
>
{t("dashboard.no_components")} {t("dashboard.no_components")}
</p> </p>
) : ( ) : (
Object.entries(status.health.components).map(([name, comp]) => ( Object.entries(status.health.components).map(([name, comp]) => (
<div <div
key={name} key={name}
className={`rounded-xl p-3 border ${healthBorder(comp.status)} transition-all duration-300 hover:scale-[1.02]`} className="rounded-2xl p-3 transition-all"
style={{ background: "rgba(10, 10, 26, 0.5)" }} style={{
border: `1px solid ${healthBorder(comp.status)}`,
background: healthBg(comp.status),
}}
onMouseEnter={(e) => {
e.currentTarget.style.transform = "scale(1.02)";
}}
onMouseLeave={(e) => {
e.currentTarget.style.transform = "scale(1)";
}}
> >
<div className="flex items-center gap-2 mb-1"> <div className="flex items-center gap-2 mb-1">
<span <span
className={`inline-block h-2 w-2 rounded-full ${healthColor(comp.status)} glow-dot`} className="status-dot"
style={{
background: healthColor(comp.status),
boxShadow: `0 0 6px ${healthColor(comp.status)}`,
}}
/> />
<span className="text-sm font-medium text-white capitalize truncate"> <span
className="text-sm font-medium truncate capitalize"
style={{ color: "var(--pc-text-primary)" }}
>
{name} {name}
</span> </span>
</div> </div>
<p className="text-xs text-[#556080] capitalize"> <p className="text-xs capitalize" style={{ color: "var(--pc-text-muted)" }}>
{comp.status} {comp.status}
</p> </p>
{comp.restart_count > 0 && ( {comp.restart_count > 0 && (
<p className="text-xs text-[#ffaa00] mt-1"> <p
className="text-xs mt-1"
style={{ color: "var(--color-status-warning)" }}
>
{t("dashboard.restarts")}: {comp.restart_count} {t("dashboard.restarts")}: {comp.restart_count}
</p> </p>
)} )}

View File

@ -14,33 +14,33 @@ import { t } from '@/lib/i18n';
function severityIcon(severity: DiagResult['severity']) { function severityIcon(severity: DiagResult['severity']) {
switch (severity) { switch (severity) {
case 'ok': case 'ok':
return <CheckCircle className="h-4 w-4 text-[#00e68a] flex-shrink-0" />; return <CheckCircle className="h-4 w-4 flex-shrink-0" style={{ color: 'var(--color-status-success)' }} />;
case 'warn': case 'warn':
return <AlertTriangle className="h-4 w-4 text-[#ffaa00] flex-shrink-0" />; return <AlertTriangle className="h-4 w-4 flex-shrink-0" style={{ color: 'var(--color-status-warning)' }} />;
case 'error': case 'error':
return <XCircle className="h-4 w-4 text-[#ff4466] flex-shrink-0" />; return <XCircle className="h-4 w-4 flex-shrink-0" style={{ color: 'var(--color-status-error)' }} />;
}
}
function severityBorder(severity: DiagResult['severity']): string {
switch (severity) {
case 'ok':
return 'border-[#00e68a20]';
case 'warn':
return 'border-[#ffaa0020]';
case 'error':
return 'border-[#ff446620]';
} }
} }
function severityBg(severity: DiagResult['severity']): string { function severityBg(severity: DiagResult['severity']): string {
switch (severity) { switch (severity) {
case 'ok': case 'ok':
return 'rgba(0,230,138,0.04)'; return 'rgba(0, 230, 138, 0.04)';
case 'warn': case 'warn':
return 'rgba(255,170,0,0.04)'; return 'rgba(255, 170, 0, 0.04)';
case 'error': case 'error':
return 'rgba(255,68,102,0.04)'; return 'rgba(239, 68, 68, 0.04)';
}
}
function severityBorder(severity: DiagResult['severity']): string {
switch (severity) {
case 'ok':
return 'border-[rgba(0,230,138,0.3)]';
case 'warn':
return 'border-[rgba(255,170,0,0.3)]';
case 'error':
return 'border-[rgba(239,68,68,0.3)]';
} }
} }
@ -80,8 +80,8 @@ export default function Doctor() {
{/* Header */} {/* Header */}
<div className="flex items-center justify-between"> <div className="flex items-center justify-between">
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
<Stethoscope className="h-5 w-5 text-[#0080ff]" /> <Stethoscope className="h-5 w-5" style={{ color: 'var(--pc-accent)' }} />
<h2 className="text-sm font-semibold text-white uppercase tracking-wider">{t('doctor.diagnostics_title')}</h2> <h2 className="text-sm font-semibold uppercase tracking-wider" style={{ color: 'var(--pc-text-primary)' }}>{t('doctor.diagnostics_title')}</h2>
</div> </div>
<button <button
onClick={handleRun} onClick={handleRun}
@ -90,12 +90,12 @@ export default function Doctor() {
> >
{loading ? ( {loading ? (
<> <>
<Loader2 className="h-4 w-4 animate-spin" /> <Loader2 className="h-4 w-4 animate-spin" />
{t('doctor.running_btn')} {t('doctor.running_btn')}
</> </>
) : ( ) : (
<> <>
<Play className="h-4 w-4" /> <Play className="h-4 w-4" />
{t('doctor.run_diagnostics')} {t('doctor.run_diagnostics')}
</> </>
)} )}
@ -104,7 +104,7 @@ export default function Doctor() {
{/* Error */} {/* Error */}
{error && ( {error && (
<div className="rounded-xl bg-[#ff446615] border border-[#ff446630] p-4 text-[#ff6680] animate-fade-in"> <div className="rounded-xl p-4 border animate-fade-in" style={{ background: 'rgba(239,68,68,0.06)', borderColor: 'rgba(239,68,68,0.2)', color: '#f87171' }}>
{error} {error}
</div> </div>
)} )}
@ -112,9 +112,9 @@ export default function Doctor() {
{/* Loading spinner */} {/* Loading spinner */}
{loading && ( {loading && (
<div className="flex flex-col items-center justify-center py-16 animate-fade-in"> <div className="flex flex-col items-center justify-center py-16 animate-fade-in">
<div className="h-12 w-12 border-2 border-[#0080ff30] border-t-[#0080ff] rounded-full animate-spin mb-4" /> <div className="h-12 w-12 border-2 rounded-full animate-spin mb-4" style={{ borderColor: 'rgba(255,255,255,0.1)', borderTopColor: 'var(--pc-accent)' }}/>
<p className="text-[#8892a8]">{t('doctor.running_desc')}</p> <p className="text-sm" style={{ color: 'var(--pc-text-muted)' }}>{t('doctor.running_desc')}</p>
<p className="text-sm text-[#334060] mt-1"> <p className="text-[13px] mt-1" style={{ color: 'var(--pc-text-faint)' }}>
{t('doctor.running_hint')} {t('doctor.running_hint')}
</p> </p>
</div> </div>
@ -124,29 +124,29 @@ export default function Doctor() {
{results && !loading && ( {results && !loading && (
<> <>
{/* Summary Bar */} {/* Summary Bar */}
<div className="glass-card flex items-center gap-4 p-4 animate-slide-in-up"> <div className="flex items-center gap-4 p-4 rounded-xl border animate-fade-in" style={{ background: 'var(--pc-bg-surface)', borderColor: 'var(--pc-border)' }}>
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
<CheckCircle className="h-5 w-5 text-[#00e68a]" /> <CheckCircle className="h-5 w-5" style={{ color: 'var(--color-status-success)' }} />
<span className="text-sm text-white font-medium"> <span className="text-sm font-medium" style={{ color: 'var(--pc-text-primary)' }}>
{okCount} <span className="text-[#556080] font-normal">ok</span> {okCount}{' '}<span className="text-sm font-normal" style={{ color: 'var(--pc-text-muted)' }}>ok</span>
</span> </span>
</div> </div>
<div className="w-px h-5 bg-[#1a1a3e]" /> <div className="w-px h-5" style={{ background: 'var(--pc-border)' }} />
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
<AlertTriangle className="h-5 w-5 text-[#ffaa00]" /> <AlertTriangle className="h-5 w-5" style={{ color: 'var(--color-status-warning)' }} />
<span className="text-sm text-white font-medium"> <span className="text-sm font-medium" style={{ color: 'var(--pc-text-primary)' }}>
{warnCount}{' '} {warnCount}{' '}
<span className="text-[#556080] font-normal"> <span className="text-sm font-normal" style={{ color: 'var(--pc-text-muted)' }}>
warning{warnCount !== 1 ? 's' : ''} warning{warnCount !== 1 ? 's' : ''}
</span> </span>
</span> </span>
</div> </div>
<div className="w-px h-5 bg-[#1a1a3e]" /> <div className="w-px h-5" style={{ background: 'var(--pc-border)' }} />
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
<XCircle className="h-5 w-5 text-[#ff4466]" /> <XCircle className="h-5 w-5" style={{ color: 'var(--color-status-error)' }} />
<span className="text-sm text-white font-medium"> <span className="text-sm font-medium" style={{ color: 'var(--pc-text-primary)' }}>
{errorCount}{' '} {errorCount}{' '}
<span className="text-[#556080] font-normal"> <span className="text-sm font-normal" style={{ color: 'var(--pc-text-muted)' }}>
error{errorCount !== 1 ? 's' : ''} error{errorCount !== 1 ? 's' : ''}
</span> </span>
</span> </span>
@ -155,15 +155,15 @@ export default function Doctor() {
{/* Overall indicator */} {/* Overall indicator */}
<div className="ml-auto"> <div className="ml-auto">
{errorCount > 0 ? ( {errorCount > 0 ? (
<span className="inline-flex items-center gap-1.5 px-3 py-1 rounded-full text-[10px] font-semibold border text-[#ff4466] border-[#ff446630]" style={{ background: 'rgba(255,68,102,0.06)' }}> <span className="inline-flex items-center gap-1.5 px-3 py-1 rounded-full text-xs font-medium border" style={{ background: 'rgba(239,68,68,0.06)', borderColor: 'rgba(239,68,68,0.3)', color: '#f87171' }}>
{t('doctor.issues_found')} {t('doctor.issues_found')}
</span> </span>
) : warnCount > 0 ? ( ) : warnCount > 0 ? (
<span className="inline-flex items-center gap-1.5 px-3 py-1 rounded-full text-[10px] font-semibold border text-[#ffaa00] border-[#ffaa0030]" style={{ background: 'rgba(255,170,0,0.06)' }}> <span className="inline-flex items-center gap-1.5 px-3 py-1 rounded-full text-xs font-medium border" style={{ background: 'rgba(255,170,0,0.06)', borderColor: 'rgba(255,170,0,0.3)', color: '#fbbf24' }}>
{t('doctor.warnings_summary')} {t('doctor.warnings_summary')}
</span> </span>
) : ( ) : (
<span className="inline-flex items-center gap-1.5 px-3 py-1 rounded-full text-[10px] font-semibold border text-[#00e68a] border-[#00e68a30]" style={{ background: 'rgba(0,230,138,0.06)' }}> <span className="inline-flex items-center gap-1.5 px-3 py-1 rounded-full text-xs font-medium border" style={{ background: 'rgba(0,230,138,0.06)', borderColor: 'rgba(0,230,138,0.3)', color: '#34d399' }}>
{t('doctor.all_clear')} {t('doctor.all_clear')}
</span> </span>
)} )}
@ -173,22 +173,21 @@ export default function Doctor() {
{/* Grouped Results */} {/* Grouped Results */}
{Object.entries(grouped) {Object.entries(grouped)
.sort(([a], [b]) => a.localeCompare(b)) .sort(([a], [b]) => a.localeCompare(b))
.map(([category, items], catIdx) => ( .map(([category, items]) => (
<div key={category} className="animate-slide-in-up" style={{ animationDelay: `${(catIdx + 1) * 100}ms` }}> <div key={category}>
<h3 className="text-[10px] font-semibold text-[#334060] uppercase tracking-wider mb-3 capitalize"> <h3 className="text-sm font-semibold uppercase tracking-wider mb-3 capitalize" style={{ color: 'var(--pc-text-muted)' }}>
{category} {category}
</h3> </h3>
<div className="space-y-2 stagger-children"> <div className="space-y-2">
{items.map((result, idx) => ( {items.map((result, idx) => (
<div <div
key={`${category}-${idx}`} key={`${category}-${idx}`}
className={`flex items-start gap-3 rounded-xl border p-3 transition-all duration-300 hover:translate-x-1 ${severityBorder(result.severity)} animate-slide-in-left`} className={`flex items-start gap-3 rounded-xl border p-3 ${severityBorder(result.severity,)} ${severityBg(result.severity)}`}
style={{ background: severityBg(result.severity) }}
> >
{severityIcon(result.severity)} {severityIcon(result.severity)}
<div className="min-w-0"> <div className="min-w-0">
<p className="text-sm text-white">{result.message}</p> <p className="text-sm" style={{ color: 'var(--pc-text-primary)' }}>{result.message}</p>
<p className="text-[10px] text-[#334060] mt-0.5 capitalize uppercase tracking-wider"> <p className="text-xs capitalize mt-0.5" style={{ color: 'var(--pc-text-faint)' }}>
{result.severity} {result.severity}
</p> </p>
</div> </div>
@ -202,12 +201,12 @@ export default function Doctor() {
{/* Empty state */} {/* Empty state */}
{!results && !loading && !error && ( {!results && !loading && !error && (
<div className="flex flex-col items-center justify-center py-16 text-[#334060] animate-fade-in"> <div className="flex flex-col items-center justify-center py-16 text-[var(--pc-text-muted)]">
<div className="h-16 w-16 rounded-2xl flex items-center justify-center mb-4 animate-float" style={{ background: 'linear-gradient(135deg, #0080ff15, #0080ff08)' }}> <div className="h-16 w-16 rounded-2xl flex items-center justify-center mb-4 animate-float" style={{ background: 'linear-gradient(135deg, var(--pc-accent-glow), transparent)' }}>
<Stethoscope className="h-8 w-8 text-[#0080ff]" /> <Stethoscope className="h-8 w-8" style={{ color: 'var(--pc-accent)' }} />
</div> </div>
<p className="text-lg font-semibold text-white mb-1">{t('doctor.system_diagnostics')}</p> <p className="text-lg font-semibold mb-1" style={{ color: 'var(--pc-text-primary)' }}>{t('doctor.system_diagnostics')}</p>
<p className="text-sm text-[#556080]"> <p className="text-sm" style={{ color: 'var(--pc-text-muted)' }}>
{t('doctor.empty_hint')} {t('doctor.empty_hint')}
</p> </p>
</div> </div>

View File

@ -10,22 +10,25 @@ function statusBadge(status: Integration['status']) {
return { return {
icon: Check, icon: Check,
label: t('integrations.status_active'), label: t('integrations.status_active'),
classes: 'text-[#00e68a] border-[#00e68a30]', color: 'var(--color-status-success)',
bg: 'rgba(0,230,138,0.06)', border: 'rgba(0, 230, 138, 0.2)',
bg: 'rgba(0, 230, 138, 0.06)'
}; };
case 'Available': case 'Available':
return { return {
icon: Zap, icon: Zap,
label: t('integrations.status_available'), label: t('integrations.status_available'),
classes: 'text-[#0080ff] border-[#0080ff30]', color: 'var(--pc-accent)',
bg: 'rgba(0,128,255,0.06)', border: 'var(--pc-accent-dim)',
bg: 'var(--pc-accent-glow)'
}; };
case 'ComingSoon': case 'ComingSoon':
return { return {
icon: Clock, icon: Clock,
label: t('integrations.status_coming_soon'), label: t('integrations.status_coming_soon'),
classes: 'text-[#556080] border-[#1a1a3e]', color: 'var(--pc-text-muted)',
bg: 'rgba(26,26,62,0.3)', border: 'var(--pc-border)',
bg: 'transparent'
}; };
} }
} }
@ -37,17 +40,12 @@ export default function Integrations() {
const [activeCategory, setActiveCategory] = useState<string>('all'); const [activeCategory, setActiveCategory] = useState<string>('all');
useEffect(() => { useEffect(() => {
getIntegrations() getIntegrations().then(setIntegrations).catch((err) => setError(err.message)).finally(() => setLoading(false));
.then(setIntegrations)
.catch((err) => setError(err.message))
.finally(() => setLoading(false));
}, []); }, []);
const categories = [ const categories = ['all',
'all', ...Array.from(new Set(integrations.map((i) => i.category))).sort()
...Array.from(new Set(integrations.map((i) => i.category))).sort(),
]; ];
const filtered = const filtered =
activeCategory === 'all' activeCategory === 'all'
? integrations ? integrations
@ -63,28 +61,27 @@ export default function Integrations() {
if (error) { if (error) {
return ( return (
<div className="p-6 animate-fade-in"> <div className="p-6 animate-fade-in">
<div className="rounded-xl bg-[#ff446615] border border-[#ff446630] p-4 text-[#ff6680]"> <div className="rounded-2xl border p-4" style={{ background: 'rgba(239, 68, 68, 0.08)', borderColor: 'rgba(239, 68, 68, 0.2)', color: '#f87171' }}>
{t('integrations.load_error')}: {error} {t('integrations.load_error')}: {error}
</div>
</div> </div>
); </div>
);
} }
if (loading) { if (loading) {
return ( return (
<div className="flex items-center justify-center h-64"> <div className="flex items-center justify-center h-64">
<div className="h-8 w-8 border-2 border-[#0080ff30] border-t-[#0080ff] rounded-full animate-spin" /> <div className="h-8 w-8 border-2 rounded-full animate-spin" style={{ borderColor: 'var(--pc-border)', borderTopColor: 'var(--pc-accent)' }} />
</div> </div>
); );
} }
return ( return (
<div className="p-6 space-y-6 animate-fade-in"> <div className="p-6 space-y-6 animate-fade-in">
{/* Header */}
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
<Puzzle className="h-5 w-5 text-[#0080ff]" /> <Puzzle className="h-5 w-5" style={{ color: 'var(--pc-accent)' }} />
<h2 className="text-sm font-semibold text-white uppercase tracking-wider"> <h2 className="text-sm font-semibold uppercase tracking-wider" style={{ color: 'var(--pc-text-primary)' }}>
{t('integrations.title')} ({integrations.length}) {t('integrations.title')} ({integrations.length})
</h2> </h2>
</div> </div>
@ -95,11 +92,11 @@ export default function Integrations() {
<button <button
key={cat} key={cat}
onClick={() => setActiveCategory(cat)} onClick={() => setActiveCategory(cat)}
className={`px-3.5 py-1.5 rounded-xl text-xs font-semibold transition-all duration-300 capitalize ${activeCategory === cat className="px-3.5 py-1.5 rounded-xl text-xs font-semibold transition-all capitalize"
? 'text-white shadow-[0_0_15px_rgba(0,128,255,0.2)]' style={activeCategory === cat
: 'text-[#556080] border border-[#1a1a3e] hover:text-white hover:border-[#0080ff40]' ? { background: 'var(--pc-accent)', color: 'white' }
}`} : { color: 'var(--pc-text-muted)', border: '1px solid var(--pc-border)', background: 'transparent' }
style={activeCategory === cat ? { background: 'linear-gradient(135deg, #0080ff, #0066cc)' } : {}} }
> >
{cat} {cat}
</button> </button>
@ -108,50 +105,48 @@ export default function Integrations() {
{/* Grouped Integration Cards */} {/* Grouped Integration Cards */}
{Object.keys(grouped).length === 0 ? ( {Object.keys(grouped).length === 0 ? (
<div className="glass-card p-8 text-center"> <div className="card p-8 text-center">
<Puzzle className="h-10 w-10 text-[#1a1a3e] mx-auto mb-3" /> <Puzzle className="h-10 w-10 mx-auto mb-3" style={{ color: 'var(--pc-text-faint)' }} />
<p className="text-[#556080]">{t('integrations.empty')}</p> <p style={{ color: 'var(--pc-text-muted)' }}>{t('integrations.empty')}</p>
</div> </div>
) : ( ) : (
Object.entries(grouped) Object.entries(grouped).sort(([a], [b]) => a.localeCompare(b)).map(([category, items]) => (
.sort(([a], [b]) => a.localeCompare(b)) <div key={category}>
.map(([category, items]) => ( <h3 className="text-[10px] font-semibold uppercase tracking-wider mb-3 capitalize" style={{ color: 'var(--pc-text-faint)' }}>
<div key={category}> {category}
<h3 className="text-[10px] font-semibold text-[#334060] uppercase tracking-wider mb-3 capitalize"> </h3>
{category} <div className="grid grid-cols-1 md:grid-cols-2 xl:grid-cols-3 gap-4 stagger-children">
</h3> {items.map((integration) => {
<div className="grid grid-cols-1 md:grid-cols-2 xl:grid-cols-3 gap-4 stagger-children"> const badge = statusBadge(integration.status);
{items.map((integration) => { const BadgeIcon = badge.icon;
const badge = statusBadge(integration.status); return (
const BadgeIcon = badge.icon; <div
return ( key={integration.name}
<div className="card p-5 animate-slide-in-up"
key={integration.name} >
className="glass-card p-5 animate-slide-in-up" <div className="flex items-start justify-between gap-3">
> <div className="min-w-0">
<div className="flex items-start justify-between gap-3"> <h4 className="text-sm font-semibold truncate" style={{ color: 'var(--pc-text-primary)' }}>
<div className="min-w-0"> {integration.name}
<h4 className="text-sm font-semibold text-white truncate"> </h4>
{integration.name} <p className="text-sm mt-1 line-clamp-2" style={{ color: 'var(--pc-text-muted)' }}>
</h4> {integration.description}
<p className="text-sm text-[#556080] mt-1 line-clamp-2"> </p>
{integration.description}
</p>
</div>
<span
className={`flex-shrink-0 inline-flex items-center gap-1 px-2.5 py-1 rounded-full text-[10px] font-semibold border ${badge.classes}`}
style={{ background: badge.bg }}
>
<BadgeIcon className="h-3 w-3" />
{badge.label}
</span>
</div> </div>
<span
className="flex-shrink-0 inline-flex items-center gap-1 px-2.5 py-1 rounded-full text-[10px] font-semibold border"
style={badge}
>
<BadgeIcon className="h-3 w-3" />
{badge.label}
</span>
</div> </div>
); </div>
})} );
</div> })}
</div> </div>
)) </div>
))
)} )}
</div> </div>
); );

View File

@ -15,31 +15,28 @@ function formatTimestamp(ts?: string): string {
return new Date(ts).toLocaleTimeString(); return new Date(ts).toLocaleTimeString();
} }
function eventTypeBadgeColor(type: string): { classes: string; bg: string } { function eventTypeStyle(type: string): { color: string; bg: string; border: string } {
switch (type.toLowerCase()) { switch (type.toLowerCase()) {
case 'error': case 'error':
return { classes: 'text-[#ff4466] border-[#ff446630]', bg: 'rgba(255,68,102,0.06)' }; return { color: 'var(--color-status-error)', bg: 'rgba(239, 68, 68, 0.06)', border: 'rgba(239, 68, 68, 0.2)' };
case 'warn': case 'warn':
case 'warning': case 'warning':
return { classes: 'text-[#ffaa00] border-[#ffaa0030]', bg: 'rgba(255,170,0,0.06)' }; return { color: 'var(--color-status-warning)', bg: 'rgba(255, 170, 0, 0.06)', border: 'rgba(255, 170, 0, 0.2)' };
case 'tool_call': case 'tool_call':
case 'tool_result': case 'tool_result':
return { classes: 'text-[#a855f7] border-[#a855f730]', bg: 'rgba(168,85,247,0.06)' }; return { color: '#a78bfa', bg: 'rgba(167, 139, 250, 0.06)', border: 'rgba(167, 139, 250, 0.2)' };
case 'message': case 'message':
case 'chat': case 'chat':
return { classes: 'text-[#0080ff] border-[#0080ff30]', bg: 'rgba(0,128,255,0.06)' }; return { color: 'var(--pc-accent)', bg: 'var(--pc-accent-glow)', border: 'var(--pc-accent-dim)' };
case 'health': case 'health':
case 'status': case 'status':
return { classes: 'text-[#00e68a] border-[#00e68a30]', bg: 'rgba(0,230,138,0.06)' }; return { color: 'var(--color-status-success)', bg: 'rgba(0, 230, 138, 0.06)', border: 'rgba(0, 230, 138, 0.2)' };
default: default:
return { classes: 'text-[#556080] border-[#1a1a3e]', bg: 'rgba(26,26,62,0.3)' }; return { color: 'var(--pc-text-muted)', bg: 'var(--pc-hover)', border: 'var(--pc-border)' };
} }
} }
interface LogEntry { interface LogEntry { id: string; event: SSEEvent; }
id: string;
event: SSEEvent;
}
export default function Logs() { export default function Logs() {
const [entries, setEntries] = useState<LogEntry[]>([]); const [entries, setEntries] = useState<LogEntry[]>([]);
@ -47,16 +44,13 @@ export default function Logs() {
const [connected, setConnected] = useState(false); const [connected, setConnected] = useState(false);
const [autoScroll, setAutoScroll] = useState(true); const [autoScroll, setAutoScroll] = useState(true);
const [typeFilters, setTypeFilters] = useState<Set<string>>(new Set()); const [typeFilters, setTypeFilters] = useState<Set<string>>(new Set());
const containerRef = useRef<HTMLDivElement>(null); const containerRef = useRef<HTMLDivElement>(null);
const sseRef = useRef<SSEClient | null>(null); const sseRef = useRef<SSEClient | null>(null);
const pausedRef = useRef(false); const pausedRef = useRef(false);
const entryIdRef = useRef(0); const entryIdRef = useRef(0);
// Keep pausedRef in sync // Keep pausedRef in sync
useEffect(() => { useEffect(() => { pausedRef.current = paused; }, [paused]);
pausedRef.current = paused;
}, [paused]);
useEffect(() => { useEffect(() => {
const client = new SSEClient(); const client = new SSEClient();
@ -81,10 +75,8 @@ export default function Logs() {
return next.length > 500 ? next.slice(-500) : next; return next.length > 500 ? next.slice(-500) : next;
}); });
}; };
client.connect(); client.connect();
sseRef.current = client; sseRef.current = client;
return () => { return () => {
client.disconnect(); client.disconnect();
}; };
@ -126,47 +118,34 @@ export default function Logs() {
}); });
}; };
const filteredEntries = const filteredEntries = typeFilters.size === 0 ? entries : entries.filter((e) => typeFilters.has(e.event.type));
typeFilters.size === 0
? entries
: entries.filter((e) => typeFilters.has(e.event.type));
return ( return (
<div className="flex flex-col h-[calc(100vh-3.5rem)]"> <div className="flex flex-col h-[calc(100vh-3.5rem)]">
{/* Toolbar */} {/* Toolbar */}
<div className="flex items-center justify-between px-6 py-3 border-b border-[#1a1a3e]/40 animate-fade-in" style={{ background: 'linear-gradient(90deg, rgba(8,8,24,0.9), rgba(5,5,16,0.9))' }}> <div className="flex items-center justify-between px-6 py-3 border-b animate-fade-in" style={{ borderColor: 'var(--pc-border)', background: 'var(--pc-bg-surface)' }}>
<div className="flex items-center gap-3"> <div className="flex items-center gap-3">
<Activity className="h-5 w-5 text-[#0080ff]" /> <Activity className="h-5 w-5" style={{ color: 'var(--pc-accent)' }} />
<h2 className="text-sm font-semibold text-white uppercase tracking-wider">{t('logs.live_logs')}</h2> <h2 className="text-sm font-semibold uppercase tracking-wider" style={{ color: 'var(--pc-text-primary)' }}>{t('logs.live_logs')}</h2>
<div className="flex items-center gap-2 ml-2"> <div className="flex items-center gap-2 ml-2">
<span <span className="status-dot" style={
className={`inline-block h-1.5 w-1.5 rounded-full glow-dot ${ connected ? { background: 'var(--color-status-success)', boxShadow: '0 0 6px var(--color-status-success)' } : { background: 'var(--color-status-error)', boxShadow: '0 0 6px var(--color-status-error)' }
connected ? 'text-[#00e68a] bg-[#00e68a]' : 'text-[#ff4466] bg-[#ff4466]' }
}`}
/> />
<span className="text-[10px] text-[#334060]"> <span className="text-[10px]" style={{ color: 'var(--pc-text-faint)' }}>
{connected ? t('logs.connected') : t('logs.disconnected')} {connected ? t('logs.connected') : t('logs.disconnected')}
</span> </span>
</div> </div>
<span className="text-[10px] text-[#334060] ml-2 font-mono"> <span className="text-[10px] font-mono ml-2" style={{ color: 'var(--pc-text-faint)' }}>
{filteredEntries.length} {t('logs.events')} {filteredEntries.length} {t('logs.events')}
</span> </span>
</div> </div>
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
{/* Pause/Resume */} {/* Pause/Resume */}
<button <button
onClick={() => setPaused(!paused)} onClick={() => setPaused(!paused)}
className={`flex items-center gap-1.5 px-3 py-1.5 rounded-xl text-xs font-semibold transition-all duration-300 ${ className="btn-electric flex items-center gap-1.5 px-3 py-1.5 text-xs font-semibold"
paused style={{ background: paused ? 'var(--color-status-success)' : 'var(--color-status-warning)', color: 'white' }}
? 'text-white shadow-[0_0_15px_rgba(0,230,138,0.2)]'
: 'text-white shadow-[0_0_15px_rgba(255,170,0,0.2)]'
}`}
style={{
background: paused
? 'linear-gradient(135deg, #00e68a, #00cc7a)'
: 'linear-gradient(135deg, #ffaa00, #ee9900)'
}}
> >
{paused ? ( {paused ? (
<> <>
@ -181,12 +160,8 @@ export default function Logs() {
{/* Jump to Bottom */} {/* Jump to Bottom */}
{!autoScroll && ( {!autoScroll && (
<button <button onClick={jumpToBottom} className="btn-electric flex items-center gap-1.5 px-3 py-1.5 text-xs font-semibold">
onClick={jumpToBottom} <ArrowDown className="h-3.5 w-3.5" />{t('logs.jump_to_bottom')}
className="btn-electric flex items-center gap-1.5 px-3 py-1.5 text-xs font-semibold"
>
<ArrowDown className="h-3.5 w-3.5" />
{t('logs.jump_to_bottom')}
</button> </button>
)} )}
</div> </div>
@ -194,28 +169,26 @@ export default function Logs() {
{/* Event type filters */} {/* Event type filters */}
{allTypes.length > 0 && ( {allTypes.length > 0 && (
<div className="flex items-center gap-2 px-6 py-2 border-b border-[#1a1a3e]/30 overflow-x-auto" style={{ background: 'rgba(5,5,16,0.6)' }}> <div className="flex items-center gap-2 px-6 py-2 border-b overflow-x-auto" style={{ borderColor: 'var(--pc-border)', background: 'var(--pc-bg-base)' }}>
<Filter className="h-3.5 w-3.5 text-[#334060] flex-shrink-0" /> <Filter className="h-3.5 w-3.5 flex-shrink-0" style={{ color: 'var(--pc-text-faint)' }} />
<span className="text-[10px] text-[#334060] flex-shrink-0 uppercase tracking-wider">{t('logs.filter_label')}:</span> <span className="text-[10px] uppercase tracking-wider flex-shrink-0" style={{ color: 'var(--pc-text-faint)' }}>{t('logs.filter_label')}:</span>
{allTypes.map((type) => ( {allTypes.map((type) => (
<label <label key={type} className="flex items-center gap-1.5 cursor-pointer flex-shrink-0">
key={type}
className="flex items-center gap-1.5 cursor-pointer flex-shrink-0"
>
<input <input
type="checkbox" type="checkbox"
checked={typeFilters.has(type)} checked={typeFilters.has(type)}
onChange={() => toggleTypeFilter(type)} onChange={() => toggleTypeFilter(type)}
className="rounded bg-[#0a0a18] border-[#1a1a3e] text-[#0080ff] focus:ring-[#0080ff] focus:ring-offset-0 h-3 w-3" className="rounded"
style={{ accentColor: 'var(--pc-accent)' }}
/> />
<span className="text-[10px] text-[#556080] capitalize">{type}</span> <span className="text-[10px] capitalize" style={{ color: 'var(--pc-text-muted)' }}>{type}</span>
</label> </label>
))} ))}
{typeFilters.size > 0 && ( {typeFilters.size > 0 && (
<button <button
onClick={() => setTypeFilters(new Set())} onClick={() => setTypeFilters(new Set())}
className="text-[10px] text-[#0080ff] hover:text-[#00d4ff] flex-shrink-0 ml-1 transition-colors" className="text-[10px] flex-shrink-0 ml-1 transition-colors"
> style={{ color: 'var(--pc-accent)' }}>
{t('logs.clear')} {t('logs.clear')}
</button> </button>
)} )}
@ -226,11 +199,11 @@ export default function Logs() {
<div <div
ref={containerRef} ref={containerRef}
onScroll={handleScroll} onScroll={handleScroll}
className="flex-1 overflow-y-auto p-4 space-y-1.5" className="flex-1 overflow-y-auto p-4 space-y-2"
> >
{filteredEntries.length === 0 ? ( {filteredEntries.length === 0 ? (
<div className="flex flex-col items-center justify-center h-full text-[#334060] animate-fade-in"> <div className="flex flex-col items-center justify-center h-full animate-fade-in" style={{ color: 'var(--pc-text-muted)' }}>
<Activity className="h-10 w-10 text-[#1a1a3e] mb-3" /> <Activity className="h-10 w-10 mb-3" style={{ color: 'var(--pc-text-faint)' }} />
<p className="text-sm"> <p className="text-sm">
{paused {paused
? t('logs.paused_hint') ? t('logs.paused_hint')
@ -240,7 +213,7 @@ export default function Logs() {
) : ( ) : (
filteredEntries.map((entry) => { filteredEntries.map((entry) => {
const { event } = entry; const { event } = entry;
const badge = eventTypeBadgeColor(event.type); const style = eventTypeStyle(event.type);
const detail = const detail =
event.message ?? event.message ??
event.content ?? event.content ??
@ -252,30 +225,29 @@ export default function Logs() {
), ),
), ),
); );
return ( return (
<div <div
key={entry.id} key={entry.id}
className="glass-card rounded-lg p-3 hover:border-[#0080ff20] transition-all duration-200" className="card rounded-xl p-3"
> >
<div className="flex items-start gap-3"> <div className="flex items-start gap-3">
<span className="text-[10px] text-[#334060] font-mono whitespace-nowrap mt-0.5"> <span className="text-[10px] font-mono whitespace-nowrap mt-0.5" style={{ color: 'var(--pc-text-faint)' }}>
{formatTimestamp(event.timestamp)} {formatTimestamp(event.timestamp)}
</span> </span>
<span <span
className={`inline-flex items-center px-2 py-0.5 rounded text-[10px] font-semibold border capitalize flex-shrink-0 ${badge.classes}`} className="inline-flex items-center px-2 py-0.5 rounded text-[10px] font-semibold border capitalize flex-shrink-0"
style={{ background: badge.bg }} style={style}
> >
{event.type} {event.type}
</span> </span>
<p className="text-sm text-[#8892a8] break-all min-w-0"> <p className="text-sm break-all min-w-0" style={{ color: 'var(--pc-text-secondary)' }}>
{typeof detail === 'string' ? detail : JSON.stringify(detail)} {typeof detail === 'string' ? detail : JSON.stringify(detail)}
</p> </p>
</div> </div>
</div> </div>
); );
}) })
)} )}
</div> </div>
</div> </div>
); );

View File

@ -40,9 +40,9 @@ export default function Memory() {
const fetchEntries = (q?: string, cat?: string) => { const fetchEntries = (q?: string, cat?: string) => {
setLoading(true); setLoading(true);
getMemory(q || undefined, cat || undefined) getMemory(q || undefined, cat || undefined)
.then(setEntries) .then(setEntries)
.catch((err) => setError(err.message)) .catch((err) => setError(err.message))
.finally(() => setLoading(false)); .finally(() => setLoading(false));
}; };
useEffect(() => { useEffect(() => {
@ -56,14 +56,10 @@ export default function Memory() {
const handleKeyDown = (e: React.KeyboardEvent) => { const handleKeyDown = (e: React.KeyboardEvent) => {
if (e.key === 'Enter') handleSearch(); if (e.key === 'Enter') handleSearch();
}; };
const categories = Array.from(new Set(entries.map((e) => e.category))).sort(); const categories = Array.from(new Set(entries.map((e) => e.category))).sort();
const handleAdd = async () => { const handleAdd = async () => {
if (!formKey.trim() || !formContent.trim()) { if (!formKey.trim() || !formContent.trim()) { setFormError(t('memory.validation_error')); return; }
setFormError(t('memory.validation_error'));
return;
}
setSubmitting(true); setSubmitting(true);
setFormError(null); setFormError(null);
try { try {
@ -74,14 +70,10 @@ export default function Memory() {
); );
fetchEntries(search, categoryFilter); fetchEntries(search, categoryFilter);
setShowForm(false); setShowForm(false);
setFormKey(''); setFormKey(''); setFormContent(''); setFormCategory('');
setFormContent('');
setFormCategory('');
} catch (err: unknown) { } catch (err: unknown) {
setFormError(err instanceof Error ? err.message : t('memory.store_error')); setFormError(err instanceof Error ? err.message : t('memory.store_error'));
} finally { } finally { setSubmitting(false); }
setSubmitting(false);
}
}; };
const handleDelete = async (key: string) => { const handleDelete = async (key: string) => {
@ -98,7 +90,7 @@ export default function Memory() {
if (error && entries.length === 0) { if (error && entries.length === 0) {
return ( return (
<div className="p-6 animate-fade-in"> <div className="p-6 animate-fade-in">
<div className="rounded-xl bg-[#ff446615] border border-[#ff446630] p-4 text-[#ff6680]"> <div className="rounded-2xl border p-4" style={{ background: 'rgba(239, 68, 68, 0.08)', borderColor: 'rgba(239, 68, 68, 0.2)', color: '#f87171' }}>
{t('memory.load_error')}: {error} {t('memory.load_error')}: {error}
</div> </div>
</div> </div>
@ -110,40 +102,25 @@ export default function Memory() {
{/* Header */} {/* Header */}
<div className="flex items-center justify-between"> <div className="flex items-center justify-between">
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
<Brain className="h-5 w-5 text-[#0080ff]" /> <Brain className="h-5 w-5" style={{ color: 'var(--pc-accent)' }} />
<h2 className="text-sm font-semibold text-white uppercase tracking-wider"> <h2 className="text-sm font-semibold uppercase tracking-wider" style={{ color: 'var(--pc-text-primary)' }}>
{t('memory.memory_title')} ({entries.length}) {t('memory.memory_title')} ({entries.length})
</h2> </h2>
</div> </div>
<button <button onClick={() => setShowForm(true)} className="btn-electric flex items-center gap-2 text-sm px-4 py-2">
onClick={() => setShowForm(true)} <Plus className="h-4 w-4" />{t('memory.add_memory')}
className="btn-electric flex items-center gap-2 text-sm px-4 py-2"
>
<Plus className="h-4 w-4" />
{t('memory.add_memory')}
</button> </button>
</div> </div>
{/* Search and Filter */} {/* Search and Filter */}
<div className="flex flex-col sm:flex-row gap-3"> <div className="flex flex-col sm:flex-row gap-3">
<div className="relative flex-1"> <div className="relative flex-1">
<Search className="absolute left-3 top-1/2 -translate-y-1/2 h-4 w-4 text-[#334060]" /> <Search className="absolute left-3 top-1/2 -translate-y-1/2 h-4 w-4" style={{ color: 'var(--pc-text-faint)' }} />
<input <input type="text" value={search} onChange={(e) => setSearch(e.target.value)} onKeyDown={handleKeyDown} placeholder={t('memory.search_placeholder')} className="input-electric w-full pl-10 pr-4 py-2.5 text-sm" />
type="text"
value={search}
onChange={(e) => setSearch(e.target.value)}
onKeyDown={handleKeyDown}
placeholder={t('memory.search_placeholder')}
className="input-electric w-full pl-10 pr-4 py-2.5 text-sm"
/>
</div> </div>
<div className="relative"> <div className="relative">
<Filter className="absolute left-3 top-1/2 -translate-y-1/2 h-4 w-4 text-[#334060]" /> <Filter className="absolute left-3 top-1/2 -translate-y-1/2 h-4 w-4" style={{ color: 'var(--pc-text-faint)' }} />
<select <select value={categoryFilter} onChange={(e) => setCategoryFilter(e.target.value)} className="input-electric pl-10 pr-8 py-2.5 text-sm appearance-none cursor-pointer">
value={categoryFilter}
onChange={(e) => setCategoryFilter(e.target.value)}
className="input-electric pl-10 pr-8 py-2.5 text-sm appearance-none cursor-pointer"
>
<option value="">{t('memory.all_categories')}</option> <option value="">{t('memory.all_categories')}</option>
{categories.map((cat) => ( {categories.map((cat) => (
<option key={cat} value={cat}> <option key={cat} value={cat}>
@ -152,17 +129,12 @@ export default function Memory() {
))} ))}
</select> </select>
</div> </div>
<button <button onClick={handleSearch} className="btn-electric px-4 py-2.5 text-sm">{t('memory.search_button')}</button>
onClick={handleSearch}
className="btn-electric px-4 py-2.5 text-sm"
>
{t('memory.search_button')}
</button>
</div> </div>
{/* Error banner (non-fatal) */} {/* Error banner (non-fatal) */}
{error && ( {error && (
<div className="rounded-xl bg-[#ff446615] border border-[#ff446630] p-3 text-sm text-[#ff6680] animate-fade-in"> <div className="rounded-xl border p-3 text-sm animate-fade-in" style={{ background: 'rgba(239, 68, 68, 0.08)', borderColor: 'rgba(239, 68, 68, 0.2)', color: '#f87171' }}>
{error} {error}
</div> </div>
)} )}
@ -170,82 +142,55 @@ export default function Memory() {
{/* Add Memory Form Modal */} {/* Add Memory Form Modal */}
{showForm && ( {showForm && (
<div className="fixed inset-0 modal-backdrop flex items-center justify-center z-50"> <div className="fixed inset-0 modal-backdrop flex items-center justify-center z-50">
<div className="glass-card p-6 w-full max-w-md mx-4 animate-fade-in-scale"> <div className="surface-panel p-6 w-full max-w-md mx-4 animate-fade-in-scale">
<div className="flex items-center justify-between mb-4"> <div className="flex items-center justify-between mb-4">
<h3 className="text-lg font-semibold text-white">{t('memory.add_modal_title')}</h3> <h3 className="text-lg font-semibold" style={{ color: 'var(--pc-text-primary)' }}>{t('memory.add_modal_title')}</h3>
<button <button
onClick={() => { onClick={() => {
setShowForm(false); setShowForm(false);
setFormError(null); setFormError(null);
}} }}
className="text-[#556080] hover:text-white transition-colors duration-300" className="btn-icon">
>
<X className="h-5 w-5" /> <X className="h-5 w-5" />
</button> </button>
</div> </div>
{formError && ( {formError && (
<div className="mb-4 rounded-xl bg-[#ff446615] border border-[#ff446630] p-3 text-sm text-[#ff6680] animate-fade-in"> <div className="mb-4 rounded-xl border p-3 text-sm animate-fade-in" style={{ background: 'rgba(239, 68, 68, 0.08)', borderColor: 'rgba(239, 68, 68, 0.2)', color: '#f87171' }}>
{formError} {formError}
</div> </div>
)} )}
<div className="space-y-4"> <div className="space-y-4">
<div> <div>
<label className="block text-xs font-semibold text-[#8892a8] mb-1.5 uppercase tracking-wider"> <label className="block text-xs font-semibold mb-1.5 uppercase tracking-wider" style={{ color: 'var(--pc-text-secondary)' }}>
{t('memory.key_required')} <span className="text-[#ff4466]">*</span> {t('memory.key_required')} <span style={{ color: 'var(--color-status-error)' }}>*</span>
</label> </label>
<input <input type="text" value={formKey} onChange={(e) => setFormKey(e.target.value)} placeholder="e.g. user_preferences" className="input-electric w-full px-3 py-2.5 text-sm" />
type="text"
value={formKey}
onChange={(e) => setFormKey(e.target.value)}
placeholder="e.g. user_preferences"
className="input-electric w-full px-3 py-2.5 text-sm"
/>
</div> </div>
<div> <div>
<label className="block text-xs font-semibold text-[#8892a8] mb-1.5 uppercase tracking-wider"> <label className="block text-xs font-semibold mb-1.5 uppercase tracking-wider" style={{ color: 'var(--pc-text-secondary)' }}>
{t('memory.content_required')} <span className="text-[#ff4466]">*</span> {t('memory.content_required')} <span style={{ color: 'var(--color-status-error)' }}>*</span>
</label> </label>
<textarea <textarea value={formContent} onChange={(e) => setFormContent(e.target.value)} placeholder="Memory content..." rows={4} className="input-electric w-full px-3 py-2.5 text-sm resize-none" />
value={formContent}
onChange={(e) => setFormContent(e.target.value)}
placeholder="Memory content..."
rows={4}
className="input-electric w-full px-3 py-2.5 text-sm resize-none"
/>
</div> </div>
<div> <div>
<label className="block text-xs font-semibold text-[#8892a8] mb-1.5 uppercase tracking-wider"> <label className="block text-xs font-semibold mb-1.5 uppercase tracking-wider" style={{ color: 'var(--pc-text-secondary)' }}>
{t('memory.category_optional')} {t('memory.category_optional')}
</label> </label>
<input <input type="text" value={formCategory} onChange={(e) => setFormCategory(e.target.value)} placeholder="e.g. preferences, context, facts" className="input-electric w-full px-3 py-2.5 text-sm" />
type="text"
value={formCategory}
onChange={(e) => setFormCategory(e.target.value)}
placeholder="e.g. preferences, context, facts"
className="input-electric w-full px-3 py-2.5 text-sm"
/>
</div> </div>
</div> </div>
<div className="flex justify-end gap-3 mt-6"> <div className="flex justify-end gap-3 mt-6">
<button <button
onClick={() => { onClick={() => {
setShowForm(false); setShowForm(false);
setFormError(null); setFormError(null);
}} }}
className="px-4 py-2 text-sm font-medium text-[#8892a8] hover:text-white border border-[#1a1a3e] rounded-xl hover:bg-[#0080ff08] transition-all duration-300" className="btn-secondary px-4 py-2 text-sm font-medium"
> >
{t('memory.cancel')} {t('memory.cancel')}
</button> </button>
<button <button
onClick={handleAdd} onClick={handleAdd} disabled={submitting} className="btn-electric px-4 py-2 text-sm font-medium">{submitting ? t('memory.saving') : t('common.save')}</button>
disabled={submitting}
className="btn-electric px-4 py-2 text-sm font-medium"
>
{submitting ? t('memory.saving') : t('common.save')}
</button>
</div> </div>
</div> </div>
</div> </div>
@ -254,65 +199,66 @@ export default function Memory() {
{/* Memory Table */} {/* Memory Table */}
{loading ? ( {loading ? (
<div className="flex items-center justify-center h-32"> <div className="flex items-center justify-center h-32">
<div className="h-8 w-8 border-2 border-[#0080ff30] border-t-[#0080ff] rounded-full animate-spin" /> <div className="h-8 w-8 border-2 rounded-full animate-spin" style={{ borderColor: 'var(--pc-border)', borderTopColor: 'var(--pc-accent)' }} />
</div> </div>
) : entries.length === 0 ? ( ) : entries.length === 0 ? (
<div className="glass-card p-8 text-center"> <div className="card p-8 text-center">
<Brain className="h-10 w-10 text-[#1a1a3e] mx-auto mb-3" /> <Brain className="h-10 w-10 mx-auto mb-3" style={{ color: 'var(--pc-text-faint)' }} />
<p className="text-[#556080]">{t('memory.empty')}</p> <p style={{ color: 'var(--pc-text-muted)' }}>{t('memory.empty')}</p>
</div> </div>
) : ( ) : (
<div className="glass-card flex-1 min-h-0 overflow-auto"> <div className="card overflow-x-auto rounded-2xl">
<table className="table-electric"> <table className="table-electric">
<thead> <thead>
<tr> <tr>
<th className="text-left">{t('memory.key')}</th> <th>{t('memory.key')}</th>
<th className="text-left">{t('memory.content')}</th> <th>{t('memory.content')}</th>
<th className="text-left">{t('memory.category')}</th> <th>{t('memory.category')}</th>
<th className="text-left">{t('memory.timestamp')}</th> <th>{t('memory.timestamp')}</th>
<th className="text-right">{t('common.actions')}</th> <th className="text-right">{t('common.actions')}</th>
</tr> </tr>
</thead> </thead>
<tbody> <tbody>
{entries.map((entry) => ( {entries.map((entry) => (
<tr key={entry.id}> <tr key={entry.id}>
<td className="px-4 py-3 text-white font-medium font-mono text-xs"> <td className="font-mono text-xs" style={{ color: 'var(--pc-text-primary)' }}>
{entry.key} {entry.key}
</td> </td>
<td className="px-4 py-3 text-[#8892a8] max-w-[300px] text-sm"> <td className="max-w-[300px] text-sm" style={{ color: 'var(--pc-text-secondary)' }}>
<span title={entry.content}> <span title={entry.content}>
{truncate(entry.content, 80)} {truncate(entry.content, 80)}
</span> </span>
</td> </td>
<td className="px-4 py-3"> <td>
<span className="inline-flex items-center px-2.5 py-0.5 rounded-full text-[10px] font-semibold capitalize border border-[#1a1a3e] text-[#8892a8]" style={{ background: 'rgba(0,128,255,0.06)' }}> <span className="inline-flex items-center px-2.5 py-0.5 rounded-full text-[10px] font-semibold capitalize border" style={{ borderColor: 'var(--pc-border)', color: 'var(--pc-text-secondary)', background: 'var(--pc-accent-glow)' }}>
{entry.category} {entry.category}
</span> </span>
</td> </td>
<td className="px-4 py-3 text-[#556080] text-xs whitespace-nowrap"> <td className="text-xs whitespace-nowrap" style={{ color: 'var(--pc-text-muted)' }}>
{formatDate(entry.timestamp)} {formatDate(entry.timestamp)}
</td> </td>
<td className="px-4 py-3 text-right"> <td className="text-right">
{confirmDelete === entry.key ? ( {confirmDelete === entry.key ? (
<div className="flex items-center justify-end gap-2 animate-fade-in"> <div className="flex items-center justify-end gap-2 animate-fade-in">
<span className="text-xs text-[#ff4466]">{t('memory.delete_confirm')}</span> <span className="text-xs" style={{ color: 'var(--color-status-error)' }}>
{t('memory.delete_confirm')}
</span>
<button <button
onClick={() => handleDelete(entry.key)} onClick={() => handleDelete(entry.key)}
className="text-[#ff4466] hover:text-[#ff6680] text-xs font-medium" className="text-xs font-medium" style={{ color: 'var(--color-status-error)' }}
> >
{t('memory.yes')} {t('memory.yes')}
</button> </button>
<button <button
onClick={() => setConfirmDelete(null)} onClick={() => setConfirmDelete(null)}
className="text-[#556080] hover:text-white text-xs font-medium" className="text-xs font-medium" style={{ color: 'var(--pc-text-muted)' }}>
>
{t('memory.no')} {t('memory.no')}
</button> </button>
</div> </div>
) : ( ) : (
<button <button
onClick={() => setConfirmDelete(entry.key)} onClick={() => setConfirmDelete(entry.key)}
className="text-[#334060] hover:text-[#ff4466] transition-all duration-300" className="btn-icon"
> >
<Trash2 className="h-4 w-4" /> <Trash2 className="h-4 w-4" />
</button> </button>

View File

@ -1,5 +1,7 @@
import { useState, useEffect, useCallback } from 'react'; import { useState, useEffect, useCallback } from 'react';
import { Smartphone, Trash2 } from 'lucide-react';
import { getAdminPairCode } from '../lib/api'; import { getAdminPairCode } from '../lib/api';
import { t } from '@/lib/i18n';
interface Device { interface Device {
id: string; id: string;
@ -47,9 +49,7 @@ export default function Pairing() {
}); });
}, []); }, []);
useEffect(() => { useEffect(() => { fetchDevices(); }, [fetchDevices]);
fetchDevices();
}, [fetchDevices]);
const handleInitiatePairing = async () => { const handleInitiatePairing = async () => {
try { try {
@ -83,84 +83,87 @@ export default function Pairing() {
}; };
if (loading) { if (loading) {
return <div className="p-6">Loading...</div>; return (
<div className="flex items-center justify-center h-64">
<div className="h-8 w-8 border-2 rounded-full animate-spin" style={{ borderColor: 'var(--pc-border)', borderTopColor: 'var(--pc-accent)' }} />
</div>
);
} }
return ( return (
<div className="p-6 max-w-4xl mx-auto"> <div className="p-6 space-y-6 animate-fade-in">
<div className="flex justify-between items-center mb-6"> <div className="flex items-center justify-between">
<h1 className="text-2xl font-bold">Device Pairing</h1> <h2 className="text-sm font-semibold uppercase tracking-wider" style={{ color: 'var(--pc-text-primary)' }}>
{t('pairing.title')}
</h2>
<button <button
onClick={handleInitiatePairing} onClick={handleInitiatePairing}
className="px-4 py-2 bg-blue-600 text-white rounded hover:bg-blue-700" className="btn-electric flex items-center gap-2 text-sm px-4 py-2"
> >
Pair New Device <Smartphone className="h-4 w-4" />
{t('pairing.pair_new_device')}
</button> </button>
</div> </div>
{error && ( {error && (
<div className="mb-4 p-3 bg-red-100 text-red-700 rounded"> <div className="rounded-xl border p-3 text-sm animate-fade-in" style={{ background: 'rgba(239, 68, 68, 0.08)', borderColor: 'rgba(239, 68, 68, 0.2)', color: '#f87171' }}>
{error} {error}
<button onClick={() => setError(null)} className="ml-2 font-bold">×</button> <button onClick={() => setError(null)} className="ml-2 font-bold">×</button>
</div> </div>
)} )}
{pairingCode && ( {pairingCode && (
<div className="mb-6 p-4 bg-blue-50 border border-blue-200 rounded"> <div className="card p-6 text-center rounded-2xl">
<h2 className="text-lg font-semibold mb-2">Pairing Code</h2> <p className="text-xs uppercase tracking-wider mb-2" style={{ color: 'var(--pc-text-muted)' }}>{t('pairing.pairing_code')}</p>
<div className="text-3xl font-mono font-bold tracking-wider text-center py-4"> <div className="text-4xl font-mono font-bold tracking-[0.4em] py-4" style={{ color: 'var(--pc-text-primary)' }}>
{pairingCode} {pairingCode}
</div> </div>
<div className="text-center my-3 text-sm text-gray-400"> <p className="text-xs" style={{ color: 'var(--pc-text-muted)' }}>{t('pairing.code_hint')}</p>
{/* QR code rendering placeholder - will use qrcode.react when available */}
<div className="inline-block border-2 border-dashed border-gray-300 p-8 rounded">
<span className="text-gray-400">QR Code</span>
</div>
</div>
<p className="text-sm text-gray-600 text-center">
Scan the QR code or enter the code manually on the new device.
</p>
</div> </div>
)} )}
<div className="bg-white rounded shadow"> <div className="card rounded-2xl overflow-hidden">
<div className="px-4 py-3 border-b"> <div className="px-5 py-4 border-b" style={{ borderColor: 'var(--pc-border)' }}>
<h2 className="font-semibold">Paired Devices ({devices.length})</h2> <h3 className="text-sm font-semibold" style={{ color: 'var(--pc-text-primary)' }}>
{t('pairing.paired_devices')} ({devices.length})
</h3>
</div> </div>
{devices.length === 0 ? ( {devices.length === 0 ? (
<div className="p-4 text-gray-500 text-center"> <div className="p-8 text-center" style={{ color: 'var(--pc-text-muted)' }}>
No devices paired yet. Click &quot;Pair New Device&quot; to get started. {t('pairing.no_devices')}
</div> </div>
) : ( ) : (
<table className="w-full"> <table className="table-electric">
<thead> <thead>
<tr className="text-left text-sm text-gray-500 border-b"> <tr>
<th className="px-4 py-2">Name</th> <th>{t('pairing.name')}</th>
<th className="px-4 py-2">Type</th> <th>{t('pairing.type')}</th>
<th className="px-4 py-2">Paired</th> <th>{t('pairing.paired')}</th>
<th className="px-4 py-2">Last Seen</th> <th>{t('pairing.last_seen')}</th>
<th className="px-4 py-2">IP</th> <th>IP</th>
<th className="px-4 py-2">Actions</th> <th className="text-right">{t('pairing.actions')}</th>
</tr> </tr>
</thead> </thead>
<tbody> <tbody>
{devices.map(device => ( {devices.map((device) => (
<tr key={device.id} className="border-b hover:bg-gray-50"> <tr key={device.id}>
<td className="px-4 py-2">{device.name || 'Unnamed'}</td> <td style={{ color: 'var(--pc-text-primary)' }}>{device.name || 'Unnamed'}</td>
<td className="px-4 py-2">{device.device_type || 'Unknown'}</td> <td style={{ color: 'var(--pc-text-secondary)' }}>{device.device_type || 'Unknown'}</td>
<td className="px-4 py-2 text-sm"> <td className="text-xs" style={{ color: 'var(--pc-text-muted)' }}>
{new Date(device.paired_at).toLocaleDateString()} {new Date(device.paired_at).toLocaleDateString()}
</td> </td>
<td className="px-4 py-2 text-sm"> <td className="text-xs" style={{ color: 'var(--pc-text-muted)' }}>
{new Date(device.last_seen).toLocaleString()} {new Date(device.last_seen).toLocaleString()}
</td> </td>
<td className="px-4 py-2 text-sm font-mono">{device.ip_address || '-'}</td> <td className="font-mono text-xs" style={{ color: 'var(--pc-text-secondary)' }}>
<td className="px-4 py-2"> {device.ip_address || '-'}
</td>
<td className="text-right">
<button <button
onClick={() => handleRevokeDevice(device.id)} onClick={() => handleRevokeDevice(device.id)}
className="text-red-600 hover:text-red-800 text-sm" className="btn-icon"
> >
Revoke <Trash2 className="h-4 w-4" />
</button> </button>
</td> </td>
</tr> </tr>

View File

@ -21,30 +21,25 @@ export default function Tools() {
useEffect(() => { useEffect(() => {
Promise.all([getTools(), getCliTools()]) Promise.all([getTools(), getCliTools()])
.then(([t, c]) => { .then(([t, c]) => { setTools(t); setCliTools(c); })
setTools(t);
setCliTools(c);
})
.catch((err) => setError(err.message)) .catch((err) => setError(err.message))
.finally(() => setLoading(false)); .finally(() => setLoading(false));
}, []); }, []);
const filtered = tools.filter( const filtered = tools.filter((t) =>
(t) => t.name.toLowerCase().includes(search.toLowerCase()) ||
t.name.toLowerCase().includes(search.toLowerCase()) || t.description.toLowerCase().includes(search.toLowerCase()),
t.description.toLowerCase().includes(search.toLowerCase()),
); );
const filteredCli = cliTools.filter( const filteredCli = cliTools.filter((t) =>
(t) => t.name.toLowerCase().includes(search.toLowerCase()) ||
t.name.toLowerCase().includes(search.toLowerCase()) || t.category.toLowerCase().includes(search.toLowerCase()),
t.category.toLowerCase().includes(search.toLowerCase()),
); );
if (error) { if (error) {
return ( return (
<div className="p-6 animate-fade-in"> <div className="p-6 animate-fade-in">
<div className="rounded-xl bg-[#ff446615] border border-[#ff446630] p-4 text-[#ff6680]"> <div className="rounded-2xl border p-4" style={{ background: 'rgba(239, 68, 68, 0.08)', borderColor: 'rgba(239, 68, 68, 0.2)', color: '#f87171' }}>
{t('tools.load_error')}: {error} {t('tools.load_error')}: {error}
</div> </div>
</div> </div>
@ -54,7 +49,7 @@ export default function Tools() {
if (loading) { if (loading) {
return ( return (
<div className="flex items-center justify-center h-64"> <div className="flex items-center justify-center h-64">
<div className="h-8 w-8 border-2 border-[#0080ff30] border-t-[#0080ff] rounded-full animate-spin" /> <div className="h-8 w-8 border-2 rounded-full animate-spin" style={{ borderColor: 'var(--pc-border)', borderTopColor: 'var(--pc-accent)' }} />
</div> </div>
); );
} }
@ -63,7 +58,7 @@ export default function Tools() {
<div className="p-6 space-y-6 animate-fade-in"> <div className="p-6 space-y-6 animate-fade-in">
{/* Search */} {/* Search */}
<div className="relative max-w-md"> <div className="relative max-w-md">
<Search className="absolute left-3 top-1/2 -translate-y-1/2 h-4 w-4 text-[#334060]" /> <Search className="absolute left-3 top-1/2 -translate-y-1/2 h-4 w-4" style={{ color: 'var(--pc-text-faint)' }} />
<input <input
type="text" type="text"
value={search} value={search}
@ -76,14 +71,14 @@ export default function Tools() {
{/* Agent Tools Grid */} {/* Agent Tools Grid */}
<div> <div>
<div className="flex items-center gap-2 mb-4"> <div className="flex items-center gap-2 mb-4">
<Wrench className="h-5 w-5 text-[#0080ff]" /> <Wrench className="h-5 w-5" style={{ color: 'var(--pc-accent)' }} />
<h2 className="text-sm font-semibold text-white uppercase tracking-wider"> <h2 className="text-sm font-semibold uppercase tracking-wider" style={{ color: 'var(--pc-text-primary)' }}>
{t('tools.agent_tools')} ({filtered.length}) {t('tools.agent_tools')} ({filtered.length})
</h2> </h2>
</div> </div>
{filtered.length === 0 ? ( {filtered.length === 0 ? (
<p className="text-sm text-[#334060]">{t('tools.empty')}</p> <p className="text-sm" style={{ color: 'var(--pc-text-muted)' }}>{t('tools.empty')}</p>
) : ( ) : (
<div className="grid grid-cols-1 md:grid-cols-2 xl:grid-cols-3 gap-4 stagger-children"> <div className="grid grid-cols-1 md:grid-cols-2 xl:grid-cols-3 gap-4 stagger-children">
{filtered.map((tool) => { {filtered.map((tool) => {
@ -91,38 +86,36 @@ export default function Tools() {
return ( return (
<div <div
key={tool.name} key={tool.name}
className="glass-card overflow-hidden animate-slide-in-up" className="card overflow-hidden animate-slide-in-up"
> >
<button <button
onClick={() => onClick={() => setExpandedTool(isExpanded ? null : tool.name)}
setExpandedTool(isExpanded ? null : tool.name) className="w-full text-left p-4 transition-all"
} style={{ background: 'transparent' }}
className="w-full text-left p-4 hover:bg-[#0080ff08] transition-all duration-300" onMouseEnter={(e) => { e.currentTarget.style.background = 'var(--pc-hover)'; }}
onMouseLeave={(e) => { e.currentTarget.style.background = 'transparent'; }}
> >
<div className="flex items-start justify-between gap-2"> <div className="flex items-start justify-between gap-2">
<div className="flex items-center gap-2 min-w-0"> <div className="flex items-center gap-2 min-w-0">
<Package className="h-4 w-4 text-[#0080ff] flex-shrink-0 mt-0.5" /> <Package className="h-4 w-4 flex-shrink-0" style={{ color: 'var(--pc-accent)' }} />
<h3 className="text-sm font-semibold text-white truncate"> <h3 className="text-sm font-semibold truncate" style={{ color: 'var(--pc-text-primary)' }}>{tool.name}</h3>
{tool.name}
</h3>
</div> </div>
{isExpanded ? ( {isExpanded
<ChevronDown className="h-4 w-4 text-[#0080ff] flex-shrink-0 transition-transform" /> ? <ChevronDown className="h-4 w-4 flex-shrink-0" style={{ color: 'var(--pc-accent)' }} />
) : ( : <ChevronRight className="h-4 w-4 flex-shrink-0" style={{ color: 'var(--pc-text-faint)' }} />
<ChevronRight className="h-4 w-4 text-[#334060] flex-shrink-0 transition-transform" /> }
)}
</div> </div>
<p className="text-sm text-[#556080] mt-2 line-clamp-2"> <p className="text-sm mt-2 line-clamp-2" style={{ color: 'var(--pc-text-muted)' }}>
{tool.description} {tool.description}
</p> </p>
</button> </button>
{isExpanded && tool.parameters && ( {isExpanded && tool.parameters && (
<div className="border-t border-[#1a1a3e] p-4 animate-fade-in"> <div className="border-t p-4 animate-fade-in" style={{ borderColor: 'var(--pc-border)' }}>
<p className="text-[10px] text-[#334060] mb-2 font-semibold uppercase tracking-wider"> <p className="text-[10px] font-semibold uppercase tracking-wider mb-2" style={{ color: 'var(--pc-text-muted)' }}>
{t('tools.parameter_schema')} {t('tools.parameter_schema')}
</p> </p>
<pre className="text-xs text-[#8892a8] rounded-xl p-3 overflow-x-auto max-h-64 overflow-y-auto" style={{ background: 'rgba(5,5,16,0.8)' }}> <pre className="text-xs rounded-xl p-3 overflow-x-auto max-h-64 overflow-y-auto font-mono" style={{ background: 'var(--pc-bg-base)', color: 'var(--pc-text-secondary)' }}>
{JSON.stringify(tool.parameters, null, 2)} {JSON.stringify(tool.parameters, null, 2)}
</pre> </pre>
</div> </div>
@ -138,36 +131,36 @@ export default function Tools() {
{filteredCli.length > 0 && ( {filteredCli.length > 0 && (
<div className="animate-slide-in-up" style={{ animationDelay: '200ms' }}> <div className="animate-slide-in-up" style={{ animationDelay: '200ms' }}>
<div className="flex items-center gap-2 mb-4"> <div className="flex items-center gap-2 mb-4">
<Terminal className="h-5 w-5 text-[#00e68a]" /> <Terminal className="h-5 w-5" style={{ color: 'var(--color-status-success)' }} />
<h2 className="text-sm font-semibold text-white uppercase tracking-wider"> <h2 className="text-sm font-semibold uppercase tracking-wider" style={{ color: 'var(--pc-text-primary)' }}>
{t('tools.cli_tools')} ({filteredCli.length}) {t('tools.cli_tools')} ({filteredCli.length})
</h2> </h2>
</div> </div>
<div className="glass-card overflow-hidden"> <div className="card overflow-hidden rounded-2xl">
<table className="table-electric"> <table className="table-electric">
<thead> <thead>
<tr> <tr>
<th className="text-left">{t('tools.name')}</th> <th>{t('tools.name')}</th>
<th className="text-left">{t('tools.path')}</th> <th>{t('tools.path')}</th>
<th className="text-left">{t('tools.version')}</th> <th>{t('tools.version')}</th>
<th className="text-left">{t('tools.category')}</th> <th>{t('tools.category')}</th>
</tr> </tr>
</thead> </thead>
<tbody> <tbody>
{filteredCli.map((tool) => ( {filteredCli.map((tool) => (
<tr key={tool.name}> <tr key={tool.name}>
<td className="px-4 py-3 text-white font-medium text-sm"> <td className="font-medium text-sm" style={{ color: 'var(--pc-text-primary)' }}>
{tool.name} {tool.name}
</td> </td>
<td className="px-4 py-3 text-[#556080] font-mono text-xs truncate max-w-[200px]"> <td className="font-mono text-xs truncate max-w-[200px]" style={{ color: 'var(--pc-text-muted)' }}>
{tool.path} {tool.path}
</td> </td>
<td className="px-4 py-3 text-[#556080] text-sm"> <td style={{ color: 'var(--pc-text-muted)' }}>
{tool.version ?? '-'} {tool.version ?? '-'}
</td> </td>
<td className="px-4 py-3"> <td>
<span className="inline-flex items-center px-2.5 py-0.5 rounded-full text-[10px] font-semibold capitalize border border-[#1a1a3e] text-[#8892a8]" style={{ background: 'rgba(0,128,255,0.06)' }}> <span className="inline-flex items-center px-2.5 py-0.5 rounded-full text-[10px] font-semibold capitalize border" style={{ borderColor: 'var(--pc-border)', color: 'var(--pc-text-secondary)', background: 'var(--pc-accent-glow)' }}>
{tool.category} {tool.category}
</span> </span>
</td> </td>