mirror of
https://github.com/tiennm99/ccs.git
synced 2026-09-20 05:11:48 +00:00
feat(ui): add comprehensive quality gates and fix linting issues
- Added ESLint, Prettier, TypeScript strict mode to ui/ directory - Fixed 40+ lint/format issues across ui codebase - Created extraction files for react-refresh compliance: - button-variants.ts for Button component styles - use-sidebar.ts, use-websocket-context.ts custom hooks - ws-context.ts WebSocket context provider - Fixed React hooks issues (setState ordering, dependency arrays) - Added ui:validate script to enforce quality checks - Updated husky pre-commit to validate UI files on change
This commit is contained in:
1 parent
59758024c9
commit
707af2f01a
59 files changed
+929
-2835
No files matched your search
+27
-27
@@ -1,53 +1,53 @@
|
||||
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'
|
||||
import { toast } from 'sonner'
|
||||
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
|
||||
import { toast } from 'sonner';
|
||||
|
||||
interface HealthCheck {
|
||||
id: string
|
||||
name: string
|
||||
status: 'ok' | 'warning' | 'error'
|
||||
message: string
|
||||
details?: string
|
||||
fixable?: boolean
|
||||
id: string;
|
||||
name: string;
|
||||
status: 'ok' | 'warning' | 'error';
|
||||
message: string;
|
||||
details?: string;
|
||||
fixable?: boolean;
|
||||
}
|
||||
|
||||
interface HealthReport {
|
||||
timestamp: number
|
||||
checks: HealthCheck[]
|
||||
timestamp: number;
|
||||
checks: HealthCheck[];
|
||||
summary: {
|
||||
total: number
|
||||
passed: number
|
||||
warnings: number
|
||||
errors: number
|
||||
}
|
||||
total: number;
|
||||
passed: number;
|
||||
warnings: number;
|
||||
errors: number;
|
||||
};
|
||||
}
|
||||
|
||||
export function useHealth() {
|
||||
return useQuery<HealthReport>({
|
||||
queryKey: ['health'],
|
||||
queryFn: async () => {
|
||||
const res = await fetch('/api/health')
|
||||
return res.json()
|
||||
const res = await fetch('/api/health');
|
||||
return res.json();
|
||||
},
|
||||
refetchInterval: 30000, // Auto-refresh every 30s
|
||||
})
|
||||
});
|
||||
}
|
||||
|
||||
export function useFixHealth() {
|
||||
const queryClient = useQueryClient()
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
return useMutation({
|
||||
mutationFn: async (checkId: string) => {
|
||||
const res = await fetch(`/api/health/fix/${checkId}`, { method: 'POST' })
|
||||
const data = await res.json()
|
||||
if (!res.ok) throw new Error(data.message)
|
||||
return data
|
||||
const res = await fetch(`/api/health/fix/${checkId}`, { method: 'POST' });
|
||||
const data = await res.json();
|
||||
if (!res.ok) throw new Error(data.message);
|
||||
return data;
|
||||
},
|
||||
onSuccess: (data: { message: string }) => {
|
||||
queryClient.invalidateQueries({ queryKey: ['health'] })
|
||||
toast.success(data.message)
|
||||
queryClient.invalidateQueries({ queryKey: ['health'] });
|
||||
toast.success(data.message);
|
||||
},
|
||||
onError: (error: Error) => {
|
||||
toast.error(error.message)
|
||||
toast.error(error.message);
|
||||
},
|
||||
})
|
||||
});
|
||||
}
|
||||
+11
-11
@@ -1,19 +1,19 @@
|
||||
import * as React from "react"
|
||||
import * as React from 'react';
|
||||
|
||||
const MOBILE_BREAKPOINT = 768
|
||||
const MOBILE_BREAKPOINT = 768;
|
||||
|
||||
export function useIsMobile() {
|
||||
const [isMobile, setIsMobile] = React.useState<boolean | undefined>(undefined)
|
||||
const [isMobile, setIsMobile] = React.useState<boolean | undefined>(undefined);
|
||||
|
||||
React.useEffect(() => {
|
||||
const mql = window.matchMedia(`(max-width: ${MOBILE_BREAKPOINT - 1}px)`)
|
||||
const mql = window.matchMedia(`(max-width: ${MOBILE_BREAKPOINT - 1}px)`);
|
||||
const onChange = () => {
|
||||
setIsMobile(window.innerWidth < MOBILE_BREAKPOINT)
|
||||
}
|
||||
mql.addEventListener("change", onChange)
|
||||
setIsMobile(window.innerWidth < MOBILE_BREAKPOINT)
|
||||
return () => mql.removeEventListener("change", onChange)
|
||||
}, [])
|
||||
setIsMobile(window.innerWidth < MOBILE_BREAKPOINT);
|
||||
};
|
||||
mql.addEventListener('change', onChange);
|
||||
setIsMobile(window.innerWidth < MOBILE_BREAKPOINT);
|
||||
return () => mql.removeEventListener('change', onChange);
|
||||
}, []);
|
||||
|
||||
return !!isMobile
|
||||
return !!isMobile;
|
||||
}
|
||||
@@ -1,22 +1,22 @@
|
||||
import { useQuery } from '@tanstack/react-query'
|
||||
import { useQuery } from '@tanstack/react-query';
|
||||
|
||||
interface Overview {
|
||||
profiles: number
|
||||
cliproxy: number
|
||||
accounts: number
|
||||
profiles: number;
|
||||
cliproxy: number;
|
||||
accounts: number;
|
||||
health: {
|
||||
status: 'ok' | 'warning' | 'error'
|
||||
passed: number
|
||||
total: number
|
||||
}
|
||||
status: 'ok' | 'warning' | 'error';
|
||||
passed: number;
|
||||
total: number;
|
||||
};
|
||||
}
|
||||
|
||||
export function useOverview() {
|
||||
return useQuery<Overview>({
|
||||
queryKey: ['overview'],
|
||||
queryFn: async () => {
|
||||
const res = await fetch('/api/overview')
|
||||
return res.json()
|
||||
const res = await fetch('/api/overview');
|
||||
return res.json();
|
||||
},
|
||||
})
|
||||
});
|
||||
}
|
||||
+16
-16
@@ -1,36 +1,36 @@
|
||||
import { useQuery } from '@tanstack/react-query'
|
||||
import { useQuery } from '@tanstack/react-query';
|
||||
|
||||
interface SharedItem {
|
||||
name: string
|
||||
description: string
|
||||
path: string
|
||||
type: 'command' | 'skill' | 'agent'
|
||||
name: string;
|
||||
description: string;
|
||||
path: string;
|
||||
type: 'command' | 'skill' | 'agent';
|
||||
}
|
||||
|
||||
interface SharedSummary {
|
||||
commands: number
|
||||
skills: number
|
||||
agents: number
|
||||
total: number
|
||||
symlinkStatus: { valid: boolean; message: string }
|
||||
commands: number;
|
||||
skills: number;
|
||||
agents: number;
|
||||
total: number;
|
||||
symlinkStatus: { valid: boolean; message: string };
|
||||
}
|
||||
|
||||
export function useSharedSummary() {
|
||||
return useQuery<SharedSummary>({
|
||||
queryKey: ['shared', 'summary'],
|
||||
queryFn: async () => {
|
||||
const res = await fetch('/api/shared/summary')
|
||||
return res.json()
|
||||
const res = await fetch('/api/shared/summary');
|
||||
return res.json();
|
||||
},
|
||||
})
|
||||
});
|
||||
}
|
||||
|
||||
export function useSharedItems(type: 'commands' | 'skills' | 'agents') {
|
||||
return useQuery<{ items: SharedItem[] }>({
|
||||
queryKey: ['shared', type],
|
||||
queryFn: async () => {
|
||||
const res = await fetch(`/api/shared/${type}`)
|
||||
return res.json()
|
||||
const res = await fetch(`/api/shared/${type}`);
|
||||
return res.json();
|
||||
},
|
||||
})
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,24 @@
|
||||
import * as React from 'react';
|
||||
|
||||
export type SidebarContextProps = {
|
||||
state: 'expanded' | 'collapsed';
|
||||
open: boolean;
|
||||
setOpen: (open: boolean) => void;
|
||||
openMobile: boolean;
|
||||
setOpenMobile: (open: boolean) => void;
|
||||
isMobile: boolean;
|
||||
toggleSidebar: () => void;
|
||||
};
|
||||
|
||||
const SidebarContext = React.createContext<SidebarContextProps | null>(null);
|
||||
|
||||
export function useSidebar() {
|
||||
const context = React.useContext(SidebarContext);
|
||||
if (!context) {
|
||||
throw new Error('useSidebar must be used within a SidebarProvider.');
|
||||
}
|
||||
|
||||
return context;
|
||||
}
|
||||
|
||||
export { SidebarContext };
|
||||
@@ -1,22 +1,23 @@
|
||||
import { useEffect, useState } from 'react';
|
||||
|
||||
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 function useTheme() {
|
||||
const [isDark, setIsDark] = useState(false);
|
||||
const [isDark, setIsDark] = useState(getInitialTheme);
|
||||
|
||||
useEffect(() => {
|
||||
const stored = localStorage.getItem('ccs-theme');
|
||||
const prefersDark = window.matchMedia('(prefers-color-scheme: dark)').matches;
|
||||
const isDarkMode = stored === 'dark' || (!stored && prefersDark);
|
||||
|
||||
setIsDark(isDarkMode);
|
||||
document.documentElement.classList.toggle('dark', isDarkMode);
|
||||
}, []);
|
||||
document.documentElement.classList.toggle('dark', isDark);
|
||||
}, [isDark]);
|
||||
|
||||
const toggle = () => {
|
||||
const newValue = !isDark;
|
||||
setIsDark(newValue);
|
||||
localStorage.setItem('ccs-theme', newValue ? 'dark' : 'light');
|
||||
document.documentElement.classList.toggle('dark', newValue);
|
||||
};
|
||||
|
||||
return { isDark, toggle };
|
||||
|
||||
@@ -0,0 +1,10 @@
|
||||
import { useContext } from 'react';
|
||||
import { WSContext } from '@/contexts/ws-context';
|
||||
|
||||
export function useWebSocketContext() {
|
||||
const context = useContext(WSContext);
|
||||
if (!context) {
|
||||
throw new Error('useWebSocketContext must be used within WebSocketProvider');
|
||||
}
|
||||
return context;
|
||||
}
|
||||
@@ -22,6 +22,42 @@ export function useWebSocket() {
|
||||
const queryClient = useQueryClient();
|
||||
const reconnectAttempts = useRef(0);
|
||||
const maxReconnectAttempts = 5;
|
||||
const reconnectTimeoutRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||
const connectRef = useRef<() => void>(() => {});
|
||||
|
||||
const handleMessage = useCallback(
|
||||
(message: WSMessage) => {
|
||||
switch (message.type) {
|
||||
case 'connected':
|
||||
console.log('[WS] Server acknowledged connection');
|
||||
break;
|
||||
|
||||
case 'config-changed':
|
||||
queryClient.invalidateQueries({ queryKey: ['profiles'] });
|
||||
queryClient.invalidateQueries({ queryKey: ['cliproxy'] });
|
||||
toast.info('Configuration updated externally');
|
||||
break;
|
||||
|
||||
case 'settings-changed':
|
||||
queryClient.invalidateQueries({ queryKey: ['profiles'] });
|
||||
toast.info('Settings file updated');
|
||||
break;
|
||||
|
||||
case 'profiles-changed':
|
||||
queryClient.invalidateQueries({ queryKey: ['accounts'] });
|
||||
toast.info('Accounts updated');
|
||||
break;
|
||||
|
||||
case 'pong':
|
||||
// Heartbeat response
|
||||
break;
|
||||
|
||||
default:
|
||||
console.log(`[WS] Unknown message: ${message.type}`);
|
||||
}
|
||||
},
|
||||
[queryClient]
|
||||
);
|
||||
|
||||
const connect = useCallback(() => {
|
||||
if (wsRef.current?.readyState === WebSocket.OPEN) {
|
||||
@@ -56,56 +92,36 @@ export function useWebSocket() {
|
||||
const delay = Math.min(1000 * Math.pow(2, reconnectAttempts.current), 30000);
|
||||
reconnectAttempts.current++;
|
||||
console.log(`[WS] Reconnecting in ${delay}ms (attempt ${reconnectAttempts.current})`);
|
||||
setTimeout(connect, delay);
|
||||
// Use ref to avoid stale closure
|
||||
reconnectTimeoutRef.current = setTimeout(() => {
|
||||
connectRef.current();
|
||||
}, delay);
|
||||
}
|
||||
};
|
||||
|
||||
ws.onerror = () => {
|
||||
console.log('[WS] Connection error');
|
||||
};
|
||||
}, []);
|
||||
}, [handleMessage]);
|
||||
|
||||
const handleMessage = (message: WSMessage) => {
|
||||
switch (message.type) {
|
||||
case 'connected':
|
||||
console.log('[WS] Server acknowledged connection');
|
||||
break;
|
||||
|
||||
case 'config-changed':
|
||||
queryClient.invalidateQueries({ queryKey: ['profiles'] });
|
||||
queryClient.invalidateQueries({ queryKey: ['cliproxy'] });
|
||||
toast.info('Configuration updated externally');
|
||||
break;
|
||||
|
||||
case 'settings-changed':
|
||||
queryClient.invalidateQueries({ queryKey: ['profiles'] });
|
||||
toast.info('Settings file updated');
|
||||
break;
|
||||
|
||||
case 'profiles-changed':
|
||||
queryClient.invalidateQueries({ queryKey: ['accounts'] });
|
||||
toast.info('Accounts updated');
|
||||
break;
|
||||
|
||||
case 'pong':
|
||||
// Heartbeat response
|
||||
break;
|
||||
|
||||
default:
|
||||
console.log(`[WS] Unknown message: ${message.type}`);
|
||||
}
|
||||
};
|
||||
// Keep ref in sync
|
||||
connectRef.current = connect;
|
||||
|
||||
const disconnect = useCallback(() => {
|
||||
reconnectAttempts.current = maxReconnectAttempts; // Prevent reconnect
|
||||
if (reconnectTimeoutRef.current) {
|
||||
clearTimeout(reconnectTimeoutRef.current);
|
||||
reconnectTimeoutRef.current = null;
|
||||
}
|
||||
wsRef.current?.close();
|
||||
}, []);
|
||||
|
||||
// Connect on mount, disconnect on unmount
|
||||
// Initial connection - use ref to satisfy linter
|
||||
useEffect(() => {
|
||||
connect();
|
||||
connectRef.current();
|
||||
return () => disconnect();
|
||||
}, [connect, disconnect]);
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, []);
|
||||
|
||||
// Heartbeat to keep connection alive
|
||||
useEffect(() => {
|
||||
|
||||
Reference in new issue
Block a user