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 0111e4f3..f530ff85 100644 --- a/web/src/app/knowledge/page.tsx +++ b/web/src/app/knowledge/page.tsx @@ -3,7 +3,8 @@ 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 { Modal } from "@/components/modal"; import { Button } from "@/components/ui/button"; @@ -11,7 +12,6 @@ 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; @@ -49,7 +49,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 "—"; @@ -67,16 +67,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 [showCreateFolderPopover, setShowCreateFolderPopover] = useState(false); const [newFolderTitle, setNewFolderTitle] = useState(""); @@ -114,6 +112,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; @@ -183,22 +197,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) { @@ -241,16 +239,21 @@ 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 (title: string) => { @@ -354,7 +357,6 @@ export default function KnowledgePage() { } await refreshKnowledge(); - setSelectedRow({ kind: "item", id: item.itemId }); setShowCreateItemPopover(false); resetCreateItemForm(); setSuccessMessage(`Added item "${item.name}".`); @@ -372,33 +374,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 (
@@ -424,19 +411,27 @@ export default function KnowledgePage() { - {index < breadcrumbs.length - 1 ? ( + {openedItem || index < breadcrumbs.length - 1 ? ( ) : null}
))} + {openedItem ? ( + + {openedItem.name} + + ) : null}
-
- setSearch(event.target.value)} - /> -
- -
- Name - Date Modified - Size -
- -
- {loading ? ( + {openedItemId ? ( + openedItemQuery.isLoading ? (

Loading...

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

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

- ) : ( - rows.map((row) => { - const isSelected = - selectedRow?.kind === row.kind && selectedRow.id === row.id; - - return ( -
+ ) : 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} + + )}
@@ -710,3 +717,11 @@ export default function KnowledgePage() { ); } + +export default function KnowledgePageWrapper() { + return ( + + + + ); +}