|
| 1 | +'use client'; |
| 2 | + |
| 3 | +import React, { useState, useEffect, useMemo, useCallback } from 'react'; |
| 4 | +import { useParams, useSearchParams } from 'next/navigation'; |
| 5 | +import Link from 'next/link'; |
| 6 | +import dynamic from 'next/dynamic'; |
| 7 | +import type { DiagramData } from '@/types/diagramData'; |
| 8 | +import DiagramDetailPanel from '@/components/DiagramDetailPanel'; |
| 9 | + |
| 10 | +// Lazy-load the canvas to avoid SSR issues with React Flow |
| 11 | +const ExplorerCanvas = dynamic( |
| 12 | + () => import('@/components/explorer/ExplorerCanvas'), |
| 13 | + { ssr: false }, |
| 14 | +); |
| 15 | + |
| 16 | +/* ------------------------------------------------------------------ */ |
| 17 | +/* Wiki page shape (matches the cache format) */ |
| 18 | +/* ------------------------------------------------------------------ */ |
| 19 | + |
| 20 | +interface CachedWikiPage { |
| 21 | + id: string; |
| 22 | + title: string; |
| 23 | + content: string; |
| 24 | + filePaths: string[]; |
| 25 | + importance: 'high' | 'medium' | 'low'; |
| 26 | + relatedPages: string[]; |
| 27 | + diagramData?: DiagramData[] | null; |
| 28 | + parentId?: string; |
| 29 | + isSection?: boolean; |
| 30 | + children?: string[]; |
| 31 | +} |
| 32 | + |
| 33 | +/* ------------------------------------------------------------------ */ |
| 34 | +/* Views */ |
| 35 | +/* ------------------------------------------------------------------ */ |
| 36 | + |
| 37 | +type ExplorerView = 'architecture' | 'dataflow' | 'dependencies'; |
| 38 | + |
| 39 | +const VIEW_LABELS: Record<ExplorerView, string> = { |
| 40 | + architecture: 'Architecture', |
| 41 | + dataflow: 'Data Flow', |
| 42 | + dependencies: 'Dependencies', |
| 43 | +}; |
| 44 | + |
| 45 | +/* ------------------------------------------------------------------ */ |
| 46 | +/* Page component */ |
| 47 | +/* ------------------------------------------------------------------ */ |
| 48 | + |
| 49 | +export default function ExplorePage() { |
| 50 | + const params = useParams(); |
| 51 | + const searchParams = useSearchParams(); |
| 52 | + |
| 53 | + const owner = params.owner as string; |
| 54 | + const repo = params.repo as string; |
| 55 | + const repoType = (searchParams.get('type') || 'github') as 'github' | 'gitlab' | 'bitbucket'; |
| 56 | + |
| 57 | + // State from URL or defaults |
| 58 | + const [selectedView, setSelectedView] = useState<ExplorerView>( |
| 59 | + (searchParams.get('view') as ExplorerView) || 'architecture', |
| 60 | + ); |
| 61 | + const [selectedDepth, setSelectedDepth] = useState<number>( |
| 62 | + Number(searchParams.get('depth') ?? 1), |
| 63 | + ); |
| 64 | + const [selectedNodeId, setSelectedNodeId] = useState<string | null>( |
| 65 | + searchParams.get('node') || null, |
| 66 | + ); |
| 67 | + |
| 68 | + // Data state |
| 69 | + const [allDiagrams, setAllDiagrams] = useState<DiagramData[]>([]); |
| 70 | + const [isLoading, setIsLoading] = useState(true); |
| 71 | + const [error, setError] = useState<string | null>(null); |
| 72 | + const [isPanelOpen, setIsPanelOpen] = useState(false); |
| 73 | + |
| 74 | + // Fetch wiki cache on mount |
| 75 | + useEffect(() => { |
| 76 | + async function fetchWikiData() { |
| 77 | + try { |
| 78 | + setIsLoading(true); |
| 79 | + const cacheParams = new URLSearchParams({ |
| 80 | + owner, |
| 81 | + repo, |
| 82 | + repo_type: repoType, |
| 83 | + }); |
| 84 | + const response = await fetch(`/api/wiki_cache?${cacheParams.toString()}`); |
| 85 | + if (!response.ok) { |
| 86 | + setError('Failed to fetch wiki data.'); |
| 87 | + setIsLoading(false); |
| 88 | + return; |
| 89 | + } |
| 90 | + |
| 91 | + const cachedData = await response.json(); |
| 92 | + if (!cachedData || !cachedData.generated_pages) { |
| 93 | + setError('No wiki data found. Generate a wiki first.'); |
| 94 | + setIsLoading(false); |
| 95 | + return; |
| 96 | + } |
| 97 | + |
| 98 | + // Extract all DiagramData from pages |
| 99 | + const diagrams: DiagramData[] = []; |
| 100 | + const pages = cachedData.generated_pages as Record<string, CachedWikiPage>; |
| 101 | + for (const page of Object.values(pages)) { |
| 102 | + if (page.diagramData && Array.isArray(page.diagramData)) { |
| 103 | + diagrams.push(...page.diagramData); |
| 104 | + } |
| 105 | + } |
| 106 | + |
| 107 | + setAllDiagrams(diagrams); |
| 108 | + setIsLoading(false); |
| 109 | + } catch (err) { |
| 110 | + console.error('Error loading explorer data:', err); |
| 111 | + setError('Error loading wiki data.'); |
| 112 | + setIsLoading(false); |
| 113 | + } |
| 114 | + } |
| 115 | + |
| 116 | + fetchWikiData(); |
| 117 | + }, [owner, repo, repoType]); |
| 118 | + |
| 119 | + // Merge all diagram data for the selected view into one DiagramData |
| 120 | + const mergedDiagram = useMemo<DiagramData | null>(() => { |
| 121 | + if (allDiagrams.length === 0) return null; |
| 122 | + |
| 123 | + // For now merge all diagrams. In future, filter by view type. |
| 124 | + const mergedNodes = new Map<string, DiagramData['nodes'][0]>(); |
| 125 | + const mergedEdges: DiagramData['edges'] = []; |
| 126 | + const edgeKeys = new Set<string>(); |
| 127 | + |
| 128 | + for (const diagram of allDiagrams) { |
| 129 | + for (const node of diagram.nodes) { |
| 130 | + if (!mergedNodes.has(node.id)) { |
| 131 | + mergedNodes.set(node.id, node); |
| 132 | + } |
| 133 | + } |
| 134 | + for (const edge of diagram.edges) { |
| 135 | + const key = `${edge.source}->${edge.target}:${edge.type}`; |
| 136 | + if (!edgeKeys.has(key)) { |
| 137 | + edgeKeys.add(key); |
| 138 | + mergedEdges.push(edge); |
| 139 | + } |
| 140 | + } |
| 141 | + } |
| 142 | + |
| 143 | + return { |
| 144 | + nodes: Array.from(mergedNodes.values()), |
| 145 | + edges: mergedEdges, |
| 146 | + mermaidSource: allDiagrams[0]?.mermaidSource ?? '', |
| 147 | + diagramType: allDiagrams[0]?.diagramType ?? 'flowchart', |
| 148 | + }; |
| 149 | + }, [allDiagrams]); |
| 150 | + |
| 151 | + // Look up the selected node info for the detail panel |
| 152 | + const selectedNodeInfo = useMemo(() => { |
| 153 | + if (!selectedNodeId || !mergedDiagram) return null; |
| 154 | + return mergedDiagram.nodes.find((n) => n.id === selectedNodeId) ?? null; |
| 155 | + }, [selectedNodeId, mergedDiagram]); |
| 156 | + |
| 157 | + // Handlers |
| 158 | + const handleNodeClick = useCallback( |
| 159 | + (nodeId: string) => { |
| 160 | + setSelectedNodeId(nodeId); |
| 161 | + setIsPanelOpen(true); |
| 162 | + }, |
| 163 | + [], |
| 164 | + ); |
| 165 | + |
| 166 | + const handleClosePanel = useCallback(() => { |
| 167 | + setIsPanelOpen(false); |
| 168 | + setSelectedNodeId(null); |
| 169 | + }, []); |
| 170 | + |
| 171 | + const handleViewChange = useCallback((view: ExplorerView) => { |
| 172 | + setSelectedView(view); |
| 173 | + }, []); |
| 174 | + |
| 175 | + const handleDepthChange = useCallback((depth: number) => { |
| 176 | + setSelectedDepth(depth); |
| 177 | + }, []); |
| 178 | + |
| 179 | + // Build back link |
| 180 | + const backHref = `/${owner}/${repo}${searchParams.get('type') ? `?type=${repoType}` : ''}`; |
| 181 | + |
| 182 | + // No diagram data state |
| 183 | + const hasNoDiagramData = !isLoading && !error && allDiagrams.length === 0; |
| 184 | + |
| 185 | + return ( |
| 186 | + <div className="h-screen flex flex-col bg-gray-50 dark:bg-zinc-950"> |
| 187 | + {/* ---- Toolbar ---- */} |
| 188 | + <div className="shrink-0 flex items-center justify-between px-4 py-2.5 border-b border-gray-200 dark:border-zinc-800 bg-white dark:bg-zinc-900"> |
| 189 | + {/* Left: Back + Repo name */} |
| 190 | + <div className="flex items-center gap-3"> |
| 191 | + <Link |
| 192 | + href={backHref} |
| 193 | + className="flex items-center gap-1.5 text-sm text-gray-500 dark:text-gray-400 hover:text-gray-900 dark:hover:text-gray-100 transition-colors" |
| 194 | + > |
| 195 | + <svg className="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}> |
| 196 | + <path strokeLinecap="round" strokeLinejoin="round" d="M15 19l-7-7 7-7" /> |
| 197 | + </svg> |
| 198 | + Back to Wiki |
| 199 | + </Link> |
| 200 | + <div className="w-px h-5 bg-gray-200 dark:bg-zinc-700" /> |
| 201 | + <span className="text-sm font-medium text-gray-900 dark:text-gray-100"> |
| 202 | + {owner}/{repo} |
| 203 | + </span> |
| 204 | + </div> |
| 205 | + |
| 206 | + {/* Center: View tabs */} |
| 207 | + <div className="flex items-center bg-gray-100 dark:bg-zinc-800 rounded-lg p-0.5 gap-0.5"> |
| 208 | + {(Object.entries(VIEW_LABELS) as [ExplorerView, string][]).map(([view, label]) => ( |
| 209 | + <button |
| 210 | + key={view} |
| 211 | + onClick={() => handleViewChange(view)} |
| 212 | + className={`px-3 py-1.5 rounded-md text-xs font-medium transition-all duration-150 ${ |
| 213 | + selectedView === view |
| 214 | + ? 'bg-white dark:bg-zinc-700 text-gray-900 dark:text-gray-100 shadow-sm' |
| 215 | + : 'text-gray-500 dark:text-gray-400 hover:text-gray-700 dark:hover:text-gray-300' |
| 216 | + }`} |
| 217 | + > |
| 218 | + {label} |
| 219 | + </button> |
| 220 | + ))} |
| 221 | + </div> |
| 222 | + |
| 223 | + {/* Right: spacer for balance */} |
| 224 | + <div className="w-[160px]" /> |
| 225 | + </div> |
| 226 | + |
| 227 | + {/* ---- Main content ---- */} |
| 228 | + <div className="flex-1 flex overflow-hidden relative"> |
| 229 | + {/* Canvas */} |
| 230 | + <div className="flex-1 overflow-hidden"> |
| 231 | + {isLoading ? ( |
| 232 | + <div className="flex items-center justify-center h-full"> |
| 233 | + <div className="text-center"> |
| 234 | + <div className="w-6 h-6 border-2 border-blue-500 border-t-transparent rounded-full animate-spin mx-auto mb-3" /> |
| 235 | + <p className="text-sm text-gray-500 dark:text-gray-400">Loading explorer data...</p> |
| 236 | + </div> |
| 237 | + </div> |
| 238 | + ) : error ? ( |
| 239 | + <div className="flex items-center justify-center h-full"> |
| 240 | + <div className="text-center max-w-md px-6"> |
| 241 | + <svg className="w-12 h-12 text-gray-300 dark:text-gray-600 mx-auto mb-3" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={1.5}> |
| 242 | + <path strokeLinecap="round" strokeLinejoin="round" d="M12 9v3.75m9-.75a9 9 0 11-18 0 9 9 0 0118 0zm-9 3.75h.008v.008H12v-.008z" /> |
| 243 | + </svg> |
| 244 | + <p className="text-sm text-gray-500 dark:text-gray-400">{error}</p> |
| 245 | + </div> |
| 246 | + </div> |
| 247 | + ) : hasNoDiagramData ? ( |
| 248 | + <div className="flex items-center justify-center h-full"> |
| 249 | + <div className="text-center max-w-md px-6"> |
| 250 | + <svg className="w-12 h-12 text-gray-300 dark:text-gray-600 mx-auto mb-3" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={1.5}> |
| 251 | + <path strokeLinecap="round" strokeLinejoin="round" d="M3.75 6A2.25 2.25 0 016 3.75h2.25A2.25 2.25 0 0110.5 6v2.25a2.25 2.25 0 01-2.25 2.25H6a2.25 2.25 0 01-2.25-2.25V6zM3.75 15.75A2.25 2.25 0 016 13.5h2.25a2.25 2.25 0 012.25 2.25V18a2.25 2.25 0 01-2.25 2.25H6A2.25 2.25 0 013.75 18v-2.25zM13.5 6a2.25 2.25 0 012.25-2.25H18A2.25 2.25 0 0120.25 6v2.25A2.25 2.25 0 0118 10.5h-2.25a2.25 2.25 0 01-2.25-2.25V6zM13.5 15.75a2.25 2.25 0 012.25-2.25H18a2.25 2.25 0 012.25 2.25V18A2.25 2.25 0 0118 20.25h-2.25A2.25 2.25 0 0113.5 18v-2.25z" /> |
| 252 | + </svg> |
| 253 | + <p className="text-sm font-medium text-gray-700 dark:text-gray-300 mb-1"> |
| 254 | + No structured diagram data available |
| 255 | + </p> |
| 256 | + <p className="text-xs text-gray-500 dark:text-gray-400"> |
| 257 | + This wiki doesn't have structured diagram data yet. Regenerate the wiki to enable the Visual Explorer. |
| 258 | + </p> |
| 259 | + </div> |
| 260 | + </div> |
| 261 | + ) : ( |
| 262 | + <ExplorerCanvas |
| 263 | + diagramData={mergedDiagram} |
| 264 | + maxDepth={selectedDepth} |
| 265 | + onNodeClick={handleNodeClick} |
| 266 | + selectedNodeId={selectedNodeId} |
| 267 | + /> |
| 268 | + )} |
| 269 | + </div> |
| 270 | + |
| 271 | + {/* Detail panel (reuse DiagramDetailPanel) */} |
| 272 | + <DiagramDetailPanel |
| 273 | + isOpen={isPanelOpen} |
| 274 | + onClose={handleClosePanel} |
| 275 | + nodeId={selectedNodeId} |
| 276 | + nodeLabel={selectedNodeInfo?.label ?? null} |
| 277 | + diagramData={mergedDiagram} |
| 278 | + repoOwner={owner} |
| 279 | + repoName={repo} |
| 280 | + repoType={repoType} |
| 281 | + /> |
| 282 | + </div> |
| 283 | + |
| 284 | + {/* ---- Depth toggle ---- */} |
| 285 | + <div className="shrink-0 flex items-center justify-center px-4 py-2 border-t border-gray-200 dark:border-zinc-800 bg-white dark:bg-zinc-900"> |
| 286 | + <div className="flex items-center gap-1 bg-gray-100 dark:bg-zinc-800 rounded-lg p-0.5"> |
| 287 | + {[ |
| 288 | + { depth: 0, label: 'Overview' }, |
| 289 | + { depth: 1, label: 'Detailed' }, |
| 290 | + { depth: 2, label: 'Full' }, |
| 291 | + ].map(({ depth, label }) => ( |
| 292 | + <button |
| 293 | + key={depth} |
| 294 | + onClick={() => handleDepthChange(depth)} |
| 295 | + className={`px-4 py-1.5 rounded-md text-xs font-medium transition-all duration-150 ${ |
| 296 | + selectedDepth === depth |
| 297 | + ? 'bg-white dark:bg-zinc-700 text-gray-900 dark:text-gray-100 shadow-sm' |
| 298 | + : 'text-gray-500 dark:text-gray-400 hover:text-gray-700 dark:hover:text-gray-300' |
| 299 | + }`} |
| 300 | + > |
| 301 | + {label} |
| 302 | + </button> |
| 303 | + ))} |
| 304 | + </div> |
| 305 | + </div> |
| 306 | + </div> |
| 307 | + ); |
| 308 | +} |
0 commit comments