Compare commits
1 Commits
master
...
feat/web-t
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
a4cb0b460f |
@ -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>
|
||||||
|
<ThemeProvider>
|
||||||
<AppContent />
|
<AppContent />
|
||||||
|
</ThemeProvider>
|
||||||
</AuthProvider>
|
</AuthProvider>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
327
web/src/components/SettingsModal.tsx
Normal file
327
web/src/components/SettingsModal.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@ -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)' }}>
|
<>
|
||||||
|
<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)', }}>
|
||||||
{/* Page title */}
|
{/* Page title */}
|
||||||
<h1 className="text-lg font-semibold text-white tracking-tight">{pageTitle}</h1>
|
<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">
|
||||||
|
{/* Settings */}
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => setSettingsOpen(true)}
|
||||||
|
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' }}
|
||||||
|
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={t('settings.title')}
|
||||||
|
>
|
||||||
|
<Settings className="h-3.5 w-3.5" />
|
||||||
|
</button>
|
||||||
|
|
||||||
{/* Language switcher */}
|
{/* Language switcher */}
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={toggleLanguage}
|
onClick={toggleLanguage}
|
||||||
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 px-3 rounded-xl text-xs font-semibold border transition-all flex items-center"
|
||||||
|
style={{
|
||||||
|
borderColor: 'var(--pc-border)',
|
||||||
|
color: 'var(--pc-text-secondary)',
|
||||||
|
background: 'var(--pc-bg-elevated)',
|
||||||
|
}}
|
||||||
|
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' : 'TR'}
|
{locale === 'en' ? 'EN' : locale === 'zh' ? 'ZH' : 'TR'}
|
||||||
</button>
|
</button>
|
||||||
|
|
||||||
{/* Logout */}
|
{/* Logout */}
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={logout}
|
onClick={logout}
|
||||||
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 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" />
|
<LogOut className="h-3.5 w-3.5" />
|
||||||
<span>{t('auth.logout')}</span>
|
<span>{t('auth.logout')}</span>
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</header>
|
</header>
|
||||||
|
|
||||||
|
<SettingsModal open={settingsOpen} onClose={() => setSettingsOpen(false)} />
|
||||||
|
</>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@ -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 />
|
||||||
|
|
||||||
|
|||||||
@ -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)' }}>
|
||||||
|
<div className="relative shrink-0">
|
||||||
|
<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))' }} />
|
||||||
<img
|
<img
|
||||||
src="/_app/zeroclaw-trans.png"
|
src="/_app/zeroclaw-trans.png"
|
||||||
alt="ZeroClaw"
|
alt="ZeroClaw"
|
||||||
className="h-10 w-10 rounded-xl object-cover animate-pulse-glow"
|
className="relative h-9 w-9 rounded-xl object-cover"
|
||||||
|
onError={(e) => {
|
||||||
|
const img = e.currentTarget;
|
||||||
|
img.style.display = 'none';
|
||||||
|
}}
|
||||||
/>
|
/>
|
||||||
<span className="text-lg font-bold text-gradient-blue tracking-wide">
|
</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>
|
||||||
);
|
);
|
||||||
|
|||||||
253
web/src/contexts/ThemeContext.tsx
Normal file
253
web/src/contexts/ThemeContext.tsx
Normal 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>;
|
||||||
|
}
|
||||||
52
web/src/contexts/ThemeContextDef.ts
Normal file
52
web/src/contexts/ThemeContextDef.ts
Normal 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: () => {},
|
||||||
|
});
|
||||||
25
web/src/contexts/fontLoader.ts
Normal file
25
web/src/contexts/fontLoader.ts
Normal 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');
|
||||||
|
}
|
||||||
44
web/src/contexts/themeStorage.ts
Normal file
44
web/src/contexts/themeStorage.ts
Normal 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;
|
||||||
|
}
|
||||||
4
web/src/hooks/useTheme.ts
Normal file
4
web/src/hooks/useTheme.ts
Normal file
@ -0,0 +1,4 @@
|
|||||||
|
import { useContext } from 'react';
|
||||||
|
import { ThemeContext } from '../contexts/ThemeContextDef';
|
||||||
|
|
||||||
|
export const useTheme = () => useContext(ThemeContext);
|
||||||
@ -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;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -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',
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@ -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 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="flex items-center gap-1.5">
|
|
||||||
<span className="w-1.5 h-1.5 bg-[#0080ff] rounded-full animate-bounce" style={{ animationDelay: '0ms' }} />
|
|
||||||
<span className="w-1.5 h-1.5 bg-[#0080ff] rounded-full animate-bounce" style={{ animationDelay: '150ms' }} />
|
|
||||||
<span className="w-1.5 h-1.5 bg-[#0080ff] rounded-full animate-bounce" style={{ animationDelay: '300ms' }} />
|
|
||||||
</div>
|
</div>
|
||||||
|
<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)' }}>
|
||||||
|
<span className="bounce-dot w-1.5 h-1.5 rounded-full" style={{ background: 'var(--pc-accent)' }} />
|
||||||
|
<span className="bounce-dot w-1.5 h-1.5 rounded-full" style={{ background: 'var(--pc-accent)' }} />
|
||||||
|
<span className="bounce-dot w-1.5 h-1.5 rounded-full" style={{ background: 'var(--pc-accent)' }} />
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
@ -312,9 +301,8 @@ 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}
|
||||||
@ -323,25 +311,28 @@ export default function AgentChat() {
|
|||||||
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 w-full px-4 py-3 text-sm resize-none overflow-y-auto disabled:opacity-40"
|
className="input-electric flex-1 px-4 text-sm resize-none disabled:opacity-40"
|
||||||
style={{ minHeight: '44px', maxHeight: '200px' }}
|
style={{ minHeight: '44px', maxHeight: '200px', paddingTop: '10px', paddingBottom: '10px' }}
|
||||||
/>
|
/>
|
||||||
</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>
|
||||||
|
|||||||
@ -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>
|
||||||
|
|||||||
@ -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,44 +99,39 @@ 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) => {
|
|
||||||
const share =
|
|
||||||
cost.monthly_cost_usd > 0
|
|
||||||
? (m.cost_usd / cost.monthly_cost_usd) * 100
|
|
||||||
: 0;
|
|
||||||
return (
|
return (
|
||||||
<tr key={m.model}>
|
<tr key={m.model}>
|
||||||
<td className="px-5 py-3 text-white font-medium text-sm">
|
<td className="font-medium text-sm" style={{ color: 'var(--pc-text-primary)' }}>
|
||||||
{m.model}
|
{m.model}
|
||||||
</td>
|
</td>
|
||||||
<td className="px-5 py-3 text-[#8892a8] text-right font-mono text-sm">
|
<td className="text-right font-mono text-sm" style={{ color: 'var(--pc-text-secondary)' }}>
|
||||||
{formatUSD(m.cost_usd)}
|
{formatUSD(m.cost_usd)}
|
||||||
</td>
|
</td>
|
||||||
<td className="px-5 py-3 text-[#8892a8] text-right text-sm">
|
<td className="text-right text-sm" style={{ color: 'var(--pc-text-secondary)' }}>
|
||||||
{m.total_tokens.toLocaleString()}
|
{m.total_tokens.toLocaleString()}
|
||||||
</td>
|
</td>
|
||||||
<td className="px-5 py-3 text-[#8892a8] text-right text-sm">
|
<td className="text-right text-sm" style={{ color: 'var(--pc-text-secondary)' }}>
|
||||||
{m.request_count.toLocaleString()}
|
{m.request_count.toLocaleString()}
|
||||||
</td>
|
</td>
|
||||||
<td className="px-5 py-3">
|
<td>
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
<div className="w-20 h-1.5 bg-[#0a0a18] rounded-full overflow-hidden">
|
<div className="w-20 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"
|
className="h-full rounded-full progress-bar-animated transition-all duration-700"
|
||||||
style={{ width: `${Math.max(share, 2)}%`, background: '#0080ff' }}
|
style={{ width: `${Math.max(share, 2)}%`, background: 'var(--pc-accent)' }}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<span className="text-xs text-[#556080] w-10 text-right font-mono">
|
<span className="text-xs font-mono w-10 text-right" style={{ color: 'var(--pc-text-muted)' }}>
|
||||||
{share.toFixed(1)}%
|
{share.toFixed(1)}%
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@ -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 = () => {
|
||||||
@ -271,19 +265,19 @@ export default function Cron() {
|
|||||||
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>
|
||||||
|
|||||||
@ -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,17 +267,16 @@ 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) {
|
if (entries.length === 0) {
|
||||||
return (
|
return (
|
||||||
<p className="text-sm text-[#334060]">
|
<p className="text-sm" style={{ color: "var(--pc-text-faint)" }}>
|
||||||
{t("dashboard.no_active_channels")}
|
{t("dashboard.no_active_channels")}
|
||||||
</p>
|
</p>
|
||||||
);
|
);
|
||||||
@ -262,66 +284,100 @@ export default function Dashboard() {
|
|||||||
return entries.map(([name, active]) => (
|
return entries.map(([name, active]) => (
|
||||||
<div
|
<div
|
||||||
key={name}
|
key={name}
|
||||||
className="flex items-center justify-between py-2.5 px-3 rounded-xl transition-all duration-300 hover:bg-[#0080ff08]"
|
className="flex items-center justify-between py-2.5 px-3 rounded-xl transition-all"
|
||||||
style={{ background: "rgba(10, 10, 26, 0.5)" }}
|
style={{ background: "var(--pc-bg-elevated)" }}
|
||||||
|
onMouseEnter={(e) => {
|
||||||
|
e.currentTarget.style.background = "var(--pc-hover)";
|
||||||
|
}}
|
||||||
|
onMouseLeave={(e) => {
|
||||||
|
e.currentTarget.style.background = "var(--pc-bg-elevated)";
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<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>
|
</span>
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
<span
|
<span
|
||||||
className={`inline-block h-2 w-2 rounded-full glow-dot ${
|
className="status-dot"
|
||||||
|
style={
|
||||||
active
|
active
|
||||||
? "text-[#00e68a] bg-[#00e68a]"
|
? {
|
||||||
: "text-[#334060] bg-[#334060]"
|
background: "var(--color-status-success)",
|
||||||
}`}
|
boxShadow: "0 0 6px var(--color-status-success)",
|
||||||
|
}
|
||||||
|
: { background: "var(--pc-text-faint)" }
|
||||||
|
}
|
||||||
/>
|
/>
|
||||||
<span className="text-xs text-[#556080]">
|
<span className="text-xs" style={{ color: "var(--pc-text-muted)" }}>
|
||||||
{active
|
{active ? t("dashboard.active") : t("dashboard.inactive")}
|
||||||
? t("dashboard.active")
|
|
||||||
: t("dashboard.inactive")}
|
|
||||||
</span>
|
</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>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@ -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}
|
||||||
@ -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>
|
||||||
|
|||||||
@ -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
|
||||||
@ -64,7 +62,7 @@ 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>
|
||||||
@ -74,17 +72,16 @@ export default function Integrations() {
|
|||||||
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,16 +105,14 @@ 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))
|
|
||||||
.map(([category, items]) => (
|
|
||||||
<div key={category}>
|
<div key={category}>
|
||||||
<h3 className="text-[10px] font-semibold text-[#334060] uppercase tracking-wider mb-3 capitalize">
|
<h3 className="text-[10px] font-semibold uppercase tracking-wider mb-3 capitalize" style={{ color: 'var(--pc-text-faint)' }}>
|
||||||
{category}
|
{category}
|
||||||
</h3>
|
</h3>
|
||||||
<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">
|
||||||
@ -127,20 +122,20 @@ export default function Integrations() {
|
|||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
key={integration.name}
|
key={integration.name}
|
||||||
className="glass-card p-5 animate-slide-in-up"
|
className="card p-5 animate-slide-in-up"
|
||||||
>
|
>
|
||||||
<div className="flex items-start justify-between gap-3">
|
<div className="flex items-start justify-between gap-3">
|
||||||
<div className="min-w-0">
|
<div className="min-w-0">
|
||||||
<h4 className="text-sm font-semibold text-white truncate">
|
<h4 className="text-sm font-semibold truncate" style={{ color: 'var(--pc-text-primary)' }}>
|
||||||
{integration.name}
|
{integration.name}
|
||||||
</h4>
|
</h4>
|
||||||
<p className="text-sm text-[#556080] mt-1 line-clamp-2">
|
<p className="text-sm mt-1 line-clamp-2" style={{ color: 'var(--pc-text-muted)' }}>
|
||||||
{integration.description}
|
{integration.description}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<span
|
<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}`}
|
className="flex-shrink-0 inline-flex items-center gap-1 px-2.5 py-1 rounded-full text-[10px] font-semibold border"
|
||||||
style={{ background: badge.bg }}
|
style={badge}
|
||||||
>
|
>
|
||||||
<BadgeIcon className="h-3 w-3" />
|
<BadgeIcon className="h-3 w-3" />
|
||||||
{badge.label}
|
{badge.label}
|
||||||
|
|||||||
@ -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,23 +225,22 @@ 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>
|
||||||
|
|||||||
@ -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>
|
||||||
|
|||||||
@ -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 "Pair New Device" 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>
|
||||||
|
|||||||
@ -21,22 +21,17 @@ 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()),
|
||||||
);
|
);
|
||||||
@ -44,7 +39,7 @@ export default function Tools() {
|
|||||||
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>
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user