Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions bun.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

128 changes: 66 additions & 62 deletions packages/dashboard/src/client/components/BrowsePage.tsx
Original file line number Diff line number Diff line change
@@ -1,19 +1,32 @@
import { ArrowLeft, FileText, Tag } from "lucide-react";
import { ArrowLeft, FileText } from "lucide-react";
import { Marked } from "marked";
import { useEffect, useMemo, useState } from "react";
import { type ArticleItem, api, type RawItem } from "../api.js";

const marked = new Marked();

const CATEGORY_COLORS: Record<string, string> = {
concept: "bg-blue-100 text-blue-700",
topic: "bg-green-100 text-green-700",
reference: "bg-orange-100 text-orange-700",
output: "bg-purple-100 text-purple-700",
concept: "bg-blue-50 text-blue-600",
topic: "bg-green-50 text-green-600",
reference: "bg-orange-50 text-orange-600",
output: "bg-purple-50 text-purple-600",
};

type Scope = "wiki" | "raw";

function SkeletonList() {
return (
<div className="space-y-2">
{[1, 2, 3, 4, 5].map((i) => (
<div key={i} className="border rounded-lg p-4">
<div className="skeleton h-3.5 w-48 mb-2" />
<div className="skeleton h-2.5 w-72" />
</div>
))}
</div>
);
}

export function BrowsePage({ revision = 0 }: { revision?: number }) {
const [scope, setScope] = useState<Scope>("raw");
const [wikiArticles, setWikiArticles] = useState<ArticleItem[]>([]);
Expand All @@ -30,7 +43,6 @@ export function BrowsePage({ revision = 0 }: { revision?: number }) {
Promise.all([api.getArticles("wiki"), api.getRawSources()]).then(([wiki, raw]) => {
setWikiArticles(wiki);
setRawSources(raw);
// Default to whichever has content
if (wiki.length === 0 && raw.length > 0) setScope("raw");
else if (wiki.length > 0) setScope("wiki");
setLoading(false);
Expand Down Expand Up @@ -78,18 +90,19 @@ export function BrowsePage({ revision = 0 }: { revision?: number }) {
return marked.parse(stripped) as string;
}, [content]);

// Article detail view
if (selectedPath) {
return (
<div className="p-8 max-w-3xl">
<div className="p-10 max-w-2xl animate-page-in">
<button
type="button"
onClick={() => {
setSelectedPath(null);
setContent("");
}}
className="flex items-center gap-1 text-sm text-[var(--color-muted)] hover:text-[var(--color-foreground)] mb-4 transition-colors"
className="flex items-center gap-1.5 text-[11px] text-[#999] hover:text-[#111] mb-8"
>
<ArrowLeft size={14} />
<ArrowLeft size={12} />
Back
</button>
<article
Expand All @@ -101,133 +114,124 @@ export function BrowsePage({ revision = 0 }: { revision?: number }) {
}

return (
<div className="p-8 max-w-4xl">
<h2 className="text-xl font-semibold mb-4">Browse</h2>
<div className="p-10 max-w-3xl animate-page-in">
<h2 className="text-lg font-semibold tracking-tight mb-6">Browse</h2>

{/* Scope tabs */}
<div className="flex gap-1 mb-4 border-b">
{/* Tabs */}
<div className="flex gap-4 mb-6">
<button
type="button"
onClick={() => setScope("wiki")}
className={`px-4 py-2 text-sm font-medium border-b-2 transition-colors ${
className={`text-xs pb-1 border-b transition-colors ${
scope === "wiki"
? "border-[var(--color-foreground)] text-[var(--color-foreground)]"
: "border-transparent text-[var(--color-muted)] hover:text-[var(--color-foreground)]"
? "border-[#111] text-[#111]"
: "border-transparent text-[#999] hover:text-[#555]"
}`}
>
Articles ({wikiArticles.length})
Articles
<span className="text-[#bbb] ml-1.5">{wikiArticles.length}</span>
</button>
<button
type="button"
onClick={() => setScope("raw")}
className={`px-4 py-2 text-sm font-medium border-b-2 transition-colors ${
className={`text-xs pb-1 border-b transition-colors ${
scope === "raw"
? "border-[var(--color-foreground)] text-[var(--color-foreground)]"
: "border-transparent text-[var(--color-muted)] hover:text-[var(--color-foreground)]"
? "border-[#111] text-[#111]"
: "border-transparent text-[#999] hover:text-[#555]"
}`}
>
Sources ({rawSources.length})
Sources
<span className="text-[#bbb] ml-1.5">{rawSources.length}</span>
</button>
</div>

<div className="flex gap-3 mb-4">
{/* Filters */}
<div className="flex gap-2 mb-5">
<input
type="text"
placeholder={scope === "wiki" ? "Filter articles..." : "Filter sources..."}
placeholder="Filter..."
value={filter}
onChange={(e) => setFilter(e.target.value)}
className="flex-1 px-3 py-2 border rounded-md text-sm bg-white focus:outline-none focus:ring-2 focus:ring-[var(--color-accent)] focus:ring-offset-1"
className="flex-1 px-3 py-2 border rounded-md text-xs bg-white"
/>
{scope === "wiki" && categories.length > 1 && (
<select
value={categoryFilter}
onChange={(e) => setCategoryFilter(e.target.value)}
className="px-3 py-2 border rounded-md text-sm bg-white focus:outline-none"
className="px-3 py-2 border rounded-md text-xs bg-white"
>
{categories.map((cat) => (
<option key={cat} value={cat}>
{cat === "all" ? "All categories" : cat}
{cat === "all" ? "All" : cat}
</option>
))}
</select>
)}
</div>

{/* Content */}
{loading ? (
<p className="text-sm text-[var(--color-muted)]">Loading...</p>
<SkeletonList />
) : scope === "wiki" ? (
filteredWiki.length === 0 ? (
<p className="text-sm text-[var(--color-muted)]">
<p className="text-xs text-[#999] py-8">
{wikiArticles.length === 0
? "No compiled articles yet. Sources are available in the Sources tab."
: "No matching articles."}
? "No compiled articles. Try the Sources tab."
: "No matches."}
</p>
) : (
<div className="space-y-2">
<div className="space-y-1.5">
{filteredWiki.map((article) => (
<button
key={article.path}
type="button"
onClick={() => openArticle(article.path, "wiki")}
className="w-full text-left border rounded-lg p-4 bg-white hover:border-[var(--color-accent)] transition-colors"
className="w-full text-left border rounded-lg px-4 py-3.5 bg-white hover:border-[#ccc] transition-colors"
>
<div className="flex items-start justify-between gap-2">
<div className="flex items-center justify-between gap-3">
<div className="flex-1 min-w-0">
<h3 className="text-sm font-medium truncate">{article.slug}</h3>
<h3 className="text-xs font-medium truncate">{article.slug}</h3>
{article.summary && (
<p className="text-xs text-[var(--color-muted)] mt-1 line-clamp-2">
{article.summary}
</p>
<p className="text-[11px] text-[#999] mt-0.5 truncate">{article.summary}</p>
)}
</div>
<span
className={`text-[10px] px-2 py-0.5 rounded-full whitespace-nowrap ${
CATEGORY_COLORS[article.category] ?? "bg-gray-100 text-gray-700"
className={`text-[9px] px-2 py-0.5 rounded-full flex-shrink-0 ${
CATEGORY_COLORS[article.category] ?? "bg-gray-50 text-gray-500"
}`}
>
{article.category}
</span>
</div>
{article.tags.length > 0 && (
<div className="flex items-center gap-1.5 mt-2 flex-wrap">
<Tag size={10} className="text-[var(--color-muted)]" />
{article.tags.map((tag) => (
<span
key={tag}
className="text-[10px] text-[var(--color-muted)] bg-gray-100 px-1.5 py-0.5 rounded"
>
{tag}
</span>
))}
</div>
)}
</button>
))}
</div>
)
) : filteredRaw.length === 0 ? (
<p className="text-sm text-[var(--color-muted)]">
{rawSources.length === 0 ? "No sources yet." : "No matching sources."}
<p className="text-xs text-[#999] py-8">
{rawSources.length === 0 ? "No sources yet." : "No matches."}
</p>
) : (
<div className="space-y-2">
<div className="space-y-1.5">
{filteredRaw.map((source) => (
<button
key={source.path}
type="button"
onClick={() => openArticle(source.path, "raw")}
className="w-full text-left border rounded-lg p-4 bg-white hover:border-[var(--color-accent)] transition-colors"
className="w-full text-left border rounded-lg px-4 py-3.5 bg-white hover:border-[#ccc] transition-colors"
>
<div className="flex items-start justify-between gap-2">
<div className="flex items-center justify-between gap-3">
<div className="flex items-center gap-2 flex-1 min-w-0">
<FileText size={14} className="text-[var(--color-muted)] flex-shrink-0" />
<h3 className="text-sm font-medium truncate">{source.title}</h3>
<FileText size={12} className="text-[#bbb] flex-shrink-0" />
<h3 className="text-xs font-medium truncate">{source.title}</h3>
</div>
<div className="flex items-center gap-2 text-[10px] text-[var(--color-muted)] whitespace-nowrap">
<div className="flex items-center gap-2 text-[10px] text-[#bbb] flex-shrink-0">
{source.sourceType && (
<span className="bg-gray-100 px-1.5 py-0.5 rounded">{source.sourceType}</span>
<span className="bg-[#f5f5f5] px-1.5 py-0.5 rounded text-[9px]">
{source.sourceType}
</span>
)}
{source.wordCount && <span>{source.wordCount.toLocaleString()} words</span>}
{source.wordCount && <span>{source.wordCount.toLocaleString()}w</span>}
</div>
</div>
</button>
Expand Down
73 changes: 47 additions & 26 deletions packages/dashboard/src/client/components/GraphPage.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -39,6 +39,7 @@ export function GraphPage({
const canvasRef = useRef<HTMLCanvasElement>(null);
const [data, setData] = useState<GraphData | null>(null);
const [hovered, setHovered] = useState<GraphNode | null>(null);
const hoveredRef = useRef<GraphNode | null>(null);
const [loading, setLoading] = useState(true);

// biome-ignore lint/correctness/useExhaustiveDependencies: revision triggers re-fetch on vault changes
Expand Down Expand Up @@ -92,9 +93,9 @@ export function GraphPage({
ctx.translate(currentTransform.x, currentTransform.y);
ctx.scale(currentTransform.k, currentTransform.k);

// Draw edges
ctx.strokeStyle = "#e0e0e0";
ctx.lineWidth = 1;
// Edges
ctx.strokeStyle = "#e8e8e8";
ctx.lineWidth = 0.5;
for (const link of links) {
const source = link.source as GraphNode;
const target = link.target as GraphNode;
Expand All @@ -105,20 +106,21 @@ export function GraphPage({
ctx.stroke();
}

// Draw nodes
// Nodes
for (const node of nodes) {
if (node.x == null || node.y == null) continue;
const radius = 6 + Math.min(node.id.length * 0.3, 6);
const color = CATEGORY_COLORS[node.category] ?? "#888";
const radius = 4 + Math.min(node.id.length * 0.25, 4);
const color = CATEGORY_COLORS[node.category] ?? "#999";
const nodeHovered = hoveredRef.current?.id === node.id;

ctx.beginPath();
ctx.arc(node.x, node.y, radius, 0, Math.PI * 2);
ctx.fillStyle = color;
ctx.arc(node.x, node.y, nodeHovered ? radius + 2 : radius, 0, Math.PI * 2);
ctx.fillStyle = nodeHovered ? color : `${color}cc`;
ctx.fill();

// Label
ctx.fillStyle = "#333";
ctx.font = "10px JetBrains Mono, monospace";
ctx.fillStyle = "#666";
ctx.font = `${nodeHovered ? "11" : "9"}px JetBrains Mono, monospace`;
ctx.textAlign = "center";
ctx.fillText(node.id, node.x, node.y + radius + 12);
}
Expand All @@ -138,7 +140,7 @@ export function GraphPage({

select(canvas).call(zoomBehavior);

// Hover detection
// Hover
const handleMouseMove = (e: MouseEvent) => {
const rect = canvas.getBoundingClientRect();
const mx = (e.clientX - rect.left - currentTransform.x) / currentTransform.k;
Expand All @@ -154,6 +156,7 @@ export function GraphPage({
break;
}
}
hoveredRef.current = found;
setHovered(found);
canvas.style.cursor = found ? "pointer" : "default";
};
Expand Down Expand Up @@ -186,31 +189,49 @@ export function GraphPage({

if (loading) {
return (
<div className="p-8">
<p className="text-sm text-[var(--color-muted)]">Loading graph...</p>
<div className="flex items-center justify-center h-full">
<p className="text-xs text-[#999]">Loading graph...</p>
</div>
);
}

if (!data || data.nodes.length === 0) {
return (
<div className="p-8">
<h2 className="text-xl font-semibold mb-4">Knowledge Graph</h2>
<p className="text-sm text-[var(--color-muted)]">
No graph data yet. Compile some sources to build connections.
</p>
<div className="flex flex-col items-center justify-center h-full animate-page-in">
<div className="w-16 h-16 rounded-full bg-[#f5f5f5] flex items-center justify-center mb-4">
<svg
width="24"
height="24"
viewBox="0 0 24 24"
fill="none"
stroke="#ccc"
strokeWidth="1.5"
role="img"
aria-label="Graph placeholder"
>
<title>Graph placeholder</title>
<circle cx="6" cy="6" r="2" />
<circle cx="18" cy="6" r="2" />
<circle cx="12" cy="18" r="2" />
<line x1="8" y1="6" x2="16" y2="6" />
<line x1="7" y1="8" x2="11" y2="16" />
<line x1="17" y1="8" x2="13" y2="16" />
</svg>
</div>
<p className="text-xs text-[#999]">No graph data yet</p>
<p className="text-[11px] text-[#ccc] mt-1">Compile sources to build connections</p>
</div>
);
}

return (
<div className="flex flex-col h-full">
<div className="p-4 border-b flex items-center justify-between">
<h2 className="text-lg font-semibold">Knowledge Graph</h2>
<div className="flex items-center gap-4 text-xs text-[var(--color-muted)]">
<div className="px-6 py-4 border-b flex items-center justify-between">
<h2 className="text-sm font-semibold tracking-tight">Knowledge Graph</h2>
<div className="flex items-center gap-4 text-[10px] text-[#999]">
{Object.entries(CATEGORY_COLORS).map(([cat, color]) => (
<div key={cat} className="flex items-center gap-1.5">
<span className="w-2.5 h-2.5 rounded-full" style={{ backgroundColor: color }} />
<span className="w-2 h-2 rounded-full" style={{ backgroundColor: color }} />
{cat}
</div>
))}
Expand All @@ -219,11 +240,11 @@ export function GraphPage({
<div className="flex-1 relative">
<canvas ref={canvasRef} className="w-full h-full" />
{hovered && (
<div className="absolute bottom-4 left-4 bg-white border rounded-lg p-3 shadow-sm max-w-xs">
<p className="text-sm font-medium">{hovered.id}</p>
<p className="text-xs text-[var(--color-muted)]">{hovered.category}</p>
<div className="absolute bottom-4 left-4 bg-white border rounded-lg px-3 py-2.5 shadow-sm max-w-xs animate-fade-in">
<p className="text-xs font-medium">{hovered.id}</p>
<p className="text-[10px] text-[#999]">{hovered.category}</p>
{hovered.summary && (
<p className="text-xs text-[var(--color-muted)] mt-1">{hovered.summary}</p>
<p className="text-[10px] text-[#999] mt-1 line-clamp-2">{hovered.summary}</p>
)}
</div>
)}
Expand Down
Loading
Loading