From 5a6d00a0644704559554c99480ae1174d78a8303 Mon Sep 17 00:00:00 2001 From: Jason Woltje Date: Mon, 23 Feb 2026 04:12:14 +0000 Subject: [PATCH] feat(web): wire knowledge pages to real API data (#476) Co-authored-by: Jason Woltje Co-committed-by: Jason Woltje --- .../app/(authenticated)/knowledge/page.tsx | 158 +++++++----- .../src/components/knowledge/EntryList.tsx | 17 +- .../knowledge/KnowledgeGraphViewer.tsx | 13 +- .../components/knowledge/StatsDashboard.tsx | 12 +- apps/web/src/lib/api/knowledge.ts | 241 +----------------- 5 files changed, 129 insertions(+), 312 deletions(-) diff --git a/apps/web/src/app/(authenticated)/knowledge/page.tsx b/apps/web/src/app/(authenticated)/knowledge/page.tsx index c50e668..f1e25e7 100644 --- a/apps/web/src/app/(authenticated)/knowledge/page.tsx +++ b/apps/web/src/app/(authenticated)/knowledge/page.tsx @@ -2,23 +2,25 @@ 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 { EntryList } from "@/components/knowledge/EntryList"; import { EntryFilters } from "@/components/knowledge/EntryFilters"; 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 { Plus } from "lucide-react"; export default function KnowledgePage(): ReactElement { - // TODO: Replace with real API call when backend is ready - // const { data: entries, isLoading } = useQuery({ - // queryKey: ["knowledge-entries"], - // queryFn: fetchEntries, - // }); - - const [isLoading] = useState(false); + // Data state + const [entries, setEntries] = useState([]); + const [tags, setTags] = useState([]); + const [totalEntries, setTotalEntries] = useState(0); + const [isLoading, setIsLoading] = useState(true); + const [error, setError] = useState(null); // Filter and sort state const [selectedStatus, setSelectedStatus] = useState("all"); @@ -31,60 +33,65 @@ export default function KnowledgePage(): ReactElement { const [currentPage, setCurrentPage] = useState(1); const itemsPerPage = 10; - // Client-side filtering and sorting - const filteredAndSortedEntries = useMemo(() => { - let filtered = [...mockEntries]; + // Load tags on mount + useEffect(() => { + let cancelled = false; - // Filter by status - if (selectedStatus !== "all") { - filtered = filtered.filter((entry) => entry.status === selectedStatus); - } + fetchTags() + .then((result) => { + if (!cancelled) { + setTags(result); + } + }) + .catch((err: unknown) => { + console.error("Failed to load tags:", err); + }); - // Filter by tag - if (selectedTag !== "all") { - filtered = filtered.filter((entry) => - entry.tags.some((tag: { slug: string }) => tag.slug === selectedTag) - ); - } + return (): void => { + cancelled = true; + }; + }, []); - // Filter by search query - if (searchQuery.trim()) { - const query = searchQuery.toLowerCase(); - filtered = filtered.filter( - (entry) => - entry.title.toLowerCase().includes(query) || - (entry.summary?.toLowerCase().includes(query) ?? false) || - entry.tags.some((tag: { name: string }): boolean => - tag.name.toLowerCase().includes(query) - ) - ); - } + // Load entries when filters/sort/page change + const loadEntries = useCallback(async (): Promise => { + setIsLoading(true); + setError(null); - // Sort entries - filtered.sort((a, b) => { - let comparison = 0; + try { + const filters: Record = { + page: currentPage, + limit: itemsPerPage, + sortBy, + sortOrder, + }; - if (sortBy === "title") { - comparison = a.title.localeCompare(b.title); - } else if (sortBy === "createdAt") { - comparison = new Date(a.createdAt).getTime() - new Date(b.createdAt).getTime(); - } else { - // updatedAt - comparison = new Date(a.updatedAt).getTime() - new Date(b.updatedAt).getTime(); + if (selectedStatus !== "all") { + filters.status = selectedStatus; + } + if (selectedTag !== "all") { + filters.tag = selectedTag; + } + if (searchQuery.trim()) { + filters.search = searchQuery.trim(); } - return sortOrder === "asc" ? comparison : -comparison; - }); + const response: EntriesResponse = await fetchEntries( + filters as Parameters[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; - }, [selectedStatus, selectedTag, searchQuery, sortBy, sortOrder]); + useEffect(() => { + void loadEntries(); + }, [loadEntries]); - // Pagination - const totalPages = Math.ceil(filteredAndSortedEntries.length / itemsPerPage); - const paginatedEntries = filteredAndSortedEntries.slice( - (currentPage - 1) * itemsPerPage, - currentPage * itemsPerPage - ); + const totalPages = Math.max(1, Math.ceil(totalEntries / itemsPerPage)); // Reset to page 1 when filters change const handleFilterChange = (callback: () => void): void => { @@ -101,6 +108,16 @@ export default function KnowledgePage(): ReactElement { setCurrentPage(1); }; + if (isLoading && entries.length === 0) { + return ( +
+
+ +
+
+ ); + } + return (
{/* Header */} @@ -125,14 +142,37 @@ export default function KnowledgePage(): ReactElement {
{ - // TODO: Refresh the entry list when real API is connected - // For now, this would trigger a refetch of the entries - window.location.reload(); + void loadEntries(); }} />
+ {/* Error state */} + {error && ( +
+

+ {error} +

+ +
+ )} + {/* Filters */} { handleFilterChange(() => { setSelectedStatus(status); @@ -161,7 +201,7 @@ export default function KnowledgePage(): ReactElement { {/* Entry list */} -
- Loading entries... + ); } if (entries.length === 0) { return ( -
- -

No entries found

-

+

+ +

+ No entries found +

+

Try adjusting your filters or create a new entry

diff --git a/apps/web/src/components/knowledge/KnowledgeGraphViewer.tsx b/apps/web/src/components/knowledge/KnowledgeGraphViewer.tsx index 8b83da3..1dde479 100644 --- a/apps/web/src/components/knowledge/KnowledgeGraphViewer.tsx +++ b/apps/web/src/components/knowledge/KnowledgeGraphViewer.tsx @@ -16,6 +16,7 @@ import { } from "@xyflow/react"; import "@xyflow/react/dist/style.css"; import { fetchKnowledgeGraph } from "@/lib/api/knowledge"; +import { MosaicSpinner } from "@/components/ui/MosaicSpinner"; import ELK from "elkjs/lib/elk.bundled.js"; // PDA-friendly status colors from CLAUDE.md @@ -376,10 +377,7 @@ export function KnowledgeGraphViewer({ if (isLoading) { return (
-
+
); } @@ -387,11 +385,14 @@ export function KnowledgeGraphViewer({ if (error || !graphData) { return (
-
Error Loading Graph
+
+ Error Loading Graph +
{error}
diff --git a/apps/web/src/components/knowledge/StatsDashboard.tsx b/apps/web/src/components/knowledge/StatsDashboard.tsx index b9c9632..c93bbc0 100644 --- a/apps/web/src/components/knowledge/StatsDashboard.tsx +++ b/apps/web/src/components/knowledge/StatsDashboard.tsx @@ -2,6 +2,7 @@ import { useEffect, useState } from "react"; import { fetchKnowledgeStats } from "@/lib/api/knowledge"; +import { MosaicSpinner } from "@/components/ui/MosaicSpinner"; import Link from "next/link"; interface KnowledgeStats { @@ -61,13 +62,20 @@ export function StatsDashboard(): React.JSX.Element { if (isLoading) { return (
-
+
); } if (error || !stats) { - return
Error loading statistics: {error}
; + return ( +
+

+ Error loading statistics +

+

{error}

+
+ ); } const { overview, mostConnected, recentActivity, tagDistribution } = stats; diff --git a/apps/web/src/lib/api/knowledge.ts b/apps/web/src/lib/api/knowledge.ts index ce3fb2c..72721f2 100644 --- a/apps/web/src/lib/api/knowledge.ts +++ b/apps/web/src/lib/api/knowledge.ts @@ -8,8 +8,9 @@ import type { KnowledgeTag, KnowledgeEntryVersionWithAuthor, PaginatedResponse, + EntryStatus, + Visibility, } from "@mosaic/shared"; -import { EntryStatus, Visibility } from "@mosaic/shared"; import { apiGet, apiPost, apiPatch, apiDelete, type ApiResponse } from "./client"; export interface EntryFilters { @@ -370,241 +371,3 @@ export async function fetchKnowledgeGraph(filters?: { const endpoint = queryString ? `/api/knowledge/graph?${queryString}` : "/api/knowledge/graph"; 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: "

Getting Started

Welcome to Mosaic Stack...

", - 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: "

Architecture

The Mosaic Stack architecture...

", - 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: "

API Docs

Work in progress...

", - 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: "

Deployment

How to deploy Mosaic Stack...

", - 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: "

Meeting Notes

Old archived notes...

", - 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(), - }, -];