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:
3
frontend/.eslintrc.json
Normal file
3
frontend/.eslintrc.json
Normal file
@ -0,0 +1,3 @@
|
||||
{
|
||||
"extends": "next/core-web-vitals"
|
||||
}
|
||||
39
frontend/.gitignore
vendored
Normal file
39
frontend/.gitignore
vendored
Normal 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
48
frontend/Dockerfile
Normal 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
20
frontend/components.json
Normal 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
7
frontend/next.config.ts
Normal 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
7426
frontend/package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load Diff
43
frontend/package.json
Normal file
43
frontend/package.json
Normal 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"
|
||||
}
|
||||
}
|
||||
8
frontend/postcss.config.mjs
Normal file
8
frontend/postcss.config.mjs
Normal file
@ -0,0 +1,8 @@
|
||||
/** @type {import('postcss-load-config').Config} */
|
||||
const config = {
|
||||
plugins: {
|
||||
tailwindcss: {},
|
||||
},
|
||||
};
|
||||
|
||||
export default config;
|
||||
0
frontend/public/favicon.ico
Normal file
0
frontend/public/favicon.ico
Normal file
37
frontend/src/app/globals.css
Normal file
37
frontend/src/app/globals.css
Normal 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;
|
||||
}
|
||||
}
|
||||
67
frontend/src/app/history/page.tsx
Normal file
67
frontend/src/app/history/page.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
123
frontend/src/app/history/trend/page.tsx
Normal file
123
frontend/src/app/history/trend/page.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
70
frontend/src/app/inspections/[id]/issues/page.tsx
Normal file
70
frontend/src/app/inspections/[id]/issues/page.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
143
frontend/src/app/inspections/[id]/page.tsx
Normal file
143
frontend/src/app/inspections/[id]/page.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
19
frontend/src/app/inspections/[id]/progress/page.tsx
Normal file
19
frontend/src/app/inspections/[id]/progress/page.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
34
frontend/src/app/layout.tsx
Normal file
34
frontend/src/app/layout.tsx
Normal 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
32
frontend/src/app/page.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
35
frontend/src/components/common/EmptyState.tsx
Normal file
35
frontend/src/components/common/EmptyState.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
33
frontend/src/components/common/ErrorState.tsx
Normal file
33
frontend/src/components/common/ErrorState.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
22
frontend/src/components/common/LoadingSpinner.tsx
Normal file
22
frontend/src/components/common/LoadingSpinner.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
23
frontend/src/components/common/ScoreBadge.tsx
Normal file
23
frontend/src/components/common/ScoreBadge.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
23
frontend/src/components/common/SeverityBadge.tsx
Normal file
23
frontend/src/components/common/SeverityBadge.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
83
frontend/src/components/dashboard/ActionButtons.tsx
Normal file
83
frontend/src/components/dashboard/ActionButtons.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
52
frontend/src/components/dashboard/CategoryScoreCard.tsx
Normal file
52
frontend/src/components/dashboard/CategoryScoreCard.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
41
frontend/src/components/dashboard/InspectionMeta.tsx
Normal file
41
frontend/src/components/dashboard/InspectionMeta.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
48
frontend/src/components/dashboard/IssueSummaryBar.tsx
Normal file
48
frontend/src/components/dashboard/IssueSummaryBar.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
51
frontend/src/components/dashboard/OverallScoreGauge.tsx
Normal file
51
frontend/src/components/dashboard/OverallScoreGauge.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
137
frontend/src/components/history/InspectionHistoryTable.tsx
Normal file
137
frontend/src/components/history/InspectionHistoryTable.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
97
frontend/src/components/history/Pagination.tsx
Normal file
97
frontend/src/components/history/Pagination.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
60
frontend/src/components/history/SearchBar.tsx
Normal file
60
frontend/src/components/history/SearchBar.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
72
frontend/src/components/inspection/CategoryProgressCard.tsx
Normal file
72
frontend/src/components/inspection/CategoryProgressCard.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
75
frontend/src/components/inspection/InspectionProgress.tsx
Normal file
75
frontend/src/components/inspection/InspectionProgress.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
32
frontend/src/components/inspection/OverallProgressBar.tsx
Normal file
32
frontend/src/components/inspection/OverallProgressBar.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
73
frontend/src/components/inspection/RecentInspections.tsx
Normal file
73
frontend/src/components/inspection/RecentInspections.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
103
frontend/src/components/inspection/UrlInputForm.tsx
Normal file
103
frontend/src/components/inspection/UrlInputForm.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
87
frontend/src/components/issues/FilterBar.tsx
Normal file
87
frontend/src/components/issues/FilterBar.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
96
frontend/src/components/issues/IssueCard.tsx
Normal file
96
frontend/src/components/issues/IssueCard.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
41
frontend/src/components/issues/IssueList.tsx
Normal file
41
frontend/src/components/issues/IssueList.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
10
frontend/src/components/layout/Footer.tsx
Normal file
10
frontend/src/components/layout/Footer.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
103
frontend/src/components/layout/Header.tsx
Normal file
103
frontend/src/components/layout/Header.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
106
frontend/src/components/trend/ComparisonSummary.tsx
Normal file
106
frontend/src/components/trend/ComparisonSummary.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
161
frontend/src/components/trend/TrendChart.tsx
Normal file
161
frontend/src/components/trend/TrendChart.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
35
frontend/src/components/ui/badge.tsx
Normal file
35
frontend/src/components/ui/badge.tsx
Normal 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 };
|
||||
55
frontend/src/components/ui/button.tsx
Normal file
55
frontend/src/components/ui/button.tsx
Normal file
@ -0,0 +1,55 @@
|
||||
import * as React from "react";
|
||||
import { Slot } from "@radix-ui/react-slot";
|
||||
import { cva, type VariantProps } from "class-variance-authority";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
const buttonVariants = cva(
|
||||
"inline-flex items-center justify-center 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 };
|
||||
85
frontend/src/components/ui/card.tsx
Normal file
85
frontend/src/components/ui/card.tsx
Normal 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,
|
||||
};
|
||||
22
frontend/src/components/ui/input.tsx
Normal file
22
frontend/src/components/ui/input.tsx
Normal 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 };
|
||||
32
frontend/src/components/ui/progress.tsx
Normal file
32
frontend/src/components/ui/progress.tsx
Normal 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 };
|
||||
116
frontend/src/components/ui/table.tsx
Normal file
116
frontend/src/components/ui/table.tsx
Normal 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,
|
||||
};
|
||||
27
frontend/src/components/ui/tooltip.tsx
Normal file
27
frontend/src/components/ui/tooltip.tsx
Normal 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 };
|
||||
103
frontend/src/hooks/useInspectionSSE.ts
Normal file
103
frontend/src/hooks/useInspectionSSE.ts
Normal 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
148
frontend/src/lib/api.ts
Normal 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);
|
||||
141
frontend/src/lib/constants.ts
Normal file
141
frontend/src/lib/constants.ts
Normal 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;
|
||||
}
|
||||
}
|
||||
23
frontend/src/lib/providers.tsx
Normal file
23
frontend/src/lib/providers.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
57
frontend/src/lib/queries.ts
Normal file
57
frontend/src/lib/queries.ts
Normal 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,
|
||||
});
|
||||
}
|
||||
6
frontend/src/lib/utils.ts
Normal file
6
frontend/src/lib/utils.ts
Normal 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));
|
||||
}
|
||||
140
frontend/src/stores/useInspectionStore.ts
Normal file
140
frontend/src/stores/useInspectionStore.ts
Normal 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,
|
||||
}),
|
||||
})
|
||||
);
|
||||
15
frontend/src/stores/useUiStore.ts
Normal file
15
frontend/src/stores/useUiStore.ts
Normal 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 }),
|
||||
}));
|
||||
207
frontend/src/types/inspection.ts
Normal file
207
frontend/src/types/inspection.ts
Normal 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;
|
||||
}
|
||||
57
frontend/tailwind.config.ts
Normal file
57
frontend/tailwind.config.ts
Normal 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
27
frontend/tsconfig.json
Normal 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"]
|
||||
}
|
||||
Reference in New Issue
Block a user