diff --git a/ui/src/App.tsx b/ui/src/App.tsx index 6db0f20a..032a7cf8 100644 --- a/ui/src/App.tsx +++ b/ui/src/App.tsx @@ -1,15 +1,11 @@ -import { lazy, Suspense } from 'react'; -import { BrowserRouter, Routes, Route, Outlet } from 'react-router-dom'; +import { lazy } from 'react'; +import { BrowserRouter, Routes, Route } from 'react-router-dom'; import { QueryClientProvider } from '@tanstack/react-query'; -import { SidebarProvider } from '@/components/ui/sidebar'; -import { AppSidebar } from '@/components/app-sidebar'; -import { ThemeToggle } from '@/components/theme-toggle'; -import { GitHubLink } from '@/components/github-link'; -import { ConnectionIndicator } from '@/components/connection-indicator'; -import { LocalhostDisclaimer } from '@/components/localhost-disclaimer'; import { Toaster } from 'sonner'; import { queryClient } from '@/lib/query-client'; -import { Skeleton } from '@/components/ui/skeleton'; +import { ThemeProvider } from '@/components/theme-provider'; +import { Layout } from '@/components/layout'; + // Eager load: HomePage (initial route) import { HomePage } from '@/pages'; @@ -33,58 +29,27 @@ const SettingsPage = lazy(() => const HealthPage = lazy(() => import('@/pages/health').then((m) => ({ default: m.HealthPage }))); const SharedPage = lazy(() => import('@/pages/shared').then((m) => ({ default: m.SharedPage }))); -// Loading fallback for lazy-loaded pages -function PageLoader() { - return ( -
- - -
- ); -} - -function Layout() { - return ( - - -
-
-
- - - -
-
-
- }> - - -
- -
-
- ); -} - export default function App() { return ( - - - }> - } /> - } /> - } /> - } /> - } /> - } /> - } /> - } /> - } /> - - - - + + + + }> + } /> + } /> + } /> + } /> + } /> + } /> + } /> + } /> + } /> + + + + + ); } diff --git a/ui/src/components/layout.tsx b/ui/src/components/layout.tsx new file mode 100644 index 00000000..fba044b2 --- /dev/null +++ b/ui/src/components/layout.tsx @@ -0,0 +1,42 @@ +import { Suspense } from 'react'; +import { Outlet } from 'react-router-dom'; +import { SidebarProvider } from '@/components/ui/sidebar'; +import { AppSidebar } from '@/components/app-sidebar'; +import { ThemeToggle } from '@/components/theme-toggle'; +import { GitHubLink } from '@/components/github-link'; +import { ConnectionIndicator } from '@/components/connection-indicator'; +import { LocalhostDisclaimer } from '@/components/localhost-disclaimer'; +import { Skeleton } from '@/components/ui/skeleton'; + +function PageLoader() { + return ( +
+ + +
+ ); +} + +export function Layout() { + return ( + + +
+
+
CCS Config
+
+ + + +
+
+
+ }> + + +
+ +
+
+ ); +} diff --git a/ui/src/components/theme-provider.tsx b/ui/src/components/theme-provider.tsx new file mode 100644 index 00000000..d113fe46 --- /dev/null +++ b/ui/src/components/theme-provider.tsx @@ -0,0 +1,61 @@ +import { useEffect, useState } from 'react'; +import { ThemeProviderContext } from '@/contexts/theme-context'; +import type { Theme } from '@/contexts/theme-context'; + +type ThemeProviderProps = { + children: React.ReactNode; + defaultTheme?: Theme; + storageKey?: string; +}; + +export function ThemeProvider({ + children, + defaultTheme = 'system', + storageKey = 'vite-ui-theme', +}: ThemeProviderProps) { + const [theme, setTheme] = useState( + () => (localStorage.getItem(storageKey) as Theme) || defaultTheme + ); + + // Track system preference separately + // Initialize with window.matchMedia value if available + const [systemIsDark, setSystemIsDark] = useState(() => { + if (typeof window !== 'undefined') { + return window.matchMedia('(prefers-color-scheme: dark)').matches; + } + return false; + }); + + // Listen for system theme changes + useEffect(() => { + const media = window.matchMedia('(prefers-color-scheme: dark)'); + + const listener = (e: MediaQueryListEvent) => { + setSystemIsDark(e.matches); + }; + + media.addEventListener('change', listener); + return () => media.removeEventListener('change', listener); + }, []); + + // Derive effective theme + const isDark = theme === 'dark' || (theme === 'system' && systemIsDark); + + // Update DOM when effective theme changes + useEffect(() => { + const root = window.document.documentElement; + root.classList.remove('light', 'dark'); + root.classList.add(isDark ? 'dark' : 'light'); + }, [isDark]); + + const value = { + theme, + setTheme: (theme: Theme) => { + localStorage.setItem(storageKey, theme); + setTheme(theme); + }, + isDark, + }; + + return {children}; +} diff --git a/ui/src/components/theme-toggle.tsx b/ui/src/components/theme-toggle.tsx index 946aed90..619b3a11 100644 --- a/ui/src/components/theme-toggle.tsx +++ b/ui/src/components/theme-toggle.tsx @@ -3,11 +3,17 @@ import { Button } from '@/components/ui/button'; import { useTheme } from '@/hooks/use-theme'; export function ThemeToggle() { - const { isDark, toggle } = useTheme(); + const { theme, setTheme } = useTheme(); return ( - ); } diff --git a/ui/src/contexts/theme-context.ts b/ui/src/contexts/theme-context.ts new file mode 100644 index 00000000..14376f92 --- /dev/null +++ b/ui/src/contexts/theme-context.ts @@ -0,0 +1,17 @@ +import { createContext } from 'react'; + +export type Theme = 'dark' | 'light' | 'system'; + +export type ThemeProviderState = { + theme: Theme; + setTheme: (theme: Theme) => void; + isDark: boolean; +}; + +export const initialState: ThemeProviderState = { + theme: 'system', + setTheme: () => null, + isDark: false, +}; + +export const ThemeProviderContext = createContext(initialState); diff --git a/ui/src/hooks/use-theme.ts b/ui/src/hooks/use-theme.ts index 5d8c6402..0ec62e5f 100644 --- a/ui/src/hooks/use-theme.ts +++ b/ui/src/hooks/use-theme.ts @@ -1,24 +1,10 @@ -import { useEffect, useState } from 'react'; +import { useContext } from 'react'; +import { ThemeProviderContext } from '@/contexts/theme-context'; -function getInitialTheme() { - if (typeof window === 'undefined') return false; - const stored = localStorage.getItem('ccs-theme'); - const prefersDark = window.matchMedia('(prefers-color-scheme: dark)').matches; - return stored === 'dark' || (!stored && prefersDark); -} +export const useTheme = () => { + const context = useContext(ThemeProviderContext); -export function useTheme() { - const [isDark, setIsDark] = useState(getInitialTheme); + if (context === undefined) throw new Error('useTheme must be used within a ThemeProvider'); - useEffect(() => { - document.documentElement.classList.toggle('dark', isDark); - }, [isDark]); - - const toggle = () => { - const newValue = !isDark; - setIsDark(newValue); - localStorage.setItem('ccs-theme', newValue ? 'dark' : 'light'); - }; - - return { isDark, toggle }; -} + return context; +}; diff --git a/ui/src/index.css b/ui/src/index.css index 1fcc98c7..664ae9c1 100644 --- a/ui/src/index.css +++ b/ui/src/index.css @@ -149,3 +149,53 @@ font-family: 'JetBrains Mono', 'Fira Code', monospace; } } + +/* Animations */ +@keyframes enter { + from { + opacity: 0; + transform: translateY(10px); + } + to { + opacity: 1; + transform: none; + } +} + +@keyframes fade-in { + from { + opacity: 0; + } + to { + opacity: 1; + } +} + +@keyframes zoom-in { + from { + transform: scale(0.95); + opacity: 0; + } + to { + transform: scale(1); + opacity: 1; + } +} + +.animate-in { + animation-duration: 0.5s; + animation-fill-mode: both; + animation-timing-function: ease-out; +} + +.fade-in { + animation-name: fade-in; +} + +.slide-in-from-bottom-4 { + animation-name: enter; +} + +.zoom-in-95 { + animation-name: zoom-in; +} diff --git a/ui/src/pages/home.tsx b/ui/src/pages/home.tsx index 0e2077dc..ca463e7f 100644 --- a/ui/src/pages/home.tsx +++ b/ui/src/pages/home.tsx @@ -1,6 +1,5 @@ import { useNavigate } from 'react-router-dom'; -import { Button } from '@/components/ui/button'; -import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'; +import { Card, CardContent } from '@/components/ui/card'; import { StatCard } from '@/components/stat-card'; import { HeroSection } from '@/components/hero-section'; import { QuickCommands } from '@/components/quick-commands'; @@ -16,7 +15,6 @@ import { BookOpen, FolderOpen, AlertTriangle, - ArrowRight, } from 'lucide-react'; import { useOverview } from '@/hooks/use-overview'; import { useSharedSummary } from '@/hooks/use-shared'; @@ -89,7 +87,7 @@ export function HomePage() { : undefined; return ( -
+
{/* Hero Section */} + Configuration Required {shared.symlinkStatus.message} @@ -108,97 +106,155 @@ export function HomePage() { )} {/* Stats Grid */} -
- navigate('/api')} - /> - navigate('/cliproxy')} - /> - navigate('/accounts')} - /> - navigate('/health')} - /> +
+
+

System Status

+
+
+ navigate('/api')} + /> + navigate('/cliproxy')} + /> + navigate('/accounts')} + /> + navigate('/health')} + /> +
+
+ +
+ {/* Left Column: Quick Actions & Shared Data */} +
+ {/* Quick Actions */} +
+
+

Quick Actions

+
+
+ navigate('/api')} + > + +
+ +
+
+

New Profile

+

Create a new API profile

+
+
+
+ + navigate('/health')} + > + +
+ +
+
+

Run Doctor

+

Check system health

+
+
+
+ + window.open('https://github.com/kaitranntt/ccs', '_blank')} + > + +
+ +
+
+

Documentation

+

View guides & reference

+
+
+
+ + navigate('/shared')} + > + +
+ +
+
+

Shared Data

+

Manage resources

+
+
+
+
+
+ + {/* Shared Data Stats */} +
+
+

Resource Summary

+
+ + +
+
+ + {shared?.commands ?? 0} + + Commands +
+
+ + {shared?.skills ?? 0} + + Skills +
+
+ + {shared?.agents ?? 0} + + Agents +
+
+
+
+
+
+ + {/* Right Column: Quick Commands */} +
+

Command Palette

+ +
- - {/* Quick Actions */} - - - Quick Actions - - - - - - - - - {/* Quick Commands */} - - - {/* Shared Data Summary */} - - - - - Shared Data - - - - -
-
- {shared?.commands ?? 0} - Commands -
-
- {shared?.skills ?? 0} - Skills -
-
- {shared?.agents ?? 0} - Agents -
-
-
-
); }