From a873ae3a3d6f5490d87a0dd002f9a78c114a1c6c Mon Sep 17 00:00:00 2001 From: Aidan Kelly Date: Tue, 31 Mar 2026 00:09:49 -0400 Subject: [PATCH 1/4] KB item links work --- server/src/routers/knowledge.ts | 19 ++ server/src/service/knowledge-service.ts | 4 + server/src/types/knowledge-types.ts | 4 + web/src/app/knowledge/page.tsx | 342 ++++++++++++------------ 4 files changed, 202 insertions(+), 167 deletions(-) diff --git a/server/src/routers/knowledge.ts b/server/src/routers/knowledge.ts index cb0412ef..2c72e191 100644 --- a/server/src/routers/knowledge.ts +++ b/server/src/routers/knowledge.ts @@ -12,6 +12,7 @@ import { deleteItemAttachmentInputSchema, getFoldersInFolderInputSchema, getItemAttachmentInputSchema, + getItemInputSchema, getItemsInFolderInputSchema, knowledgeAttachmentOutputSchema, knowledgeAttachmentWithFileOutputSchema, @@ -78,6 +79,23 @@ const getItemsInFolder = protectedProcedure }), ); +const getItem = protectedProcedure + .input(getItemInputSchema) + .output(knowledgeItemOutputSchema) + .meta({ + openapi: { + method: "POST", + path: "/knowledge.getItem", + summary: "Get a single knowledge item by id", + tags: ["Knowledge"], + }, + }) + .query(({ input }) => + withErrorHandling("getItem", async () => { + return await knowledgeService.getItemById(input.itemId); + }), + ); + const getItemAttachment = protectedProcedure .input(getItemAttachmentInputSchema) .output(knowledgeAttachmentWithFileOutputSchema.nullable()) @@ -246,6 +264,7 @@ export const knowledgeRouter = router({ getRootFolders, getFoldersInFolder, getItemsInFolder, + getItem, getItemAttachment, createFolder, createFolderInFolder, diff --git a/server/src/service/knowledge-service.ts b/server/src/service/knowledge-service.ts index 1c6dba7d..ca4d7a0e 100644 --- a/server/src/service/knowledge-service.ts +++ b/server/src/service/knowledge-service.ts @@ -41,6 +41,10 @@ export class KnowledgeService { return await this.knowledgeRepo.getItemsByFolder(folderId); } + async getItemById(itemId: string) { + return await this.knowledgeRepo.getItemById(itemId); + } + async getItemAttachment(itemId: string) { return await this.knowledgeRepo.getItemAttachment(itemId); } diff --git a/server/src/types/knowledge-types.ts b/server/src/types/knowledge-types.ts index f4553e80..6dd2b858 100644 --- a/server/src/types/knowledge-types.ts +++ b/server/src/types/knowledge-types.ts @@ -44,6 +44,10 @@ export const getItemsInFolderInputSchema = z.object({ folderId: uuidSchema, }); +export const getItemInputSchema = z.object({ + itemId: uuidSchema, +}); + export const getItemAttachmentInputSchema = z.object({ itemId: uuidSchema, }); diff --git a/web/src/app/knowledge/page.tsx b/web/src/app/knowledge/page.tsx index 5d86d8d2..0836dff4 100644 --- a/web/src/app/knowledge/page.tsx +++ b/web/src/app/knowledge/page.tsx @@ -3,14 +3,14 @@ import { useQuery, useQueryClient } from "@tanstack/react-query"; import { TRPCClientError } from "@trpc/client"; import { ChevronRight, FileText, Folder, FolderOpen, Plus } from "lucide-react"; -import { useCallback, useEffect, useMemo, useState } from "react"; +import { useRouter, useSearchParams } from "next/navigation"; +import { Suspense, useCallback, useEffect, useMemo, useState } from "react"; import { TitleShell } from "@/components/layouts/title-shell"; import { Button } from "@/components/ui/button"; import { Card, CardContent } from "@/components/ui/card"; import { Input } from "@/components/ui/input"; import { Textarea } from "@/components/ui/textarea"; import { useTRPCClient } from "@/lib/trpc"; -import { cn } from "@/lib/utils"; type FolderRecord = { folderId: string; @@ -48,7 +48,7 @@ type Row = raw: ItemRecord; }; -const formatDate = (value: string) => { +const formatDate = (value: string | Date) => { const date = new Date(value); if (Number.isNaN(date.getTime())) { return "—"; @@ -66,16 +66,14 @@ const toErrorMessage = (error: unknown) => { return "Something went wrong. Please try again."; }; -export default function KnowledgePage() { +function KnowledgePage() { const trpcClient = useTRPCClient(); const queryClient = useQueryClient(); - - const [currentFolderId, setCurrentFolderId] = useState(null); + const router = useRouter(); + const searchParams = useSearchParams(); + const openedItemId = searchParams.get("item"); + const currentFolderId = searchParams.get("folder"); const [folderLookup, setFolderLookup] = useState({}); - const [selectedRow, setSelectedRow] = useState<{ - kind: Row["kind"]; - id: string; - } | null>(null); const [search, setSearch] = useState(""); const [showCreateItemForm, setShowCreateItemForm] = useState(false); const [newItemTitle, setNewItemTitle] = useState(""); @@ -111,6 +109,22 @@ export default function KnowledgePage() { }) as Promise, }); + const openedItemQuery = useQuery({ + queryKey: ["knowledge", "item", openedItemId], + enabled: Boolean(openedItemId), + queryFn: () => + trpcClient.knowledge.getItem.query({ itemId: openedItemId ?? "" }), + }); + + const openedItemAttachmentQuery = useQuery({ + queryKey: ["knowledge", "attachment", openedItemId], + enabled: Boolean(openedItemId), + queryFn: () => + trpcClient.knowledge.getItemAttachment.query({ + itemId: openedItemId ?? "", + }), + }); + const registerFolders = useCallback((folders: FolderRecord[]) => { if (folders.length === 0) { return; @@ -180,22 +194,6 @@ export default function KnowledgePage() { return allRows.filter((row) => row.name.toLowerCase().includes(term)); }, [folders, items, search]); - const selectedItem = useMemo(() => { - if (!selectedRow || selectedRow.kind !== "item") { - return null; - } - return items.find((item) => item.itemId === selectedRow.id) ?? null; - }, [items, selectedRow]); - - const selectedItemAttachmentQuery = useQuery({ - queryKey: ["knowledge", "attachment", selectedItem?.itemId ?? null], - enabled: Boolean(selectedItem?.itemId), - queryFn: () => - trpcClient.knowledge.getItemAttachment.query({ - itemId: selectedItem?.itemId ?? "", - }), - }); - const breadcrumbs = useMemo(() => { const root = [{ id: null as string | null, title: "Knowledge Base" }]; if (!currentFolderId) { @@ -238,16 +236,19 @@ export default function KnowledgePage() { }; const handleOpenFolder = (folder: FolderRecord) => { - setCurrentFolderId(folder.folderId); - setSelectedRow(null); + router.push(`/knowledge?folder=${folder.folderId}`); }; const handleNavigateUp = () => { + if (openedItemId) { + router.push(currentFolderId ? `/knowledge?folder=${currentFolderId}` : "/knowledge"); + return; + } if (!currentFolder) { return; } - setCurrentFolderId(currentFolder.parentFolderId); - setSelectedRow(null); + const parentId = currentFolder.parentFolderId; + router.push(parentId ? `/knowledge?folder=${parentId}` : "/knowledge"); }; const handleCreateFolder = async () => { @@ -360,7 +361,6 @@ export default function KnowledgePage() { } await refreshKnowledge(); - setSelectedRow({ kind: "item", id: item.itemId }); setShowCreateItemForm(false); resetCreateItemForm(); setSuccessMessage(`Added item "${item.name}".`); @@ -378,33 +378,18 @@ export default function KnowledgePage() { } }; - const handleOpenItem = async (item: ItemRecord) => { - setPendingAction("open-item"); - clearMessages(); - - try { - const attachment = await trpcClient.knowledge.getItemAttachment.query({ - itemId: item.itemId, - }); - - if (!attachment) { - setErrorMessage("This item has no file attached."); - return; - } - - const file = await trpcClient.files.getFile.query({ - fileId: attachment.fileId, - }); - - window.open(file.data, "_blank", "noopener,noreferrer"); - setSuccessMessage(`Opened "${item.name}".`); - } catch (error) { - setErrorMessage(toErrorMessage(error)); - } finally { - setPendingAction(null); + const handleOpenItemAttachment = async () => { + if (!openedItemAttachmentQuery.data) { + return; } + const file = await trpcClient.files.getFile.query({ + fileId: openedItemAttachmentQuery.data.fileId, + }); + window.open(file.data, "_blank", "noopener,noreferrer"); }; + const openedItem = openedItemQuery.data ?? null; + return (
@@ -430,19 +415,23 @@ export default function KnowledgePage() { - {index < breadcrumbs.length - 1 ? ( + {(openedItem || index < breadcrumbs.length - 1) ? ( ) : null}
))} + {openedItem ? ( + + {openedItem.name} + + ) : null}
+ ) : openedItem ? ( +
+
+

+ Description +

+

+ {openedItem.description?.trim() || "—"} +

+
+ +
+

+ Body +

+

+ {openedItem.body?.trim() || "—"} +

+
+ +
+

+ Last Modified +

+

{formatDate(openedItem.updatedAt)}

+
+ +
+
+ {openedItemAttachmentQuery.isLoading + ? "Loading attachment..." + : openedItemAttachmentQuery.data + ? `Attachment: ${openedItemAttachmentQuery.data.fileName}` + : "No attachment"} +
+ - ); - }) - )} -
+ Open Attachment + +
+ + ) : null + ) : ( + <> +
+ setSearch(event.target.value)} + /> +
- {selectedItem ? ( -
-

- Item Contents -

-
-

- Title:{" "} - {selectedItem.name} -

-

- Description:{" "} - {selectedItem.description?.trim() || "—"} -

-

- Body:{" "} - {selectedItem.body?.trim() || "—"} -

+
+ Name + Date Modified + Size
-
-
- {selectedItemAttachmentQuery.isLoading - ? "Loading attachment..." - : selectedItemAttachmentQuery.data - ? `Attachment: ${selectedItemAttachmentQuery.data.fileName}` - : "No attachment"} -
- +
+ {loading ? ( +

+ Loading... +

+ ) : rows.length === 0 ? ( +

+ {currentFolderId + ? "This folder is empty." + : "No folders yet. Create one to get started."} +

+ ) : ( + rows.map((row) => ( + + )) + )}
-
- ) : null} + + )} +
); } + +export default function KnowledgePageWrapper() { + return ( + + + + ); +} From 3e06f126ae188838438888e11e91b3c157dc56fb Mon Sep 17 00:00:00 2001 From: Aidan Kelly Date: Tue, 31 Mar 2026 00:15:57 -0400 Subject: [PATCH 2/4] url error fix --- web/src/app/knowledge/page.tsx | 9 +++++---- 1 file changed, 5 insertions(+), 4 deletions(-) diff --git a/web/src/app/knowledge/page.tsx b/web/src/app/knowledge/page.tsx index 0836dff4..68e1d471 100644 --- a/web/src/app/knowledge/page.tsx +++ b/web/src/app/knowledge/page.tsx @@ -620,10 +620,11 @@ function KnowledgePage() { if (row.kind === "folder") { handleOpenFolder(row.raw); } else { - const url = currentFolderId - ? `/knowledge?folder=${currentFolderId}&item=${row.id}` - : `/knowledge?item=${row.id}`; - router.push(url); + router.push( + currentFolderId + ? `/knowledge?folder=${currentFolderId}&item=${row.id}` + : `/knowledge?item=${row.id}`, + ); } }} > From 88721d76918da64210f215a02a7db42dd9ffd597 Mon Sep 17 00:00:00 2001 From: Aidan Kelly Date: Tue, 31 Mar 2026 14:16:27 -0400 Subject: [PATCH 3/4] lint format --- web/src/app/knowledge/page.tsx | 20 +++++++++++++------- 1 file changed, 13 insertions(+), 7 deletions(-) diff --git a/web/src/app/knowledge/page.tsx b/web/src/app/knowledge/page.tsx index 68e1d471..26911f4c 100644 --- a/web/src/app/knowledge/page.tsx +++ b/web/src/app/knowledge/page.tsx @@ -241,7 +241,9 @@ function KnowledgePage() { const handleNavigateUp = () => { if (openedItemId) { - router.push(currentFolderId ? `/knowledge?folder=${currentFolderId}` : "/knowledge"); + router.push( + currentFolderId ? `/knowledge?folder=${currentFolderId}` : "/knowledge", + ); return; } if (!currentFolder) { @@ -415,14 +417,18 @@ function KnowledgePage() { - {(openedItem || index < breadcrumbs.length - 1) ? ( + {openedItem || index < breadcrumbs.length - 1 ? ( ) : null}
@@ -439,7 +445,7 @@ function KnowledgePage() { variant="outline" size="sm" onClick={handleNavigateUp} - disabled={!currentFolderId && !openedItemId || isBusy} + disabled={(!currentFolderId && !openedItemId) || isBusy} > Back @@ -521,7 +527,6 @@ function KnowledgePage() { ) : null} - {openedItemId ? ( openedItemQuery.isLoading ? (

@@ -557,7 +562,9 @@ function KnowledgePage() {

Last Modified

-

{formatDate(openedItem.updatedAt)}

+

+ {formatDate(openedItem.updatedAt)} +

@@ -650,7 +657,6 @@ function KnowledgePage() {
)} - From 33395efdbd71f334cd105d4b01bdb459443cd8b6 Mon Sep 17 00:00:00 2001 From: Aidan Kelly Date: Tue, 31 Mar 2026 14:48:29 -0400 Subject: [PATCH 4/4] Final adjustments --- web/src/app/knowledge/page.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/web/src/app/knowledge/page.tsx b/web/src/app/knowledge/page.tsx index 26911f4c..5a0082ed 100644 --- a/web/src/app/knowledge/page.tsx +++ b/web/src/app/knowledge/page.tsx @@ -622,7 +622,7 @@ function KnowledgePage() {