initial wcag-ada

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

View file

@ -0,0 +1,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;"]

View 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>

View 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;
}
}
}

View 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"
}
}

View file

@ -0,0 +1,6 @@
export default {
plugins: {
tailwindcss: {},
autoprefixer: {},
},
}

View 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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View 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")],
}

View 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" }
]
}

View 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,
},
},
},
});