feat(web): wire knowledge pages to real API data (#476)
Some checks failed
ci/woodpecker/push/web Pipeline failed
Some checks failed
ci/woodpecker/push/web Pipeline failed
Co-authored-by: Jason Woltje <jason@diversecanvas.com> Co-committed-by: Jason Woltje <jason@diversecanvas.com>
This commit was merged in pull request #476.
This commit is contained in:
@@ -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<KnowledgeEntryWithTags[]>([]);
|
||||
const [tags, setTags] = useState<KnowledgeTag[]>([]);
|
||||
const [totalEntries, setTotalEntries] = useState(0);
|
||||
const [isLoading, setIsLoading] = useState(true);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
// Filter and sort state
|
||||
const [selectedStatus, setSelectedStatus] = useState<EntryStatus | "all">("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<void> => {
|
||||
setIsLoading(true);
|
||||
setError(null);
|
||||
|
||||
// Sort entries
|
||||
filtered.sort((a, b) => {
|
||||
let comparison = 0;
|
||||
try {
|
||||
const filters: Record<string, unknown> = {
|
||||
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<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;
|
||||
}, [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 (
|
||||
<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 (
|
||||
<main className="container mx-auto px-4 py-8 max-w-5xl">
|
||||
{/* Header */}
|
||||
@@ -125,14 +142,37 @@ export default function KnowledgePage(): ReactElement {
|
||||
<div className="flex justify-end">
|
||||
<ImportExportActions
|
||||
onImportComplete={() => {
|
||||
// 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();
|
||||
}}
|
||||
/>
|
||||
</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 */}
|
||||
<EntryFilters
|
||||
selectedStatus={selectedStatus}
|
||||
@@ -140,7 +180,7 @@ export default function KnowledgePage(): ReactElement {
|
||||
searchQuery={searchQuery}
|
||||
sortBy={sortBy}
|
||||
sortOrder={sortOrder}
|
||||
tags={mockTags}
|
||||
tags={tags}
|
||||
onStatusChange={(status) => {
|
||||
handleFilterChange(() => {
|
||||
setSelectedStatus(status);
|
||||
@@ -161,7 +201,7 @@ export default function KnowledgePage(): ReactElement {
|
||||
|
||||
{/* Entry list */}
|
||||
<EntryList
|
||||
entries={paginatedEntries}
|
||||
entries={entries}
|
||||
isLoading={isLoading}
|
||||
currentPage={currentPage}
|
||||
totalPages={totalPages}
|
||||
|
||||
Reference in New Issue
Block a user