feat(web): wire knowledge pages to real API data #476

Merged
jason.woltje merged 1 commits from feat/knowledge-real-api into main 2026-02-23 04:12:16 +00:00
5 changed files with 129 additions and 312 deletions

View File

@@ -2,23 +2,25 @@
import type { ReactElement } from "react"; import type { ReactElement } from "react";
import { useState, useMemo } from "react"; import { useState, useEffect, useCallback } from "react";
import type { KnowledgeEntryWithTags, KnowledgeTag } from "@mosaic/shared";
import type { EntryStatus } from "@mosaic/shared"; import type { EntryStatus } from "@mosaic/shared";
import { EntryList } from "@/components/knowledge/EntryList"; import { EntryList } from "@/components/knowledge/EntryList";
import { EntryFilters } from "@/components/knowledge/EntryFilters"; import { EntryFilters } from "@/components/knowledge/EntryFilters";
import { ImportExportActions } from "@/components/knowledge"; import { ImportExportActions } from "@/components/knowledge";
import { mockEntries, mockTags } from "@/lib/api/knowledge"; import { fetchEntries, fetchTags } from "@/lib/api/knowledge";
import type { EntriesResponse } from "@/lib/api/knowledge";
import { MosaicSpinner } from "@/components/ui/MosaicSpinner";
import Link from "next/link"; import Link from "next/link";
import { Plus } from "lucide-react"; import { Plus } from "lucide-react";
export default function KnowledgePage(): ReactElement { export default function KnowledgePage(): ReactElement {
// TODO: Replace with real API call when backend is ready // Data state
// const { data: entries, isLoading } = useQuery({ const [entries, setEntries] = useState<KnowledgeEntryWithTags[]>([]);
// queryKey: ["knowledge-entries"], const [tags, setTags] = useState<KnowledgeTag[]>([]);
// queryFn: fetchEntries, const [totalEntries, setTotalEntries] = useState(0);
// }); const [isLoading, setIsLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
const [isLoading] = useState(false);
// Filter and sort state // Filter and sort state
const [selectedStatus, setSelectedStatus] = useState<EntryStatus | "all">("all"); const [selectedStatus, setSelectedStatus] = useState<EntryStatus | "all">("all");
@@ -31,60 +33,65 @@ export default function KnowledgePage(): ReactElement {
const [currentPage, setCurrentPage] = useState(1); const [currentPage, setCurrentPage] = useState(1);
const itemsPerPage = 10; const itemsPerPage = 10;
// Client-side filtering and sorting // Load tags on mount
const filteredAndSortedEntries = useMemo(() => { useEffect(() => {
let filtered = [...mockEntries]; let cancelled = false;
// Filter by status fetchTags()
if (selectedStatus !== "all") { .then((result) => {
filtered = filtered.filter((entry) => entry.status === selectedStatus); if (!cancelled) {
} setTags(result);
}
})
.catch((err: unknown) => {
console.error("Failed to load tags:", err);
});
// Filter by tag return (): void => {
if (selectedTag !== "all") { cancelled = true;
filtered = filtered.filter((entry) => };
entry.tags.some((tag: { slug: string }) => tag.slug === selectedTag) }, []);
);
}
// Filter by search query // Load entries when filters/sort/page change
if (searchQuery.trim()) { const loadEntries = useCallback(async (): Promise<void> => {
const query = searchQuery.toLowerCase(); setIsLoading(true);
filtered = filtered.filter( setError(null);
(entry) =>
entry.title.toLowerCase().includes(query) ||
(entry.summary?.toLowerCase().includes(query) ?? false) ||
entry.tags.some((tag: { name: string }): boolean =>
tag.name.toLowerCase().includes(query)
)
);
}
// Sort entries try {
filtered.sort((a, b) => { const filters: Record<string, unknown> = {
let comparison = 0; page: currentPage,
limit: itemsPerPage,
sortBy,
sortOrder,
};
if (sortBy === "title") { if (selectedStatus !== "all") {
comparison = a.title.localeCompare(b.title); filters.status = selectedStatus;
} else if (sortBy === "createdAt") { }
comparison = new Date(a.createdAt).getTime() - new Date(b.createdAt).getTime(); if (selectedTag !== "all") {
} else { filters.tag = selectedTag;
// updatedAt }
comparison = new Date(a.updatedAt).getTime() - new Date(b.updatedAt).getTime(); if (searchQuery.trim()) {
filters.search = searchQuery.trim();
} }
return sortOrder === "asc" ? comparison : -comparison; const response: EntriesResponse = await fetchEntries(
}); filters as Parameters<typeof fetchEntries>[0]
);
setEntries(response.data);
setTotalEntries(response.meta?.total ?? response.data.length);
} catch (err: unknown) {
setError(err instanceof Error ? err.message : "Failed to load entries");
} finally {
setIsLoading(false);
}
}, [currentPage, itemsPerPage, sortBy, sortOrder, selectedStatus, selectedTag, searchQuery]);
return filtered; useEffect(() => {
}, [selectedStatus, selectedTag, searchQuery, sortBy, sortOrder]); void loadEntries();
}, [loadEntries]);
// Pagination const totalPages = Math.max(1, Math.ceil(totalEntries / itemsPerPage));
const totalPages = Math.ceil(filteredAndSortedEntries.length / itemsPerPage);
const paginatedEntries = filteredAndSortedEntries.slice(
(currentPage - 1) * itemsPerPage,
currentPage * itemsPerPage
);
// Reset to page 1 when filters change // Reset to page 1 when filters change
const handleFilterChange = (callback: () => void): void => { const handleFilterChange = (callback: () => void): void => {
@@ -101,6 +108,16 @@ export default function KnowledgePage(): ReactElement {
setCurrentPage(1); setCurrentPage(1);
}; };
if (isLoading && entries.length === 0) {
return (
<main className="container mx-auto px-4 py-8 max-w-5xl">
<div className="flex justify-center items-center py-20">
<MosaicSpinner size={48} label="Loading knowledge base..." />
</div>
</main>
);
}
return ( return (
<main className="container mx-auto px-4 py-8 max-w-5xl"> <main className="container mx-auto px-4 py-8 max-w-5xl">
{/* Header */} {/* Header */}
@@ -125,14 +142,37 @@ export default function KnowledgePage(): ReactElement {
<div className="flex justify-end"> <div className="flex justify-end">
<ImportExportActions <ImportExportActions
onImportComplete={() => { onImportComplete={() => {
// TODO: Refresh the entry list when real API is connected void loadEntries();
// For now, this would trigger a refetch of the entries
window.location.reload();
}} }}
/> />
</div> </div>
</div> </div>
{/* Error state */}
{error && (
<div
className="mb-6 p-4 rounded-lg border"
style={{
borderColor: "var(--danger)",
background: "rgba(229,72,77,0.08)",
}}
>
<p className="text-sm" style={{ color: "var(--danger)" }}>
{error}
</p>
<button
type="button"
onClick={() => {
void loadEntries();
}}
className="mt-2 text-sm font-medium underline"
style={{ color: "var(--danger)" }}
>
Retry
</button>
</div>
)}
{/* Filters */} {/* Filters */}
<EntryFilters <EntryFilters
selectedStatus={selectedStatus} selectedStatus={selectedStatus}
@@ -140,7 +180,7 @@ export default function KnowledgePage(): ReactElement {
searchQuery={searchQuery} searchQuery={searchQuery}
sortBy={sortBy} sortBy={sortBy}
sortOrder={sortOrder} sortOrder={sortOrder}
tags={mockTags} tags={tags}
onStatusChange={(status) => { onStatusChange={(status) => {
handleFilterChange(() => { handleFilterChange(() => {
setSelectedStatus(status); setSelectedStatus(status);
@@ -161,7 +201,7 @@ export default function KnowledgePage(): ReactElement {
{/* Entry list */} {/* Entry list */}
<EntryList <EntryList
entries={paginatedEntries} entries={entries}
isLoading={isLoading} isLoading={isLoading}
currentPage={currentPage} currentPage={currentPage}
totalPages={totalPages} totalPages={totalPages}

View File

@@ -1,6 +1,7 @@
import type { KnowledgeEntryWithTags } from "@mosaic/shared"; import type { KnowledgeEntryWithTags } from "@mosaic/shared";
import { EntryCard } from "./EntryCard"; import { EntryCard } from "./EntryCard";
import { BookOpen } from "lucide-react"; import { BookOpen } from "lucide-react";
import { MosaicSpinner } from "@/components/ui/MosaicSpinner";
interface EntryListProps { interface EntryListProps {
entries: KnowledgeEntryWithTags[]; entries: KnowledgeEntryWithTags[];
@@ -20,18 +21,22 @@ export function EntryList({
if (isLoading) { if (isLoading) {
return ( return (
<div className="flex justify-center items-center p-12"> <div className="flex justify-center items-center p-12">
<div className="animate-spin rounded-full h-8 w-8 border-b-2 border-blue-600"></div> <MosaicSpinner size={36} label="Loading entries..." />
<span className="ml-3 text-gray-600">Loading entries...</span>
</div> </div>
); );
} }
if (entries.length === 0) { if (entries.length === 0) {
return ( return (
<div className="text-center p-12 bg-white rounded-lg shadow-sm border border-gray-200"> <div
<BookOpen className="w-12 h-12 text-gray-400 mx-auto mb-3" /> className="text-center p-12 rounded-lg border"
<p className="text-lg text-gray-700 font-medium">No entries found</p> style={{ background: "var(--surface)", borderColor: "var(--border)" }}
<p className="text-sm text-gray-500 mt-2"> >
<BookOpen className="w-12 h-12 mx-auto mb-3" style={{ color: "var(--text-muted)" }} />
<p className="text-lg font-medium" style={{ color: "var(--text-muted)" }}>
No entries found
</p>
<p className="text-sm mt-2" style={{ color: "var(--text-muted)" }}>
Try adjusting your filters or create a new entry Try adjusting your filters or create a new entry
</p> </p>
</div> </div>

View File

@@ -16,6 +16,7 @@ import {
} from "@xyflow/react"; } from "@xyflow/react";
import "@xyflow/react/dist/style.css"; import "@xyflow/react/dist/style.css";
import { fetchKnowledgeGraph } from "@/lib/api/knowledge"; import { fetchKnowledgeGraph } from "@/lib/api/knowledge";
import { MosaicSpinner } from "@/components/ui/MosaicSpinner";
import ELK from "elkjs/lib/elk.bundled.js"; import ELK from "elkjs/lib/elk.bundled.js";
// PDA-friendly status colors from CLAUDE.md // PDA-friendly status colors from CLAUDE.md
@@ -376,10 +377,7 @@ export function KnowledgeGraphViewer({
if (isLoading) { if (isLoading) {
return ( return (
<div className="flex items-center justify-center h-screen"> <div className="flex items-center justify-center h-screen">
<div <MosaicSpinner size={48} label="Loading knowledge graph..." />
data-testid="loading-spinner"
className="animate-spin rounded-full h-12 w-12 border-b-2 border-blue-500"
/>
</div> </div>
); );
} }
@@ -387,11 +385,14 @@ export function KnowledgeGraphViewer({
if (error || !graphData) { if (error || !graphData) {
return ( return (
<div className="flex flex-col items-center justify-center h-screen p-8"> <div className="flex flex-col items-center justify-center h-screen p-8">
<div className="text-red-500 text-xl font-semibold mb-2">Error Loading Graph</div> <div className="text-xl font-semibold mb-2" style={{ color: "var(--danger)" }}>
Error Loading Graph
</div>
<div className="text-sm text-gray-500 dark:text-gray-400">{error}</div> <div className="text-sm text-gray-500 dark:text-gray-400">{error}</div>
<button <button
onClick={loadGraph} onClick={loadGraph}
className="mt-4 px-4 py-2 bg-blue-500 text-white rounded hover:bg-blue-600" className="mt-4 px-4 py-2 rounded text-white"
style={{ background: "var(--danger)" }}
> >
Retry Retry
</button> </button>

View File

@@ -2,6 +2,7 @@
import { useEffect, useState } from "react"; import { useEffect, useState } from "react";
import { fetchKnowledgeStats } from "@/lib/api/knowledge"; import { fetchKnowledgeStats } from "@/lib/api/knowledge";
import { MosaicSpinner } from "@/components/ui/MosaicSpinner";
import Link from "next/link"; import Link from "next/link";
interface KnowledgeStats { interface KnowledgeStats {
@@ -61,13 +62,20 @@ export function StatsDashboard(): React.JSX.Element {
if (isLoading) { if (isLoading) {
return ( return (
<div className="flex items-center justify-center p-12"> <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" /> <MosaicSpinner size={36} label="Loading statistics..." />
</div> </div>
); );
} }
if (error || !stats) { if (error || !stats) {
return <div className="p-8 text-center text-red-500">Error loading statistics: {error}</div>; return (
<div className="p-8 text-center">
<p className="font-medium mb-2" style={{ color: "var(--danger)" }}>
Error loading statistics
</p>
<p className="text-sm text-gray-500 dark:text-gray-400">{error}</p>
</div>
);
} }
const { overview, mostConnected, recentActivity, tagDistribution } = stats; const { overview, mostConnected, recentActivity, tagDistribution } = stats;

View File

@@ -8,8 +8,9 @@ import type {
KnowledgeTag, KnowledgeTag,
KnowledgeEntryVersionWithAuthor, KnowledgeEntryVersionWithAuthor,
PaginatedResponse, PaginatedResponse,
EntryStatus,
Visibility,
} from "@mosaic/shared"; } from "@mosaic/shared";
import { EntryStatus, Visibility } from "@mosaic/shared";
import { apiGet, apiPost, apiPatch, apiDelete, type ApiResponse } from "./client"; import { apiGet, apiPost, apiPatch, apiDelete, type ApiResponse } from "./client";
export interface EntryFilters { export interface EntryFilters {
@@ -370,241 +371,3 @@ export async function fetchKnowledgeGraph(filters?: {
const endpoint = queryString ? `/api/knowledge/graph?${queryString}` : "/api/knowledge/graph"; const endpoint = queryString ? `/api/knowledge/graph?${queryString}` : "/api/knowledge/graph";
return apiGet(endpoint); return apiGet(endpoint);
} }
/**
* Mock entries for development (until backend endpoints are ready)
*/
export const mockEntries: KnowledgeEntryWithTags[] = [
{
id: "entry-1",
workspaceId: "workspace-1",
slug: "getting-started",
title: "Getting Started with Mosaic Stack",
content: "# Getting Started\n\nWelcome to Mosaic Stack...",
contentHtml: "<h1>Getting Started</h1><p>Welcome to Mosaic Stack...</p>",
summary: "A comprehensive guide to getting started with the Mosaic Stack platform.",
status: EntryStatus.PUBLISHED,
visibility: Visibility.PUBLIC,
createdBy: "user-1",
updatedBy: "user-1",
createdAt: new Date("2026-01-20"),
updatedAt: new Date("2026-01-28"),
tags: [
{
id: "tag-1",
workspaceId: "workspace-1",
name: "Tutorial",
slug: "tutorial",
color: "#3B82F6",
createdAt: new Date(),
updatedAt: new Date(),
},
{
id: "tag-2",
workspaceId: "workspace-1",
name: "Onboarding",
slug: "onboarding",
color: "#10B981",
createdAt: new Date(),
updatedAt: new Date(),
},
],
},
{
id: "entry-2",
workspaceId: "workspace-1",
slug: "architecture-overview",
title: "Architecture Overview",
content: "# Architecture\n\nThe Mosaic Stack architecture...",
contentHtml: "<h1>Architecture</h1><p>The Mosaic Stack architecture...</p>",
summary: "Overview of the system architecture and design patterns used in Mosaic Stack.",
status: EntryStatus.PUBLISHED,
visibility: Visibility.WORKSPACE,
createdBy: "user-1",
updatedBy: "user-1",
createdAt: new Date("2026-01-15"),
updatedAt: new Date("2026-01-27"),
tags: [
{
id: "tag-3",
workspaceId: "workspace-1",
name: "Architecture",
slug: "architecture",
color: "#8B5CF6",
createdAt: new Date(),
updatedAt: new Date(),
},
{
id: "tag-4",
workspaceId: "workspace-1",
name: "Technical",
slug: "technical",
color: "#F59E0B",
createdAt: new Date(),
updatedAt: new Date(),
},
],
},
{
id: "entry-3",
workspaceId: "workspace-1",
slug: "api-documentation-draft",
title: "API Documentation (Draft)",
content: "# API Docs\n\nWork in progress...",
contentHtml: "<h1>API Docs</h1><p>Work in progress...</p>",
summary: "Comprehensive API documentation for developers.",
status: EntryStatus.DRAFT,
visibility: Visibility.PRIVATE,
createdBy: "user-1",
updatedBy: "user-1",
createdAt: new Date("2026-01-29"),
updatedAt: new Date("2026-01-29"),
tags: [
{
id: "tag-4",
workspaceId: "workspace-1",
name: "Technical",
slug: "technical",
color: "#F59E0B",
createdAt: new Date(),
updatedAt: new Date(),
},
{
id: "tag-5",
workspaceId: "workspace-1",
name: "API",
slug: "api",
color: "#EF4444",
createdAt: new Date(),
updatedAt: new Date(),
},
],
},
{
id: "entry-4",
workspaceId: "workspace-1",
slug: "deployment-guide",
title: "Deployment Guide",
content: "# Deployment\n\nHow to deploy Mosaic Stack...",
contentHtml: "<h1>Deployment</h1><p>How to deploy Mosaic Stack...</p>",
summary: "Step-by-step guide for deploying Mosaic Stack to production.",
status: EntryStatus.PUBLISHED,
visibility: Visibility.WORKSPACE,
createdBy: "user-1",
updatedBy: "user-1",
createdAt: new Date("2026-01-18"),
updatedAt: new Date("2026-01-25"),
tags: [
{
id: "tag-6",
workspaceId: "workspace-1",
name: "DevOps",
slug: "devops",
color: "#14B8A6",
createdAt: new Date(),
updatedAt: new Date(),
},
{
id: "tag-1",
workspaceId: "workspace-1",
name: "Tutorial",
slug: "tutorial",
color: "#3B82F6",
createdAt: new Date(),
updatedAt: new Date(),
},
],
},
{
id: "entry-5",
workspaceId: "workspace-1",
slug: "old-meeting-notes",
title: "Q4 2025 Meeting Notes",
content: "# Meeting Notes\n\nOld archived notes...",
contentHtml: "<h1>Meeting Notes</h1><p>Old archived notes...</p>",
summary: "Meeting notes from Q4 2025 - archived for reference.",
status: EntryStatus.ARCHIVED,
visibility: Visibility.PRIVATE,
createdBy: "user-1",
updatedBy: "user-1",
createdAt: new Date("2025-12-15"),
updatedAt: new Date("2026-01-05"),
tags: [
{
id: "tag-7",
workspaceId: "workspace-1",
name: "Meetings",
slug: "meetings",
color: "#6B7280",
createdAt: new Date(),
updatedAt: new Date(),
},
],
},
];
export const mockTags: KnowledgeTag[] = [
{
id: "tag-1",
workspaceId: "workspace-1",
name: "Tutorial",
slug: "tutorial",
color: "#3B82F6",
createdAt: new Date(),
updatedAt: new Date(),
},
{
id: "tag-2",
workspaceId: "workspace-1",
name: "Onboarding",
slug: "onboarding",
color: "#10B981",
createdAt: new Date(),
updatedAt: new Date(),
},
{
id: "tag-3",
workspaceId: "workspace-1",
name: "Architecture",
slug: "architecture",
color: "#8B5CF6",
createdAt: new Date(),
updatedAt: new Date(),
},
{
id: "tag-4",
workspaceId: "workspace-1",
name: "Technical",
slug: "technical",
color: "#F59E0B",
createdAt: new Date(),
updatedAt: new Date(),
},
{
id: "tag-5",
workspaceId: "workspace-1",
name: "API",
slug: "api",
color: "#EF4444",
createdAt: new Date(),
updatedAt: new Date(),
},
{
id: "tag-6",
workspaceId: "workspace-1",
name: "DevOps",
slug: "devops",
color: "#14B8A6",
createdAt: new Date(),
updatedAt: new Date(),
},
{
id: "tag-7",
workspaceId: "workspace-1",
name: "Meetings",
slug: "meetings",
color: "#6B7280",
createdAt: new Date(),
updatedAt: new Date(),
},
];