All checks were successful
ci/woodpecker/push/woodpecker Pipeline was successful
Fixes all 542 ESLint problems in the web package to achieve 0 errors and 0 warnings. Changes: - Fixed 144 issues: nullish coalescing, return types, unused variables - Fixed 118 issues: unnecessary conditions, type safety, template literals - Fixed 79 issues: non-null assertions, unsafe assignments, empty functions - Fixed 67 issues: explicit return types, promise handling, enum comparisons - Fixed 45 final warnings: missing return types, optional chains - Fixed 25 typecheck-related issues: async/await, type assertions, formatting - Fixed JSX.Element namespace errors across 90+ files All Quality Rails violations resolved. Lint and typecheck both pass with 0 problems. Files modified: 118 components, tests, hooks, and utilities Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
314 lines
10 KiB
TypeScript
314 lines
10 KiB
TypeScript
"use client";
|
|
|
|
import { useEffect, useState, useCallback } from "react";
|
|
import { fetchEntryGraph } from "@/lib/api/knowledge";
|
|
import Link from "next/link";
|
|
|
|
interface GraphNode {
|
|
id: string;
|
|
slug: string;
|
|
title: string;
|
|
summary: string | null;
|
|
tags: {
|
|
id: string;
|
|
name: string;
|
|
slug: string;
|
|
color: string | null;
|
|
}[];
|
|
depth: number;
|
|
}
|
|
|
|
interface GraphEdge {
|
|
id: string;
|
|
sourceId: string;
|
|
targetId: string;
|
|
linkText: string;
|
|
}
|
|
|
|
interface EntryGraphResponse {
|
|
centerNode: GraphNode;
|
|
nodes: GraphNode[];
|
|
edges: GraphEdge[];
|
|
stats: {
|
|
totalNodes: number;
|
|
totalEdges: number;
|
|
maxDepth: number;
|
|
};
|
|
}
|
|
|
|
interface EntryGraphViewerProps {
|
|
slug: string;
|
|
initialDepth?: number;
|
|
}
|
|
|
|
export function EntryGraphViewer({
|
|
slug,
|
|
initialDepth = 1,
|
|
}: EntryGraphViewerProps): React.JSX.Element {
|
|
const [graphData, setGraphData] = useState<EntryGraphResponse | null>(null);
|
|
const [depth, setDepth] = useState(initialDepth);
|
|
const [isLoading, setIsLoading] = useState(true);
|
|
const [error, setError] = useState<string | null>(null);
|
|
const [selectedNode, setSelectedNode] = useState<GraphNode | null>(null);
|
|
|
|
const loadGraph = useCallback(async () => {
|
|
try {
|
|
setIsLoading(true);
|
|
setError(null);
|
|
const data = await fetchEntryGraph(slug, depth);
|
|
setGraphData(data as EntryGraphResponse);
|
|
} catch (err) {
|
|
setError(err instanceof Error ? err.message : "Failed to load graph");
|
|
} finally {
|
|
setIsLoading(false);
|
|
}
|
|
}, [slug, depth]);
|
|
|
|
useEffect(() => {
|
|
void loadGraph();
|
|
}, [loadGraph]);
|
|
|
|
const handleDepthChange = (newDepth: number): void => {
|
|
setDepth(newDepth);
|
|
};
|
|
|
|
if (isLoading) {
|
|
return (
|
|
<div className="flex items-center justify-center p-12">
|
|
<div className="animate-spin rounded-full h-8 w-8 border-b-2 border-blue-500" />
|
|
</div>
|
|
);
|
|
}
|
|
|
|
if (error ?? !graphData) {
|
|
return (
|
|
<div className="p-8 text-center">
|
|
<div className="text-red-500 mb-2">Error loading graph</div>
|
|
<div className="text-sm text-gray-500 dark:text-gray-400">{error}</div>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
const { centerNode, nodes, edges, stats } = graphData;
|
|
|
|
// Group nodes by depth for better visualization
|
|
const nodesByDepth = nodes.reduce<Record<number, GraphNode[]>>((acc, node) => {
|
|
const d = node.depth;
|
|
acc[d] ??= [];
|
|
acc[d].push(node);
|
|
return acc;
|
|
}, {});
|
|
|
|
return (
|
|
<div className="flex flex-col h-full">
|
|
{/* Toolbar */}
|
|
<div className="flex items-center justify-between p-4 border-b border-gray-200 dark:border-gray-700 bg-white dark:bg-gray-800">
|
|
<div className="flex items-center gap-4">
|
|
<h2 className="text-lg font-semibold text-gray-900 dark:text-gray-100">Graph View</h2>
|
|
<div className="text-sm text-gray-500 dark:text-gray-400">
|
|
{stats.totalNodes} nodes • {stats.totalEdges} connections
|
|
</div>
|
|
</div>
|
|
|
|
<div className="flex items-center gap-3">
|
|
<label className="text-sm font-medium text-gray-700 dark:text-gray-300">Depth:</label>
|
|
<div className="flex rounded-lg overflow-hidden border border-gray-200 dark:border-gray-700">
|
|
{[1, 2, 3].map((d) => (
|
|
<button
|
|
key={d}
|
|
onClick={() => {
|
|
handleDepthChange(d);
|
|
}}
|
|
className={`px-3 py-1.5 text-sm font-medium transition-colors ${
|
|
depth === d
|
|
? "bg-blue-500 text-white"
|
|
: "bg-white dark:bg-gray-800 text-gray-700 dark:text-gray-300 hover:bg-gray-100 dark:hover:bg-gray-700"
|
|
}`}
|
|
>
|
|
{d}
|
|
</button>
|
|
))}
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
{/* Graph Visualization - Simple List View */}
|
|
<div className="flex-1 overflow-auto p-6">
|
|
<div className="max-w-4xl mx-auto space-y-6">
|
|
{/* Center Node */}
|
|
<div className="text-center mb-8">
|
|
<div className="inline-block">
|
|
<NodeCard
|
|
node={centerNode}
|
|
isCenter
|
|
onClick={() => {
|
|
setSelectedNode(centerNode);
|
|
}}
|
|
isSelected={selectedNode?.id === centerNode.id}
|
|
/>
|
|
</div>
|
|
</div>
|
|
|
|
{/* Nodes by Depth */}
|
|
{Object.entries(nodesByDepth)
|
|
.filter(([d]) => d !== "0")
|
|
.sort(([a], [b]) => Number(a) - Number(b))
|
|
.map(([depthLevel, depthNodes]) => (
|
|
<div key={depthLevel} className="space-y-3">
|
|
<h3 className="text-sm font-semibold text-gray-500 dark:text-gray-400 uppercase tracking-wide">
|
|
Depth {depthLevel} ({depthNodes.length}{" "}
|
|
{depthNodes.length === 1 ? "node" : "nodes"})
|
|
</h3>
|
|
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
|
{depthNodes.map((node) => (
|
|
<NodeCard
|
|
key={node.id}
|
|
node={node}
|
|
onClick={() => {
|
|
setSelectedNode(node);
|
|
}}
|
|
isSelected={selectedNode?.id === node.id}
|
|
connections={getNodeConnections(node.id, edges)}
|
|
/>
|
|
))}
|
|
</div>
|
|
</div>
|
|
))}
|
|
</div>
|
|
</div>
|
|
|
|
{/* Selected Node Details */}
|
|
{selectedNode && (
|
|
<div className="border-t border-gray-200 dark:border-gray-700 p-4 bg-gray-50 dark:bg-gray-800">
|
|
<div className="flex items-start justify-between max-w-4xl mx-auto">
|
|
<div className="flex-1">
|
|
<h3 className="font-semibold text-gray-900 dark:text-gray-100 text-lg">
|
|
{selectedNode.title}
|
|
</h3>
|
|
{selectedNode.summary && (
|
|
<p className="mt-2 text-sm text-gray-600 dark:text-gray-300">
|
|
{selectedNode.summary}
|
|
</p>
|
|
)}
|
|
{selectedNode.tags.length > 0 && (
|
|
<div className="flex flex-wrap gap-2 mt-3">
|
|
{selectedNode.tags.map((tag) => (
|
|
<span
|
|
key={tag.id}
|
|
className="inline-flex items-center px-2.5 py-0.5 rounded text-xs font-medium"
|
|
style={{
|
|
backgroundColor: tag.color ?? "#6B7280",
|
|
color: "#FFFFFF",
|
|
}}
|
|
>
|
|
{tag.name}
|
|
</span>
|
|
))}
|
|
</div>
|
|
)}
|
|
<div className="mt-4">
|
|
<Link
|
|
href={`/knowledge/${selectedNode.slug}`}
|
|
className="text-sm text-blue-600 hover:text-blue-800 dark:text-blue-400 dark:hover:text-blue-300"
|
|
>
|
|
View Full Entry →
|
|
</Link>
|
|
</div>
|
|
</div>
|
|
<button
|
|
onClick={() => {
|
|
setSelectedNode(null);
|
|
}}
|
|
className="ml-4 text-gray-400 hover:text-gray-600 dark:hover:text-gray-200"
|
|
>
|
|
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
|
<path
|
|
strokeLinecap="round"
|
|
strokeLinejoin="round"
|
|
strokeWidth={2}
|
|
d="M6 18L18 6M6 6l12 12"
|
|
/>
|
|
</svg>
|
|
</button>
|
|
</div>
|
|
</div>
|
|
)}
|
|
</div>
|
|
);
|
|
}
|
|
|
|
interface NodeCardProps {
|
|
node: GraphNode;
|
|
isCenter?: boolean;
|
|
onClick?: () => void;
|
|
isSelected?: boolean;
|
|
connections?: { incoming: number; outgoing: number };
|
|
}
|
|
|
|
function NodeCard({
|
|
node,
|
|
isCenter,
|
|
onClick,
|
|
isSelected,
|
|
connections,
|
|
}: NodeCardProps): React.JSX.Element {
|
|
return (
|
|
<div
|
|
onClick={onClick}
|
|
className={`p-4 rounded-lg border-2 transition-all cursor-pointer ${
|
|
isCenter
|
|
? "bg-blue-50 dark:bg-blue-900/20 border-blue-500 dark:border-blue-500"
|
|
: isSelected
|
|
? "bg-gray-100 dark:bg-gray-700 border-blue-400 dark:border-blue-400"
|
|
: "bg-white dark:bg-gray-800 border-gray-200 dark:border-gray-700 hover:border-gray-300 dark:hover:border-gray-600"
|
|
}`}
|
|
>
|
|
<div className="flex items-start justify-between">
|
|
<div className="flex-1 min-w-0">
|
|
<h4 className="font-medium text-gray-900 dark:text-gray-100 truncate">{node.title}</h4>
|
|
{node.summary && (
|
|
<p className="mt-1 text-sm text-gray-600 dark:text-gray-400 line-clamp-2">
|
|
{node.summary}
|
|
</p>
|
|
)}
|
|
{node.tags.length > 0 && (
|
|
<div className="flex flex-wrap gap-1 mt-2">
|
|
{node.tags.slice(0, 3).map((tag) => (
|
|
<span
|
|
key={tag.id}
|
|
className="inline-flex items-center px-2 py-0.5 rounded text-xs font-medium"
|
|
style={{
|
|
backgroundColor: tag.color ?? "#6B7280",
|
|
color: "#FFFFFF",
|
|
}}
|
|
>
|
|
{tag.name}
|
|
</span>
|
|
))}
|
|
{node.tags.length > 3 && (
|
|
<span className="inline-flex items-center px-2 py-0.5 rounded text-xs font-medium bg-gray-200 dark:bg-gray-600 text-gray-700 dark:text-gray-300">
|
|
+{node.tags.length - 3}
|
|
</span>
|
|
)}
|
|
</div>
|
|
)}
|
|
{connections && (
|
|
<div className="mt-2 text-xs text-gray-500 dark:text-gray-400">
|
|
{connections.incoming} incoming • {connections.outgoing} outgoing
|
|
</div>
|
|
)}
|
|
</div>
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
function getNodeConnections(
|
|
nodeId: string,
|
|
edges: GraphEdge[]
|
|
): { incoming: number; outgoing: number } {
|
|
const incoming = edges.filter((e) => e.targetId === nodeId).length;
|
|
const outgoing = edges.filter((e) => e.sourceId === nodeId).length;
|
|
return { incoming, outgoing };
|
|
}
|