feat: 웹사이트 표준화 검사 도구 구현

- 4개 검사 엔진: HTML/CSS, 접근성(WCAG), SEO, 성능/보안 (총 50개 항목)
- FastAPI 백엔드 (9개 API, SSE 실시간 진행, PDF/JSON 리포트)
- Next.js 15 프론트엔드 (6개 페이지, 29개 컴포넌트, 반원 게이지 차트)
- Docker Compose 배포 (Backend:8011, Frontend:3011, MongoDB:27022, Redis:6392)
- 전체 테스트 32/32 PASS

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
jungwoo choi
2026-02-13 13:57:27 +09:00
parent c37cda5b13
commit b5fa5d96b9
93 changed files with 18735 additions and 22 deletions

3
frontend/.eslintrc.json Normal file
View File

@ -0,0 +1,3 @@
{
"extends": "next/core-web-vitals"
}

39
frontend/.gitignore vendored Normal file
View File

@ -0,0 +1,39 @@
# dependencies
/node_modules
/.pnp
.pnp.*
.yarn/*
!.yarn/patches
!.yarn/plugins
!.yarn/releases
!.yarn/versions
# testing
/coverage
# next.js
/.next/
/out/
# production
/build
# misc
.DS_Store
*.pem
# debug
npm-debug.log*
yarn-debug.log*
yarn-error.log*
.pnpm-debug.log*
# env files
.env*.local
# vercel
.vercel
# typescript
*.tsbuildinfo
next-env.d.ts

48
frontend/Dockerfile Normal file
View File

@ -0,0 +1,48 @@
# ==========================================
# Stage 1: Install dependencies
# ==========================================
FROM node:20-alpine AS deps
WORKDIR /app
COPY package.json package-lock.json* ./
RUN npm ci --legacy-peer-deps
# ==========================================
# Stage 2: Build the application
# ==========================================
FROM node:20-alpine AS builder
WORKDIR /app
COPY --from=deps /app/node_modules ./node_modules
COPY . .
ENV NEXT_TELEMETRY_DISABLED=1
ENV NEXT_PUBLIC_API_URL=http://backend:8000
RUN npm run build
# ==========================================
# Stage 3: Production runner
# ==========================================
FROM node:20-alpine AS runner
WORKDIR /app
ENV NODE_ENV=production
ENV NEXT_TELEMETRY_DISABLED=1
RUN addgroup --system --gid 1001 nodejs
RUN adduser --system --uid 1001 nextjs
COPY --from=builder /app/public ./public
# Standalone output
COPY --from=builder --chown=nextjs:nodejs /app/.next/standalone ./
COPY --from=builder --chown=nextjs:nodejs /app/.next/static ./.next/static
USER nextjs
EXPOSE 3000
ENV PORT=3000
ENV HOSTNAME="0.0.0.0"
CMD ["node", "server.js"]

20
frontend/components.json Normal file
View File

@ -0,0 +1,20 @@
{
"$schema": "https://ui.shadcn.com/schema.json",
"style": "default",
"rsc": true,
"tsx": true,
"tailwind": {
"config": "tailwind.config.ts",
"css": "src/app/globals.css",
"baseColor": "slate",
"cssVariables": true,
"prefix": ""
},
"aliases": {
"components": "@/components",
"utils": "@/lib/utils",
"ui": "@/components/ui",
"lib": "@/lib",
"hooks": "@/hooks"
}
}

7
frontend/next.config.ts Normal file
View File

@ -0,0 +1,7 @@
import type { NextConfig } from "next";
const nextConfig: NextConfig = {
output: "standalone",
};
export default nextConfig;

7426
frontend/package-lock.json generated Normal file

File diff suppressed because it is too large Load Diff

43
frontend/package.json Normal file
View File

@ -0,0 +1,43 @@
{
"name": "web-inspector-frontend",
"version": "0.1.0",
"private": true,
"scripts": {
"dev": "next dev -p 3011",
"build": "next build",
"start": "next start -p 3011",
"lint": "next lint"
},
"dependencies": {
"@radix-ui/react-dialog": "^1.1.4",
"@radix-ui/react-dropdown-menu": "^2.1.4",
"@radix-ui/react-progress": "^1.1.1",
"@radix-ui/react-select": "^2.1.4",
"@radix-ui/react-slot": "^1.1.1",
"@radix-ui/react-tabs": "^1.1.2",
"@radix-ui/react-toggle": "^1.1.1",
"@radix-ui/react-toggle-group": "^1.1.1",
"@radix-ui/react-tooltip": "^1.1.6",
"@tanstack/react-query": "^5.62.0",
"class-variance-authority": "^0.7.1",
"clsx": "^2.1.1",
"lucide-react": "^0.468.0",
"next": "15.1.0",
"react": "^19.0.0",
"react-dom": "^19.0.0",
"recharts": "^2.15.0",
"tailwind-merge": "^2.6.0",
"tailwindcss-animate": "^1.0.7",
"zustand": "^5.0.2"
},
"devDependencies": {
"@types/node": "^22.0.0",
"@types/react": "^19.0.0",
"@types/react-dom": "^19.0.0",
"eslint": "^9.0.0",
"eslint-config-next": "15.1.0",
"postcss": "^8.4.49",
"tailwindcss": "^3.4.16",
"typescript": "^5.7.0"
}
}

View File

@ -0,0 +1,8 @@
/** @type {import('postcss-load-config').Config} */
const config = {
plugins: {
tailwindcss: {},
},
};
export default config;

View File

View File

@ -0,0 +1,37 @@
@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: 222.2 47.4% 11.2%;
--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%;
--border: 214.3 31.8% 91.4%;
--input: 214.3 31.8% 91.4%;
--ring: 222.2 84% 4.9%;
--radius: 0.5rem;
}
}
@layer base {
* {
@apply border-border;
}
body {
@apply bg-background text-foreground;
}
}

View File

@ -0,0 +1,67 @@
"use client";
import { useState } from "react";
import { useInspectionHistory } from "@/lib/queries";
import { SearchBar } from "@/components/history/SearchBar";
import { InspectionHistoryTable } from "@/components/history/InspectionHistoryTable";
import { Pagination } from "@/components/history/Pagination";
import { ErrorState } from "@/components/common/ErrorState";
export default function HistoryPage() {
const [page, setPage] = useState(1);
const [searchQuery, setSearchQuery] = useState("");
const { data, isLoading, isError, refetch } = useInspectionHistory(
page,
searchQuery || undefined
);
const handleSearch = (query: string) => {
setSearchQuery(query);
setPage(1); // 검색 시 1페이지로 리셋
};
const handlePageChange = (newPage: number) => {
setPage(newPage);
window.scrollTo({ top: 0, behavior: "smooth" });
};
return (
<div className="container mx-auto px-4 py-8">
<h1 className="text-2xl font-bold mb-6"> </h1>
{/* 검색 바 */}
<div className="mb-6 max-w-md">
<SearchBar
query={searchQuery}
onSearch={handleSearch}
placeholder="URL 검색..."
/>
</div>
{/* 테이블 또는 에러 */}
{isError ? (
<ErrorState
message="검사 이력을 불러올 수 없습니다"
onRetry={() => refetch()}
/>
) : (
<>
<InspectionHistoryTable
inspections={data?.items}
isLoading={isLoading}
/>
{/* 페이지네이션 */}
{data && data.total_pages > 1 && (
<Pagination
currentPage={data.page}
totalPages={data.total_pages}
onPageChange={handlePageChange}
/>
)}
</>
)}
</div>
);
}

View File

@ -0,0 +1,123 @@
"use client";
import { Suspense } from "react";
import { useSearchParams, useRouter } from "next/navigation";
import { useInspectionTrend } from "@/lib/queries";
import { TrendChart } from "@/components/trend/TrendChart";
import { ComparisonSummary } from "@/components/trend/ComparisonSummary";
import { LoadingSpinner } from "@/components/common/LoadingSpinner";
import { EmptyState } from "@/components/common/EmptyState";
import { ErrorState } from "@/components/common/ErrorState";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import { Button } from "@/components/ui/button";
import { ExternalLink, RotateCcw } from "lucide-react";
import { api, ApiError } from "@/lib/api";
import { useInspectionStore } from "@/stores/useInspectionStore";
function TrendPageContent() {
const searchParams = useSearchParams();
const router = useRouter();
const trendUrl = searchParams.get("url") || "";
const { setInspection } = useInspectionStore();
const { data, isLoading, isError, refetch } = useInspectionTrend(
trendUrl || undefined
);
const handleReinspect = async () => {
if (!trendUrl) return;
try {
const response = await api.startInspection(trendUrl);
setInspection(response.inspection_id, trendUrl);
router.push(`/inspections/${response.inspection_id}/progress`);
} catch (err) {
if (err instanceof ApiError) {
alert(err.detail);
} else {
alert("재검사 시작에 실패했습니다.");
}
}
};
return (
<div className="container mx-auto px-4 py-8">
{/* 페이지 제목 */}
<div className="mb-6">
<h1 className="text-2xl font-bold"> </h1>
{trendUrl && (
<div className="flex items-center gap-2 mt-2 text-muted-foreground">
<ExternalLink className="h-4 w-4" />
<span className="text-sm">{trendUrl}</span>
</div>
)}
</div>
{/* 콘텐츠 */}
{!trendUrl ? (
<EmptyState
message="URL이 지정되지 않았습니다"
description="검사 이력에서 트렌드 버튼을 클릭하여 접근해주세요"
/>
) : isLoading ? (
<LoadingSpinner message="트렌드 데이터를 불러오는 중..." />
) : isError ? (
<ErrorState
message="트렌드 데이터를 불러올 수 없습니다"
onRetry={() => refetch()}
/>
) : !data || data.data_points.length === 0 ? (
<EmptyState
message="검사 이력이 없습니다"
description="해당 URL의 검사 결과가 없습니다"
>
<Button onClick={handleReinspect}> </Button>
</EmptyState>
) : data.data_points.length === 1 ? (
<div className="space-y-6">
<EmptyState
message="비교할 이력이 없습니다"
description="동일 URL의 검사를 2회 이상 수행해야 트렌드를 확인할 수 있습니다"
>
<Button onClick={handleReinspect}>
<RotateCcw className="h-4 w-4" />
</Button>
</EmptyState>
<ComparisonSummary latest={data.data_points[0]} previous={null} />
</div>
) : (
<div className="space-y-6">
{/* 차트 */}
<Card>
<CardHeader>
<CardTitle className="text-lg"> </CardTitle>
</CardHeader>
<CardContent>
<TrendChart dataPoints={data.data_points} />
</CardContent>
</Card>
{/* 비교 요약 */}
<ComparisonSummary
latest={data.data_points[data.data_points.length - 1]}
previous={data.data_points[data.data_points.length - 2]}
/>
</div>
)}
</div>
);
}
export default function TrendPage() {
return (
<Suspense
fallback={
<div className="container mx-auto px-4 py-8">
<LoadingSpinner message="페이지를 불러오는 중..." />
</div>
}
>
<TrendPageContent />
</Suspense>
);
}

View File

@ -0,0 +1,70 @@
"use client";
import { use, useState } from "react";
import { useSearchParams } from "next/navigation";
import Link from "next/link";
import { useInspectionIssues } from "@/lib/queries";
import { FilterBar } from "@/components/issues/FilterBar";
import { IssueList } from "@/components/issues/IssueList";
import { ErrorState } from "@/components/common/ErrorState";
import { Button } from "@/components/ui/button";
import { ArrowLeft } from "lucide-react";
export default function IssuesPage({
params,
}: {
params: Promise<{ id: string }>;
}) {
const { id } = use(params);
const searchParams = useSearchParams();
const initialCategory = searchParams.get("category") || "all";
const [selectedCategory, setSelectedCategory] = useState(initialCategory);
const [selectedSeverity, setSelectedSeverity] = useState("all");
const { data, isLoading, isError, refetch } = useInspectionIssues(
id,
selectedCategory === "all" ? undefined : selectedCategory,
selectedSeverity === "all" ? undefined : selectedSeverity
);
return (
<div className="container mx-auto px-4 py-8">
{/* 뒤로가기 */}
<div className="mb-6">
<Link href={`/inspections/${id}`}>
<Button variant="ghost" size="sm">
<ArrowLeft className="h-4 w-4" />
</Button>
</Link>
</div>
<h1 className="text-2xl font-bold mb-6"> </h1>
{/* 필터 */}
<div className="mb-6">
<FilterBar
selectedCategory={selectedCategory}
selectedSeverity={selectedSeverity}
onCategoryChange={setSelectedCategory}
onSeverityChange={setSelectedSeverity}
/>
</div>
{/* 이슈 목록 */}
{isError ? (
<ErrorState
message="이슈 목록을 불러올 수 없습니다"
onRetry={() => refetch()}
/>
) : (
<IssueList
issues={data?.issues}
isLoading={isLoading}
total={data?.total || 0}
/>
)}
</div>
);
}

View File

@ -0,0 +1,143 @@
"use client";
import { use, useCallback } from "react";
import { useRouter } from "next/navigation";
import { useInspectionResult } from "@/lib/queries";
import { OverallScoreGauge } from "@/components/dashboard/OverallScoreGauge";
import { CategoryScoreCard } from "@/components/dashboard/CategoryScoreCard";
import { IssueSummaryBar } from "@/components/dashboard/IssueSummaryBar";
import { InspectionMeta } from "@/components/dashboard/InspectionMeta";
import { ActionButtons } from "@/components/dashboard/ActionButtons";
import { LoadingSpinner } from "@/components/common/LoadingSpinner";
import { ErrorState } from "@/components/common/ErrorState";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import { CATEGORY_LABELS, CATEGORY_KEYS } from "@/lib/constants";
import { api, ApiError } from "@/lib/api";
import { useInspectionStore } from "@/stores/useInspectionStore";
import type { CategoryKey } from "@/types/inspection";
export default function ResultDashboardPage({
params,
}: {
params: Promise<{ id: string }>;
}) {
const { id } = use(params);
const router = useRouter();
const { setInspection } = useInspectionStore();
const { data: result, isLoading, isError, error, refetch } =
useInspectionResult(id);
const handleViewIssues = useCallback(() => {
router.push(`/inspections/${id}/issues`);
}, [id, router]);
const handleCategoryClick = useCallback(
(category: CategoryKey) => {
router.push(`/inspections/${id}/issues?category=${category}`);
},
[id, router]
);
const handleReinspect = useCallback(async () => {
if (!result) return;
try {
const response = await api.startInspection(result.url);
setInspection(response.inspection_id, result.url);
router.push(`/inspections/${response.inspection_id}/progress`);
} catch (err) {
if (err instanceof ApiError) {
alert(err.detail);
} else {
alert("재검사 시작에 실패했습니다.");
}
}
}, [result, router, setInspection]);
if (isLoading) {
return (
<div className="container mx-auto px-4 py-8">
<LoadingSpinner message="검사 결과를 불러오는 중..." />
</div>
);
}
if (isError || !result) {
return (
<div className="container mx-auto px-4 py-8">
<ErrorState
message={
error instanceof ApiError
? error.detail
: "검사 결과를 불러올 수 없습니다"
}
onRetry={() => refetch()}
/>
</div>
);
}
return (
<div className="container mx-auto px-4 py-8">
{/* 종합 점수 */}
<Card className="mb-6">
<CardHeader className="text-center">
<CardTitle className="text-xl"> </CardTitle>
</CardHeader>
<CardContent className="flex justify-center">
<OverallScoreGauge
score={result.overall_score}
grade={result.grade}
/>
</CardContent>
</Card>
{/* 카테고리별 점수 카드 */}
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-4 gap-4 mb-6">
{CATEGORY_KEYS.map((key) => {
const cat = result.categories[key];
return (
<CategoryScoreCard
key={key}
categoryName={CATEGORY_LABELS[key]}
score={cat.score}
grade={cat.grade}
issueCount={cat.total_issues}
onClick={() => handleCategoryClick(key)}
/>
);
})}
</div>
{/* 검사 메타 정보 */}
<div className="mb-6">
<InspectionMeta
url={result.url}
createdAt={result.created_at}
durationSeconds={result.duration_seconds}
/>
</div>
{/* 액션 버튼 */}
<div className="mb-6">
<ActionButtons
inspectionId={id}
onViewIssues={handleViewIssues}
onReinspect={handleReinspect}
/>
</div>
{/* 이슈 요약 바 */}
<Card>
<CardContent className="py-4">
<IssueSummaryBar
critical={result.summary.critical}
major={result.summary.major}
minor={result.summary.minor}
info={result.summary.info}
total={result.summary.total_issues}
/>
</CardContent>
</Card>
</div>
);
}

View File

@ -0,0 +1,19 @@
"use client";
import { use } from "react";
import { InspectionProgress } from "@/components/inspection/InspectionProgress";
export default function ProgressPage({
params,
}: {
params: Promise<{ id: string }>;
}) {
const { id } = use(params);
return (
<div className="container mx-auto px-4 py-8">
<h1 className="text-2xl font-bold mb-6 text-center"> </h1>
<InspectionProgress inspectionId={id} />
</div>
);
}

View File

@ -0,0 +1,34 @@
import type { Metadata } from "next";
import { Inter } from "next/font/google";
import "./globals.css";
import { Providers } from "@/lib/providers";
import { Header } from "@/components/layout/Header";
import { Footer } from "@/components/layout/Footer";
const inter = Inter({ subsets: ["latin"] });
export const metadata: Metadata = {
title: "Web Inspector - 웹사이트 표준 검사",
description:
"URL을 입력하면 HTML/CSS, 접근성, SEO, 성능/보안을 한 번에 검사합니다",
};
export default function RootLayout({
children,
}: {
children: React.ReactNode;
}) {
return (
<html lang="ko">
<body className={inter.className}>
<Providers>
<div className="flex min-h-screen flex-col">
<Header />
<main className="flex-1">{children}</main>
<Footer />
</div>
</Providers>
</body>
</html>
);
}

32
frontend/src/app/page.tsx Normal file
View File

@ -0,0 +1,32 @@
"use client";
import { UrlInputForm } from "@/components/inspection/UrlInputForm";
import { RecentInspections } from "@/components/inspection/RecentInspections";
import { Search } from "lucide-react";
export default function HomePage() {
return (
<div className="container mx-auto px-4 py-12">
{/* 히어로 섹션 */}
<section className="text-center mb-10">
<div className="flex items-center justify-center gap-3 mb-4">
<div className="rounded-full bg-primary/10 p-3">
<Search className="h-8 w-8 text-primary" />
</div>
</div>
<h1 className="text-3xl sm:text-4xl font-bold tracking-tight mb-3">
</h1>
<p className="text-muted-foreground text-lg max-w-xl mx-auto">
URL을 HTML/CSS, , SEO, /
</p>
</section>
{/* URL 입력 폼 */}
<UrlInputForm />
{/* 최근 검사 이력 */}
<RecentInspections />
</div>
);
}

View File

@ -0,0 +1,35 @@
import { cn } from "@/lib/utils";
import { Inbox } from "lucide-react";
interface EmptyStateProps {
message?: string;
description?: string;
className?: string;
children?: React.ReactNode;
}
/** 빈 상태 표시 */
export function EmptyState({
message = "데이터가 없습니다",
description,
className,
children,
}: EmptyStateProps) {
return (
<div
className={cn(
"flex flex-col items-center justify-center py-12 text-center",
className
)}
>
<Inbox className="h-12 w-12 text-muted-foreground/50" />
<p className="mt-4 text-lg font-medium text-muted-foreground">
{message}
</p>
{description && (
<p className="mt-1 text-sm text-muted-foreground/70">{description}</p>
)}
{children && <div className="mt-4">{children}</div>}
</div>
);
}

View File

@ -0,0 +1,33 @@
import { cn } from "@/lib/utils";
import { AlertTriangle } from "lucide-react";
import { Button } from "@/components/ui/button";
interface ErrorStateProps {
message?: string;
onRetry?: () => void;
className?: string;
}
/** 에러 상태 표시 */
export function ErrorState({
message = "오류가 발생했습니다",
onRetry,
className,
}: ErrorStateProps) {
return (
<div
className={cn(
"flex flex-col items-center justify-center py-12 text-center",
className
)}
>
<AlertTriangle className="h-12 w-12 text-destructive" />
<p className="mt-4 text-lg font-medium text-destructive">{message}</p>
{onRetry && (
<Button onClick={onRetry} variant="outline" className="mt-4">
</Button>
)}
</div>
);
}

View File

@ -0,0 +1,22 @@
import { cn } from "@/lib/utils";
import { Loader2 } from "lucide-react";
interface LoadingSpinnerProps {
message?: string;
className?: string;
}
/** 로딩 스피너 */
export function LoadingSpinner({
message = "로딩 중...",
className,
}: LoadingSpinnerProps) {
return (
<div
className={cn("flex flex-col items-center justify-center py-12", className)}
>
<Loader2 className="h-8 w-8 animate-spin text-primary" />
<p className="mt-3 text-sm text-muted-foreground">{message}</p>
</div>
);
}

View File

@ -0,0 +1,23 @@
import { cn } from "@/lib/utils";
import { GRADE_BG_COLORS } from "@/lib/constants";
import type { Grade } from "@/types/inspection";
interface ScoreBadgeProps {
grade: Grade;
className?: string;
}
/** 등급 배지 (A+/A/B/C/D/F, 색상 코딩) */
export function ScoreBadge({ grade, className }: ScoreBadgeProps) {
return (
<span
className={cn(
"inline-flex items-center justify-center rounded-md px-2 py-0.5 text-sm font-bold",
GRADE_BG_COLORS[grade],
className
)}
>
{grade}
</span>
);
}

View File

@ -0,0 +1,23 @@
import { cn } from "@/lib/utils";
import { SEVERITY_COLORS, SEVERITY_LABELS } from "@/lib/constants";
import type { Severity } from "@/types/inspection";
interface SeverityBadgeProps {
severity: Severity;
className?: string;
}
/** 심각도 배지 (Critical=빨강, Major=주황, Minor=노랑, Info=파랑) */
export function SeverityBadge({ severity, className }: SeverityBadgeProps) {
return (
<span
className={cn(
"inline-flex items-center justify-center rounded px-2 py-0.5 text-xs font-semibold",
SEVERITY_COLORS[severity],
className
)}
>
{SEVERITY_LABELS[severity]}
</span>
);
}

View File

@ -0,0 +1,83 @@
"use client";
import { useState } from "react";
import { Button } from "@/components/ui/button";
import { FileText, Download, List, Loader2, RotateCcw } from "lucide-react";
import { api } from "@/lib/api";
interface ActionButtonsProps {
inspectionId: string;
onViewIssues: () => void;
onReinspect?: () => void;
}
/** 이슈 상세, PDF, JSON 버튼 */
export function ActionButtons({
inspectionId,
onViewIssues,
onReinspect,
}: ActionButtonsProps) {
const [isPdfLoading, setIsPdfLoading] = useState(false);
const [isJsonLoading, setIsJsonLoading] = useState(false);
const handleDownloadPdf = async () => {
setIsPdfLoading(true);
try {
await api.downloadPdf(inspectionId);
} catch {
alert("PDF 다운로드에 실패했습니다.");
} finally {
setIsPdfLoading(false);
}
};
const handleDownloadJson = async () => {
setIsJsonLoading(true);
try {
await api.downloadJson(inspectionId);
} catch {
alert("JSON 다운로드에 실패했습니다.");
} finally {
setIsJsonLoading(false);
}
};
return (
<div className="flex flex-wrap gap-3">
<Button onClick={onViewIssues} variant="default">
<List className="h-4 w-4" />
</Button>
<Button
onClick={handleDownloadPdf}
variant="outline"
disabled={isPdfLoading}
>
{isPdfLoading ? (
<Loader2 className="h-4 w-4 animate-spin" />
) : (
<FileText className="h-4 w-4" />
)}
PDF
</Button>
<Button
onClick={handleDownloadJson}
variant="outline"
disabled={isJsonLoading}
>
{isJsonLoading ? (
<Loader2 className="h-4 w-4 animate-spin" />
) : (
<Download className="h-4 w-4" />
)}
JSON
</Button>
{onReinspect && (
<Button onClick={onReinspect} variant="secondary">
<RotateCcw className="h-4 w-4" />
</Button>
)}
</div>
);
}

View File

@ -0,0 +1,52 @@
"use client";
import { Card, CardContent } from "@/components/ui/card";
import { ScoreBadge } from "@/components/common/ScoreBadge";
import { cn } from "@/lib/utils";
import { getScoreTailwindColor } from "@/lib/constants";
import type { Grade } from "@/types/inspection";
interface CategoryScoreCardProps {
categoryName: string;
score: number;
grade: Grade;
issueCount: number;
onClick?: () => void;
}
/** 카테고리별 점수 카드 */
export function CategoryScoreCard({
categoryName,
score,
grade,
issueCount,
onClick,
}: CategoryScoreCardProps) {
return (
<Card
className={cn(
"cursor-pointer hover:shadow-md transition-shadow",
onClick && "hover:border-primary/50"
)}
onClick={onClick}
>
<CardContent className="pt-5 pb-4 px-5 text-center">
<h3 className="text-sm font-medium text-muted-foreground mb-2">
{categoryName}
</h3>
<div
className={cn(
"text-3xl font-bold mb-1",
getScoreTailwindColor(score)
)}
>
{score}
</div>
<ScoreBadge grade={grade} className="mb-2" />
<p className="text-xs text-muted-foreground mt-2">
{issueCount}
</p>
</CardContent>
</Card>
);
}

View File

@ -0,0 +1,41 @@
"use client";
import { formatDateTime } from "@/lib/constants";
import { Clock, ExternalLink, Timer } from "lucide-react";
interface InspectionMetaProps {
url: string;
createdAt: string;
durationSeconds: number;
}
/** 검사 메타 정보 (URL, 일시, 소요시간) */
export function InspectionMeta({
url,
createdAt,
durationSeconds,
}: InspectionMetaProps) {
return (
<div className="flex flex-wrap items-center gap-4 text-sm text-muted-foreground">
<div className="flex items-center gap-1.5">
<ExternalLink className="h-4 w-4" />
<a
href={url}
target="_blank"
rel="noopener noreferrer"
className="hover:underline text-primary"
>
{url}
</a>
</div>
<div className="flex items-center gap-1.5">
<Clock className="h-4 w-4" />
<span> : {formatDateTime(createdAt)}</span>
</div>
<div className="flex items-center gap-1.5">
<Timer className="h-4 w-4" />
<span> : {durationSeconds}</span>
</div>
</div>
);
}

View File

@ -0,0 +1,48 @@
"use client";
import { SEVERITY_COLORS, SEVERITY_LABELS } from "@/lib/constants";
import type { Severity } from "@/types/inspection";
interface IssueSummaryBarProps {
critical: number;
major: number;
minor: number;
info: number;
total: number;
}
/** 심각도별 이슈 수 요약 바 */
export function IssueSummaryBar({
critical,
major,
minor,
info,
total,
}: IssueSummaryBarProps) {
const items: { severity: Severity; count: number }[] = [
{ severity: "critical", count: critical },
{ severity: "major", count: major },
{ severity: "minor", count: minor },
{ severity: "info", count: info },
];
return (
<div className="flex flex-wrap items-center gap-3">
{items.map(({ severity, count }) => (
<div key={severity} className="flex items-center gap-1.5">
<span
className={`inline-flex items-center justify-center rounded px-2 py-0.5 text-xs font-semibold ${SEVERITY_COLORS[severity]}`}
>
{SEVERITY_LABELS[severity]}
</span>
<span className="text-sm font-medium">{count}</span>
</div>
))}
<div className="border-l pl-3 ml-1">
<span className="text-sm text-muted-foreground">
<span className="font-bold text-foreground">{total}</span>
</span>
</div>
</div>
);
}

View File

@ -0,0 +1,51 @@
"use client";
import { PieChart, Pie, Cell } from "recharts";
import { getScoreColor } from "@/lib/constants";
import type { Grade } from "@/types/inspection";
interface OverallScoreGaugeProps {
score: number;
grade: Grade;
}
/** 종합 점수 반원형 게이지 (Recharts PieChart 기반) */
export function OverallScoreGauge({ score, grade }: OverallScoreGaugeProps) {
const color = getScoreColor(score);
const data = [
{ name: "score", value: score },
{ name: "remaining", value: 100 - score },
];
return (
<div className="flex flex-col items-center">
<div className="relative" style={{ width: 220, height: 130 }}>
<PieChart width={220} height={130}>
<Pie
data={data}
cx={110}
cy={110}
startAngle={180}
endAngle={0}
innerRadius={65}
outerRadius={90}
paddingAngle={0}
dataKey="value"
stroke="none"
>
<Cell fill={color} />
<Cell fill="#E5E7EB" />
</Pie>
</PieChart>
<div className="absolute inset-0 flex flex-col items-center justify-end pb-2">
<span className="text-4xl font-bold" style={{ color }}>
{score}
</span>
</div>
</div>
<span className="mt-1 text-lg font-semibold text-muted-foreground">
: {grade}
</span>
</div>
);
}

View File

@ -0,0 +1,137 @@
"use client";
import { useRouter } from "next/navigation";
import {
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow,
} from "@/components/ui/table";
import { Button } from "@/components/ui/button";
import { ScoreBadge } from "@/components/common/ScoreBadge";
import { LoadingSpinner } from "@/components/common/LoadingSpinner";
import { EmptyState } from "@/components/common/EmptyState";
import { formatDateTime, getScoreTailwindColor } from "@/lib/constants";
import { Eye, TrendingUp } from "lucide-react";
import { cn } from "@/lib/utils";
import type { InspectionHistoryItem } from "@/types/inspection";
interface InspectionHistoryTableProps {
inspections: InspectionHistoryItem[] | undefined;
isLoading: boolean;
}
/** 이력 테이블 */
export function InspectionHistoryTable({
inspections,
isLoading,
}: InspectionHistoryTableProps) {
const router = useRouter();
if (isLoading) {
return <LoadingSpinner message="검사 이력을 불러오는 중..." />;
}
if (!inspections || inspections.length === 0) {
return (
<EmptyState
message="검사 이력이 없습니다"
description="URL을 입력하여 첫 번째 검사를 시작해보세요"
/>
);
}
const handleRowClick = (inspectionId: string) => {
router.push(`/inspections/${inspectionId}`);
};
const handleTrendClick = (e: React.MouseEvent, url: string) => {
e.stopPropagation();
router.push(`/history/trend?url=${encodeURIComponent(url)}`);
};
return (
<div className="border rounded-lg overflow-hidden">
<Table>
<TableHeader>
<TableRow>
<TableHead>URL</TableHead>
<TableHead className="hidden sm:table-cell"> </TableHead>
<TableHead className="text-center"> </TableHead>
<TableHead className="text-center hidden md:table-cell">
</TableHead>
<TableHead className="text-center hidden md:table-cell">
</TableHead>
<TableHead className="text-center"></TableHead>
</TableRow>
</TableHeader>
<TableBody>
{inspections.map((item) => (
<TableRow
key={item.inspection_id}
className="cursor-pointer"
onClick={() => handleRowClick(item.inspection_id)}
>
<TableCell className="max-w-[200px] lg:max-w-[300px]">
<span className="truncate block text-sm font-medium">
{item.url}
</span>
<span className="text-xs text-muted-foreground sm:hidden">
{formatDateTime(item.created_at)}
</span>
</TableCell>
<TableCell className="hidden sm:table-cell text-sm text-muted-foreground">
{formatDateTime(item.created_at)}
</TableCell>
<TableCell className="text-center">
<span
className={cn(
"text-lg font-bold",
getScoreTailwindColor(item.overall_score)
)}
>
{item.overall_score}
</span>
</TableCell>
<TableCell className="text-center hidden md:table-cell">
<ScoreBadge grade={item.grade} />
</TableCell>
<TableCell className="text-center hidden md:table-cell text-sm">
{item.total_issues}
</TableCell>
<TableCell className="text-center">
<div className="flex items-center justify-center gap-1">
<Button
variant="ghost"
size="icon"
className="h-8 w-8"
onClick={(e) => {
e.stopPropagation();
handleRowClick(item.inspection_id);
}}
aria-label="결과 보기"
>
<Eye className="h-4 w-4" />
</Button>
<Button
variant="ghost"
size="icon"
className="h-8 w-8"
onClick={(e) => handleTrendClick(e, item.url)}
aria-label="트렌드 보기"
>
<TrendingUp className="h-4 w-4" />
</Button>
</div>
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
</div>
);
}

View File

@ -0,0 +1,97 @@
"use client";
import { Button } from "@/components/ui/button";
import { ChevronLeft, ChevronRight } from "lucide-react";
import { cn } from "@/lib/utils";
interface PaginationProps {
currentPage: number;
totalPages: number;
onPageChange: (page: number) => void;
}
/** 페이지네이션 */
export function Pagination({
currentPage,
totalPages,
onPageChange,
}: PaginationProps) {
if (totalPages <= 1) return null;
// 표시할 페이지 번호 생성
const getPageNumbers = () => {
const pages: (number | "...")[] = [];
const delta = 2; // 현재 페이지 좌우로 표시할 번호 수
const rangeStart = Math.max(2, currentPage - delta);
const rangeEnd = Math.min(totalPages - 1, currentPage + delta);
pages.push(1);
if (rangeStart > 2) {
pages.push("...");
}
for (let i = rangeStart; i <= rangeEnd; i++) {
pages.push(i);
}
if (rangeEnd < totalPages - 1) {
pages.push("...");
}
if (totalPages > 1) {
pages.push(totalPages);
}
return pages;
};
return (
<div className="flex items-center justify-center gap-1 mt-6">
<Button
variant="outline"
size="icon"
onClick={() => onPageChange(currentPage - 1)}
disabled={currentPage <= 1}
aria-label="이전 페이지"
>
<ChevronLeft className="h-4 w-4" />
</Button>
{getPageNumbers().map((page, index) => {
if (page === "...") {
return (
<span
key={`ellipsis-${index}`}
className="px-2 text-muted-foreground"
>
...
</span>
);
}
return (
<Button
key={page}
variant={page === currentPage ? "default" : "outline"}
size="sm"
className={cn("min-w-[36px]")}
onClick={() => onPageChange(page)}
>
{page}
</Button>
);
})}
<Button
variant="outline"
size="icon"
onClick={() => onPageChange(currentPage + 1)}
disabled={currentPage >= totalPages}
aria-label="다음 페이지"
>
<ChevronRight className="h-4 w-4" />
</Button>
</div>
);
}

View File

@ -0,0 +1,60 @@
"use client";
import { useState, type FormEvent } from "react";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Search, X } from "lucide-react";
interface SearchBarProps {
query: string;
onSearch: (query: string) => void;
placeholder?: string;
}
/** URL 검색 입력 */
export function SearchBar({
query,
onSearch,
placeholder = "URL 검색...",
}: SearchBarProps) {
const [value, setValue] = useState(query);
const handleSubmit = (e: FormEvent) => {
e.preventDefault();
onSearch(value.trim());
};
const handleClear = () => {
setValue("");
onSearch("");
};
return (
<form onSubmit={handleSubmit} className="flex gap-2">
<div className="relative flex-1">
<Search className="absolute left-3 top-1/2 -translate-y-1/2 h-4 w-4 text-muted-foreground" />
<Input
type="text"
value={value}
onChange={(e) => setValue(e.target.value)}
placeholder={placeholder}
className="pl-10 pr-8"
aria-label="URL 검색"
/>
{value && (
<button
type="button"
onClick={handleClear}
className="absolute right-3 top-1/2 -translate-y-1/2 text-muted-foreground hover:text-foreground"
aria-label="검색어 지우기"
>
<X className="h-4 w-4" />
</button>
)}
</div>
<Button type="submit" variant="default">
</Button>
</form>
);
}

View File

@ -0,0 +1,72 @@
"use client";
import { Card, CardContent } from "@/components/ui/card";
import { Progress } from "@/components/ui/progress";
import { cn } from "@/lib/utils";
import { CheckCircle2, Loader2, Clock, AlertCircle } from "lucide-react";
import type { CategoryProgress } from "@/types/inspection";
interface CategoryProgressCardProps {
categoryName: string;
status: CategoryProgress["status"];
progress: number;
currentStep?: string;
score?: number;
}
/** 카테고리별 진행 카드 */
export function CategoryProgressCard({
categoryName,
status,
progress,
currentStep,
score,
}: CategoryProgressCardProps) {
const statusIcon = {
pending: <Clock className="h-5 w-5 text-muted-foreground" />,
running: <Loader2 className="h-5 w-5 text-blue-500 animate-spin" />,
completed: <CheckCircle2 className="h-5 w-5 text-green-500" />,
error: <AlertCircle className="h-5 w-5 text-red-500" />,
};
const statusLabel = {
pending: "대기 중",
running: currentStep || "검사 중...",
completed: score !== undefined ? `완료 - ${score}` : "완료",
error: "오류 발생",
};
const indicatorColor = {
pending: "bg-gray-300",
running: "bg-blue-500",
completed: "bg-green-500",
error: "bg-red-500",
};
return (
<Card
className={cn(
"transition-all",
status === "completed" && "border-green-200 bg-green-50/30",
status === "error" && "border-red-200 bg-red-50/30",
status === "running" && "border-blue-200 bg-blue-50/30"
)}
>
<CardContent className="py-4 px-5">
<div className="flex items-center justify-between mb-2">
<div className="flex items-center gap-2">
{statusIcon[status]}
<span className="font-medium">{categoryName}</span>
</div>
<span className="text-sm font-bold">{Math.round(progress)}%</span>
</div>
<Progress
value={progress}
className="h-2 mb-2"
indicatorClassName={indicatorColor[status]}
/>
<p className="text-xs text-muted-foreground">{statusLabel[status]}</p>
</CardContent>
</Card>
);
}

View File

@ -0,0 +1,75 @@
"use client";
import { useInspectionStore } from "@/stores/useInspectionStore";
import { useInspectionSSE } from "@/hooks/useInspectionSSE";
import { OverallProgressBar } from "./OverallProgressBar";
import { CategoryProgressCard } from "./CategoryProgressCard";
import { ErrorState } from "@/components/common/ErrorState";
import { CATEGORY_LABELS, CATEGORY_KEYS } from "@/lib/constants";
import { ExternalLink } from "lucide-react";
interface InspectionProgressProps {
inspectionId: string;
}
/** 검사 진행 페이지 컨테이너 (SSE 연결 관리) */
export function InspectionProgress({
inspectionId,
}: InspectionProgressProps) {
const { status, overallProgress, categories, url, errorMessage } =
useInspectionStore();
// SSE 연결
useInspectionSSE(inspectionId);
const handleRetry = () => {
// 페이지 새로고침으로 SSE 재연결
window.location.reload();
};
return (
<div className="max-w-2xl mx-auto">
{/* URL 표시 */}
{url && (
<div className="flex items-center gap-2 mb-6 text-muted-foreground">
<ExternalLink className="h-4 w-4" />
<span className="text-sm truncate">{url}</span>
</div>
)}
{/* 전체 진행률 */}
<OverallProgressBar progress={overallProgress} />
{/* 카테고리별 진행 */}
<div className="space-y-3 mt-6">
{CATEGORY_KEYS.map((key) => (
<CategoryProgressCard
key={key}
categoryName={CATEGORY_LABELS[key]}
status={categories[key].status}
progress={categories[key].progress}
currentStep={categories[key].currentStep}
score={categories[key].score}
/>
))}
</div>
{/* 에러 상태 */}
{status === "error" && (
<div className="mt-6">
<ErrorState
message={errorMessage || "검사 중 오류가 발생했습니다"}
onRetry={handleRetry}
/>
</div>
)}
{/* 연결 중 상태 */}
{status === "connecting" && (
<p className="mt-4 text-sm text-center text-muted-foreground">
...
</p>
)}
</div>
);
}

View File

@ -0,0 +1,32 @@
"use client";
import { Progress } from "@/components/ui/progress";
interface OverallProgressBarProps {
progress: number;
}
/** 전체 진행률 바 */
export function OverallProgressBar({ progress }: OverallProgressBarProps) {
return (
<div className="space-y-2">
<div className="flex items-center justify-between">
<span className="text-sm font-medium text-muted-foreground">
</span>
<span className="text-sm font-bold">{Math.round(progress)}%</span>
</div>
<Progress
value={progress}
className="h-3"
indicatorClassName={
progress >= 100
? "bg-green-500"
: progress > 0
? "bg-blue-500"
: "bg-gray-300"
}
/>
</div>
);
}

View File

@ -0,0 +1,73 @@
"use client";
import Link from "next/link";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import { ScoreBadge } from "@/components/common/ScoreBadge";
import { LoadingSpinner } from "@/components/common/LoadingSpinner";
import { EmptyState } from "@/components/common/EmptyState";
import { useRecentInspections } from "@/lib/queries";
import { formatDate, getScoreTailwindColor } from "@/lib/constants";
import { ExternalLink } from "lucide-react";
import { cn } from "@/lib/utils";
export function RecentInspections() {
const { data, isLoading, isError } = useRecentInspections();
if (isLoading) {
return <LoadingSpinner message="최근 검사 이력을 불러오는 중..." />;
}
if (isError || !data) {
return null; // 메인 페이지에서는 에러 시 섹션 숨김
}
if (data.items.length === 0) {
return (
<EmptyState
message="검사 이력이 없습니다"
description="URL을 입력하여 첫 번째 검사를 시작해보세요"
/>
);
}
return (
<div className="w-full max-w-2xl mx-auto mt-8">
<h2 className="text-lg font-semibold mb-4"> </h2>
<div className="space-y-3">
{data.items.map((item) => (
<Link
key={item.inspection_id}
href={`/inspections/${item.inspection_id}`}
>
<Card className="hover:shadow-md transition-shadow cursor-pointer">
<CardContent className="flex items-center justify-between py-4 px-5">
<div className="flex-1 min-w-0">
<div className="flex items-center gap-2">
<ExternalLink className="h-4 w-4 text-muted-foreground shrink-0" />
<span className="text-sm font-medium truncate">
{item.url}
</span>
</div>
<p className="text-xs text-muted-foreground mt-1">
{formatDate(item.created_at)}
</p>
</div>
<div className="flex items-center gap-3 ml-4">
<span
className={cn(
"text-lg font-bold",
getScoreTailwindColor(item.overall_score)
)}
>
{item.overall_score}
</span>
<ScoreBadge grade={item.grade} />
</div>
</CardContent>
</Card>
</Link>
))}
</div>
</div>
);
}

View File

@ -0,0 +1,103 @@
"use client";
import { useState, type FormEvent } from "react";
import { useRouter } from "next/navigation";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Card, CardContent } from "@/components/ui/card";
import { Search, Loader2 } from "lucide-react";
import { api, ApiError } from "@/lib/api";
import { isValidUrl } from "@/lib/constants";
import { useInspectionStore } from "@/stores/useInspectionStore";
export function UrlInputForm() {
const [url, setUrl] = useState("");
const [error, setError] = useState<string | null>(null);
const [isLoading, setIsLoading] = useState(false);
const router = useRouter();
const { setInspection } = useInspectionStore();
const handleSubmit = async (e: FormEvent) => {
e.preventDefault();
setError(null);
const trimmedUrl = url.trim();
// 클라이언트 사이드 URL 검증
if (!trimmedUrl) {
setError("URL을 입력해주세요");
return;
}
if (!isValidUrl(trimmedUrl)) {
setError("유효한 URL을 입력해주세요 (http:// 또는 https://로 시작)");
return;
}
setIsLoading(true);
try {
const response = await api.startInspection(trimmedUrl);
setInspection(response.inspection_id, trimmedUrl);
router.push(`/inspections/${response.inspection_id}/progress`);
} catch (err) {
if (err instanceof ApiError) {
setError(err.detail);
} else {
setError("검사 시작 중 오류가 발생했습니다. 다시 시도해주세요.");
}
} finally {
setIsLoading(false);
}
};
return (
<Card className="w-full max-w-2xl mx-auto">
<CardContent className="pt-6">
<form onSubmit={handleSubmit} className="flex flex-col sm:flex-row gap-3">
<div className="flex-1 relative">
<Search className="absolute left-3 top-1/2 -translate-y-1/2 h-4 w-4 text-muted-foreground" />
<Input
type="text"
value={url}
onChange={(e) => {
setUrl(e.target.value);
if (error) setError(null);
}}
placeholder="https://example.com"
className="pl-10 h-12 text-base"
disabled={isLoading}
aria-label="검사할 URL 입력"
aria-invalid={!!error}
aria-describedby={error ? "url-error" : undefined}
/>
</div>
<Button
type="submit"
size="lg"
className="h-12 px-6 text-base"
disabled={isLoading}
>
{isLoading ? (
<>
<Loader2 className="h-4 w-4 animate-spin" />
...
</>
) : (
"검사 시작"
)}
</Button>
</form>
{error && (
<p
id="url-error"
className="mt-2 text-sm text-destructive"
role="alert"
>
{error}
</p>
)}
</CardContent>
</Card>
);
}

View File

@ -0,0 +1,87 @@
"use client";
import { Button } from "@/components/ui/button";
import { cn } from "@/lib/utils";
import {
CATEGORY_LABELS,
CATEGORY_KEYS,
SEVERITY_LABELS,
SEVERITY_COLORS,
} from "@/lib/constants";
import type { CategoryKey, Severity } from "@/types/inspection";
interface FilterBarProps {
selectedCategory: string;
selectedSeverity: string;
onCategoryChange: (category: string) => void;
onSeverityChange: (severity: string) => void;
}
const severities: Severity[] = ["critical", "major", "minor", "info"];
/** 카테고리/심각도 필터 바 */
export function FilterBar({
selectedCategory,
selectedSeverity,
onCategoryChange,
onSeverityChange,
}: FilterBarProps) {
return (
<div className="space-y-3">
{/* 카테고리 필터 */}
<div>
<span className="text-sm font-medium text-muted-foreground mr-3">
:
</span>
<div className="inline-flex flex-wrap gap-1.5">
<Button
variant={selectedCategory === "all" ? "default" : "outline"}
size="sm"
onClick={() => onCategoryChange("all")}
>
</Button>
{CATEGORY_KEYS.map((key: CategoryKey) => (
<Button
key={key}
variant={selectedCategory === key ? "default" : "outline"}
size="sm"
onClick={() => onCategoryChange(key)}
>
{CATEGORY_LABELS[key]}
</Button>
))}
</div>
</div>
{/* 심각도 필터 */}
<div>
<span className="text-sm font-medium text-muted-foreground mr-3">
:
</span>
<div className="inline-flex flex-wrap gap-1.5">
<Button
variant={selectedSeverity === "all" ? "default" : "outline"}
size="sm"
onClick={() => onSeverityChange("all")}
>
</Button>
{severities.map((sev) => (
<Button
key={sev}
variant={selectedSeverity === sev ? "default" : "outline"}
size="sm"
className={cn(
selectedSeverity === sev && SEVERITY_COLORS[sev]
)}
onClick={() => onSeverityChange(sev)}
>
{SEVERITY_LABELS[sev]}
</Button>
))}
</div>
</div>
</div>
);
}

View File

@ -0,0 +1,96 @@
"use client";
import { useState } from "react";
import { Card, CardContent } from "@/components/ui/card";
import { SeverityBadge } from "@/components/common/SeverityBadge";
import { cn } from "@/lib/utils";
import { SEVERITY_BG_COLORS } from "@/lib/constants";
import { ChevronDown, ChevronUp, Lightbulb, Code2 } from "lucide-react";
import type { Issue } from "@/types/inspection";
interface IssueCardProps {
issue: Issue;
}
/** 개별 이슈 카드 */
export function IssueCard({ issue }: IssueCardProps) {
const [isExpanded, setIsExpanded] = useState(false);
return (
<Card
className={cn(
"cursor-pointer transition-all border-l-4",
SEVERITY_BG_COLORS[issue.severity]
)}
onClick={() => setIsExpanded(!isExpanded)}
>
<CardContent className="py-4 px-5">
{/* 헤더: 심각도 + 코드 + 메시지 */}
<div className="flex items-start justify-between gap-3">
<div className="flex items-start gap-3 flex-1 min-w-0">
<SeverityBadge severity={issue.severity} />
<div className="flex-1 min-w-0">
<div className="flex items-center gap-2 flex-wrap">
<span className="text-xs font-mono font-bold text-muted-foreground">
{issue.code}
</span>
{issue.wcag_criterion && (
<span className="text-xs text-muted-foreground">
(WCAG {issue.wcag_criterion})
</span>
)}
</div>
<p className="text-sm font-medium mt-1">{issue.message}</p>
</div>
</div>
<button
className="shrink-0 text-muted-foreground hover:text-foreground"
aria-label={isExpanded ? "접기" : "펼치기"}
>
{isExpanded ? (
<ChevronUp className="h-4 w-4" />
) : (
<ChevronDown className="h-4 w-4" />
)}
</button>
</div>
{/* 확장 영역 */}
{isExpanded && (
<div className="mt-4 space-y-3 border-t pt-3">
{/* 요소 정보 */}
{issue.element && (
<div className="flex items-start gap-2">
<Code2 className="h-4 w-4 text-muted-foreground mt-0.5 shrink-0" />
<div>
<span className="text-xs font-medium text-muted-foreground">
{issue.line && ` (라인 ${issue.line})`}:
</span>
<pre className="mt-1 text-xs bg-muted/50 rounded p-2 overflow-x-auto font-mono">
{issue.element}
</pre>
</div>
</div>
)}
{/* 개선 제안 */}
{issue.suggestion && (
<div className="flex items-start gap-2">
<Lightbulb className="h-4 w-4 text-yellow-500 mt-0.5 shrink-0" />
<div>
<span className="text-xs font-medium text-muted-foreground">
:
</span>
<p className="mt-1 text-sm text-foreground">
{issue.suggestion}
</p>
</div>
</div>
)}
</div>
)}
</CardContent>
</Card>
);
}

View File

@ -0,0 +1,41 @@
"use client";
import { IssueCard } from "./IssueCard";
import { LoadingSpinner } from "@/components/common/LoadingSpinner";
import { EmptyState } from "@/components/common/EmptyState";
import type { Issue } from "@/types/inspection";
interface IssueListProps {
issues: Issue[] | undefined;
isLoading: boolean;
total: number;
}
/** 이슈 목록 컨테이너 */
export function IssueList({ issues, isLoading, total }: IssueListProps) {
if (isLoading) {
return <LoadingSpinner message="이슈 목록을 불러오는 중..." />;
}
if (!issues || issues.length === 0) {
return (
<EmptyState
message="이슈가 없습니다"
description="선택한 필터 조건에 해당하는 이슈가 없습니다"
/>
);
}
return (
<div>
<p className="text-sm text-muted-foreground mb-4">
<span className="font-bold text-foreground">{total}</span>
</p>
<div className="space-y-3">
{issues.map((issue, index) => (
<IssueCard key={`${issue.code}-${index}`} issue={issue} />
))}
</div>
</div>
);
}

View File

@ -0,0 +1,10 @@
export function Footer() {
return (
<footer className="border-t bg-muted/30 py-6 mt-auto">
<div className="container mx-auto px-4 text-center text-sm text-muted-foreground">
<p>Web Inspector - </p>
<p className="mt-1">HTML/CSS, , SEO, / </p>
</div>
</footer>
);
}

View File

@ -0,0 +1,103 @@
"use client";
import Link from "next/link";
import { usePathname } from "next/navigation";
import { cn } from "@/lib/utils";
import { Search, BarChart3, Menu, X } from "lucide-react";
import { Button } from "@/components/ui/button";
import { useUiStore } from "@/stores/useUiStore";
const navItems = [
{ label: "검사 시작", href: "/", icon: Search },
{ label: "검사 이력", href: "/history", icon: BarChart3 },
];
export function Header() {
const pathname = usePathname();
const { isMobileMenuOpen, toggleMobileMenu, closeMobileMenu } = useUiStore();
return (
<header className="sticky top-0 z-50 w-full border-b bg-white">
<div className="container mx-auto flex h-14 items-center justify-between px-4">
{/* 로고 */}
<Link
href="/"
className="flex items-center gap-2 font-bold text-lg"
onClick={closeMobileMenu}
>
<Search className="h-5 w-5 text-primary" />
<span>Web Inspector</span>
</Link>
{/* 데스크탑 네비게이션 */}
<nav className="hidden md:flex items-center gap-1">
{navItems.map((item) => {
const Icon = item.icon;
const isActive =
item.href === "/"
? pathname === "/"
: pathname.startsWith(item.href);
return (
<Link key={item.href} href={item.href}>
<Button
variant={isActive ? "secondary" : "ghost"}
size="sm"
className={cn(
"gap-2",
isActive && "font-semibold"
)}
>
<Icon className="h-4 w-4" />
{item.label}
</Button>
</Link>
);
})}
</nav>
{/* 모바일 메뉴 토글 */}
<Button
variant="ghost"
size="icon"
className="md:hidden"
onClick={toggleMobileMenu}
aria-label="메뉴 열기/닫기"
>
{isMobileMenuOpen ? (
<X className="h-5 w-5" />
) : (
<Menu className="h-5 w-5" />
)}
</Button>
</div>
{/* 모바일 네비게이션 */}
{isMobileMenuOpen && (
<nav className="md:hidden border-t bg-white px-4 py-2">
{navItems.map((item) => {
const Icon = item.icon;
const isActive =
item.href === "/"
? pathname === "/"
: pathname.startsWith(item.href);
return (
<Link
key={item.href}
href={item.href}
onClick={closeMobileMenu}
>
<Button
variant={isActive ? "secondary" : "ghost"}
className="w-full justify-start gap-2 mb-1"
>
<Icon className="h-4 w-4" />
{item.label}
</Button>
</Link>
);
})}
</nav>
)}
</header>
);
}

View File

@ -0,0 +1,106 @@
"use client";
import { Card, CardContent } from "@/components/ui/card";
import { cn } from "@/lib/utils";
import { ArrowUp, ArrowDown, Minus } from "lucide-react";
import type { TrendDataPoint } from "@/types/inspection";
interface ComparisonSummaryProps {
latest: TrendDataPoint;
previous: TrendDataPoint | null;
}
interface ComparisonItemProps {
label: string;
current: number;
previous: number | null;
}
function ComparisonItem({ label, current, previous }: ComparisonItemProps) {
if (previous === null) {
return (
<div className="text-center">
<p className="text-sm text-muted-foreground">{label}</p>
<p className="text-xl font-bold">{current}</p>
</div>
);
}
const diff = current - previous;
const DiffIcon = diff > 0 ? ArrowUp : diff < 0 ? ArrowDown : Minus;
const diffColor =
diff > 0
? "text-green-600"
: diff < 0
? "text-red-600"
: "text-muted-foreground";
return (
<div className="text-center">
<p className="text-sm text-muted-foreground">{label}</p>
<div className="flex items-center justify-center gap-1">
<span className="text-base text-muted-foreground">{previous}</span>
<span className="text-muted-foreground"></span>
<span className="text-xl font-bold">{current}</span>
<span className={cn("flex items-center text-sm font-medium", diffColor)}>
<DiffIcon className="h-3 w-3" />
{diff > 0 ? `+${diff}` : diff < 0 ? diff : "0"}
</span>
</div>
</div>
);
}
/** 최근 vs 이전 검사 비교 요약 */
export function ComparisonSummary({
latest,
previous,
}: ComparisonSummaryProps) {
const items = [
{
label: "종합",
current: latest.overall_score,
previous: previous?.overall_score ?? null,
},
{
label: "HTML/CSS",
current: latest.html_css,
previous: previous?.html_css ?? null,
},
{
label: "접근성",
current: latest.accessibility,
previous: previous?.accessibility ?? null,
},
{
label: "SEO",
current: latest.seo,
previous: previous?.seo ?? null,
},
{
label: "성능/보안",
current: latest.performance_security,
previous: previous?.performance_security ?? null,
},
];
return (
<Card>
<CardContent className="py-4">
<h3 className="text-sm font-medium text-muted-foreground mb-4 text-center">
{previous ? "최근 vs 이전 검사 비교" : "최근 검사 결과"}
</h3>
<div className="grid grid-cols-2 sm:grid-cols-5 gap-4">
{items.map((item) => (
<ComparisonItem
key={item.label}
label={item.label}
current={item.current}
previous={item.previous}
/>
))}
</div>
</CardContent>
</Card>
);
}

View File

@ -0,0 +1,161 @@
"use client";
import { useCallback, useState } from "react";
import { useRouter } from "next/navigation";
import {
LineChart,
Line,
XAxis,
YAxis,
CartesianGrid,
Tooltip,
ResponsiveContainer,
} from "recharts";
import { CHART_COLORS, formatShortDate } from "@/lib/constants";
import type { TrendDataPoint } from "@/types/inspection";
interface TrendChartProps {
dataPoints: TrendDataPoint[];
}
const LINE_KEYS = [
{ key: "overall_score", label: "종합", color: CHART_COLORS.overall },
{ key: "html_css", label: "HTML/CSS", color: CHART_COLORS.html_css },
{
key: "accessibility",
label: "접근성",
color: CHART_COLORS.accessibility,
},
{ key: "seo", label: "SEO", color: CHART_COLORS.seo },
{
key: "performance_security",
label: "성능/보안",
color: CHART_COLORS.performance_security,
},
];
/** 시계열 라인 차트 */
export function TrendChart({ dataPoints }: TrendChartProps) {
const router = useRouter();
const [visibleLines, setVisibleLines] = useState<Set<string>>(
new Set(LINE_KEYS.map((l) => l.key))
);
const toggleLine = useCallback((key: string) => {
setVisibleLines((prev) => {
const next = new Set(prev);
if (next.has(key)) {
// 최소 1개 라인은 유지
if (next.size > 1) next.delete(key);
} else {
next.add(key);
}
return next;
});
}, []);
const handlePointClick = useCallback(
(data: TrendDataPoint) => {
if (data?.inspection_id) {
router.push(`/inspections/${data.inspection_id}`);
}
},
[router]
);
const chartData = dataPoints.map((dp) => ({
...dp,
date: formatShortDate(dp.created_at),
}));
return (
<div>
{/* 범례 (토글 기능) */}
<div className="flex flex-wrap items-center gap-3 mb-4">
{LINE_KEYS.map((line) => (
<button
key={line.key}
onClick={() => toggleLine(line.key)}
className={`flex items-center gap-1.5 text-sm px-2 py-1 rounded transition-opacity ${
visibleLines.has(line.key) ? "opacity-100" : "opacity-40"
}`}
>
<div
className="w-3 h-3 rounded-full"
style={{ backgroundColor: line.color }}
/>
<span>{line.label}</span>
</button>
))}
</div>
{/* 차트 */}
<ResponsiveContainer width="100%" height={350}>
<LineChart
data={chartData}
onClick={(e) => {
if (e?.activePayload?.[0]?.payload) {
handlePointClick(e.activePayload[0].payload);
}
}}
>
<CartesianGrid strokeDasharray="3 3" className="stroke-muted" />
<XAxis
dataKey="date"
tick={{ fontSize: 12 }}
className="text-muted-foreground"
/>
<YAxis
domain={[0, 100]}
tick={{ fontSize: 12 }}
className="text-muted-foreground"
/>
<Tooltip
content={({ active, payload, label }) => {
if (!active || !payload?.length) return null;
return (
<div className="bg-white border rounded-lg shadow-lg p-3 text-sm">
<p className="font-medium mb-2">{label}</p>
{payload.map((entry) => (
<div
key={entry.dataKey}
className="flex items-center gap-2"
>
<div
className="w-2 h-2 rounded-full"
style={{ backgroundColor: entry.color }}
/>
<span className="text-muted-foreground">
{LINE_KEYS.find((l) => l.key === entry.dataKey)
?.label || entry.dataKey}
:
</span>
<span className="font-medium">{entry.value}</span>
</div>
))}
<p className="text-xs text-muted-foreground mt-2">
</p>
</div>
);
}}
/>
{LINE_KEYS.filter((line) => visibleLines.has(line.key)).map(
(line) => (
<Line
key={line.key}
type="monotone"
dataKey={line.key}
stroke={line.color}
strokeWidth={line.key === "overall_score" ? 3 : 2}
dot={{ r: 4, cursor: "pointer" }}
activeDot={{ r: 6, cursor: "pointer" }}
name={line.label}
/>
)
)}
</LineChart>
</ResponsiveContainer>
</div>
);
}

View File

@ -0,0 +1,35 @@
import * as React from "react";
import { cva, type VariantProps } from "class-variance-authority";
import { cn } from "@/lib/utils";
const badgeVariants = cva(
"inline-flex items-center rounded-full border px-2.5 py-0.5 text-xs font-semibold transition-colors focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2",
{
variants: {
variant: {
default:
"border-transparent bg-primary text-primary-foreground hover:bg-primary/80",
secondary:
"border-transparent bg-secondary text-secondary-foreground hover:bg-secondary/80",
destructive:
"border-transparent bg-destructive text-destructive-foreground hover:bg-destructive/80",
outline: "text-foreground",
},
},
defaultVariants: {
variant: "default",
},
}
);
export interface BadgeProps
extends React.HTMLAttributes<HTMLDivElement>,
VariantProps<typeof badgeVariants> {}
function Badge({ className, variant, ...props }: BadgeProps) {
return (
<div className={cn(badgeVariants({ variant }), className)} {...props} />
);
}
export { Badge, badgeVariants };

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 gap-2 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 [&_svg]:pointer-events-none [&_svg]:size-4 [&_svg]:shrink-0",
{
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,85 @@
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<
HTMLDivElement,
React.HTMLAttributes<HTMLDivElement>
>(({ className, ...props }, ref) => (
<div
ref={ref}
className={cn(
"text-2xl font-semibold leading-none tracking-tight",
className
)}
{...props}
/>
));
CardTitle.displayName = "CardTitle";
const CardDescription = React.forwardRef<
HTMLDivElement,
React.HTMLAttributes<HTMLDivElement>
>(({ className, ...props }, ref) => (
<div
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,22 @@
import * as React from "react";
import { cn } from "@/lib/utils";
const Input = React.forwardRef<
HTMLInputElement,
React.InputHTMLAttributes<HTMLInputElement>
>(({ className, type, ...props }, ref) => {
return (
<input
type={type}
className={cn(
"flex h-10 w-full rounded-md border border-input bg-background px-3 py-2 text-sm ring-offset-background file:border-0 file:bg-transparent file:text-sm file:font-medium file:text-foreground placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50",
className
)}
ref={ref}
{...props}
/>
);
});
Input.displayName = "Input";
export { Input };

View File

@ -0,0 +1,32 @@
"use client";
import * as React from "react";
import * as ProgressPrimitive from "@radix-ui/react-progress";
import { cn } from "@/lib/utils";
const Progress = React.forwardRef<
React.ComponentRef<typeof ProgressPrimitive.Root>,
React.ComponentPropsWithoutRef<typeof ProgressPrimitive.Root> & {
indicatorClassName?: string;
}
>(({ className, value, indicatorClassName, ...props }, ref) => (
<ProgressPrimitive.Root
ref={ref}
className={cn(
"relative h-4 w-full overflow-hidden rounded-full bg-secondary",
className
)}
{...props}
>
<ProgressPrimitive.Indicator
className={cn(
"h-full w-full flex-1 bg-primary transition-all",
indicatorClassName
)}
style={{ transform: `translateX(-${100 - (value || 0)}%)` }}
/>
</ProgressPrimitive.Root>
));
Progress.displayName = ProgressPrimitive.Root.displayName;
export { Progress };

View File

@ -0,0 +1,116 @@
import * as React from "react";
import { cn } from "@/lib/utils";
const Table = React.forwardRef<
HTMLTableElement,
React.HTMLAttributes<HTMLTableElement>
>(({ className, ...props }, ref) => (
<div className="relative w-full overflow-auto">
<table
ref={ref}
className={cn("w-full caption-bottom text-sm", className)}
{...props}
/>
</div>
));
Table.displayName = "Table";
const TableHeader = React.forwardRef<
HTMLTableSectionElement,
React.HTMLAttributes<HTMLTableSectionElement>
>(({ className, ...props }, ref) => (
<thead ref={ref} className={cn("[&_tr]:border-b", className)} {...props} />
));
TableHeader.displayName = "TableHeader";
const TableBody = React.forwardRef<
HTMLTableSectionElement,
React.HTMLAttributes<HTMLTableSectionElement>
>(({ className, ...props }, ref) => (
<tbody
ref={ref}
className={cn("[&_tr:last-child]:border-0", className)}
{...props}
/>
));
TableBody.displayName = "TableBody";
const TableFooter = React.forwardRef<
HTMLTableSectionElement,
React.HTMLAttributes<HTMLTableSectionElement>
>(({ className, ...props }, ref) => (
<tfoot
ref={ref}
className={cn(
"border-t bg-muted/50 font-medium [&>tr]:last:border-b-0",
className
)}
{...props}
/>
));
TableFooter.displayName = "TableFooter";
const TableRow = React.forwardRef<
HTMLTableRowElement,
React.HTMLAttributes<HTMLTableRowElement>
>(({ className, ...props }, ref) => (
<tr
ref={ref}
className={cn(
"border-b transition-colors hover:bg-muted/50 data-[state=selected]:bg-muted",
className
)}
{...props}
/>
));
TableRow.displayName = "TableRow";
const TableHead = React.forwardRef<
HTMLTableCellElement,
React.ThHTMLAttributes<HTMLTableCellElement>
>(({ className, ...props }, ref) => (
<th
ref={ref}
className={cn(
"h-12 px-4 text-left align-middle font-medium text-muted-foreground [&:has([role=checkbox])]:pr-0",
className
)}
{...props}
/>
));
TableHead.displayName = "TableHead";
const TableCell = React.forwardRef<
HTMLTableCellElement,
React.TdHTMLAttributes<HTMLTableCellElement>
>(({ className, ...props }, ref) => (
<td
ref={ref}
className={cn("p-4 align-middle [&:has([role=checkbox])]:pr-0", className)}
{...props}
/>
));
TableCell.displayName = "TableCell";
const TableCaption = React.forwardRef<
HTMLTableCaptionElement,
React.HTMLAttributes<HTMLTableCaptionElement>
>(({ className, ...props }, ref) => (
<caption
ref={ref}
className={cn("mt-4 text-sm text-muted-foreground", className)}
{...props}
/>
));
TableCaption.displayName = "TableCaption";
export {
Table,
TableHeader,
TableBody,
TableFooter,
TableHead,
TableRow,
TableCell,
TableCaption,
};

View File

@ -0,0 +1,27 @@
"use client";
import * as React from "react";
import * as TooltipPrimitive from "@radix-ui/react-tooltip";
import { cn } from "@/lib/utils";
const TooltipProvider = TooltipPrimitive.Provider;
const Tooltip = TooltipPrimitive.Root;
const TooltipTrigger = TooltipPrimitive.Trigger;
const TooltipContent = React.forwardRef<
React.ComponentRef<typeof TooltipPrimitive.Content>,
React.ComponentPropsWithoutRef<typeof TooltipPrimitive.Content>
>(({ className, sideOffset = 4, ...props }, ref) => (
<TooltipPrimitive.Content
ref={ref}
sideOffset={sideOffset}
className={cn(
"z-50 overflow-hidden rounded-md border bg-popover px-3 py-1.5 text-sm text-popover-foreground shadow-md animate-in fade-in-0 zoom-in-95 data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=closed]:zoom-out-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2",
className
)}
{...props}
/>
));
TooltipContent.displayName = TooltipPrimitive.Content.displayName;
export { Tooltip, TooltipTrigger, TooltipContent, TooltipProvider };

View File

@ -0,0 +1,103 @@
"use client";
import { useEffect, useRef } from "react";
import { useRouter } from "next/navigation";
import { useInspectionStore } from "@/stores/useInspectionStore";
import { api } from "@/lib/api";
import type {
SSEProgressEvent,
SSECategoryCompleteEvent,
SSECompleteEvent,
} from "@/types/inspection";
/**
* SSE를 통해 검사 진행 상태를 수신하는 커스텀 훅.
* EventSource로 실시간 진행 상태를 수신하고 Zustand 스토어를 업데이트한다.
*/
export function useInspectionSSE(inspectionId: string | null) {
const {
updateProgress,
setCategoryComplete,
setCompleted,
setError,
setConnecting,
} = useInspectionStore();
const router = useRouter();
const eventSourceRef = useRef<EventSource | null>(null);
useEffect(() => {
if (!inspectionId) return;
setConnecting();
const streamUrl = api.getStreamUrl(inspectionId);
const eventSource = new EventSource(streamUrl);
eventSourceRef.current = eventSource;
eventSource.addEventListener("progress", (e: MessageEvent) => {
try {
const data: SSEProgressEvent = JSON.parse(e.data);
updateProgress(data);
} catch {
// JSON 파싱 실패 무시
}
});
eventSource.addEventListener("category_complete", (e: MessageEvent) => {
try {
const data: SSECategoryCompleteEvent = JSON.parse(e.data);
setCategoryComplete(data);
} catch {
// JSON 파싱 실패 무시
}
});
eventSource.addEventListener("complete", (e: MessageEvent) => {
try {
const data: SSECompleteEvent = JSON.parse(e.data);
setCompleted(data);
eventSource.close();
// 결과 페이지로 자동 이동
router.push(`/inspections/${inspectionId}`);
} catch {
// JSON 파싱 실패 무시
}
});
eventSource.addEventListener("error", (e: Event) => {
// SSE 프로토콜 에러 vs 검사 에러 구분
if (e instanceof MessageEvent) {
try {
const data = JSON.parse(e.data);
setError(data.message || "검사 중 오류가 발생했습니다");
} catch {
setError("검사 중 오류가 발생했습니다");
}
}
// 네트워크 에러인 경우 재연결하지 않고 에러 표시
if (eventSource.readyState === EventSource.CLOSED) {
setError("서버와의 연결이 끊어졌습니다");
}
});
// SSE 연결 타임아웃 (120초)
const timeout = setTimeout(() => {
eventSource.close();
setError("검사 시간이 초과되었습니다 (120초)");
}, 120000);
return () => {
clearTimeout(timeout);
eventSource.close();
eventSourceRef.current = null;
};
}, [
inspectionId,
updateProgress,
setCategoryComplete,
setCompleted,
setError,
setConnecting,
router,
]);
}

148
frontend/src/lib/api.ts Normal file
View File

@ -0,0 +1,148 @@
import type {
StartInspectionResponse,
InspectionResult,
IssueListResponse,
IssueFilters,
PaginatedResponse,
HistoryParams,
TrendResponse,
} from "@/types/inspection";
const API_BASE_URL =
process.env.NEXT_PUBLIC_API_URL || "http://localhost:8011";
/** API 에러 클래스 */
export class ApiError extends Error {
status: number;
detail: string;
constructor(status: number, detail: string) {
super(detail);
this.name = "ApiError";
this.status = status;
this.detail = detail;
}
}
/** API 클라이언트 */
class ApiClient {
private baseUrl: string;
constructor(baseUrl: string) {
this.baseUrl = baseUrl;
}
private async request<T>(path: string, options?: RequestInit): Promise<T> {
const response = await fetch(`${this.baseUrl}${path}`, {
headers: { "Content-Type": "application/json" },
...options,
});
if (!response.ok) {
let detail = "요청 처리 중 오류가 발생했습니다";
try {
const error = await response.json();
detail = error.detail || detail;
} catch {
// JSON 파싱 실패 시 기본 메시지 사용
}
throw new ApiError(response.status, detail);
}
return response.json();
}
/** 검사 시작 */
async startInspection(url: string): Promise<StartInspectionResponse> {
return this.request("/api/inspections", {
method: "POST",
body: JSON.stringify({ url }),
});
}
/** 검사 결과 조회 */
async getInspection(id: string): Promise<InspectionResult> {
return this.request(`/api/inspections/${id}`);
}
/** 이슈 목록 조회 */
async getIssues(
id: string,
filters?: IssueFilters
): Promise<IssueListResponse> {
const params = new URLSearchParams();
if (filters?.category && filters.category !== "all") {
params.set("category", filters.category);
}
if (filters?.severity && filters.severity !== "all") {
params.set("severity", filters.severity);
}
const qs = params.toString();
return this.request(`/api/inspections/${id}/issues${qs ? `?${qs}` : ""}`);
}
/** 이력 목록 조회 (페이지네이션) */
async getInspections(params: HistoryParams): Promise<PaginatedResponse> {
const qs = new URLSearchParams();
qs.set("page", String(params.page || 1));
qs.set("limit", String(params.limit || 20));
if (params.url) qs.set("url", params.url);
if (params.sort) qs.set("sort", params.sort);
return this.request(`/api/inspections?${qs}`);
}
/** 트렌드 데이터 조회 */
async getTrend(url: string, limit = 10): Promise<TrendResponse> {
const qs = new URLSearchParams({ url, limit: String(limit) });
return this.request(`/api/inspections/trend?${qs}`);
}
/** PDF 다운로드 */
async downloadPdf(id: string): Promise<void> {
const response = await fetch(
`${this.baseUrl}/api/inspections/${id}/report/pdf`
);
if (!response.ok) {
throw new ApiError(response.status, "PDF 다운로드에 실패했습니다");
}
const blob = await response.blob();
const url = URL.createObjectURL(blob);
const a = document.createElement("a");
a.href = url;
a.download =
response.headers.get("content-disposition")?.split("filename=")[1] ||
`web-inspector-report.pdf`;
document.body.appendChild(a);
a.click();
document.body.removeChild(a);
URL.revokeObjectURL(url);
}
/** JSON 다운로드 */
async downloadJson(id: string): Promise<void> {
const response = await fetch(
`${this.baseUrl}/api/inspections/${id}/report/json`
);
if (!response.ok) {
throw new ApiError(response.status, "JSON 다운로드에 실패했습니다");
}
const blob = await response.blob();
const url = URL.createObjectURL(blob);
const a = document.createElement("a");
a.href = url;
a.download =
response.headers.get("content-disposition")?.split("filename=")[1] ||
`web-inspector-report.json`;
document.body.appendChild(a);
a.click();
document.body.removeChild(a);
URL.revokeObjectURL(url);
}
/** SSE 스트림 URL 반환 */
getStreamUrl(inspectionId: string): string {
return `${this.baseUrl}/api/inspections/${inspectionId}/stream`;
}
}
export const api = new ApiClient(API_BASE_URL);

View File

@ -0,0 +1,141 @@
import type { CategoryKey, Grade, Severity } from "@/types/inspection";
/** 카테고리 한국어 라벨 */
export const CATEGORY_LABELS: Record<CategoryKey, string> = {
html_css: "HTML/CSS 표준",
accessibility: "접근성 (WCAG)",
seo: "SEO 최적화",
performance_security: "성능/보안",
};
/** 카테고리 짧은 라벨 */
export const CATEGORY_SHORT_LABELS: Record<CategoryKey, string> = {
html_css: "HTML/CSS",
accessibility: "접근성",
seo: "SEO",
performance_security: "성능/보안",
};
/** 카테고리 키 배열 */
export const CATEGORY_KEYS: CategoryKey[] = [
"html_css",
"accessibility",
"seo",
"performance_security",
];
/** 심각도 한국어 라벨 */
export const SEVERITY_LABELS: Record<Severity, string> = {
critical: "Critical",
major: "Major",
minor: "Minor",
info: "Info",
};
/** 심각도 색상 */
export const SEVERITY_COLORS: Record<Severity, string> = {
critical: "bg-red-500 text-white",
major: "bg-orange-500 text-white",
minor: "bg-yellow-500 text-white",
info: "bg-blue-500 text-white",
};
/** 심각도 배경 색상 (연한 버전) */
export const SEVERITY_BG_COLORS: Record<Severity, string> = {
critical: "bg-red-50 border-red-200",
major: "bg-orange-50 border-orange-200",
minor: "bg-yellow-50 border-yellow-200",
info: "bg-blue-50 border-blue-200",
};
/** 등급 색상 */
export const GRADE_COLORS: Record<Grade, string> = {
"A+": "text-green-600",
A: "text-green-600",
B: "text-blue-600",
C: "text-yellow-600",
D: "text-orange-600",
F: "text-red-600",
};
/** 등급 배경 색상 */
export const GRADE_BG_COLORS: Record<Grade, string> = {
"A+": "bg-green-100 text-green-700",
A: "bg-green-100 text-green-700",
B: "bg-blue-100 text-blue-700",
C: "bg-yellow-100 text-yellow-700",
D: "bg-orange-100 text-orange-700",
F: "bg-red-100 text-red-700",
};
/** 점수에 따른 색상 반환 */
export function getScoreColor(score: number): string {
if (score >= 80) return "#22C55E"; // 초록
if (score >= 50) return "#F59E0B"; // 주황
return "#EF4444"; // 빨강
}
/** 점수에 따른 Tailwind 색상 클래스 */
export function getScoreTailwindColor(score: number): string {
if (score >= 80) return "text-green-500";
if (score >= 50) return "text-yellow-500";
return "text-red-500";
}
/** 차트 카테고리 색상 */
export const CHART_COLORS: Record<string, string> = {
overall: "#6366F1",
html_css: "#3B82F6",
accessibility: "#22C55E",
seo: "#F59E0B",
performance_security: "#EF4444",
};
/** 차트 범례 라벨 */
export const CHART_LEGEND_LABELS: Record<string, string> = {
overall_score: "종합",
html_css: "HTML/CSS",
accessibility: "접근성",
seo: "SEO",
performance_security: "성능/보안",
};
/** 날짜 포맷 (로컬) */
export function formatDate(dateString: string): string {
const date = new Date(dateString);
return date.toLocaleDateString("ko-KR", {
year: "numeric",
month: "2-digit",
day: "2-digit",
});
}
/** 날짜+시간 포맷 (로컬) */
export function formatDateTime(dateString: string): string {
const date = new Date(dateString);
return date.toLocaleString("ko-KR", {
year: "numeric",
month: "2-digit",
day: "2-digit",
hour: "2-digit",
minute: "2-digit",
});
}
/** 짧은 날짜 포맷 (MM-DD) */
export function formatShortDate(dateString: string): string {
const date = new Date(dateString);
const month = String(date.getMonth() + 1).padStart(2, "0");
const day = String(date.getDate()).padStart(2, "0");
return `${month}-${day}`;
}
/** URL 유효성 검사 */
export function isValidUrl(url: string): boolean {
try {
const parsed = new URL(url);
return parsed.protocol === "http:" || parsed.protocol === "https:";
} catch {
return false;
}
}

View File

@ -0,0 +1,23 @@
"use client";
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import { useState, type ReactNode } from "react";
export function Providers({ children }: { children: ReactNode }) {
const [queryClient] = useState(
() =>
new QueryClient({
defaultOptions: {
queries: {
staleTime: 60 * 1000,
retry: 1,
refetchOnWindowFocus: false,
},
},
})
);
return (
<QueryClientProvider client={queryClient}>{children}</QueryClientProvider>
);
}

View File

@ -0,0 +1,57 @@
import {
useQuery,
keepPreviousData,
} from "@tanstack/react-query";
import { api } from "@/lib/api";
/** 검사 결과 조회 */
export function useInspectionResult(inspectionId: string | undefined) {
return useQuery({
queryKey: ["inspection", inspectionId],
queryFn: () => api.getInspection(inspectionId!),
enabled: !!inspectionId,
staleTime: 5 * 60 * 1000,
});
}
/** 이슈 목록 조회 */
export function useInspectionIssues(
inspectionId: string | undefined,
category?: string,
severity?: string
) {
return useQuery({
queryKey: ["inspection-issues", inspectionId, category, severity],
queryFn: () =>
api.getIssues(inspectionId!, { category, severity }),
enabled: !!inspectionId,
});
}
/** 이력 목록 조회 (페이지네이션) */
export function useInspectionHistory(page: number, url?: string) {
return useQuery({
queryKey: ["inspection-history", page, url],
queryFn: () => api.getInspections({ page, limit: 20, url }),
placeholderData: keepPreviousData,
});
}
/** 트렌드 데이터 조회 */
export function useInspectionTrend(url: string | undefined) {
return useQuery({
queryKey: ["inspection-trend", url],
queryFn: () => api.getTrend(url!),
enabled: !!url,
staleTime: 10 * 60 * 1000,
});
}
/** 최근 검사 이력 (메인 페이지용, 5건) */
export function useRecentInspections() {
return useQuery({
queryKey: ["recent-inspections"],
queryFn: () => api.getInspections({ page: 1, limit: 5 }),
staleTime: 60 * 1000,
});
}

View File

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

View File

@ -0,0 +1,140 @@
import { create } from "zustand";
import type {
CategoryKey,
CategoryProgress,
SSEProgressEvent,
SSECategoryCompleteEvent,
SSECompleteEvent,
} from "@/types/inspection";
const initialCategoryProgress: CategoryProgress = {
status: "pending",
progress: 0,
};
interface InspectionProgressState {
inspectionId: string | null;
url: string | null;
status: "idle" | "connecting" | "running" | "completed" | "error";
overallProgress: number;
categories: Record<CategoryKey, CategoryProgress>;
errorMessage: string | null;
overallScore: number | null;
// Actions
setInspection: (id: string, url: string) => void;
setConnecting: () => void;
updateProgress: (data: SSEProgressEvent) => void;
setCategoryComplete: (data: SSECategoryCompleteEvent) => void;
setCompleted: (data: SSECompleteEvent) => void;
setError: (message: string) => void;
reset: () => void;
}
export const useInspectionStore = create<InspectionProgressState>(
(set) => ({
inspectionId: null,
url: null,
status: "idle",
overallProgress: 0,
categories: {
html_css: { ...initialCategoryProgress },
accessibility: { ...initialCategoryProgress },
seo: { ...initialCategoryProgress },
performance_security: { ...initialCategoryProgress },
},
errorMessage: null,
overallScore: null,
setInspection: (id, url) =>
set({
inspectionId: id,
url,
status: "connecting",
overallProgress: 0,
categories: {
html_css: { ...initialCategoryProgress },
accessibility: { ...initialCategoryProgress },
seo: { ...initialCategoryProgress },
performance_security: { ...initialCategoryProgress },
},
errorMessage: null,
overallScore: null,
}),
setConnecting: () =>
set({ status: "connecting" }),
updateProgress: (data) =>
set({
status: "running",
overallProgress: data.overall_progress,
categories: {
html_css: {
status: data.categories.html_css.status,
progress: data.categories.html_css.progress,
currentStep: data.categories.html_css.current_step,
},
accessibility: {
status: data.categories.accessibility.status,
progress: data.categories.accessibility.progress,
currentStep: data.categories.accessibility.current_step,
},
seo: {
status: data.categories.seo.status,
progress: data.categories.seo.progress,
currentStep: data.categories.seo.current_step,
},
performance_security: {
status: data.categories.performance_security.status,
progress: data.categories.performance_security.progress,
currentStep:
data.categories.performance_security.current_step,
},
},
}),
setCategoryComplete: (data) =>
set((state) => ({
categories: {
...state.categories,
[data.category]: {
...state.categories[data.category],
status: "completed" as const,
progress: 100,
score: data.score,
totalIssues: data.total_issues,
},
},
})),
setCompleted: (data) =>
set({
status: "completed",
overallProgress: 100,
overallScore: data.overall_score,
}),
setError: (message) =>
set({
status: "error",
errorMessage: message,
}),
reset: () =>
set({
inspectionId: null,
url: null,
status: "idle",
overallProgress: 0,
categories: {
html_css: { ...initialCategoryProgress },
accessibility: { ...initialCategoryProgress },
seo: { ...initialCategoryProgress },
performance_security: { ...initialCategoryProgress },
},
errorMessage: null,
overallScore: null,
}),
})
);

View File

@ -0,0 +1,15 @@
import { create } from "zustand";
interface UiState {
/** 사이드바 열림 상태 (모바일) */
isMobileMenuOpen: boolean;
toggleMobileMenu: () => void;
closeMobileMenu: () => void;
}
export const useUiStore = create<UiState>((set) => ({
isMobileMenuOpen: false,
toggleMobileMenu: () =>
set((state) => ({ isMobileMenuOpen: !state.isMobileMenuOpen })),
closeMobileMenu: () => set({ isMobileMenuOpen: false }),
}));

View File

@ -0,0 +1,207 @@
/** 심각도 레벨 */
export type Severity = "critical" | "major" | "minor" | "info";
/** 검사 카테고리 식별자 */
export type CategoryKey =
| "html_css"
| "accessibility"
| "seo"
| "performance_security";
/** 등급 */
export type Grade = "A+" | "A" | "B" | "C" | "D" | "F";
/** 검사 상태 */
export type InspectionStatus = "running" | "completed" | "error";
// ───────────────────────────────────────────────────────
// API 응답 타입
// ───────────────────────────────────────────────────────
/** POST /api/inspections 응답 */
export interface StartInspectionResponse {
inspection_id: string;
status: InspectionStatus;
url: string;
stream_url: string;
}
/** 개별 이슈 */
export interface Issue {
code: string;
severity: Severity;
message: string;
element?: string;
line?: number;
suggestion?: string;
wcag_criterion?: string;
}
/** 카테고리별 이슈 수 */
export interface IssueCounts {
critical: number;
major: number;
minor: number;
info: number;
}
/** 카테고리 결과 */
export interface CategoryResult {
score: number;
grade: Grade;
total_issues: number;
critical: number;
major: number;
minor: number;
info: number;
issues?: Issue[];
wcag_level?: string;
meta_info?: Record<string, unknown>;
sub_scores?: {
security: number;
performance: number;
};
metrics?: Record<string, unknown>;
}
/** 이슈 요약 */
export interface IssueSummary {
total_issues: number;
critical: number;
major: number;
minor: number;
info: number;
}
/** GET /api/inspections/{id} 응답 - 검사 결과 */
export interface InspectionResult {
inspection_id: string;
url: string;
status: InspectionStatus;
created_at: string;
completed_at?: string;
duration_seconds: number;
overall_score: number;
grade: Grade;
categories: Record<CategoryKey, CategoryResult>;
summary: IssueSummary;
}
/** GET /api/inspections/{id}/issues 응답 */
export interface IssueListResponse {
inspection_id: string;
issues: Issue[];
total: number;
category?: string;
severity?: string;
}
/** 이슈 필터 파라미터 */
export interface IssueFilters {
category?: string;
severity?: string;
}
/** 이력 목록 파라미터 */
export interface HistoryParams {
page?: number;
limit?: number;
url?: string;
sort?: string;
}
/** 이력 목록 항목 */
export interface InspectionHistoryItem {
inspection_id: string;
url: string;
created_at: string;
overall_score: number;
grade: Grade;
total_issues: number;
}
/** GET /api/inspections 응답 - 페이지네이션 */
export interface PaginatedResponse {
items: InspectionHistoryItem[];
total: number;
page: number;
limit: number;
total_pages: number;
}
/** 트렌드 데이터 포인트 */
export interface TrendDataPoint {
inspection_id: string;
created_at: string;
overall_score: number;
html_css: number;
accessibility: number;
seo: number;
performance_security: number;
}
/** GET /api/inspections/trend 응답 */
export interface TrendResponse {
url: string;
data_points: TrendDataPoint[];
}
// ───────────────────────────────────────────────────────
// SSE 이벤트 타입
// ───────────────────────────────────────────────────────
/** SSE 카테고리 진행 상태 */
export interface SSECategoryStatus {
status: "pending" | "running" | "completed" | "error";
progress: number;
current_step?: string;
}
/** SSE progress 이벤트 데이터 */
export interface SSEProgressEvent {
inspection_id: string;
status: string;
overall_progress: number;
categories: Record<CategoryKey, SSECategoryStatus>;
}
/** SSE category_complete 이벤트 데이터 */
export interface SSECategoryCompleteEvent {
inspection_id: string;
category: CategoryKey;
score: number;
total_issues: number;
}
/** SSE complete 이벤트 데이터 */
export interface SSECompleteEvent {
inspection_id: string;
status: "completed";
overall_score: number;
redirect_url: string;
}
/** SSE error 이벤트 데이터 */
export interface SSEErrorEvent {
inspection_id: string;
status: "error";
message: string;
}
// ───────────────────────────────────────────────────────
// 프론트엔드 내부 상태 타입
// ───────────────────────────────────────────────────────
/** 카테고리 진행률 (Zustand Store) */
export interface CategoryProgress {
status: "pending" | "running" | "completed" | "error";
progress: number;
currentStep?: string;
score?: number;
totalIssues?: number;
}
/** API 에러 */
export interface ApiErrorResponse {
detail: string;
}

View File

@ -0,0 +1,57 @@
import type { Config } from "tailwindcss";
import tailwindcssAnimate from "tailwindcss-animate";
const config: Config = {
content: [
"./src/pages/**/*.{js,ts,jsx,tsx,mdx}",
"./src/components/**/*.{js,ts,jsx,tsx,mdx}",
"./src/app/**/*.{js,ts,jsx,tsx,mdx}",
],
theme: {
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))",
},
},
borderRadius: {
lg: "var(--radius)",
md: "calc(var(--radius) - 2px)",
sm: "calc(var(--radius) - 4px)",
},
},
},
plugins: [tailwindcssAnimate],
};
export default config;

27
frontend/tsconfig.json Normal file
View File

@ -0,0 +1,27 @@
{
"compilerOptions": {
"target": "ES2017",
"lib": ["dom", "dom.iterable", "esnext"],
"allowJs": true,
"skipLibCheck": true,
"strict": true,
"noEmit": true,
"esModuleInterop": true,
"module": "esnext",
"moduleResolution": "bundler",
"resolveJsonModule": true,
"isolatedModules": true,
"jsx": "preserve",
"incremental": true,
"plugins": [
{
"name": "next"
}
],
"paths": {
"@/*": ["./src/*"]
}
},
"include": ["next-env.d.ts", "**/*.ts", "**/*.tsx", ".next/types/**/*.ts"],
"exclude": ["node_modules"]
}