initial wcag-ada

This commit is contained in:
Boki 2025-06-28 11:11:34 -04:00
parent 042b8cb83a
commit d52cfe7de2
112 changed files with 9069 additions and 0 deletions

View file

@ -0,0 +1,70 @@
import { useEffect } from 'react';
import { Routes, Route, Navigate } from 'react-router-dom';
import { useAuthStore } from '@/store/auth-store';
import { apiClient } from '@/lib/api-client';
import { MainLayout } from '@/components/layout/main-layout';
import { AuthLayout } from '@/components/layout/auth-layout';
import { ProtectedRoute } from '@/components/layout/protected-route';
// Pages
import { LoginPage } from '@/pages/auth/login';
import { RegisterPage } from '@/pages/auth/register';
import { DashboardPage } from '@/pages/dashboard';
import { WebsitesPage } from '@/pages/websites';
import { WebsiteDetailPage } from '@/pages/websites/[id]';
import { ScansPage } from '@/pages/scans';
import { ScanDetailPage } from '@/pages/scans/[id]';
import { ReportsPage } from '@/pages/reports';
import { SettingsPage } from '@/pages/settings';
function App() {
const { setAuth, setLoading, token } = useAuthStore();
useEffect(() => {
const initAuth = async () => {
if (!token) {
setLoading(false);
return;
}
try {
const user = await apiClient.getMe();
setAuth(user, token);
} catch (error) {
console.error('Auth check failed:', error);
setLoading(false);
}
};
initAuth();
}, [token, setAuth, setLoading]);
return (
<Routes>
{/* Auth routes */}
<Route element={<AuthLayout />}>
<Route path="/login" element={<LoginPage />} />
<Route path="/register" element={<RegisterPage />} />
</Route>
{/* Protected routes */}
<Route element={<ProtectedRoute />}>
<Route element={<MainLayout />}>
<Route path="/" element={<Navigate to="/dashboard" replace />} />
<Route path="/dashboard" element={<DashboardPage />} />
<Route path="/websites" element={<WebsitesPage />} />
<Route path="/websites/:id" element={<WebsiteDetailPage />} />
<Route path="/scans" element={<ScansPage />} />
<Route path="/scans/:id" element={<ScanDetailPage />} />
<Route path="/reports" element={<ReportsPage />} />
<Route path="/settings" element={<SettingsPage />} />
</Route>
</Route>
{/* 404 */}
<Route path="*" element={<Navigate to="/" replace />} />
</Routes>
);
}
export default App;

View file

@ -0,0 +1,26 @@
import { Navigate, Outlet } from 'react-router-dom';
import { useAuthStore } from '@/store/auth-store';
export function AuthLayout() {
const { isAuthenticated } = useAuthStore();
if (isAuthenticated) {
return <Navigate to="/dashboard" replace />;
}
return (
<div className="min-h-screen flex items-center justify-center bg-gray-50 dark:bg-gray-900 py-12 px-4 sm:px-6 lg:px-8">
<div className="max-w-md w-full space-y-8">
<div className="text-center">
<h1 className="text-3xl font-bold text-gray-900 dark:text-white">
WCAG-ADA Compliance
</h1>
<p className="mt-2 text-gray-600 dark:text-gray-400">
Automated accessibility testing and compliance monitoring
</p>
</div>
<Outlet />
</div>
</div>
);
}

View file

@ -0,0 +1,189 @@
import { Outlet, Link, useLocation } from 'react-router-dom';
import { cn } from '@/lib/utils';
import { Button } from '@/components/ui/button';
import { useAuthStore } from '@/store/auth-store';
import {
Home,
Globe,
Scan,
FileText,
Settings,
LogOut,
Menu,
X,
Shield,
} from 'lucide-react';
import { useState } from 'react';
const navigation = [
{ name: 'Dashboard', href: '/dashboard', icon: Home },
{ name: 'Websites', href: '/websites', icon: Globe },
{ name: 'Scans', href: '/scans', icon: Scan },
{ name: 'Reports', href: '/reports', icon: FileText },
{ name: 'Settings', href: '/settings', icon: Settings },
];
export function MainLayout() {
const location = useLocation();
const { user, logout } = useAuthStore();
const [sidebarOpen, setSidebarOpen] = useState(false);
return (
<div className="min-h-screen bg-gray-50 dark:bg-gray-900">
{/* Mobile sidebar */}
<div
className={cn(
'fixed inset-0 z-50 lg:hidden',
sidebarOpen ? 'block' : 'hidden'
)}
>
<div
className="fixed inset-0 bg-gray-900/80"
onClick={() => setSidebarOpen(false)}
/>
<div className="fixed inset-y-0 left-0 flex w-64 flex-col bg-white dark:bg-gray-800">
<div className="flex h-16 items-center justify-between px-6">
<div className="flex items-center gap-2">
<Shield className="h-8 w-8 text-primary" />
<span className="text-xl font-semibold">WCAG-ADA</span>
</div>
<Button
variant="ghost"
size="icon"
onClick={() => setSidebarOpen(false)}
>
<X className="h-5 w-5" />
</Button>
</div>
<nav className="flex-1 space-y-1 px-3 py-4">
{navigation.map((item) => {
const Icon = item.icon;
const isActive = location.pathname.startsWith(item.href);
return (
<Link
key={item.name}
to={item.href}
className={cn(
'flex items-center gap-3 rounded-md px-3 py-2 text-sm font-medium transition-colors',
isActive
? 'bg-primary text-primary-foreground'
: 'text-gray-700 hover:bg-gray-100 dark:text-gray-300 dark:hover:bg-gray-700'
)}
onClick={() => setSidebarOpen(false)}
>
<Icon className="h-5 w-5" />
{item.name}
</Link>
);
})}
</nav>
<div className="border-t p-4">
<div className="flex items-center gap-3 mb-3">
<div className="h-8 w-8 rounded-full bg-primary/10 flex items-center justify-center">
<span className="text-sm font-medium">
{user?.name?.[0] || user?.email[0].toUpperCase()}
</span>
</div>
<div className="flex-1 min-w-0">
<p className="text-sm font-medium truncate">
{user?.name || user?.email}
</p>
<p className="text-xs text-gray-500 dark:text-gray-400">
{user?.role}
</p>
</div>
</div>
<Button
variant="outline"
className="w-full justify-start"
onClick={logout}
>
<LogOut className="h-4 w-4 mr-2" />
Logout
</Button>
</div>
</div>
</div>
{/* Desktop sidebar */}
<div className="hidden lg:fixed lg:inset-y-0 lg:flex lg:w-64 lg:flex-col">
<div className="flex flex-1 flex-col bg-white dark:bg-gray-800 border-r">
<div className="flex h-16 items-center px-6">
<div className="flex items-center gap-2">
<Shield className="h-8 w-8 text-primary" />
<span className="text-xl font-semibold">WCAG-ADA</span>
</div>
</div>
<nav className="flex-1 space-y-1 px-3 py-4">
{navigation.map((item) => {
const Icon = item.icon;
const isActive = location.pathname.startsWith(item.href);
return (
<Link
key={item.name}
to={item.href}
className={cn(
'flex items-center gap-3 rounded-md px-3 py-2 text-sm font-medium transition-colors',
isActive
? 'bg-primary text-primary-foreground'
: 'text-gray-700 hover:bg-gray-100 dark:text-gray-300 dark:hover:bg-gray-700'
)}
>
<Icon className="h-5 w-5" />
{item.name}
</Link>
);
})}
</nav>
<div className="border-t p-4">
<div className="flex items-center gap-3 mb-3">
<div className="h-8 w-8 rounded-full bg-primary/10 flex items-center justify-center">
<span className="text-sm font-medium">
{user?.name?.[0] || user?.email[0].toUpperCase()}
</span>
</div>
<div className="flex-1 min-w-0">
<p className="text-sm font-medium truncate">
{user?.name || user?.email}
</p>
<p className="text-xs text-gray-500 dark:text-gray-400">
{user?.role}
</p>
</div>
</div>
<Button
variant="outline"
className="w-full justify-start"
onClick={logout}
>
<LogOut className="h-4 w-4 mr-2" />
Logout
</Button>
</div>
</div>
</div>
{/* Main content */}
<div className="lg:pl-64">
<div className="sticky top-0 z-40 flex h-16 items-center gap-4 border-b bg-white dark:bg-gray-800 px-4 sm:px-6 lg:px-8">
<Button
variant="ghost"
size="icon"
className="lg:hidden"
onClick={() => setSidebarOpen(true)}
>
<Menu className="h-5 w-5" />
</Button>
<h1 className="text-lg font-semibold">
{navigation.find((n) => location.pathname.startsWith(n.href))?.name || 'WCAG-ADA Compliance'}
</h1>
</div>
<main className="py-6">
<div className="px-4 sm:px-6 lg:px-8">
<Outlet />
</div>
</main>
</div>
</div>
);
}

View file

@ -0,0 +1,21 @@
import { Navigate, Outlet } from 'react-router-dom';
import { useAuthStore } from '@/store/auth-store';
import { Loader2 } from 'lucide-react';
export function ProtectedRoute() {
const { isAuthenticated, isLoading } = useAuthStore();
if (isLoading) {
return (
<div className="flex min-h-screen items-center justify-center">
<Loader2 className="h-8 w-8 animate-spin text-primary" />
</div>
);
}
if (!isAuthenticated) {
return <Navigate to="/login" replace />;
}
return <Outlet />;
}

View file

@ -0,0 +1,55 @@
import * as React from 'react';
import { Slot } from '@radix-ui/react-slot';
import { cva, type VariantProps } from 'class-variance-authority';
import { cn } from '@/lib/utils';
const buttonVariants = cva(
'inline-flex items-center justify-center whitespace-nowrap rounded-md text-sm font-medium ring-offset-background transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:pointer-events-none disabled:opacity-50',
{
variants: {
variant: {
default: 'bg-primary text-primary-foreground hover:bg-primary/90',
destructive:
'bg-destructive text-destructive-foreground hover:bg-destructive/90',
outline:
'border border-input bg-background hover:bg-accent hover:text-accent-foreground',
secondary:
'bg-secondary text-secondary-foreground hover:bg-secondary/80',
ghost: 'hover:bg-accent hover:text-accent-foreground',
link: 'text-primary underline-offset-4 hover:underline',
},
size: {
default: 'h-10 px-4 py-2',
sm: 'h-9 rounded-md px-3',
lg: 'h-11 rounded-md px-8',
icon: 'h-10 w-10',
},
},
defaultVariants: {
variant: 'default',
size: 'default',
},
}
);
export interface ButtonProps
extends React.ButtonHTMLAttributes<HTMLButtonElement>,
VariantProps<typeof buttonVariants> {
asChild?: boolean;
}
const Button = React.forwardRef<HTMLButtonElement, ButtonProps>(
({ className, variant, size, asChild = false, ...props }, ref) => {
const Comp = asChild ? Slot : 'button';
return (
<Comp
className={cn(buttonVariants({ variant, size, className }))}
ref={ref}
{...props}
/>
);
}
);
Button.displayName = 'Button';
export { Button, buttonVariants };

View file

@ -0,0 +1,78 @@
import * as React from 'react';
import { cn } from '@/lib/utils';
const Card = React.forwardRef<
HTMLDivElement,
React.HTMLAttributes<HTMLDivElement>
>(({ className, ...props }, ref) => (
<div
ref={ref}
className={cn(
'rounded-lg border bg-card text-card-foreground shadow-sm',
className
)}
{...props}
/>
));
Card.displayName = 'Card';
const CardHeader = React.forwardRef<
HTMLDivElement,
React.HTMLAttributes<HTMLDivElement>
>(({ className, ...props }, ref) => (
<div
ref={ref}
className={cn('flex flex-col space-y-1.5 p-6', className)}
{...props}
/>
));
CardHeader.displayName = 'CardHeader';
const CardTitle = React.forwardRef<
HTMLParagraphElement,
React.HTMLAttributes<HTMLHeadingElement>
>(({ className, ...props }, ref) => (
<h3
ref={ref}
className={cn(
'text-2xl font-semibold leading-none tracking-tight',
className
)}
{...props}
/>
));
CardTitle.displayName = 'CardTitle';
const CardDescription = React.forwardRef<
HTMLParagraphElement,
React.HTMLAttributes<HTMLParagraphElement>
>(({ className, ...props }, ref) => (
<p
ref={ref}
className={cn('text-sm text-muted-foreground', className)}
{...props}
/>
));
CardDescription.displayName = 'CardDescription';
const CardContent = React.forwardRef<
HTMLDivElement,
React.HTMLAttributes<HTMLDivElement>
>(({ className, ...props }, ref) => (
<div ref={ref} className={cn('p-6 pt-0', className)} {...props} />
));
CardContent.displayName = 'CardContent';
const CardFooter = React.forwardRef<
HTMLDivElement,
React.HTMLAttributes<HTMLDivElement>
>(({ className, ...props }, ref) => (
<div
ref={ref}
className={cn('flex items-center p-6 pt-0', className)}
{...props}
/>
));
CardFooter.displayName = 'CardFooter';
export { Card, CardHeader, CardFooter, CardTitle, CardDescription, CardContent };

View file

@ -0,0 +1,167 @@
import axios, { AxiosError, AxiosInstance } from 'axios';
import { useAuthStore } from '@/store/auth-store';
class ApiClient {
private client: AxiosInstance;
constructor() {
this.client = axios.create({
baseURL: '/api',
headers: {
'Content-Type': 'application/json',
},
});
// Request interceptor to add auth token
this.client.interceptors.request.use(
(config) => {
const token = useAuthStore.getState().token;
if (token) {
config.headers.Authorization = `Bearer ${token}`;
}
return config;
},
(error) => Promise.reject(error)
);
// Response interceptor to handle errors
this.client.interceptors.response.use(
(response) => response,
async (error: AxiosError) => {
if (error.response?.status === 401) {
// Token expired or invalid
useAuthStore.getState().logout();
window.location.href = '/login';
}
return Promise.reject(error);
}
);
}
// Auth endpoints
async login(email: string, password: string) {
const response = await this.client.post('/auth/login', { email, password });
return response.data;
}
async register(data: { email: string; password: string; name?: string; company?: string }) {
const response = await this.client.post('/auth/register', data);
return response.data;
}
async getMe() {
const response = await this.client.get('/auth/me');
return response.data;
}
async refreshApiKey() {
const response = await this.client.post('/auth/refresh-api-key');
return response.data;
}
// Website endpoints
async getWebsites(params?: { page?: number; limit?: number; search?: string }) {
const response = await this.client.get('/websites', { params });
return response.data;
}
async getWebsite(id: string) {
const response = await this.client.get(`/websites/${id}`);
return response.data;
}
async createWebsite(data: any) {
const response = await this.client.post('/websites', data);
return response.data;
}
async updateWebsite(id: string, data: any) {
const response = await this.client.patch(`/websites/${id}`, data);
return response.data;
}
async deleteWebsite(id: string) {
const response = await this.client.delete(`/websites/${id}`);
return response.data;
}
async getWebsiteScans(id: string, params?: { page?: number; limit?: number }) {
const response = await this.client.get(`/websites/${id}/scans`, { params });
return response.data;
}
// Scan endpoints
async createScan(data: { websiteId: string; url?: string; options?: any }) {
const response = await this.client.post('/scans', data);
return response.data;
}
async getScan(id: string) {
const response = await this.client.get(`/scans/${id}`);
return response.data;
}
async getScanResult(id: string) {
const response = await this.client.get(`/scans/${id}/result`);
return response.data;
}
async getScanViolations(id: string, params?: { impact?: string; tag?: string }) {
const response = await this.client.get(`/scans/${id}/violations`, { params });
return response.data;
}
async cancelScan(id: string) {
const response = await this.client.delete(`/scans/${id}`);
return response.data;
}
async getScans(params?: { page?: number; limit?: number; status?: string; websiteId?: string }) {
const response = await this.client.get('/scans', { params });
return response.data;
}
// Report endpoints
async generateReport(data: {
websiteId: string;
type: 'COMPLIANCE' | 'EXECUTIVE' | 'TECHNICAL' | 'TREND';
format: 'PDF' | 'HTML' | 'JSON' | 'CSV';
period: { start: string; end: string };
}) {
const response = await this.client.post('/reports/generate', data);
return response.data;
}
async getReports(params?: { page?: number; limit?: number; websiteId?: string; type?: string }) {
const response = await this.client.get('/reports', { params });
return response.data;
}
async getReport(id: string) {
const response = await this.client.get(`/reports/${id}`);
return response.data;
}
async downloadReport(id: string) {
const response = await this.client.get(`/reports/${id}/download`);
return response.data;
}
async getComplianceTrends(websiteId: string, days: number = 30) {
const response = await this.client.get(`/reports/trends/${websiteId}`, { params: { days } });
return response.data;
}
// Health endpoints
async getHealth() {
const response = await this.client.get('/health');
return response.data;
}
async getStats() {
const response = await this.client.get('/health/stats');
return response.data;
}
}
export const apiClient = new ApiClient();

View file

@ -0,0 +1,6 @@
import { type ClassValue, clsx } from 'clsx';
import { twMerge } from 'tailwind-merge';
export function cn(...inputs: ClassValue[]) {
return twMerge(clsx(inputs));
}

View file

@ -0,0 +1,27 @@
import React from 'react';
import ReactDOM from 'react-dom/client';
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
import { ReactQueryDevtools } from '@tanstack/react-query-devtools';
import { BrowserRouter } from 'react-router-dom';
import App from './App';
import './styles/globals.css';
const queryClient = new QueryClient({
defaultOptions: {
queries: {
staleTime: 1000 * 60 * 5, // 5 minutes
retry: 1,
},
},
});
ReactDOM.createRoot(document.getElementById('root')!).render(
<React.StrictMode>
<QueryClientProvider client={queryClient}>
<BrowserRouter>
<App />
</BrowserRouter>
<ReactQueryDevtools initialIsOpen={false} />
</QueryClientProvider>
</React.StrictMode>
);

View file

@ -0,0 +1,112 @@
import { useState } from 'react';
import { useNavigate, Link } from 'react-router-dom';
import { useForm } from 'react-hook-form';
import { zodResolver } from '@hookform/resolvers/zod';
import { z } from 'zod';
import { apiClient } from '@/lib/api-client';
import { useAuthStore } from '@/store/auth-store';
import { Button } from '@/components/ui/button';
import { Loader2 } from 'lucide-react';
const loginSchema = z.object({
email: z.string().email('Invalid email address'),
password: z.string().min(8, 'Password must be at least 8 characters'),
});
type LoginForm = z.infer<typeof loginSchema>;
export function LoginPage() {
const navigate = useNavigate();
const { setAuth } = useAuthStore();
const [isLoading, setIsLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
const {
register,
handleSubmit,
formState: { errors },
} = useForm<LoginForm>({
resolver: zodResolver(loginSchema),
});
const onSubmit = async (data: LoginForm) => {
setIsLoading(true);
setError(null);
try {
const response = await apiClient.login(data.email, data.password);
setAuth(response.user, response.token);
navigate('/dashboard');
} catch (err: any) {
setError(err.response?.data?.error || 'Login failed. Please try again.');
} finally {
setIsLoading(false);
}
};
return (
<form className="mt-8 space-y-6" onSubmit={handleSubmit(onSubmit)}>
<div className="rounded-md shadow-sm -space-y-px">
<div>
<label htmlFor="email" className="sr-only">
Email address
</label>
<input
{...register('email')}
type="email"
autoComplete="email"
className="appearance-none rounded-none relative block w-full px-3 py-2 border border-gray-300 placeholder-gray-500 text-gray-900 rounded-t-md focus:outline-none focus:ring-primary focus:border-primary focus:z-10 sm:text-sm dark:bg-gray-700 dark:border-gray-600 dark:text-white"
placeholder="Email address"
/>
{errors.email && (
<p className="mt-1 text-sm text-red-600">{errors.email.message}</p>
)}
</div>
<div>
<label htmlFor="password" className="sr-only">
Password
</label>
<input
{...register('password')}
type="password"
autoComplete="current-password"
className="appearance-none rounded-none relative block w-full px-3 py-2 border border-gray-300 placeholder-gray-500 text-gray-900 rounded-b-md focus:outline-none focus:ring-primary focus:border-primary focus:z-10 sm:text-sm dark:bg-gray-700 dark:border-gray-600 dark:text-white"
placeholder="Password"
/>
{errors.password && (
<p className="mt-1 text-sm text-red-600">{errors.password.message}</p>
)}
</div>
</div>
{error && (
<div className="rounded-md bg-red-50 dark:bg-red-900/20 p-4">
<p className="text-sm text-red-800 dark:text-red-400">{error}</p>
</div>
)}
<div>
<Button
type="submit"
className="w-full"
disabled={isLoading}
>
{isLoading && <Loader2 className="mr-2 h-4 w-4 animate-spin" />}
Sign in
</Button>
</div>
<div className="text-center">
<span className="text-sm text-gray-600 dark:text-gray-400">
Don't have an account?{' '}
<Link
to="/register"
className="font-medium text-primary hover:text-primary/80"
>
Sign up
</Link>
</span>
</div>
</form>
);
}

View file

@ -0,0 +1,141 @@
import { useState } from 'react';
import { useNavigate, Link } from 'react-router-dom';
import { useForm } from 'react-hook-form';
import { zodResolver } from '@hookform/resolvers/zod';
import { z } from 'zod';
import { apiClient } from '@/lib/api-client';
import { useAuthStore } from '@/store/auth-store';
import { Button } from '@/components/ui/button';
import { Loader2 } from 'lucide-react';
const registerSchema = z.object({
email: z.string().email('Invalid email address'),
password: z.string().min(8, 'Password must be at least 8 characters'),
name: z.string().optional(),
company: z.string().optional(),
});
type RegisterForm = z.infer<typeof registerSchema>;
export function RegisterPage() {
const navigate = useNavigate();
const { setAuth } = useAuthStore();
const [isLoading, setIsLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
const {
register,
handleSubmit,
formState: { errors },
} = useForm<RegisterForm>({
resolver: zodResolver(registerSchema),
});
const onSubmit = async (data: RegisterForm) => {
setIsLoading(true);
setError(null);
try {
const response = await apiClient.register(data);
setAuth(response.user, response.token);
navigate('/dashboard');
} catch (err: any) {
setError(err.response?.data?.error || 'Registration failed. Please try again.');
} finally {
setIsLoading(false);
}
};
return (
<form className="mt-8 space-y-6" onSubmit={handleSubmit(onSubmit)}>
<div className="space-y-4">
<div>
<label htmlFor="email" className="block text-sm font-medium text-gray-700 dark:text-gray-300">
Email address
</label>
<input
{...register('email')}
type="email"
autoComplete="email"
className="mt-1 appearance-none relative block w-full px-3 py-2 border border-gray-300 rounded-md placeholder-gray-500 text-gray-900 focus:outline-none focus:ring-primary focus:border-primary focus:z-10 sm:text-sm dark:bg-gray-700 dark:border-gray-600 dark:text-white"
placeholder="Email address"
/>
{errors.email && (
<p className="mt-1 text-sm text-red-600">{errors.email.message}</p>
)}
</div>
<div>
<label htmlFor="password" className="block text-sm font-medium text-gray-700 dark:text-gray-300">
Password
</label>
<input
{...register('password')}
type="password"
autoComplete="new-password"
className="mt-1 appearance-none relative block w-full px-3 py-2 border border-gray-300 rounded-md placeholder-gray-500 text-gray-900 focus:outline-none focus:ring-primary focus:border-primary focus:z-10 sm:text-sm dark:bg-gray-700 dark:border-gray-600 dark:text-white"
placeholder="Password"
/>
{errors.password && (
<p className="mt-1 text-sm text-red-600">{errors.password.message}</p>
)}
</div>
<div>
<label htmlFor="name" className="block text-sm font-medium text-gray-700 dark:text-gray-300">
Name (optional)
</label>
<input
{...register('name')}
type="text"
autoComplete="name"
className="mt-1 appearance-none relative block w-full px-3 py-2 border border-gray-300 rounded-md placeholder-gray-500 text-gray-900 focus:outline-none focus:ring-primary focus:border-primary focus:z-10 sm:text-sm dark:bg-gray-700 dark:border-gray-600 dark:text-white"
placeholder="Your name"
/>
</div>
<div>
<label htmlFor="company" className="block text-sm font-medium text-gray-700 dark:text-gray-300">
Company (optional)
</label>
<input
{...register('company')}
type="text"
autoComplete="organization"
className="mt-1 appearance-none relative block w-full px-3 py-2 border border-gray-300 rounded-md placeholder-gray-500 text-gray-900 focus:outline-none focus:ring-primary focus:border-primary focus:z-10 sm:text-sm dark:bg-gray-700 dark:border-gray-600 dark:text-white"
placeholder="Your company"
/>
</div>
</div>
{error && (
<div className="rounded-md bg-red-50 dark:bg-red-900/20 p-4">
<p className="text-sm text-red-800 dark:text-red-400">{error}</p>
</div>
)}
<div>
<Button
type="submit"
className="w-full"
disabled={isLoading}
>
{isLoading && <Loader2 className="mr-2 h-4 w-4 animate-spin" />}
Create account
</Button>
</div>
<div className="text-center">
<span className="text-sm text-gray-600 dark:text-gray-400">
Already have an account?{' '}
<Link
to="/login"
className="font-medium text-primary hover:text-primary/80"
>
Sign in
</Link>
</span>
</div>
</form>
);
}

View file

@ -0,0 +1,243 @@
import { useQuery } from '@tanstack/react-query';
import { apiClient } from '@/lib/api-client';
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card';
import { Loader2, Globe, Scan, AlertCircle, CheckCircle2, TrendingUp, TrendingDown } from 'lucide-react';
import { Link } from 'react-router-dom';
import { Button } from '@/components/ui/button';
import { LineChart, Line, AreaChart, Area, BarChart, Bar, XAxis, YAxis, CartesianGrid, Tooltip, ResponsiveContainer } from 'recharts';
import { format } from 'date-fns';
export function DashboardPage() {
const { data: stats, isLoading: statsLoading } = useQuery({
queryKey: ['stats'],
queryFn: () => apiClient.getStats(),
});
const { data: recentScans } = useQuery({
queryKey: ['recent-scans'],
queryFn: () => apiClient.getScans({ limit: 5 }),
});
const { data: websites } = useQuery({
queryKey: ['websites-summary'],
queryFn: () => apiClient.getWebsites({ limit: 5 }),
});
if (statsLoading) {
return (
<div className="flex items-center justify-center h-64">
<Loader2 className="h-8 w-8 animate-spin text-primary" />
</div>
);
}
return (
<div className="space-y-6">
{/* Stats Grid */}
<div className="grid gap-4 md:grid-cols-2 lg:grid-cols-4">
<Card>
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
<CardTitle className="text-sm font-medium">Total Websites</CardTitle>
<Globe className="h-4 w-4 text-muted-foreground" />
</CardHeader>
<CardContent>
<div className="text-2xl font-bold">{stats?.database?.websites || 0}</div>
<p className="text-xs text-muted-foreground">
Active websites being monitored
</p>
</CardContent>
</Card>
<Card>
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
<CardTitle className="text-sm font-medium">Total Scans</CardTitle>
<Scan className="h-4 w-4 text-muted-foreground" />
</CardHeader>
<CardContent>
<div className="text-2xl font-bold">{stats?.database?.scanResults || 0}</div>
<p className="text-xs text-muted-foreground">
Accessibility scans performed
</p>
</CardContent>
</Card>
<Card>
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
<CardTitle className="text-sm font-medium">Queue Status</CardTitle>
<AlertCircle className="h-4 w-4 text-muted-foreground" />
</CardHeader>
<CardContent>
<div className="text-2xl font-bold">{stats?.queue?.active || 0}</div>
<p className="text-xs text-muted-foreground">
Active scans in progress
</p>
</CardContent>
</Card>
<Card>
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
<CardTitle className="text-sm font-medium">Success Rate</CardTitle>
<CheckCircle2 className="h-4 w-4 text-muted-foreground" />
</CardHeader>
<CardContent>
<div className="text-2xl font-bold">
{stats?.queue?.completed && stats?.queue?.failed
? Math.round((stats.queue.completed / (stats.queue.completed + stats.queue.failed)) * 100)
: 100}%
</div>
<p className="text-xs text-muted-foreground">
Scan completion rate
</p>
</CardContent>
</Card>
</div>
{/* Charts Row */}
<div className="grid gap-4 md:grid-cols-2">
<Card>
<CardHeader>
<CardTitle>Compliance Trends</CardTitle>
<CardDescription>Average compliance scores over time</CardDescription>
</CardHeader>
<CardContent>
<div className="h-[300px]">
<ResponsiveContainer width="100%" height="100%">
<LineChart
data={[
{ date: 'Mon', score: 85 },
{ date: 'Tue', score: 87 },
{ date: 'Wed', score: 86 },
{ date: 'Thu', score: 89 },
{ date: 'Fri', score: 91 },
{ date: 'Sat', score: 90 },
{ date: 'Sun', score: 92 },
]}
>
<CartesianGrid strokeDasharray="3 3" />
<XAxis dataKey="date" />
<YAxis domain={[0, 100]} />
<Tooltip />
<Line
type="monotone"
dataKey="score"
stroke="hsl(var(--primary))"
strokeWidth={2}
/>
</LineChart>
</ResponsiveContainer>
</div>
</CardContent>
</Card>
<Card>
<CardHeader>
<CardTitle>Issue Distribution</CardTitle>
<CardDescription>Violations by severity level</CardDescription>
</CardHeader>
<CardContent>
<div className="h-[300px]">
<ResponsiveContainer width="100%" height="100%">
<BarChart
data={[
{ severity: 'Critical', count: 5, fill: 'hsl(var(--destructive))' },
{ severity: 'Serious', count: 12, fill: 'hsl(var(--warning))' },
{ severity: 'Moderate', count: 25, fill: 'hsl(var(--primary))' },
{ severity: 'Minor', count: 45, fill: 'hsl(var(--muted))' },
]}
>
<CartesianGrid strokeDasharray="3 3" />
<XAxis dataKey="severity" />
<YAxis />
<Tooltip />
<Bar dataKey="count" />
</BarChart>
</ResponsiveContainer>
</div>
</CardContent>
</Card>
</div>
{/* Recent Activity */}
<div className="grid gap-4 md:grid-cols-2">
<Card>
<CardHeader className="flex flex-row items-center justify-between">
<div>
<CardTitle>Recent Scans</CardTitle>
<CardDescription>Latest accessibility scan results</CardDescription>
</div>
<Button variant="outline" size="sm" asChild>
<Link to="/scans">View all</Link>
</Button>
</CardHeader>
<CardContent>
<div className="space-y-4">
{recentScans?.scans?.map((scan: any) => (
<div key={scan.id} className="flex items-center justify-between">
<div className="flex-1">
<p className="text-sm font-medium">{scan.website?.name}</p>
<p className="text-xs text-muted-foreground">
{format(new Date(scan.createdAt), 'MMM d, h:mm a')}
</p>
</div>
<div className="flex items-center gap-2">
{scan.status === 'COMPLETED' ? (
<div className="flex items-center gap-1">
<span className="text-sm font-medium">
{scan.result?.summary?.score || 0}%
</span>
{scan.result?.summary?.score > 90 ? (
<TrendingUp className="h-4 w-4 text-success" />
) : (
<TrendingDown className="h-4 w-4 text-destructive" />
)}
</div>
) : (
<span className="text-xs text-muted-foreground">
{scan.status}
</span>
)}
</div>
</div>
))}
</div>
</CardContent>
</Card>
<Card>
<CardHeader className="flex flex-row items-center justify-between">
<div>
<CardTitle>Websites</CardTitle>
<CardDescription>Your monitored websites</CardDescription>
</div>
<Button variant="outline" size="sm" asChild>
<Link to="/websites">Manage</Link>
</Button>
</CardHeader>
<CardContent>
<div className="space-y-4">
{websites?.websites?.map((website: any) => (
<div key={website.id} className="flex items-center justify-between">
<div className="flex-1">
<p className="text-sm font-medium">{website.name}</p>
<p className="text-xs text-muted-foreground">{website.url}</p>
</div>
<div className="flex items-center gap-2">
{website.complianceScore !== null ? (
<span className="text-sm font-medium">
{Math.round(website.complianceScore)}%
</span>
) : (
<span className="text-xs text-muted-foreground">
No scans yet
</span>
)}
</div>
</div>
))}
</div>
</CardContent>
</Card>
</div>
</div>
);
}

View file

@ -0,0 +1,8 @@
export function ReportsPage() {
return (
<div>
<h1 className="text-2xl font-bold">Reports</h1>
<p className="text-muted-foreground">Compliance reports and analytics coming soon...</p>
</div>
);
}

View file

@ -0,0 +1,8 @@
export function ScanDetailPage() {
return (
<div>
<h1 className="text-2xl font-bold">Scan Results</h1>
<p className="text-muted-foreground">Scan detail page coming soon...</p>
</div>
);
}

View file

@ -0,0 +1,8 @@
export function ScansPage() {
return (
<div>
<h1 className="text-2xl font-bold">Scans</h1>
<p className="text-muted-foreground">Scan history and management coming soon...</p>
</div>
);
}

View file

@ -0,0 +1,8 @@
export function SettingsPage() {
return (
<div>
<h1 className="text-2xl font-bold">Settings</h1>
<p className="text-muted-foreground">Account settings and preferences coming soon...</p>
</div>
);
}

View file

@ -0,0 +1,8 @@
export function WebsiteDetailPage() {
return (
<div>
<h1 className="text-2xl font-bold">Website Details</h1>
<p className="text-muted-foreground">Website detail page coming soon...</p>
</div>
);
}

View file

@ -0,0 +1,88 @@
import { useState } from 'react';
import { useQuery } from '@tanstack/react-query';
import { apiClient } from '@/lib/api-client';
import { Button } from '@/components/ui/button';
import { Card } from '@/components/ui/card';
import { Plus, Globe, ExternalLink, MoreVertical } from 'lucide-react';
import { Link } from 'react-router-dom';
import { format } from 'date-fns';
export function WebsitesPage() {
const [page, setPage] = useState(1);
const { data, isLoading } = useQuery({
queryKey: ['websites', page],
queryFn: () => apiClient.getWebsites({ page, limit: 12 }),
});
return (
<div className="space-y-6">
<div className="flex items-center justify-between">
<div>
<h1 className="text-2xl font-bold">Websites</h1>
<p className="text-muted-foreground">
Manage your monitored websites and their scan schedules
</p>
</div>
<Button>
<Plus className="h-4 w-4 mr-2" />
Add Website
</Button>
</div>
{isLoading ? (
<div className="grid gap-4 md:grid-cols-2 lg:grid-cols-3">
{[...Array(6)].map((_, i) => (
<Card key={i} className="h-48 animate-pulse bg-muted" />
))}
</div>
) : (
<div className="grid gap-4 md:grid-cols-2 lg:grid-cols-3">
{data?.websites?.map((website: any) => (
<Card key={website.id} className="p-6">
<div className="flex items-start justify-between">
<Globe className="h-5 w-5 text-muted-foreground" />
<Button variant="ghost" size="icon">
<MoreVertical className="h-4 w-4" />
</Button>
</div>
<div className="mt-4">
<h3 className="font-semibold">{website.name}</h3>
<a
href={website.url}
target="_blank"
rel="noopener noreferrer"
className="text-sm text-muted-foreground hover:text-primary flex items-center gap-1 mt-1"
>
{website.url}
<ExternalLink className="h-3 w-3" />
</a>
</div>
<div className="mt-4 flex items-center justify-between">
<div>
{website.complianceScore !== null ? (
<div>
<span className="text-2xl font-bold">
{Math.round(website.complianceScore)}%
</span>
<p className="text-xs text-muted-foreground">Compliance</p>
</div>
) : (
<p className="text-sm text-muted-foreground">No scans yet</p>
)}
</div>
<Button variant="outline" size="sm" asChild>
<Link to={`/websites/${website.id}`}>View Details</Link>
</Button>
</div>
{website.lastScanAt && (
<p className="text-xs text-muted-foreground mt-2">
Last scan: {format(new Date(website.lastScanAt), 'MMM d, h:mm a')}
</p>
)}
</Card>
))}
</div>
)}
</div>
);
}

View file

@ -0,0 +1,55 @@
import { create } from 'zustand';
import { persist } from 'zustand/middleware';
interface User {
id: string;
email: string;
name?: string;
company?: string;
role: string;
apiKey?: string;
}
interface AuthState {
user: User | null;
token: string | null;
isAuthenticated: boolean;
isLoading: boolean;
setAuth: (user: User, token: string) => void;
setUser: (user: User) => void;
setLoading: (loading: boolean) => void;
logout: () => void;
}
export const useAuthStore = create<AuthState>()(
persist(
(set) => ({
user: null,
token: null,
isAuthenticated: false,
isLoading: true,
setAuth: (user, token) =>
set({
user,
token,
isAuthenticated: true,
isLoading: false,
}),
setUser: (user) => set({ user }),
setLoading: (loading) => set({ isLoading: loading }),
logout: () =>
set({
user: null,
token: null,
isAuthenticated: false,
isLoading: false,
}),
}),
{
name: 'wcag-auth',
partialize: (state) => ({
token: state.token,
}),
}
)
);

View file

@ -0,0 +1,67 @@
@tailwind base;
@tailwind components;
@tailwind utilities;
@layer base {
:root {
--background: 0 0% 100%;
--foreground: 222.2 84% 4.9%;
--card: 0 0% 100%;
--card-foreground: 222.2 84% 4.9%;
--popover: 0 0% 100%;
--popover-foreground: 222.2 84% 4.9%;
--primary: 221.2 83.2% 53.3%;
--primary-foreground: 210 40% 98%;
--secondary: 210 40% 96.1%;
--secondary-foreground: 222.2 47.4% 11.2%;
--muted: 210 40% 96.1%;
--muted-foreground: 215.4 16.3% 46.9%;
--accent: 210 40% 96.1%;
--accent-foreground: 222.2 47.4% 11.2%;
--destructive: 0 84.2% 60.2%;
--destructive-foreground: 210 40% 98%;
--success: 142.1 76.2% 36.3%;
--success-foreground: 355.7 100% 97.3%;
--warning: 47.9 95.8% 53.1%;
--warning-foreground: 26 83.3% 14.1%;
--border: 214.3 31.8% 91.4%;
--input: 214.3 31.8% 91.4%;
--ring: 221.2 83.2% 53.3%;
--radius: 0.5rem;
}
.dark {
--background: 222.2 84% 4.9%;
--foreground: 210 40% 98%;
--card: 222.2 84% 4.9%;
--card-foreground: 210 40% 98%;
--popover: 222.2 84% 4.9%;
--popover-foreground: 210 40% 98%;
--primary: 217.2 91.2% 59.8%;
--primary-foreground: 222.2 47.4% 11.2%;
--secondary: 217.2 32.6% 17.5%;
--secondary-foreground: 210 40% 98%;
--muted: 217.2 32.6% 17.5%;
--muted-foreground: 215 20.2% 65.1%;
--accent: 217.2 32.6% 17.5%;
--accent-foreground: 210 40% 98%;
--destructive: 0 62.8% 30.6%;
--destructive-foreground: 210 40% 98%;
--success: 142.1 70.6% 45.3%;
--success-foreground: 144.9 80.4% 10%;
--warning: 47.9 80% 60%;
--warning-foreground: 47.5 90% 10%;
--border: 217.2 32.6% 17.5%;
--input: 217.2 32.6% 17.5%;
--ring: 224.3 76.3% 48%;
}
}
@layer base {
* {
@apply border-border;
}
body {
@apply bg-background text-foreground;
}
}