initial wcag-ada
This commit is contained in:
parent
042b8cb83a
commit
d52cfe7de2
112 changed files with 9069 additions and 0 deletions
70
apps/wcag-ada/dashboard/src/App.tsx
Normal file
70
apps/wcag-ada/dashboard/src/App.tsx
Normal 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;
|
||||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
189
apps/wcag-ada/dashboard/src/components/layout/main-layout.tsx
Normal file
189
apps/wcag-ada/dashboard/src/components/layout/main-layout.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
|
|
@ -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 />;
|
||||
}
|
||||
55
apps/wcag-ada/dashboard/src/components/ui/button.tsx
Normal file
55
apps/wcag-ada/dashboard/src/components/ui/button.tsx
Normal 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 };
|
||||
78
apps/wcag-ada/dashboard/src/components/ui/card.tsx
Normal file
78
apps/wcag-ada/dashboard/src/components/ui/card.tsx
Normal 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 };
|
||||
167
apps/wcag-ada/dashboard/src/lib/api-client.ts
Normal file
167
apps/wcag-ada/dashboard/src/lib/api-client.ts
Normal 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();
|
||||
6
apps/wcag-ada/dashboard/src/lib/utils.ts
Normal file
6
apps/wcag-ada/dashboard/src/lib/utils.ts
Normal 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));
|
||||
}
|
||||
27
apps/wcag-ada/dashboard/src/main.tsx
Normal file
27
apps/wcag-ada/dashboard/src/main.tsx
Normal 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>
|
||||
);
|
||||
112
apps/wcag-ada/dashboard/src/pages/auth/login.tsx
Normal file
112
apps/wcag-ada/dashboard/src/pages/auth/login.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
141
apps/wcag-ada/dashboard/src/pages/auth/register.tsx
Normal file
141
apps/wcag-ada/dashboard/src/pages/auth/register.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
243
apps/wcag-ada/dashboard/src/pages/dashboard/index.tsx
Normal file
243
apps/wcag-ada/dashboard/src/pages/dashboard/index.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
8
apps/wcag-ada/dashboard/src/pages/reports/index.tsx
Normal file
8
apps/wcag-ada/dashboard/src/pages/reports/index.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
8
apps/wcag-ada/dashboard/src/pages/scans/[id].tsx
Normal file
8
apps/wcag-ada/dashboard/src/pages/scans/[id].tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
8
apps/wcag-ada/dashboard/src/pages/scans/index.tsx
Normal file
8
apps/wcag-ada/dashboard/src/pages/scans/index.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
8
apps/wcag-ada/dashboard/src/pages/settings/index.tsx
Normal file
8
apps/wcag-ada/dashboard/src/pages/settings/index.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
8
apps/wcag-ada/dashboard/src/pages/websites/[id].tsx
Normal file
8
apps/wcag-ada/dashboard/src/pages/websites/[id].tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
88
apps/wcag-ada/dashboard/src/pages/websites/index.tsx
Normal file
88
apps/wcag-ada/dashboard/src/pages/websites/index.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
55
apps/wcag-ada/dashboard/src/store/auth-store.ts
Normal file
55
apps/wcag-ada/dashboard/src/store/auth-store.ts
Normal 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,
|
||||
}),
|
||||
}
|
||||
)
|
||||
);
|
||||
67
apps/wcag-ada/dashboard/src/styles/globals.css
Normal file
67
apps/wcag-ada/dashboard/src/styles/globals.css
Normal 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;
|
||||
}
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue