/** * SitePagesSection Component * * Combined view of site hierarchy and analysed pages with rescan functionality. * Features: * - Tree view with hierarchy visualization * - Rescan all pages button * - Add specific URL to scan * - Rescan individual pages * - Analysis status per page * * @component * @layer Presentation */ import { useState, useMemo } from 'react'; import { ChevronRight, ChevronDown, Globe, FileText, ExternalLink, RefreshCw, Plus, Loader2, Database, CheckCircle2, AlertCircle, } from 'lucide-react'; import type { DiscoveredPage, PageAnalysis, PageElement } from '@domain/entities'; import { AnalysisStatus, PageStatus } from '@domain/entities'; import { ANALYSIS_STATUS_CONFIG, PAGE_STATUS_CONFIG } from '@domain/constants'; import { PageTrainingDataModal } from './PageTrainingDataModal'; import { Pagination } from '@/components/ui/Pagination'; import type { ScanProgress } from '../../presentation/features/info-center/hooks'; import type { PaginationInfo } from '../../presentation/features/info-center/hooks/useInfoCenter'; interface SitePagesSectionProps { pages: DiscoveredPage[]; tenantId: string; pageAnalyses: PageAnalysis[]; scanProgress: ScanProgress; pagination: PaginationInfo; onRescanAll: (tenantId: string) => void; onRescanPage: (pageId: string) => void; onAddUrl: (tenantId: string, url: string) => void; onElementUpdate: (analysisId: string, elementId: string, updatedElement: PageElement) => void; onPageChange: (page: number) => void; onPageSizeChange: (pageSize: number) => void; } /** * Build tree structure from flat page list */ function buildTree(pages: DiscoveredPage[]): TreeNode[] { // Create map of pages by ID const pageMap = new Map(); pages.forEach((page) => { pageMap.set(page.id, page); }); // Find root pages (no parent) const rootPages = pages.filter((page) => !page.parentPageId); // Build tree recursively function buildNode(page: DiscoveredPage): TreeNode { const children = pages .filter((p) => p.parentPageId === page.id) .map((child) => buildNode(child)) .sort((a, b) => a.page.url.localeCompare(b.page.url)); return { page, children, }; } return rootPages.map((page) => buildNode(page)).sort((a, b) => a.page.depth - b.page.depth); } /** * Tree Node Interface */ interface TreeNode { page: DiscoveredPage; children: TreeNode[]; } /** * Analysis Status Badge */ function AnalysisStatusBadge({ status }: { status: AnalysisStatus }) { const config = ANALYSIS_STATUS_CONFIG[status]; const colorClasses: Record = { green: 'bg-green-100 text-green-800 border-green-200', blue: 'bg-blue-100 text-blue-800 border-blue-200', yellow: 'bg-yellow-100 text-yellow-800 border-yellow-200', orange: 'bg-orange-100 text-orange-800 border-orange-200', red: 'bg-red-100 text-red-800 border-red-200', gray: 'bg-gray-100 text-gray-700 border-gray-200', }; // Extract color name from config.color (e.g., "text-green-600" -> "green") const colorName = config.color.split('-')[1] || 'gray'; return ( {config.label} ); } /** * Page Status Badge */ function PageStatusBadge({ status }: { status: PageStatus }) { const config = PAGE_STATUS_CONFIG[status]; const colorClasses: Record = { green: 'bg-green-100 text-green-800 border-green-200', blue: 'bg-blue-100 text-blue-800 border-blue-200', yellow: 'bg-yellow-100 text-yellow-800 border-yellow-200', red: 'bg-red-100 text-red-800 border-red-200', gray: 'bg-gray-100 text-gray-700 border-gray-200', }; // Extract color name from config.color (e.g., "text-green-600" -> "green") const colorName = config.color.split('-')[1] || 'gray'; return ( {config.label} ); } /** * TreeNode Component (Recursive) */ function TreeNodeComponent({ node, depth = 0, onRescanPage, rescanningPageId, onViewTrainingData, }: { node: TreeNode; depth?: number; onRescanPage: (pageId: string) => void; rescanningPageId: string | null; onViewTrainingData: (url: string) => void; }) { const [isExpanded, setIsExpanded] = useState(depth < 2); // Auto-expand first 2 levels const hasChildren = node.children.length > 0; const isRescanning = rescanningPageId === node.page.id; return (
{/* Expand/Collapse Icon */}
{hasChildren ? ( ) : null}
{/* Page Icon */}
{depth === 0 ? ( ) : ( )}
{/* Full URL */} e.stopPropagation()} title={node.page.url} > {node.page.url} {/* Page Title (if available) */} {node.page.title && ( {node.page.title} )} {/* Element Count */} {node.page.elementCount !== undefined && ( {node.page.elementCount} elements )} {/* Page Status */} {/* Analysis Status */} {/* Depth Badge */} L{node.page.depth} {/* View Training Data Button */} {/* Rescan Button */}
{/* Children */} {hasChildren && isExpanded && (
{node.children.map((child) => ( ))}
)}
); } /** * SitePagesSection Component */ export function SitePagesSection({ pages, tenantId, pageAnalyses, scanProgress, pagination, onRescanAll, onRescanPage, onAddUrl, onElementUpdate, onPageChange, onPageSizeChange, }: SitePagesSectionProps) { const [newUrl, setNewUrl] = useState(''); const [rescanningPageId, setRescanningPageId] = useState(null); const [viewingPageUrl, setViewingPageUrl] = useState(null); // Derive scanning state from scanProgress prop const isScanning = scanProgress.status === 'scanning'; const isScanCompleted = scanProgress.status === 'completed'; const isScanError = scanProgress.status === 'error'; // Build tree structure const tree = useMemo(() => buildTree(pages), [pages]); // Calculate stats const stats = useMemo(() => { const depthCounts: Record = {}; let maxDepth = 0; pages.forEach((page) => { depthCounts[page.depth] = (depthCounts[page.depth] || 0) + 1; maxDepth = Math.max(maxDepth, page.depth); }); return { depthCounts, maxDepth }; }, [pages]); /** * Handle rescan all - delegates to parent hook which manages scan state */ const handleRescanAll = () => { // Prevent multiple scans - button should be disabled but double check if (isScanning) return; onRescanAll(tenantId); }; /** * Handle rescan page */ const handleRescanPage = async (pageId: string) => { setRescanningPageId(pageId); try { await onRescanPage(pageId); } finally { setRescanningPageId(null); } }; /** * Handle add URL */ const handleAddUrl = () => { if (!newUrl.trim()) return; // Validate URL try { new URL(newUrl); onAddUrl(tenantId, newUrl.trim()); setNewUrl(''); } catch (error) { alert('Please enter a valid URL (e.g., https://example.com/page)'); } }; /** * Handle view training data */ const handleViewTrainingData = (url: string) => { setViewingPageUrl(url); }; return (
{/* Header with Actions */}

Site Pages & Hierarchy

{pages.length} pages (max depth: {stats.maxDepth})

{/* Rescan All Button */}
{/* Scan Progress Indicator */} {(isScanning || isScanCompleted || isScanError) && (
{isScanning ? ( ) : isScanCompleted ? ( ) : ( )}
{isScanning ? 'Analysis in Progress' : isScanCompleted ? 'Analysis Jobs Queued' : 'Analysis Failed'} {scanProgress.completedPages} / {scanProgress.totalPages} pages {scanProgress.failedPages > 0 && ( ({scanProgress.failedPages} failed) )}
{isScanning && scanProgress.currentPage && (

{scanProgress.currentPage}

)} {isScanCompleted && (

Full site scan has been scheduled. The Scout crawler will crawl your website, discover pages, and analyze training data. Refresh to see updates.

)} {isScanError && scanProgress.errorMessage && (

{scanProgress.errorMessage}

)}
{/* Progress bar */} {isScanning && (
{scanProgress.progress}% complete
)}
)} {/* Add URL Input */}
setNewUrl(e.target.value)} onKeyDown={(e) => e.key === 'Enter' && handleAddUrl()} placeholder="https://example.com/page" className="w-full px-4 py-2 border border-gray-300 rounded-lg focus:outline-none focus:ring-2 focus:ring-cyan-500 focus:border-transparent" />
{/* Depth Distribution */}
Depth distribution: {Object.entries(stats.depthCounts) .sort(([a], [b]) => Number(a) - Number(b)) .map(([depth, count]) => (
L{depth}:{' '} {count}
))}
{/* Tree View */}
{tree.length === 0 ? (

No pages to display

Add a URL above to start scanning

) : (
{tree.map((node) => ( ))}
)}
{/* Pagination Controls */} {pagination.total > 0 && (
)} {/* Legend */}
Legend:
Root page
Child page
L0, L1, L2... Depth level
Training Data (hover)
Rescan (hover to show)
{/* PageTrainingDataModal */} {viewingPageUrl && ( a.url === viewingPageUrl) || null} onClose={() => setViewingPageUrl(null)} onElementUpdate={onElementUpdate} /> )}
); }