This commit is contained in:
@@ -7,6 +7,7 @@ import { EntryStatus, Visibility } from "@mosaic/shared";
|
||||
import { EntryViewer } from "@/components/knowledge/EntryViewer";
|
||||
import { EntryEditor } from "@/components/knowledge/EntryEditor";
|
||||
import { EntryMetadata } from "@/components/knowledge/EntryMetadata";
|
||||
import { EntryGraphViewer } from "@/components/knowledge/EntryGraphViewer";
|
||||
import { fetchEntry, updateEntry, deleteEntry, fetchTags } from "@/lib/api/knowledge";
|
||||
|
||||
/**
|
||||
@@ -20,6 +21,7 @@ export default function EntryPage() {
|
||||
|
||||
const [entry, setEntry] = useState<KnowledgeEntryWithTags | null>(null);
|
||||
const [isEditing, setIsEditing] = useState(false);
|
||||
const [showGraph, setShowGraph] = useState(false);
|
||||
const [isLoading, setIsLoading] = useState(true);
|
||||
const [isSaving, setIsSaving] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
@@ -44,7 +46,7 @@ export default function EntryPage() {
|
||||
setEditContent(data.content);
|
||||
setEditStatus(data.status);
|
||||
setEditVisibility(data.visibility);
|
||||
setEditTags(data.tags.map((tag) => tag.id));
|
||||
setEditTags(data.tags.map((tag: { id: string }) => tag.id));
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : "Failed to load entry");
|
||||
} finally {
|
||||
@@ -80,7 +82,7 @@ export default function EntryPage() {
|
||||
editStatus !== entry.status ||
|
||||
editVisibility !== entry.visibility ||
|
||||
JSON.stringify(editTags.sort()) !==
|
||||
JSON.stringify(entry.tags.map((t) => t.id).sort());
|
||||
JSON.stringify(entry.tags.map((t: { id: string }) => t.id).sort());
|
||||
|
||||
setHasUnsavedChanges(changed);
|
||||
}, [entry, isEditing, editTitle, editContent, editStatus, editVisibility, editTags]);
|
||||
@@ -156,7 +158,7 @@ export default function EntryPage() {
|
||||
setEditContent(entry.content);
|
||||
setEditStatus(entry.status);
|
||||
setEditVisibility(entry.visibility);
|
||||
setEditTags(entry.tags.map((tag) => tag.id));
|
||||
setEditTags(entry.tags.map((tag: { id: string }) => tag.id));
|
||||
setIsEditing(false);
|
||||
setHasUnsavedChanges(false);
|
||||
}
|
||||
@@ -248,7 +250,7 @@ export default function EntryPage() {
|
||||
</span>
|
||||
|
||||
{/* Tags */}
|
||||
{entry.tags.map((tag) => (
|
||||
{entry.tags.map((tag: { id: string; name: string; color: string | null }) => (
|
||||
<span
|
||||
key={tag.id}
|
||||
className="px-3 py-1 rounded-full text-xs font-medium bg-gray-200 dark:bg-gray-700 text-gray-700 dark:text-gray-300"
|
||||
@@ -268,10 +270,42 @@ export default function EntryPage() {
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* View Tabs */}
|
||||
{!isEditing && (
|
||||
<div className="mb-4 border-b border-gray-200 dark:border-gray-700">
|
||||
<div className="flex gap-4">
|
||||
<button
|
||||
onClick={() => setShowGraph(false)}
|
||||
className={`px-4 py-2 text-sm font-medium border-b-2 transition-colors ${
|
||||
!showGraph
|
||||
? "border-blue-500 text-blue-600 dark:text-blue-400"
|
||||
: "border-transparent text-gray-500 dark:text-gray-400 hover:text-gray-700 dark:hover:text-gray-300"
|
||||
}`}
|
||||
>
|
||||
Content
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setShowGraph(true)}
|
||||
className={`px-4 py-2 text-sm font-medium border-b-2 transition-colors ${
|
||||
showGraph
|
||||
? "border-blue-500 text-blue-600 dark:text-blue-400"
|
||||
: "border-transparent text-gray-500 dark:text-gray-400 hover:text-gray-700 dark:hover:text-gray-300"
|
||||
}`}
|
||||
>
|
||||
Graph View
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Content */}
|
||||
<div className="mb-6">
|
||||
{isEditing ? (
|
||||
<EntryEditor content={editContent} onChange={setEditContent} />
|
||||
) : showGraph ? (
|
||||
<div className="bg-white dark:bg-gray-800 rounded-lg shadow-sm border border-gray-200 dark:border-gray-700 overflow-hidden" style={{ height: '600px' }}>
|
||||
<EntryGraphViewer slug={slug} initialDepth={1} />
|
||||
</div>
|
||||
) : (
|
||||
<EntryViewer entry={entry} />
|
||||
)}
|
||||
|
||||
@@ -40,7 +40,7 @@ export default function KnowledgePage() {
|
||||
// Filter by tag
|
||||
if (selectedTag !== "all") {
|
||||
filtered = filtered.filter((entry) =>
|
||||
entry.tags.some((tag) => tag.slug === selectedTag)
|
||||
entry.tags.some((tag: { slug: string }) => tag.slug === selectedTag)
|
||||
);
|
||||
}
|
||||
|
||||
@@ -51,7 +51,7 @@ export default function KnowledgePage() {
|
||||
(entry) =>
|
||||
entry.title.toLowerCase().includes(query) ||
|
||||
entry.summary?.toLowerCase().includes(query) ||
|
||||
entry.tags.some((tag) => tag.name.toLowerCase().includes(query))
|
||||
entry.tags.some((tag: { name: string }) => tag.name.toLowerCase().includes(query))
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,5 @@
|
||||
import { StatsDashboard } from "@/components/knowledge";
|
||||
|
||||
export default function KnowledgeStatsPage() {
|
||||
return <StatsDashboard />;
|
||||
}
|
||||
@@ -61,7 +61,7 @@ export function EntryCard({ entry }: EntryCardProps) {
|
||||
{/* Tags */}
|
||||
{entry.tags && entry.tags.length > 0 && (
|
||||
<div className="flex flex-wrap gap-1.5 mb-3">
|
||||
{entry.tags.map((tag) => (
|
||||
{entry.tags.map((tag: { id: string; name: string; color: string | null }) => (
|
||||
<span
|
||||
key={tag.id}
|
||||
className="inline-flex items-center px-2 py-0.5 rounded-full text-xs font-medium"
|
||||
@@ -79,10 +79,12 @@ export function EntryCard({ entry }: EntryCardProps) {
|
||||
{/* Metadata row */}
|
||||
<div className="flex flex-wrap items-center gap-3 text-xs text-gray-500">
|
||||
{/* Status */}
|
||||
<span className={`inline-flex items-center gap-1 px-2 py-1 rounded-full ${statusInfo.className}`}>
|
||||
<span>{statusInfo.icon}</span>
|
||||
<span>{statusInfo.label}</span>
|
||||
</span>
|
||||
{statusInfo && (
|
||||
<span className={`inline-flex items-center gap-1 px-2 py-1 rounded-full ${statusInfo.className}`}>
|
||||
<span>{statusInfo.icon}</span>
|
||||
<span>{statusInfo.label}</span>
|
||||
</span>
|
||||
)}
|
||||
|
||||
{/* Visibility */}
|
||||
<span className="inline-flex items-center gap-1">
|
||||
|
||||
293
apps/web/src/components/knowledge/EntryGraphViewer.tsx
Normal file
293
apps/web/src/components/knowledge/EntryGraphViewer.tsx
Normal file
@@ -0,0 +1,293 @@
|
||||
"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: Array<{
|
||||
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) {
|
||||
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) => {
|
||||
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((acc, node) => {
|
||||
const d = node.depth;
|
||||
if (!acc[d]) acc[d] = [];
|
||||
acc[d].push(node);
|
||||
return acc;
|
||||
}, {} as Record<number, GraphNode[]>);
|
||||
|
||||
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) {
|
||||
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[]) {
|
||||
const incoming = edges.filter((e) => e.targetId === nodeId).length;
|
||||
const outgoing = edges.filter((e) => e.sourceId === nodeId).length;
|
||||
return { incoming, outgoing };
|
||||
}
|
||||
249
apps/web/src/components/knowledge/StatsDashboard.tsx
Normal file
249
apps/web/src/components/knowledge/StatsDashboard.tsx
Normal file
@@ -0,0 +1,249 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect, useState } from "react";
|
||||
import { fetchKnowledgeStats } from "@/lib/api/knowledge";
|
||||
import Link from "next/link";
|
||||
|
||||
interface KnowledgeStats {
|
||||
overview: {
|
||||
totalEntries: number;
|
||||
totalTags: number;
|
||||
totalLinks: number;
|
||||
publishedEntries: number;
|
||||
draftEntries: number;
|
||||
archivedEntries: number;
|
||||
};
|
||||
mostConnected: Array<{
|
||||
id: string;
|
||||
slug: string;
|
||||
title: string;
|
||||
incomingLinks: number;
|
||||
outgoingLinks: number;
|
||||
totalConnections: number;
|
||||
}>;
|
||||
recentActivity: Array<{
|
||||
id: string;
|
||||
slug: string;
|
||||
title: string;
|
||||
updatedAt: string;
|
||||
status: string;
|
||||
}>;
|
||||
tagDistribution: Array<{
|
||||
id: string;
|
||||
name: string;
|
||||
slug: string;
|
||||
color: string | null;
|
||||
entryCount: number;
|
||||
}>;
|
||||
}
|
||||
|
||||
export function StatsDashboard() {
|
||||
const [stats, setStats] = useState<KnowledgeStats | null>(null);
|
||||
const [isLoading, setIsLoading] = useState(true);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
async function loadStats() {
|
||||
try {
|
||||
setIsLoading(true);
|
||||
const data = await fetchKnowledgeStats();
|
||||
setStats(data as KnowledgeStats);
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : "Failed to load statistics");
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
}
|
||||
|
||||
void loadStats();
|
||||
}, []);
|
||||
|
||||
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 || !stats) {
|
||||
return (
|
||||
<div className="p-8 text-center text-red-500">
|
||||
Error loading statistics: {error}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const { overview, mostConnected, recentActivity, tagDistribution } = stats;
|
||||
|
||||
return (
|
||||
<div className="p-6 space-y-6">
|
||||
<div className="flex items-center justify-between">
|
||||
<h1 className="text-2xl font-bold text-gray-900 dark:text-gray-100">
|
||||
Knowledge Base Statistics
|
||||
</h1>
|
||||
<Link
|
||||
href="/knowledge"
|
||||
className="text-sm text-blue-600 hover:text-blue-800 dark:text-blue-400 dark:hover:text-blue-300"
|
||||
>
|
||||
Back to Entries
|
||||
</Link>
|
||||
</div>
|
||||
|
||||
{/* Overview Cards */}
|
||||
<div className="grid grid-cols-1 md:grid-cols-3 gap-4">
|
||||
<StatsCard
|
||||
title="Total Entries"
|
||||
value={overview.totalEntries}
|
||||
subtitle={`${overview.publishedEntries} published • ${overview.draftEntries} drafts`}
|
||||
icon="📚"
|
||||
/>
|
||||
<StatsCard
|
||||
title="Tags"
|
||||
value={overview.totalTags}
|
||||
subtitle="Organize your knowledge"
|
||||
icon="🏷️"
|
||||
/>
|
||||
<StatsCard
|
||||
title="Connections"
|
||||
value={overview.totalLinks}
|
||||
subtitle="Links between entries"
|
||||
icon="🔗"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Two Column Layout */}
|
||||
<div className="grid grid-cols-1 lg:grid-cols-2 gap-6">
|
||||
{/* Most Connected Entries */}
|
||||
<div className="bg-white dark:bg-gray-800 rounded-lg shadow p-6">
|
||||
<h2 className="text-lg font-semibold text-gray-900 dark:text-gray-100 mb-4">
|
||||
Most Connected Entries
|
||||
</h2>
|
||||
<div className="space-y-3">
|
||||
{mostConnected.slice(0, 10).map((entry) => (
|
||||
<div
|
||||
key={entry.id}
|
||||
className="flex items-center justify-between p-3 bg-gray-50 dark:bg-gray-700 rounded hover:bg-gray-100 dark:hover:bg-gray-600 transition-colors"
|
||||
>
|
||||
<div className="flex-1 min-w-0">
|
||||
<Link
|
||||
href={`/knowledge/${entry.slug}`}
|
||||
className="text-sm font-medium text-gray-900 dark:text-gray-100 hover:text-blue-600 dark:hover:text-blue-400 truncate block"
|
||||
>
|
||||
{entry.title}
|
||||
</Link>
|
||||
<div className="text-xs text-gray-500 dark:text-gray-400 mt-1">
|
||||
{entry.incomingLinks} incoming • {entry.outgoingLinks} outgoing
|
||||
</div>
|
||||
</div>
|
||||
<div className="ml-4 flex-shrink-0">
|
||||
<span className="inline-flex items-center justify-center w-10 h-10 rounded-full bg-blue-100 dark:bg-blue-900 text-blue-600 dark:text-blue-400 font-semibold text-sm">
|
||||
{entry.totalConnections}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
{mostConnected.length === 0 && (
|
||||
<p className="text-sm text-gray-500 dark:text-gray-400 text-center py-4">
|
||||
No connections yet
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Recent Activity */}
|
||||
<div className="bg-white dark:bg-gray-800 rounded-lg shadow p-6">
|
||||
<h2 className="text-lg font-semibold text-gray-900 dark:text-gray-100 mb-4">
|
||||
Recent Activity
|
||||
</h2>
|
||||
<div className="space-y-3">
|
||||
{recentActivity.slice(0, 10).map((entry) => (
|
||||
<div
|
||||
key={entry.id}
|
||||
className="flex items-start justify-between p-3 bg-gray-50 dark:bg-gray-700 rounded hover:bg-gray-100 dark:hover:bg-gray-600 transition-colors"
|
||||
>
|
||||
<div className="flex-1 min-w-0">
|
||||
<Link
|
||||
href={`/knowledge/${entry.slug}`}
|
||||
className="text-sm font-medium text-gray-900 dark:text-gray-100 hover:text-blue-600 dark:hover:text-blue-400 truncate block"
|
||||
>
|
||||
{entry.title}
|
||||
</Link>
|
||||
<div className="text-xs text-gray-500 dark:text-gray-400 mt-1">
|
||||
{new Date(entry.updatedAt).toLocaleDateString()} •{" "}
|
||||
<span className="capitalize">{entry.status.toLowerCase()}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
{recentActivity.length === 0 && (
|
||||
<p className="text-sm text-gray-500 dark:text-gray-400 text-center py-4">
|
||||
No recent activity
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Tag Distribution */}
|
||||
<div className="bg-white dark:bg-gray-800 rounded-lg shadow p-6">
|
||||
<h2 className="text-lg font-semibold text-gray-900 dark:text-gray-100 mb-4">
|
||||
Tag Distribution
|
||||
</h2>
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 md:grid-cols-3 lg:grid-cols-4 gap-3">
|
||||
{tagDistribution.slice(0, 12).map((tag) => (
|
||||
<div
|
||||
key={tag.id}
|
||||
className="flex items-center justify-between p-3 rounded bg-gray-50 dark:bg-gray-700"
|
||||
>
|
||||
<div className="flex items-center gap-2 flex-1 min-w-0">
|
||||
{tag.color && (
|
||||
<div
|
||||
className="w-3 h-3 rounded-full flex-shrink-0"
|
||||
style={{ backgroundColor: tag.color }}
|
||||
/>
|
||||
)}
|
||||
<span className="text-sm font-medium text-gray-900 dark:text-gray-100 truncate">
|
||||
{tag.name}
|
||||
</span>
|
||||
</div>
|
||||
<span className="ml-2 text-xs font-semibold text-gray-600 dark:text-gray-400 flex-shrink-0">
|
||||
{tag.entryCount}
|
||||
</span>
|
||||
</div>
|
||||
))}
|
||||
{tagDistribution.length === 0 && (
|
||||
<p className="text-sm text-gray-500 dark:text-gray-400 col-span-full text-center py-4">
|
||||
No tags yet
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function StatsCard({
|
||||
title,
|
||||
value,
|
||||
subtitle,
|
||||
icon,
|
||||
}: {
|
||||
title: string;
|
||||
value: number;
|
||||
subtitle: string;
|
||||
icon: string;
|
||||
}) {
|
||||
return (
|
||||
<div className="bg-white dark:bg-gray-800 rounded-lg shadow p-6">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<p className="text-sm font-medium text-gray-600 dark:text-gray-400">{title}</p>
|
||||
<p className="text-3xl font-bold text-gray-900 dark:text-gray-100 mt-2">{value}</p>
|
||||
<p className="text-xs text-gray-500 dark:text-gray-400 mt-1">{subtitle}</p>
|
||||
</div>
|
||||
<div className="text-4xl">{icon}</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,7 +1,8 @@
|
||||
/**
|
||||
* Knowledge module components
|
||||
*/
|
||||
|
||||
export { EntryViewer } from "./EntryViewer";
|
||||
export { EntryCard } from "./EntryCard";
|
||||
export { EntryEditor } from "./EntryEditor";
|
||||
export { EntryFilters } from "./EntryFilters";
|
||||
export { EntryList } from "./EntryList";
|
||||
export { EntryMetadata } from "./EntryMetadata";
|
||||
export { EntryViewer } from "./EntryViewer";
|
||||
export { StatsDashboard } from "./StatsDashboard";
|
||||
export { EntryGraphViewer } from "./EntryGraphViewer";
|
||||
|
||||
@@ -128,6 +128,22 @@ export async function fetchTags(): Promise<KnowledgeTag[]> {
|
||||
return response.data;
|
||||
}
|
||||
|
||||
/**
|
||||
* Fetch entry-centered graph view
|
||||
*/
|
||||
export async function fetchEntryGraph(slug: string, depth: number = 1) {
|
||||
const params = new URLSearchParams();
|
||||
params.append("depth", depth.toString());
|
||||
return apiGet(`/api/knowledge/entries/${slug}/graph?${params.toString()}`);
|
||||
}
|
||||
|
||||
/**
|
||||
* Fetch knowledge base statistics
|
||||
*/
|
||||
export async function fetchKnowledgeStats() {
|
||||
return apiGet("/api/knowledge/stats");
|
||||
}
|
||||
|
||||
/**
|
||||
* Mock entries for development (until backend endpoints are ready)
|
||||
*/
|
||||
|
||||
Reference in New Issue
Block a user