initial wcag-ada
This commit is contained in:
parent
042b8cb83a
commit
d52cfe7de2
112 changed files with 9069 additions and 0 deletions
57
apps/wcag-ada/dashboard/Dockerfile
Normal file
57
apps/wcag-ada/dashboard/Dockerfile
Normal file
|
|
@ -0,0 +1,57 @@
|
|||
# Build stage
|
||||
FROM oven/bun:1-alpine as builder
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
# Copy workspace files
|
||||
COPY package.json bun.lockb ./
|
||||
COPY apps/wcag-ada/dashboard/package.json ./apps/wcag-ada/dashboard/
|
||||
COPY apps/wcag-ada/shared/package.json ./apps/wcag-ada/shared/
|
||||
|
||||
# Install dependencies
|
||||
RUN bun install --frozen-lockfile
|
||||
|
||||
# Copy source code
|
||||
COPY apps/wcag-ada/dashboard ./apps/wcag-ada/dashboard
|
||||
COPY apps/wcag-ada/shared ./apps/wcag-ada/shared
|
||||
COPY tsconfig.json ./
|
||||
|
||||
# Build the application
|
||||
WORKDIR /app/apps/wcag-ada/dashboard
|
||||
RUN bun run build
|
||||
|
||||
# Production stage with nginx
|
||||
FROM nginx:alpine
|
||||
|
||||
# Install runtime dependencies
|
||||
RUN apk add --no-cache curl
|
||||
|
||||
# Copy nginx configuration
|
||||
COPY apps/wcag-ada/dashboard/nginx.conf /etc/nginx/nginx.conf
|
||||
|
||||
# Copy built application
|
||||
COPY --from=builder /app/apps/wcag-ada/dashboard/dist /usr/share/nginx/html
|
||||
|
||||
# Create non-root user
|
||||
RUN addgroup -g 1001 -S nodejs && \
|
||||
adduser -S nodejs -u 1001
|
||||
|
||||
# Set ownership
|
||||
RUN chown -R nodejs:nodejs /usr/share/nginx/html && \
|
||||
chown -R nodejs:nodejs /var/cache/nginx && \
|
||||
chown -R nodejs:nodejs /var/log/nginx && \
|
||||
chown -R nodejs:nodejs /etc/nginx/conf.d && \
|
||||
touch /var/run/nginx.pid && \
|
||||
chown -R nodejs:nodejs /var/run/nginx.pid
|
||||
|
||||
USER nodejs
|
||||
|
||||
# Expose port
|
||||
EXPOSE 8080
|
||||
|
||||
# Health check
|
||||
HEALTHCHECK --interval=30s --timeout=3s --start-period=5s --retries=3 \
|
||||
CMD curl -f http://localhost:8080/ || exit 1
|
||||
|
||||
# Start nginx
|
||||
CMD ["nginx", "-g", "daemon off;"]
|
||||
13
apps/wcag-ada/dashboard/index.html
Normal file
13
apps/wcag-ada/dashboard/index.html
Normal file
|
|
@ -0,0 +1,13 @@
|
|||
<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<link rel="icon" type="image/svg+xml" href="/icon.svg" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>WCAG-ADA Compliance Dashboard</title>
|
||||
</head>
|
||||
<body>
|
||||
<div id="root"></div>
|
||||
<script type="module" src="/src/main.tsx"></script>
|
||||
</body>
|
||||
</html>
|
||||
79
apps/wcag-ada/dashboard/nginx.conf
Normal file
79
apps/wcag-ada/dashboard/nginx.conf
Normal file
|
|
@ -0,0 +1,79 @@
|
|||
worker_processes auto;
|
||||
error_log /var/log/nginx/error.log warn;
|
||||
pid /var/run/nginx.pid;
|
||||
|
||||
events {
|
||||
worker_connections 1024;
|
||||
}
|
||||
|
||||
http {
|
||||
include /etc/nginx/mime.types;
|
||||
default_type application/octet-stream;
|
||||
|
||||
log_format main '$remote_addr - $remote_user [$time_local] "$request" '
|
||||
'$status $body_bytes_sent "$http_referer" '
|
||||
'"$http_user_agent" "$http_x_forwarded_for"';
|
||||
|
||||
access_log /var/log/nginx/access.log main;
|
||||
|
||||
sendfile on;
|
||||
tcp_nopush on;
|
||||
tcp_nodelay on;
|
||||
keepalive_timeout 65;
|
||||
types_hash_max_size 2048;
|
||||
|
||||
# Gzip compression
|
||||
gzip on;
|
||||
gzip_vary on;
|
||||
gzip_proxied any;
|
||||
gzip_comp_level 6;
|
||||
gzip_types text/plain text/css text/xml text/javascript application/json application/javascript application/xml+rss application/rss+xml application/atom+xml image/svg+xml;
|
||||
|
||||
# Security headers
|
||||
add_header X-Frame-Options "SAMEORIGIN" always;
|
||||
add_header X-Content-Type-Options "nosniff" always;
|
||||
add_header X-XSS-Protection "1; mode=block" always;
|
||||
add_header Referrer-Policy "no-referrer-when-downgrade" always;
|
||||
add_header Content-Security-Policy "default-src 'self' http: https: data: blob: 'unsafe-inline' 'unsafe-eval'" always;
|
||||
|
||||
server {
|
||||
listen 8080;
|
||||
server_name localhost;
|
||||
root /usr/share/nginx/html;
|
||||
index index.html;
|
||||
|
||||
# Cache static assets
|
||||
location ~* \.(js|css|png|jpg|jpeg|gif|ico|svg|woff|woff2|ttf|eot)$ {
|
||||
expires 1y;
|
||||
add_header Cache-Control "public, immutable";
|
||||
}
|
||||
|
||||
# Serve index.html for all routes (React Router)
|
||||
location / {
|
||||
try_files $uri $uri/ /index.html;
|
||||
add_header Cache-Control "no-cache, no-store, must-revalidate";
|
||||
add_header Pragma "no-cache";
|
||||
add_header Expires "0";
|
||||
}
|
||||
|
||||
# API proxy (if needed in production)
|
||||
location /api {
|
||||
proxy_pass http://wcag-ada-api:3001;
|
||||
proxy_http_version 1.1;
|
||||
proxy_set_header Upgrade $http_upgrade;
|
||||
proxy_set_header Connection 'upgrade';
|
||||
proxy_set_header Host $host;
|
||||
proxy_cache_bypass $http_upgrade;
|
||||
proxy_set_header X-Real-IP $remote_addr;
|
||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||
proxy_set_header X-Forwarded-Proto $scheme;
|
||||
}
|
||||
|
||||
# Health check endpoint
|
||||
location /health {
|
||||
access_log off;
|
||||
return 200 "healthy\n";
|
||||
add_header Content-Type text/plain;
|
||||
}
|
||||
}
|
||||
}
|
||||
61
apps/wcag-ada/dashboard/package.json
Normal file
61
apps/wcag-ada/dashboard/package.json
Normal file
|
|
@ -0,0 +1,61 @@
|
|||
{
|
||||
"name": "@wcag-ada/dashboard",
|
||||
"version": "0.1.0",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "vite",
|
||||
"build": "tsc && vite build",
|
||||
"preview": "vite preview",
|
||||
"typecheck": "tsc --noEmit",
|
||||
"lint": "eslint src"
|
||||
},
|
||||
"dependencies": {
|
||||
"react": "^18.2.0",
|
||||
"react-dom": "^18.2.0",
|
||||
"react-router-dom": "^6.21.1",
|
||||
"@tanstack/react-query": "^5.17.0",
|
||||
"axios": "^1.6.5",
|
||||
"zustand": "^4.4.7",
|
||||
"@wcag-ada/shared": "workspace:*",
|
||||
"@wcag-ada/config": "workspace:*",
|
||||
"recharts": "^2.10.3",
|
||||
"date-fns": "^3.0.6",
|
||||
"clsx": "^2.1.0",
|
||||
"lucide-react": "^0.303.0",
|
||||
"@radix-ui/react-alert-dialog": "^1.0.5",
|
||||
"@radix-ui/react-checkbox": "^1.0.4",
|
||||
"@radix-ui/react-dialog": "^1.0.5",
|
||||
"@radix-ui/react-dropdown-menu": "^2.0.6",
|
||||
"@radix-ui/react-label": "^2.0.2",
|
||||
"@radix-ui/react-popover": "^1.0.7",
|
||||
"@radix-ui/react-select": "^2.0.0",
|
||||
"@radix-ui/react-separator": "^1.0.3",
|
||||
"@radix-ui/react-slot": "^1.0.2",
|
||||
"@radix-ui/react-switch": "^1.0.3",
|
||||
"@radix-ui/react-tabs": "^1.0.4",
|
||||
"@radix-ui/react-toast": "^1.1.5",
|
||||
"@radix-ui/react-tooltip": "^1.0.7",
|
||||
"tailwindcss-animate": "^1.0.7",
|
||||
"react-hook-form": "^7.48.2",
|
||||
"@hookform/resolvers": "^3.3.4",
|
||||
"zod": "^3.22.4",
|
||||
"class-variance-authority": "^0.7.0",
|
||||
"tailwind-merge": "^2.2.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/react": "^18.2.47",
|
||||
"@types/react-dom": "^18.2.18",
|
||||
"@typescript-eslint/eslint-plugin": "^6.18.1",
|
||||
"@typescript-eslint/parser": "^6.18.1",
|
||||
"@vitejs/plugin-react": "^4.2.1",
|
||||
"autoprefixer": "^10.4.16",
|
||||
"eslint": "^8.56.0",
|
||||
"eslint-plugin-react-hooks": "^4.6.0",
|
||||
"eslint-plugin-react-refresh": "^0.4.5",
|
||||
"postcss": "^8.4.33",
|
||||
"tailwindcss": "^3.4.0",
|
||||
"typescript": "^5.3.3",
|
||||
"vite": "^5.0.11"
|
||||
}
|
||||
}
|
||||
6
apps/wcag-ada/dashboard/postcss.config.js
Normal file
6
apps/wcag-ada/dashboard/postcss.config.js
Normal file
|
|
@ -0,0 +1,6 @@
|
|||
export default {
|
||||
plugins: {
|
||||
tailwindcss: {},
|
||||
autoprefixer: {},
|
||||
},
|
||||
}
|
||||
5
apps/wcag-ada/dashboard/public/icon.svg
Normal file
5
apps/wcag-ada/dashboard/public/icon.svg
Normal file
|
|
@ -0,0 +1,5 @@
|
|||
<svg width="32" height="32" viewBox="0 0 32 32" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<rect width="32" height="32" rx="8" fill="#3B82F6"/>
|
||||
<path d="M16 6C10.4772 6 6 10.4772 6 16C6 21.5228 10.4772 26 16 26C21.5228 26 26 21.5228 26 16C26 10.4772 21.5228 6 16 6ZM16 8C20.4183 8 24 11.5817 24 16C24 20.4183 20.4183 24 16 24C11.5817 24 8 20.4183 8 16C8 11.5817 11.5817 8 16 8Z" fill="white"/>
|
||||
<path d="M16 10C12.6863 10 10 12.6863 10 16C10 19.3137 12.6863 22 16 22C19.3137 22 22 19.3137 22 16C22 12.6863 19.3137 10 16 10ZM13 15H15V17H13V15ZM17 15H19V17H17V15Z" fill="white"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 594 B |
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;
|
||||
}
|
||||
}
|
||||
85
apps/wcag-ada/dashboard/tailwind.config.js
Normal file
85
apps/wcag-ada/dashboard/tailwind.config.js
Normal file
|
|
@ -0,0 +1,85 @@
|
|||
/** @type {import('tailwindcss').Config} */
|
||||
module.exports = {
|
||||
darkMode: ["class"],
|
||||
content: [
|
||||
'./pages/**/*.{ts,tsx}',
|
||||
'./components/**/*.{ts,tsx}',
|
||||
'./app/**/*.{ts,tsx}',
|
||||
'./src/**/*.{ts,tsx}',
|
||||
],
|
||||
prefix: "",
|
||||
theme: {
|
||||
container: {
|
||||
center: true,
|
||||
padding: "2rem",
|
||||
screens: {
|
||||
"2xl": "1400px",
|
||||
},
|
||||
},
|
||||
extend: {
|
||||
colors: {
|
||||
border: "hsl(var(--border))",
|
||||
input: "hsl(var(--input))",
|
||||
ring: "hsl(var(--ring))",
|
||||
background: "hsl(var(--background))",
|
||||
foreground: "hsl(var(--foreground))",
|
||||
primary: {
|
||||
DEFAULT: "hsl(var(--primary))",
|
||||
foreground: "hsl(var(--primary-foreground))",
|
||||
},
|
||||
secondary: {
|
||||
DEFAULT: "hsl(var(--secondary))",
|
||||
foreground: "hsl(var(--secondary-foreground))",
|
||||
},
|
||||
destructive: {
|
||||
DEFAULT: "hsl(var(--destructive))",
|
||||
foreground: "hsl(var(--destructive-foreground))",
|
||||
},
|
||||
muted: {
|
||||
DEFAULT: "hsl(var(--muted))",
|
||||
foreground: "hsl(var(--muted-foreground))",
|
||||
},
|
||||
accent: {
|
||||
DEFAULT: "hsl(var(--accent))",
|
||||
foreground: "hsl(var(--accent-foreground))",
|
||||
},
|
||||
popover: {
|
||||
DEFAULT: "hsl(var(--popover))",
|
||||
foreground: "hsl(var(--popover-foreground))",
|
||||
},
|
||||
card: {
|
||||
DEFAULT: "hsl(var(--card))",
|
||||
foreground: "hsl(var(--card-foreground))",
|
||||
},
|
||||
success: {
|
||||
DEFAULT: "hsl(var(--success))",
|
||||
foreground: "hsl(var(--success-foreground))",
|
||||
},
|
||||
warning: {
|
||||
DEFAULT: "hsl(var(--warning))",
|
||||
foreground: "hsl(var(--warning-foreground))",
|
||||
},
|
||||
},
|
||||
borderRadius: {
|
||||
lg: "var(--radius)",
|
||||
md: "calc(var(--radius) - 2px)",
|
||||
sm: "calc(var(--radius) - 4px)",
|
||||
},
|
||||
keyframes: {
|
||||
"accordion-down": {
|
||||
from: { height: "0" },
|
||||
to: { height: "var(--radix-accordion-content-height)" },
|
||||
},
|
||||
"accordion-up": {
|
||||
from: { height: "var(--radix-accordion-content-height)" },
|
||||
to: { height: "0" },
|
||||
},
|
||||
},
|
||||
animation: {
|
||||
"accordion-down": "accordion-down 0.2s ease-out",
|
||||
"accordion-up": "accordion-up 0.2s ease-out",
|
||||
},
|
||||
},
|
||||
},
|
||||
plugins: [require("tailwindcss-animate")],
|
||||
}
|
||||
34
apps/wcag-ada/dashboard/tsconfig.json
Normal file
34
apps/wcag-ada/dashboard/tsconfig.json
Normal file
|
|
@ -0,0 +1,34 @@
|
|||
{
|
||||
"compilerOptions": {
|
||||
"target": "ES2020",
|
||||
"useDefineForClassFields": true,
|
||||
"lib": ["ES2020", "DOM", "DOM.Iterable"],
|
||||
"module": "ESNext",
|
||||
"skipLibCheck": true,
|
||||
|
||||
/* Bundler mode */
|
||||
"moduleResolution": "bundler",
|
||||
"allowImportingTsExtensions": true,
|
||||
"resolveJsonModule": true,
|
||||
"isolatedModules": true,
|
||||
"noEmit": true,
|
||||
"jsx": "react-jsx",
|
||||
|
||||
/* Linting */
|
||||
"strict": true,
|
||||
"noUnusedLocals": true,
|
||||
"noUnusedParameters": true,
|
||||
"noFallthroughCasesInSwitch": true,
|
||||
|
||||
/* Path aliases */
|
||||
"baseUrl": ".",
|
||||
"paths": {
|
||||
"@/*": ["src/*"]
|
||||
}
|
||||
},
|
||||
"include": ["src"],
|
||||
"references": [
|
||||
{ "path": "../shared" },
|
||||
{ "path": "../config" }
|
||||
]
|
||||
}
|
||||
22
apps/wcag-ada/dashboard/vite.config.ts
Normal file
22
apps/wcag-ada/dashboard/vite.config.ts
Normal file
|
|
@ -0,0 +1,22 @@
|
|||
import { defineConfig } from 'vite';
|
||||
import react from '@vitejs/plugin-react';
|
||||
import path from 'path';
|
||||
|
||||
// https://vitejs.dev/config/
|
||||
export default defineConfig({
|
||||
plugins: [react()],
|
||||
resolve: {
|
||||
alias: {
|
||||
'@': path.resolve(__dirname, './src'),
|
||||
},
|
||||
},
|
||||
server: {
|
||||
port: 3000,
|
||||
proxy: {
|
||||
'/api': {
|
||||
target: 'http://localhost:3001',
|
||||
changeOrigin: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
Loading…
Add table
Add a link
Reference in a new issue