diff --git a/package-lock.json b/package-lock.json index 4c554a2..5c6337e 100644 --- a/package-lock.json +++ b/package-lock.json @@ -16,6 +16,7 @@ "@radix-ui/react-progress": "^1.1.7", "@radix-ui/react-scroll-area": "^1.2.10", "@radix-ui/react-slot": "^1.2.3", + "@radix-ui/react-tabs": "^1.1.13", "@radix-ui/react-tooltip": "^1.2.8", "@sentry/nextjs": "^10.11.0", "@tanstack/react-query": "^5.87.4", @@ -4456,6 +4457,36 @@ } } }, + "node_modules/@radix-ui/react-tabs": { + "version": "1.1.13", + "resolved": "https://registry.npmjs.org/@radix-ui/react-tabs/-/react-tabs-1.1.13.tgz", + "integrity": "sha512-7xdcatg7/U+7+Udyoj2zodtI9H/IIopqo+YOIcZOq1nJwXWBZ9p8xiu5llXlekDbZkca79a/fozEYQXIA4sW6A==", + "license": "MIT", + "dependencies": { + "@radix-ui/primitive": "1.1.3", + "@radix-ui/react-context": "1.1.2", + "@radix-ui/react-direction": "1.1.1", + "@radix-ui/react-id": "1.1.1", + "@radix-ui/react-presence": "1.1.5", + "@radix-ui/react-primitive": "2.1.3", + "@radix-ui/react-roving-focus": "1.1.11", + "@radix-ui/react-use-controllable-state": "1.2.2" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, "node_modules/@radix-ui/react-tooltip": { "version": "1.2.8", "resolved": "https://registry.npmjs.org/@radix-ui/react-tooltip/-/react-tooltip-1.2.8.tgz", diff --git a/package.json b/package.json index e807ee5..4a9b0ef 100644 --- a/package.json +++ b/package.json @@ -22,6 +22,7 @@ "@radix-ui/react-progress": "^1.1.7", "@radix-ui/react-scroll-area": "^1.2.10", "@radix-ui/react-slot": "^1.2.3", + "@radix-ui/react-tabs": "^1.1.13", "@radix-ui/react-tooltip": "^1.2.8", "@sentry/nextjs": "^10.11.0", "@tanstack/react-query": "^5.87.4", diff --git a/src/app/doc/[...path]/page.tsx b/src/app/doc/[...path]/page.tsx index 512e7f7..2f7f7a1 100644 --- a/src/app/doc/[...path]/page.tsx +++ b/src/app/doc/[...path]/page.tsx @@ -1,280 +1,13 @@ "use client"; import * as React from "react"; -import { useQuery } from "@tanstack/react-query"; -import CodeMirror from "@uiw/react-codemirror"; -import { markdown } from "@codemirror/lang-markdown"; -import { Button } from "@/components/ui/button"; -import { Input } from "@/components/ui/input"; -import { Dialog, DialogContent, DialogHeader, DialogTitle } from "@/components/ui/dialog"; -import { MarkdownEditor } from "@/components/editor/markdown-editor"; -import { toast } from "sonner"; -import { TagsDialog } from "@/components/files/tags-dialog"; -import { Breadcrumbs } from "@/components/navigation/breadcrumbs"; +import { DocViewer } from "@/components/doc/doc-viewer"; type PageProps = { params: { path?: string[] }; }; -async function fetchContent(path: string) { - const url = new URL("/api/files/content", window.location.origin); - url.searchParams.set("path", path); - const res = await fetch(url.toString(), { cache: "no-store" }); - if (!res.ok) { - const payload = await res.json().catch(() => ({})); - throw new Error(payload?.message || `Failed to load content (${res.status})`); - } - return (await res.json()) as { ok: boolean; content?: string; mimeType?: string }; -} - -async function fetchTagHistory(path: string) { - const url = new URL("/api/files/tags/history", window.location.origin); - url.searchParams.set("path", path); - const res = await fetch(url.toString(), { cache: "no-store" }); - if (!res.ok) { - const payload = await res.json().catch(() => ({})); - throw new Error(payload?.message || `Failed to load tag history (${res.status})`); - } - return (await res.json()) as { ok: boolean; history?: Array<{ id: string; path: string; actor?: string; action: string; from?: string[]; to: string[]; at: string }> }; -} - -function buildOutline(md?: string) { - if (!md) return [] as Array<{ level: number; text: string; line: number }>; - const lines = md.split(/\r?\n/); - const out: Array<{ level: number; text: string; line: number }> = []; - lines.forEach((ln, i) => { - const m = /^(#{1,6})\s+(.*)$/.exec(ln); - if (m) { - out.push({ level: m[1].length, text: m[2].trim(), line: i }); - } - }); - return out; -} - -function simpleUnifiedDiff(a: string, b: string) { - // naive line-by-line diff for small files - const aLines = a.split(/\r?\n/); - const bLines = b.split(/\r?\n/); - const max = Math.max(aLines.length, bLines.length); - const rows: Array<{ type: "ctx" | "del" | "add"; text: string }> = []; - for (let i = 0; i < max; i++) { - const A = aLines[i]; - const B = bLines[i]; - if (A === B) { - if (A !== undefined) rows.push({ type: "ctx", text: A }); - } else { - if (A !== undefined) rows.push({ type: "del", text: A }); - if (B !== undefined) rows.push({ type: "add", text: B }); - } - } - return rows; -} - export default function DocPage({ params }: PageProps) { const path = "/" + (params.path?.join("/") ?? ""); - const [activeTab, setActiveTab] = React.useState<"content" | "versions" | "diff" | "tags" | "history">("content"); - - const [editorOpen, setEditorOpen] = React.useState(false); - const [tagsOpen, setTagsOpen] = React.useState(false); - - // Content - const contentQuery = useQuery({ - queryKey: ["doc-content", path], - queryFn: () => fetchContent(path), - }); - - const outline = React.useMemo(() => buildOutline(contentQuery.data?.content || ""), [contentQuery.data?.content]); - - // Diff - const [comparePath, setComparePath] = React.useState(""); - const [diffRows, setDiffRows] = React.useState | null>(null); - const [diffError, setDiffError] = React.useState(null); - const [diffLoading, setDiffLoading] = React.useState(false); - - async function runDiff() { - try { - setDiffError(null); - setDiffLoading(true); - if (!contentQuery.data?.content) throw new Error("Primary document not loaded"); - const primary = contentQuery.data.content; - const primaryBytes = new TextEncoder().encode(primary).length; - const res = await fetchContent(comparePath); - const other = res.content ?? ""; - const otherBytes = new TextEncoder().encode(other).length; - if (primaryBytes > 500_000 || otherBytes > 500_000) { - throw new Error("Diff is limited to files ≤ 500KB each"); - } - const rows = simpleUnifiedDiff(other, primary); // show changes from other -> current - setDiffRows(rows); - } catch (e) { - const msg = e instanceof Error ? e.message : String(e); - setDiffError(msg); - toast.error(msg); - } finally { - setDiffLoading(false); - } - } - - // History - const historyQuery = useQuery({ - queryKey: ["doc-tag-history", path], - queryFn: () => fetchTagHistory(path), - enabled: activeTab === "history", - }); - - function downloadCurrent() { - const url = new URL("/api/files/download", window.location.origin); - url.searchParams.set("path", path); - window.open(url.toString(), "_blank", "noopener,noreferrer"); - } - - return ( -
-
- {}} /> -
- - - -
-
- - {/* Tabs */} -
- {(["content", "versions", "diff", "tags", "history"] as const).map((t) => ( - - ))} -
- - {/* Tab Content */} -
- {activeTab === "content" ? ( -
-
- -
- -
- ) : null} - - {activeTab === "versions" ? ( -
- Versions are not yet integrated with Nextcloud for this MVP. Use Download to retrieve content or maintain your own revisions. This tab will be wired to Nextcloud versions when available. -
- ) : null} - - {activeTab === "diff" ? ( -
-
-
- - setComparePath(e.target.value)} - /> -
- -
- {diffError ?
{diffError}
: null} - {diffRows ? ( -
-                {diffRows.map((r, i) => {
-                  const prefix = r.type === "ctx" ? " " : r.type === "del" ? "-" : "+";
-                  const cls =
-                    r.type === "ctx"
-                      ? ""
-                      : r.type === "del"
-                        ? "text-red-500"
-                        : "text-green-500";
-                  return (
-                    
- {prefix} {r.text} -
- ); - })} -
- ) : null} -
- ) : null} - - {activeTab === "tags" ? ( -
- - setTagsOpen(v)} - initialTags={[]} - onSaved={() => { - toast.success("Tags updated"); - }} - /> -
- ) : null} - - {activeTab === "history" ? ( -
-
Tag History
- {historyQuery.isLoading ?
Loading…
: null} - {historyQuery.data?.history && historyQuery.data.history.length > 0 ? ( -
- {historyQuery.data.history.map((h) => ( -
-
{new Date(h.at).toLocaleString()} • {h.actor ?? "unknown"}
-
Tags: {(h.to ?? []).join(", ")}
-
- ))} -
- ) : ( -
No tag history
- )} -
- ) : null} -
- - {/* Editor dialog */} - setEditorOpen(v)}> - - - Edit Document - -
- {editorOpen ? : null} -
-
-
-
- ); + return ; } diff --git a/src/app/page.tsx b/src/app/page.tsx index b808b04..37e302d 100644 --- a/src/app/page.tsx +++ b/src/app/page.tsx @@ -1,445 +1,125 @@ "use client"; import * as React from "react"; -import { useRouter } from "next/navigation"; -import { SidebarTree } from "@/components/sidebar/sidebar-tree"; -import { Breadcrumbs } from "@/components/navigation/breadcrumbs"; -import { FileTable, type FileRow } from "@/components/files/file-table"; -import { UploadDialog } from "@/components/files/upload-dialog"; -import { Input } from "@/components/ui/input"; -import { Button } from "@/components/ui/button"; -import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query"; -import { Checkbox } from "@/components/ui/checkbox"; -import { Dialog, DialogContent, DialogHeader, DialogTitle } from "@/components/ui/dialog"; -import { MarkdownEditor } from "@/components/editor/markdown-editor"; -import { toast } from "sonner"; -import { TagsDialog } from "@/components/files/tags-dialog"; +import * as Sentry from "@sentry/nextjs"; +import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs"; import { ModeToggle } from "@/components/theme/mode-toggle"; -import { SearchResultsList } from "@/components/search/results-list"; -import { FiltersBar } from "@/components/search/filters-bar"; -import { CommandPalette } from "@/components/search/command-palette"; -import type { SearchResult, SearchHit, FacetFilters } from "@/types/search"; +import SignOutButton from "@/components/auth/signout-button"; +import { FilesPanel } from "@/components/files/files-panel"; +import { SearchPanel } from "@/components/search/search-panel"; +import { DocViewer } from "@/components/doc/doc-viewer"; +import { QdrantPanel } from "@/components/qdrant/qdrant-panel"; +import { TagsDialog } from "@/components/files/tags-dialog"; +import { normalizePath } from "@/lib/paths"; -type FilesListResponse = { - total: number; - page: number; - perPage: number; - items: Array<{ - id: string; - name: string; - path: string; - parentPath?: string; - kind: "File"; - sizeBytes: number; - mimeType: string; - etag?: string; - }>; -}; - - -async function fetchFiles(path?: string, page = 1, perPage = 50) { - const url = new URL("/api/files/list", window.location.origin); - if (path) url.searchParams.set("path", path); - url.searchParams.set("page", String(page)); - url.searchParams.set("perPage", String(perPage)); - const res = await fetch(url.toString(), { cache: "no-store" }); - if (!res.ok) throw new Error(`Failed to load files: ${res.status}`); - const data = (await res.json()) as FilesListResponse; - return data; -} - -async function executeSearch( - q: string, - semantic: boolean, - page: number, - perPage: number, - filters: FacetFilters, -): Promise { - const res = await fetch("/api/search/query", { - method: "POST", - headers: { "content-type": "application/json" }, - body: JSON.stringify({ q, filters, semantic, page, perPage }), - }); - if (!res.ok) throw new Error(`Search failed: ${res.status}`); - const data = (await res.json()) as SearchResult; - return data; -} +type DashboardTab = "files" | "search" | "document" | "qdrant"; export default function Home() { - const router = useRouter(); - const queryClient = useQueryClient(); - const [path, setPath] = React.useState(undefined); - const [page, setPage] = React.useState(1); - const [perPage] = React.useState(50); - const [selectedFolders, setSelectedFolders] = React.useState([]); - - const [q, setQ] = React.useState(""); - const [semantic, setSemantic] = React.useState(false); - const [filters, setFilters] = React.useState({}); - const [paletteOpen, setPaletteOpen] = React.useState(false); - const searching = q.trim().length > 0; - - // Editor state - const [editPath, setEditPath] = React.useState(null); - - // Tags dialog state + const [activeTab, setActiveTab] = React.useState("files"); + const [selectedPath, setSelectedPath] = React.useState(null); const [tagsPath, setTagsPath] = React.useState(null); - const filesQuery = useQuery({ - queryKey: ["files", path, page, perPage], - queryFn: () => fetchFiles(path, page, perPage), - enabled: !searching, - }); + const handleTabChange = (next: string) => { + const from = activeTab; + const to = (next as DashboardTab) || "files"; + Sentry.startSpan( + { op: "ui.click", name: "Dashboard Tab Switch" }, + (span) => { + span.setAttribute("from", from); + span.setAttribute("to", to); + setActiveTab(to); + }, + ); + }; - const searchQuery = useQuery({ - queryKey: ["search", q, semantic, page, perPage, filters], - queryFn: () => executeSearch(q.trim(), semantic, page, perPage, filters), - enabled: searching, - }); - - const searchHits: SearchHit[] = React.useMemo(() => { - return (searchQuery.data?.hits ?? []) as SearchHit[]; - }, [searchQuery.data]); - - const files: FileRow[] = React.useMemo(() => { - const items = filesQuery.data?.items ?? []; - return items.map((it) => ({ - id: it.id, - name: it.name, - path: it.path, - parentPath: it.parentPath, - sizeBytes: it.sizeBytes, - mimeType: it.mimeType, - etag: it.etag, - })); - }, [filesQuery.data]); - - function handleDownload(item: FileRow) { - const url = new URL("/api/files/download", window.location.origin); - url.searchParams.set("path", item.path); - // open in a new tab to trigger save/open dialog - window.open(url.toString(), "_blank", "noopener,noreferrer"); - } - - function encodeDocPath(p: string) { - // Convert an absolute path like "/a/b c/d.md" -> "/doc/a/b%20c/d.md" - const segments = p.replace(/^\/+/, "").split("/"); - const encoded = segments.map((s) => encodeURIComponent(s)).join("/"); - return `/doc/${encoded}`; - } - - function navigateToDoc(p: string) { - router.push(encodeDocPath(p)); - } - - function handleOpen(item: FileRow) { - // Navigate to the Document View for the selected file - navigateToDoc(item.path); - } - - function handleEdit(item: FileRow) { - setEditPath(item.path); - } - - async function postJSON(url: string, body: unknown) { - const res = await fetch(url, { - method: "POST", - headers: { "content-type": "application/json" }, - body: JSON.stringify(body), - }); - if (!res.ok) { - const payload = await res.json().catch(() => ({})); - throw new Error(payload?.message || `Request failed (${res.status})`); + const selectDocument = (path: string | null) => { + if (!path) { + setSelectedPath(null); + setActiveTab("document"); + return; } - return res.json(); - } + const normalized = normalizePath(path); + Sentry.startSpan( + { op: "ui.action", name: "Open Document" }, + (span) => { + span.setAttribute("path", normalized); + setSelectedPath(normalized); + setActiveTab("document"); + }, + ); + }; - // Optimistic delete mutation - const deleteMutation = useMutation({ - mutationFn: async (file: FileRow) => { - return postJSON("/api/files/delete", { path: file.path }); - }, - onMutate: async (file: FileRow) => { - // Cancel any outgoing refetches (so they don't overwrite our optimistic update) - await queryClient.cancelQueries({ queryKey: ["files", path, page, perPage] }); - // Snapshot previous value - const prev = queryClient.getQueryData(["files", path, page, perPage]) as - | { total: number; page: number; perPage: number; items: FileRow[] } - | undefined; - - if (prev) { - // Optimistically remove the item - const nextItems = prev.items.filter((x) => x.id !== file.id); - queryClient.setQueryData(["files", path, page, perPage], { - ...prev, - total: Math.max(0, (prev.total ?? nextItems.length) - 1), - items: nextItems, - }); - } - - return { prev }; - }, - onError: (error, _file, ctx) => { - if (ctx?.prev) { - queryClient.setQueryData(["files", path, page, perPage], ctx.prev); - } - const message = error instanceof Error ? error.message : String(error); - toast.error(message); - }, - onSuccess: () => { - toast.success("Deleted"); - }, - onSettled: () => { - queryClient.invalidateQueries({ queryKey: ["files", path, page, perPage] }); - }, - }); - - // Optimistic rename mutation - const renameMutation = useMutation({ - mutationFn: async (vars: { item: FileRow; to: string; newName: string }) => { - return postJSON("/api/files/rename", { from: vars.item.path, to: vars.to }); - }, - onMutate: async ({ item, to, newName }) => { - await queryClient.cancelQueries({ queryKey: ["files", path, page, perPage] }); - const prev = queryClient.getQueryData(["files", path, page, perPage]) as - | { total: number; page: number; perPage: number; items: FileRow[] } - | undefined; - if (prev) { - const nextItems = prev.items.map((x) => - x.id === item.id ? { ...x, name: newName, path: to } : x, - ); - queryClient.setQueryData(["files", path, page, perPage], { - ...prev, - items: nextItems, - }); - } - return { prev }; - }, - onError: (error, _vars, ctx) => { - if (ctx?.prev) { - queryClient.setQueryData(["files", path, page, perPage], ctx.prev); - } - const message = error instanceof Error ? error.message : String(error); - toast.error(message); - }, - onSuccess: () => { - toast.success("Renamed"); - }, - onSettled: () => { - queryClient.invalidateQueries({ queryKey: ["files", path, page, perPage] }); - }, - }); - - // Optimistic copy mutation - const copyMutation = useMutation({ - mutationFn: async (vars: { item: FileRow; to: string; newName: string }) => { - return postJSON("/api/files/copy", { from: vars.item.path, to: vars.to }); - }, - onMutate: async ({ item, to, newName }) => { - await queryClient.cancelQueries({ queryKey: ["files", path, page, perPage] }); - const prev = queryClient.getQueryData(["files", path, page, perPage]) as - | { total: number; page: number; perPage: number; items: FileRow[] } - | undefined; - if (prev) { - const temp: FileRow = { - ...item, - id: `temp-${Date.now()}`, - name: newName, - path: to, - }; - const nextItems = [temp, ...(prev.items ?? [])]; - queryClient.setQueryData(["files", path, page, perPage], { - ...prev, - total: (prev.total ?? nextItems.length), - items: nextItems, - }); - } - return { prev }; - }, - onError: (error, _vars, ctx) => { - if (ctx?.prev) { - queryClient.setQueryData(["files", path, page, perPage], ctx.prev); - } - const message = error instanceof Error ? error.message : String(error); - toast.error(message); - }, - onSuccess: () => { - toast.success("Copied"); - }, - onSettled: () => { - queryClient.invalidateQueries({ queryKey: ["files", path, page, perPage] }); - }, - }); - - async function handleRename(item: FileRow) { - const newName = window.prompt("New name (or full destination path):", item.name); - if (!newName) return; - const to = - newName.startsWith("/") - ? newName - : `${item.parentPath ?? ""}/${newName}`.replace(/\/+/g, "/"); - await renameMutation.mutateAsync({ item, to, newName }); - } - - async function handleCopy(item: FileRow) { - const newPath = window.prompt("Copy to path (absolute or name in same folder):", `${item.parentPath ?? ""}/${item.name}`); - if (!newPath) return; - const to = - newPath.startsWith("/") - ? newPath - : `${item.parentPath ?? ""}/${newPath}`.replace(/\/+/g, "/"); - const newName = newPath.startsWith("/") - ? newPath.split("/").filter(Boolean).pop() ?? item.name - : newPath; - await copyMutation.mutateAsync({ item, to, newName }); - } - - async function handleDelete(item: FileRow) { - const yes = window.confirm(`Delete "${item.name}"? This cannot be undone.`); - if (!yes) return; - await deleteMutation.mutateAsync(item); - } - - function handleTags(item: FileRow) { - setTagsPath(item.path); - } - - // Helpers for search results list (path-only operations) - function downloadByPath(p: string) { + const onDownloadPath = (p: string) => { const url = new URL("/api/files/download", window.location.origin); url.searchParams.set("path", p); window.open(url.toString(), "_blank", "noopener,noreferrer"); - } - function openByPath(p: string) { - navigateToDoc(p); - } - function tagsByPath(p: string) { - setTagsPath(p); - } - - function handleUploaded() { - if (!searching) { - filesQuery.refetch(); - } - } + }; return ( -
- {/* Sidebar */} - +
+ {/* Top header */} +
+
Dashboard
+
+ + +
+
- {/* Main content */} -
-
- { setPage(1); setPath(p); }} /> -
- + {/* Tabs container */} +
+ +
+ + Files + Search + Document + Qdrant +
-
-
-
- { setPage(1); setQ(e.target.value); }} + + selectDocument(file.path)} + onDownload={(file) => onDownloadPath(file.path)} + onTagsPath={(p) => setTagsPath(p)} /> -
- setSemantic(Boolean(v))} - /> - -
- -
- -
+ - {searching ? ( -
- -
- ) : null} - -
- {searching ? ( -
- {searchQuery.isLoading - ? "Searching…" - : `Found ${searchQuery.data?.total ?? 0} in ${searchQuery.data?.tookMs ?? 0}ms`} -
- ) : null} - {searching ? ( - + selectDocument(p)} + onDownloadPath={onDownloadPath} + onTagsPath={(p) => setTagsPath(p)} /> - ) : ( - - )} -
-
+ - {/* Editor dialog */} - !v && setEditPath(null)}> - - - Markdown Editor - -
- {editPath ? : null} -
-
-
+ + {selectedPath ? ( + + ) : ( +
+
+ Select a file from Files or Search to view the document here. +
+
+ )} +
- {/* Tags dialog */} + + + + +
+ + {/* Tags dialog (shared) */} !v && setTagsPath(null)} initialTags={[]} onSaved={() => { - // Optionally refresh file list to reflect updated tags when surface supports it - if (!searching) filesQuery.refetch(); - }} - /> - {/* Command palette (Cmd/Ctrl+K) */} - { - setPage(1); - setQ(text); + // Panels handle their own refetch; we only need UX feedback here. }} />
diff --git a/src/app/qdrant/page.tsx b/src/app/qdrant/page.tsx index c560763..31a5acf 100644 --- a/src/app/qdrant/page.tsx +++ b/src/app/qdrant/page.tsx @@ -1,244 +1,12 @@ "use client"; import * as React from "react"; -import { useQuery } from "@tanstack/react-query"; -import { Input } from "@/components/ui/input"; -import { Button } from "@/components/ui/button"; -import { ScrollArea } from "@/components/ui/scroll-area"; -import { EmbeddingScatter, type EmbeddingPoint } from "@/components/qdrant/embedding-scatter"; - -type CollectionRow = { - name: string; - vectors_count?: number; - points_count?: number; -}; - -type CollectionsResponse = { - collections: CollectionRow[]; -}; - -type ScrollPoint = { - id: string | number; - payload?: Record; - vector?: number[] | Record; -}; - -type PointsResponse = { - points: ScrollPoint[]; - next_page_offset?: string | number | null; -}; - -async function fetchCollections() { - const res = await fetch("/api/qdrant/collections", { cache: "no-store" }); - if (!res.ok) { - const payload = await res.json().catch(() => ({})); - throw new Error(payload?.message || `Failed to load collections (${res.status})`); - } - return (await res.json()) as CollectionsResponse; -} - -async function fetchPoints(collection: string, limit: number, offset?: string | number | null, withVector = false) { - const url = new URL("/api/qdrant/points", window.location.origin); - url.searchParams.set("collection", collection); - url.searchParams.set("limit", String(limit)); - if (offset != null) url.searchParams.set("offset", String(offset)); - url.searchParams.set("withVector", String(withVector)); - const res = await fetch(url.toString(), { cache: "no-store" }); - if (!res.ok) { - const payload = await res.json().catch(() => ({})); - throw new Error(payload?.message || `Failed to load points (${res.status})`); - } - return (await res.json()) as PointsResponse; -} +import { QdrantPanel } from "@/components/qdrant/qdrant-panel"; export default function QdrantPage() { - const [selected, setSelected] = React.useState(null); - const [limit, setLimit] = React.useState(100); - const [withVector, setWithVector] = React.useState(false); - const [offset, setOffset] = React.useState(null); - - const collectionsQuery = useQuery({ - queryKey: ["qdrant-collections"], - queryFn: fetchCollections, - }); - - React.useEffect(() => { - const names = collectionsQuery.data?.collections?.map((c) => c.name) ?? []; - if (!selected && names.length > 0) { - // Prefer fortura-db, then miguel_responses, then first - const preferred = - names.find((n) => n === "fortura-db") || - names.find((n) => n === "miguel_responses") || - names[0]; - setSelected(preferred); - } - }, [collectionsQuery.data, selected]); - - const pointsQuery = useQuery({ - queryKey: ["qdrant-points", selected, limit, offset, withVector], - queryFn: () => fetchPoints(selected!, limit, offset, withVector), - enabled: !!selected, - }); - - function pretty(v: unknown) { - try { - return JSON.stringify(v, null, 2); - } catch { - return String(v); - } - } - return (
-
-

Qdrant Collections

-
-
- - -
-
-
Collection:
- { - setSelected(e.target.value || null); - setOffset(null); - }} - placeholder="collection name" - className="max-w-xs" - /> -
-
Limit
- setLimit(Math.max(1, Math.min(1000, Number(e.target.value) || 100)))} - className="w-24" - /> -
- - -
- -
- {/* Points list */} -
-
Points
- -
- {pointsQuery.isLoading &&
Loading…
} - {pointsQuery.error && ( -
- {(pointsQuery.error as Error).message} -
- )} - {(pointsQuery.data?.points ?? []).map((p, idx) => ( -
-
id: {String(p.id)}
-
-                        {pretty(p.payload)}
-                      
- {withVector && p.vector && ( -
-                          {Array.isArray(p.vector) ? `[${p.vector.slice(0, 8).join(", ")} …]` : pretty(p.vector)}
-                        
- )} -
- ))} - {(pointsQuery.data?.points ?? []).length === 0 && !pointsQuery.isLoading && !pointsQuery.error && ( -
No points
- )} -
-
-
-
- Offset: {pointsQuery.data?.next_page_offset != null ? String(pointsQuery.data.next_page_offset) : "—"} -
-
- - -
-
-
- - {/* Embedding scatter */} -
-
Embedding Visualization
-
- { - // Future: drill-through to the file using payload/path mapping via ES index - console.log("open point", p); - }} - onSelect={(sel) => { - console.log("selected", sel.length); - }} - /> -
-
-
-
-
+
); } diff --git a/src/components/auth/signout-button.tsx b/src/components/auth/signout-button.tsx new file mode 100644 index 0000000..3a8c2ac --- /dev/null +++ b/src/components/auth/signout-button.tsx @@ -0,0 +1,84 @@ +"use client"; + +import * as React from "react"; +import { useRouter } from "next/navigation"; +import { useQueryClient } from "@tanstack/react-query"; +import * as Sentry from "@sentry/nextjs"; +import { Button } from "@/components/ui/button"; + +type SignOutButtonProps = { + size?: "sm" | "default" | "lg"; + variant?: "ghost" | "secondary" | "default"; + className?: string; +}; + +export function SignOutButton({ + size = "default", + variant = "ghost", + className, +}: SignOutButtonProps) { + const router = useRouter(); + const queryClient = useQueryClient(); + const [pending, setPending] = React.useState(false); + + const handleClick = async () => { + if (pending) return; + setPending(true); + try { + await Sentry.startSpan( + { op: "ui.click", name: "Sign Out Click" }, + async (span) => { + span.setAttribute("component", "SignOutButton"); + const res = await fetch("/api/auth/signout", { + method: "POST", + credentials: "include", + headers: { "content-type": "application/json" }, + }); + if (!res.ok) { + const payload = await res.json().catch(() => ({})); + const message = payload?.message || `Sign out failed (${res.status})`; + span.setAttribute("status_code", res.status); + throw new Error(message); + } + span.setAttribute("status_code", 200); + }, + ); + + // Clear caches to avoid stale authenticated state after sign out + queryClient.clear(); + + // Redirect to homepage and refresh so OnboardingGate re-evaluates + router.push("/"); + router.refresh(); + + // Hard reload fallback + setTimeout(() => { + try { + if (typeof window !== "undefined") window.location.href = "/"; + } catch { + // noop + } + }, 50); + } catch (error) { + Sentry.captureException(error); + // eslint-disable-next-line no-console + console.error(error); + } finally { + setPending(false); + } + }; + + return ( + + ); +} + +export default SignOutButton; diff --git a/src/components/doc/doc-viewer.tsx b/src/components/doc/doc-viewer.tsx new file mode 100644 index 0000000..6568708 --- /dev/null +++ b/src/components/doc/doc-viewer.tsx @@ -0,0 +1,309 @@ +"use client"; + +import * as React from "react"; +import { useQuery } from "@tanstack/react-query"; +import CodeMirror from "@uiw/react-codemirror"; +import { markdown } from "@codemirror/lang-markdown"; +import { Button } from "@/components/ui/button"; +import { Input } from "@/components/ui/input"; +import { Dialog, DialogContent, DialogHeader, DialogTitle } from "@/components/ui/dialog"; +import { MarkdownEditor } from "@/components/editor/markdown-editor"; +import { toast } from "sonner"; +import { TagsDialog } from "@/components/files/tags-dialog"; +import { Breadcrumbs } from "@/components/navigation/breadcrumbs"; +import SignOutButton from "@/components/auth/signout-button"; + +async function fetchContent(path: string) { + const url = new URL("/api/files/content", window.location.origin); + url.searchParams.set("path", path); + const res = await fetch(url.toString(), { cache: "no-store" }); + if (!res.ok) { + const payload = await res.json().catch(() => ({})); + throw new Error(payload?.message || `Failed to load content (${res.status})`); + } + return (await res.json()) as { ok: boolean; content?: string; mimeType?: string }; +} + +async function fetchTagHistory(path: string) { + const url = new URL("/api/files/tags/history", window.location.origin); + url.searchParams.set("path", path); + const res = await fetch(url.toString(), { cache: "no-store" }); + if (!res.ok) { + const payload = await res.json().catch(() => ({})); + throw new Error(payload?.message || `Failed to load tag history (${res.status})`); + } + return (await res.json()) as { + ok: boolean; + history?: Array<{ + id: string; + path: string; + actor?: string; + action: string; + from?: string[]; + to: string[]; + at: string; + }>; + }; +} + +function buildOutline(md?: string) { + if (!md) return [] as Array<{ level: number; text: string; line: number }>; + const lines = md.split(/\r?\n/); + const out: Array<{ level: number; text: string; line: number }> = []; + lines.forEach((ln, i) => { + const m = /^(#{1,6})\s+(.*)$/.exec(ln); + if (m) { + out.push({ level: m[1].length, text: m[2].trim(), line: i }); + } + }); + return out; +} + +function simpleUnifiedDiff(a: string, b: string) { + // naive line-by-line diff for small files + const aLines = a.split(/\r?\n/); + const bLines = b.split(/\r?\n/); + const max = Math.max(aLines.length, bLines.length); + const rows: Array<{ type: "ctx" | "del" | "add"; text: string }> = []; + for (let i = 0; i < max; i++) { + const A = aLines[i]; + const B = bLines[i]; + if (A === B) { + if (A !== undefined) rows.push({ type: "ctx", text: A }); + } else { + if (A !== undefined) rows.push({ type: "del", text: A }); + if (B !== undefined) rows.push({ type: "add", text: B }); + } + } + return rows; +} + +export type DocViewerProps = { + path: string; +}; + +export function DocViewer({ path }: DocViewerProps) { + const [activeTab, setActiveTab] = React.useState< + "content" | "versions" | "diff" | "tags" | "history" + >("content"); + + const [editorOpen, setEditorOpen] = React.useState(false); + const [tagsOpen, setTagsOpen] = React.useState(false); + + // Content + const contentQuery = useQuery({ + queryKey: ["doc-content", path], + queryFn: () => fetchContent(path), + }); + + const outline = React.useMemo( + () => buildOutline(contentQuery.data?.content || ""), + [contentQuery.data?.content], + ); + + // Diff + const [comparePath, setComparePath] = React.useState(""); + const [diffRows, setDiffRows] = React.useState< + Array<{ type: "ctx" | "del" | "add"; text: string }> | null + >(null); + const [diffError, setDiffError] = React.useState(null); + const [diffLoading, setDiffLoading] = React.useState(false); + + async function runDiff() { + try { + setDiffError(null); + setDiffLoading(true); + if (!contentQuery.data?.content) throw new Error("Primary document not loaded"); + const primary = contentQuery.data.content; + const primaryBytes = new TextEncoder().encode(primary).length; + const res = await fetchContent(comparePath); + const other = res.content ?? ""; + const otherBytes = new TextEncoder().encode(other).length; + if (primaryBytes > 500_000 || otherBytes > 500_000) { + throw new Error("Diff is limited to files ≤ 500KB each"); + } + const rows = simpleUnifiedDiff(other, primary); // show changes from other -> current + setDiffRows(rows); + } catch (e) { + const msg = e instanceof Error ? e.message : String(e); + setDiffError(msg); + toast.error(msg); + } finally { + setDiffLoading(false); + } + } + + // History + const historyQuery = useQuery({ + queryKey: ["doc-tag-history", path], + queryFn: () => fetchTagHistory(path), + enabled: activeTab === "history", + }); + + function downloadCurrent() { + const url = new URL("/api/files/download", window.location.origin); + url.searchParams.set("path", path); + window.open(url.toString(), "_blank", "noopener,noreferrer"); + } + + return ( +
+
+ {}} /> +
+ + + + +
+
+ + {/* Tabs */} +
+ {(["content", "versions", "diff", "tags", "history"] as const).map((t) => ( + + ))} +
+ + {/* Tab Content */} +
+ {activeTab === "content" ? ( +
+
+ +
+ +
+ ) : null} + + {activeTab === "versions" ? ( +
+ Versions are not yet integrated with Nextcloud for this MVP. Use Download to retrieve + content or maintain your own revisions. This tab will be wired to Nextcloud versions when + available. +
+ ) : null} + + {activeTab === "diff" ? ( +
+
+
+ + setComparePath(e.target.value)} + /> +
+ +
+ {diffError ?
{diffError}
: null} + {diffRows ? ( +
+                {diffRows.map((r, i) => {
+                  const prefix = r.type === "ctx" ? " " : r.type === "del" ? "-" : "+";
+                  const cls =
+                    r.type === "ctx"
+                      ? ""
+                      : r.type === "del"
+                        ? "text-red-500"
+                        : "text-green-500";
+                  return (
+                    
+ {prefix} {r.text} +
+ ); + })} +
+ ) : null} +
+ ) : null} + + {activeTab === "tags" ? ( +
+ + setTagsOpen(v)} + initialTags={[]} + onSaved={() => { + toast.success("Tags updated"); + }} + /> +
+ ) : null} + + {activeTab === "history" ? ( +
+
Tag History
+ {historyQuery.isLoading ? ( +
Loading…
+ ) : null} + {historyQuery.data?.history && historyQuery.data.history.length > 0 ? ( +
+ {historyQuery.data.history.map((h) => ( +
+
+ {new Date(h.at).toLocaleString()} • {h.actor ?? "unknown"} +
+
Tags: {(h.to ?? []).join(", ")}
+
+ ))} +
+ ) : ( +
No tag history
+ )} +
+ ) : null} +
+ + {/* Editor dialog */} + setEditorOpen(v)}> + + + Edit Document + +
+ {editorOpen ? : null} +
+
+
+
+ ); +} + +export default DocViewer; diff --git a/src/components/files/files-panel.tsx b/src/components/files/files-panel.tsx new file mode 100644 index 0000000..22b119a --- /dev/null +++ b/src/components/files/files-panel.tsx @@ -0,0 +1,351 @@ +"use client"; + +import * as React from "react"; +import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query"; +import { SidebarTree } from "@/components/sidebar/sidebar-tree"; +import { Breadcrumbs } from "@/components/navigation/breadcrumbs"; +import { FileTable, type FileRow } from "@/components/files/file-table"; +import { UploadDialog } from "@/components/files/upload-dialog"; +import { Button } from "@/components/ui/button"; +import { Dialog, DialogContent, DialogHeader, DialogTitle } from "@/components/ui/dialog"; +import { MarkdownEditor } from "@/components/editor/markdown-editor"; +import { toast } from "sonner"; +import { TagsDialog } from "@/components/files/tags-dialog"; + +type FilesListResponse = { + total: number; + page: number; + perPage: number; + items: Array<{ + id: string; + name: string; + path: string; + parentPath?: string; + kind: "File"; + sizeBytes: number; + mimeType: string; + etag?: string; + }>; +}; + +async function fetchFiles(path?: string, page = 1, perPage = 50) { + const url = new URL("/api/files/list", window.location.origin); + if (path) url.searchParams.set("path", path); + url.searchParams.set("page", String(page)); + url.searchParams.set("perPage", String(perPage)); + const res = await fetch(url.toString(), { cache: "no-store" }); + if (!res.ok) throw new Error(`Failed to load files: ${res.status}`); + const data = (await res.json()) as FilesListResponse; + return data; +} + +async function postJSON(url: string, body: unknown) { + const res = await fetch(url, { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify(body), + }); + if (!res.ok) { + const payload = await res.json().catch(() => ({})); + throw new Error(payload?.message || `Request failed (${res.status})`); + } + return res.json(); +} + +export type FilesPanelProps = { + onOpen?: (file: FileRow) => void; + onDownload?: (file: FileRow) => void; + onTagsPath?: (path: string) => void; +}; + +export function FilesPanel({ onOpen, onDownload, onTagsPath }: FilesPanelProps) { + const queryClient = useQueryClient(); + + const [path, setPath] = React.useState(undefined); + const [page, setPage] = React.useState(1); + const [perPage] = React.useState(50); + const [selectedFolders, setSelectedFolders] = React.useState([]); + + // Editor state + const [editPath, setEditPath] = React.useState(null); + + // Tags dialog state + const [tagsPath, setTagsPath] = React.useState(null); + + const filesQuery = useQuery({ + queryKey: ["files", path, page, perPage], + queryFn: () => fetchFiles(path, page, perPage), + }); + + const files: FileRow[] = React.useMemo(() => { + const items = filesQuery.data?.items ?? []; + return items.map((it) => ({ + id: it.id, + name: it.name, + path: it.path, + parentPath: it.parentPath, + sizeBytes: it.sizeBytes, + mimeType: it.mimeType, + etag: it.etag, + })); + }, [filesQuery.data]); + + function handleDownload(item: FileRow) { + if (onDownload) { + onDownload(item); + return; + } + const url = new URL("/api/files/download", window.location.origin); + url.searchParams.set("path", item.path); + // open in a new tab to trigger save/open dialog + window.open(url.toString(), "_blank", "noopener,noreferrer"); + } + + function handleOpen(item: FileRow) { + if (onOpen) { + onOpen(item); + } + } + + function handleEdit(item: FileRow) { + setEditPath(item.path); + } + + // Optimistic delete mutation + const deleteMutation = useMutation({ + mutationFn: async (file: FileRow) => { + return postJSON("/api/files/delete", { path: file.path }); + }, + onMutate: async (file: FileRow) => { + await queryClient.cancelQueries({ queryKey: ["files", path, page, perPage] }); + const prev = queryClient.getQueryData(["files", path, page, perPage]) as + | { total: number; page: number; perPage: number; items: FileRow[] } + | undefined; + + if (prev) { + const nextItems = prev.items.filter((x) => x.id !== file.id); + queryClient.setQueryData(["files", path, page, perPage], { + ...prev, + total: Math.max(0, (prev.total ?? nextItems.length) - 1), + items: nextItems, + }); + } + + return { prev }; + }, + onError: (error, _file, ctx) => { + if (ctx?.prev) { + queryClient.setQueryData(["files", path, page, perPage], ctx.prev); + } + const message = error instanceof Error ? error.message : String(error); + toast.error(message); + }, + onSuccess: () => { + toast.success("Deleted"); + }, + onSettled: () => { + queryClient.invalidateQueries({ queryKey: ["files", path, page, perPage] }); + }, + }); + + // Optimistic rename mutation + const renameMutation = useMutation({ + mutationFn: async (vars: { item: FileRow; to: string; newName: string }) => { + return postJSON("/api/files/rename", { from: vars.item.path, to: vars.to }); + }, + onMutate: async ({ item, to, newName }) => { + await queryClient.cancelQueries({ queryKey: ["files", path, page, perPage] }); + const prev = queryClient.getQueryData(["files", path, page, perPage]) as + | { total: number; page: number; perPage: number; items: FileRow[] } + | undefined; + if (prev) { + const nextItems = prev.items.map((x) => + x.id === item.id ? { ...x, name: newName, path: to } : x, + ); + queryClient.setQueryData(["files", path, page, perPage], { + ...prev, + items: nextItems, + }); + } + return { prev }; + }, + onError: (error, _vars, ctx) => { + if (ctx?.prev) { + queryClient.setQueryData(["files", path, page, perPage], ctx.prev); + } + const message = error instanceof Error ? error.message : String(error); + toast.error(message); + }, + onSuccess: () => { + toast.success("Renamed"); + }, + onSettled: () => { + queryClient.invalidateQueries({ queryKey: ["files", path, page, perPage] }); + }, + }); + + // Optimistic copy mutation + const copyMutation = useMutation({ + mutationFn: async (vars: { item: FileRow; to: string; newName: string }) => { + return postJSON("/api/files/copy", { from: vars.item.path, to: vars.to }); + }, + onMutate: async ({ item, to, newName }) => { + await queryClient.cancelQueries({ queryKey: ["files", path, page, perPage] }); + const prev = queryClient.getQueryData(["files", path, page, perPage]) as + | { total: number; page: number; perPage: number; items: FileRow[] } + | undefined; + if (prev) { + const temp: FileRow = { + ...item, + id: `temp-${Date.now()}`, + name: newName, + path: to, + }; + const nextItems = [temp, ...(prev.items ?? [])]; + queryClient.setQueryData(["files", path, page, perPage], { + ...prev, + total: prev.total ?? nextItems.length, + items: nextItems, + }); + } + return { prev }; + }, + onError: (error, _vars, ctx) => { + if (ctx?.prev) { + queryClient.setQueryData(["files", path, page, perPage], ctx.prev); + } + const message = error instanceof Error ? error.message : String(error); + toast.error(message); + }, + onSuccess: () => { + toast.success("Copied"); + }, + onSettled: () => { + queryClient.invalidateQueries({ queryKey: ["files", path, page, perPage] }); + }, + }); + + async function handleRename(item: FileRow) { + const newName = window.prompt("New name (or full destination path):", item.name); + if (!newName) return; + const to = + newName.startsWith("/") + ? newName + : `${item.parentPath ?? ""}/${newName}`.replace(/\/+/g, "/"); + await renameMutation.mutateAsync({ item, to, newName }); + } + + async function handleCopy(item: FileRow) { + const newPath = window.prompt( + "Copy to path (absolute or name in same folder):", + `${item.parentPath ?? ""}/${item.name}`, + ); + if (!newPath) return; + const to = + newPath.startsWith("/") + ? newPath + : `${item.parentPath ?? ""}/${newPath}`.replace(/\/+/g, "/"); + const newName = newPath.startsWith("/") + ? newPath.split("/").filter(Boolean).pop() ?? item.name + : newPath; + await copyMutation.mutateAsync({ item, to, newName }); + } + + async function handleDelete(item: FileRow) { + const yes = window.confirm(`Delete "${item.name}"? This cannot be undone.`); + if (!yes) return; + await deleteMutation.mutateAsync(item); + } + + function handleTags(item: FileRow) { + if (onTagsPath) { + onTagsPath(item.path); + } else { + setTagsPath(item.path); + } + } + + function handleUploaded() { + filesQuery.refetch(); + } + + return ( +
+ {/* Sidebar */} + + + {/* Main content */} +
+
+ { + setPage(1); + setPath(p); + }} + /> +
+ + +
+
+ +
+ +
+
+ + {/* Editor dialog */} + !v && setEditPath(null)}> + + + Markdown Editor + +
+ {editPath ? : null} +
+
+
+ + {/* Tags dialog */} + !v && setTagsPath(null)} + initialTags={[]} + onSaved={() => { + filesQuery.refetch(); + }} + /> +
+ ); +} + +export default FilesPanel; diff --git a/src/components/qdrant/qdrant-panel.tsx b/src/components/qdrant/qdrant-panel.tsx new file mode 100644 index 0000000..19bae19 --- /dev/null +++ b/src/components/qdrant/qdrant-panel.tsx @@ -0,0 +1,258 @@ +"use client"; + +import * as React from "react"; +import { useQuery } from "@tanstack/react-query"; +import { Input } from "@/components/ui/input"; +import { Button } from "@/components/ui/button"; +import { ScrollArea } from "@/components/ui/scroll-area"; +import { EmbeddingScatter, type EmbeddingPoint } from "@/components/qdrant/embedding-scatter"; + +type CollectionRow = { + name: string; + vectors_count?: number; + points_count?: number; +}; + +type CollectionsResponse = { + collections: CollectionRow[]; +}; + +type ScrollPoint = { + id: string | number; + payload?: Record; + vector?: number[] | Record; +}; + +type PointsResponse = { + points: ScrollPoint[]; + next_page_offset?: string | number | null; +}; + +async function fetchCollections() { + const res = await fetch("/api/qdrant/collections", { cache: "no-store" }); + if (!res.ok) { + const payload = await res.json().catch(() => ({})); + throw new Error(payload?.message || `Failed to load collections (${res.status})`); + } + return (await res.json()) as CollectionsResponse; +} + +async function fetchPoints( + collection: string, + limit: number, + offset?: string | number | null, + withVector = false, +) { + const url = new URL("/api/qdrant/points", window.location.origin); + url.searchParams.set("collection", collection); + url.searchParams.set("limit", String(limit)); + if (offset != null) url.searchParams.set("offset", String(offset)); + url.searchParams.set("withVector", String(withVector)); + const res = await fetch(url.toString(), { cache: "no-store" }); + if (!res.ok) { + const payload = await res.json().catch(() => ({})); + throw new Error(payload?.message || `Failed to load points (${res.status})`); + } + return (await res.json()) as PointsResponse; +} + +function pretty(v: unknown) { + try { + return JSON.stringify(v, null, 2); + } catch { + return String(v); + } +} + +export function QdrantPanel() { + const [selected, setSelected] = React.useState(null); + const [limit, setLimit] = React.useState(100); + const [withVector, setWithVector] = React.useState(false); + const [offset, setOffset] = React.useState(null); + + const collectionsQuery = useQuery({ + queryKey: ["qdrant-collections"], + queryFn: fetchCollections, + }); + + React.useEffect(() => { + const names = collectionsQuery.data?.collections?.map((c) => c.name) ?? []; + if (!selected && names.length > 0) { + // Prefer fortura-db, then miguel_responses, then first + const preferred = + names.find((n) => n === "fortura-db") || + names.find((n) => n === "miguel_responses") || + names[0]; + setSelected(preferred); + } + }, [collectionsQuery.data, selected]); + + const pointsQuery = useQuery({ + queryKey: ["qdrant-points", selected, limit, offset, withVector], + queryFn: () => fetchPoints(selected!, limit, offset, withVector), + enabled: !!selected, + }); + + return ( +
+
+ + +
+
+
Collection:
+ { + setSelected(e.target.value || null); + setOffset(null); + }} + placeholder="collection name" + className="max-w-xs" + /> +
+
Limit
+ + setLimit(Math.max(1, Math.min(1000, Number(e.target.value) || 100))) + } + className="w-24" + /> +
+ + +
+ +
+ {/* Points list */} +
+
Points
+ +
+ {pointsQuery.isLoading && ( +
Loading…
+ )} + {pointsQuery.error && ( +
+ {(pointsQuery.error as Error).message} +
+ )} + {(pointsQuery.data?.points ?? []).map((p, idx) => ( +
+
id: {String(p.id)}
+
{pretty(p.payload)}
+ {withVector && p.vector && ( +
+                          {Array.isArray(p.vector)
+                            ? `[${p.vector.slice(0, 8).join(", ")} …]`
+                            : pretty(p.vector)}
+                        
+ )} +
+ ))} + {(pointsQuery.data?.points ?? []).length === 0 && + !pointsQuery.isLoading && + !pointsQuery.error && ( +
No points
+ )} +
+
+
+
+ Offset:{" "} + {pointsQuery.data?.next_page_offset != null + ? String(pointsQuery.data.next_page_offset) + : "—"} +
+
+ + +
+
+
+ + {/* Embedding scatter */} +
+
Embedding Visualization
+
+ { + // Future: drill-through to the file using payload/path mapping via ES index + console.log("open point", p); + }} + onSelect={(sel) => { + console.log("selected", sel.length); + }} + /> +
+
+
+
+
+
+ ); +} + +export default QdrantPanel; diff --git a/src/components/search/search-panel.tsx b/src/components/search/search-panel.tsx new file mode 100644 index 0000000..20e8252 --- /dev/null +++ b/src/components/search/search-panel.tsx @@ -0,0 +1,145 @@ +"use client"; + +import * as React from "react"; +import { useQuery } from "@tanstack/react-query"; +import { Input } from "@/components/ui/input"; +import { Button } from "@/components/ui/button"; +import { Checkbox } from "@/components/ui/checkbox"; +import { FiltersBar } from "@/components/search/filters-bar"; +import { SearchResultsList } from "@/components/search/results-list"; +import type { FacetFilters, SearchResult, SearchHit } from "@/types/search"; +import * as Sentry from "@sentry/nextjs"; + +async function executeSearch( + q: string, + semantic: boolean, + page: number, + perPage: number, + filters: FacetFilters, +): Promise { + const res = await fetch("/api/search/query", { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ q, filters, semantic, page, perPage }), + }); + if (!res.ok) throw new Error(`Search failed: ${res.status}`); + const data = (await res.json()) as SearchResult; + return data; +} + +export type SearchPanelProps = { + onOpenPath: (path: string) => void; + onDownloadPath?: (path: string) => void; + onTagsPath?: (path: string) => void; +}; + +export function SearchPanel({ onOpenPath, onDownloadPath, onTagsPath }: SearchPanelProps) { + const [q, setQ] = React.useState(""); + const [semantic, setSemantic] = React.useState(false); + const [filters, setFilters] = React.useState({}); + const [page, setPage] = React.useState(1); + const [perPage] = React.useState(50); + + const searching = q.trim().length > 0; + + const searchQuery = useQuery({ + queryKey: ["search", q, semantic, page, perPage, filters], + queryFn: () => executeSearch(q.trim(), semantic, page, perPage, filters), + enabled: searching, + }); + + const hits: SearchHit[] = React.useMemo(() => { + return (searchQuery.data?.hits ?? []) as SearchHit[]; + }, [searchQuery.data]); + + function handleOpenPath(path: string) { + Sentry.startSpan( + { op: "ui.action", name: "Open Document" }, + (span) => { + span.setAttribute("path", path); + onOpenPath(path); + }, + ); + } + + function handleDownloadPath(path: string) { + if (onDownloadPath) { + onDownloadPath(path); + return; + } + const url = new URL("/api/files/download", window.location.origin); + url.searchParams.set("path", path); + window.open(url.toString(), "_blank", "noopener,noreferrer"); + } + + function handleTagsPath(path: string) { + if (onTagsPath) onTagsPath(path); + } + + return ( +
+ {/* Controls */} +
+
+ { + setPage(1); + setQ(e.target.value); + }} + /> +
+ setSemantic(Boolean(v))} + /> + +
+ +
+
+ + {searching ? ( +
+ +
+ ) : ( +
+ Enter a query to search. +
+ )} + +
+ {searching ? ( + <> +
+ {searchQuery.isLoading + ? "Searching…" + : `Found ${searchQuery.data?.total ?? 0} in ${searchQuery.data?.tookMs ?? 0}ms`} +
+ + + ) : null} +
+
+ ); +} + +export default SearchPanel; diff --git a/src/components/ui/tabs.tsx b/src/components/ui/tabs.tsx new file mode 100644 index 0000000..497ba5e --- /dev/null +++ b/src/components/ui/tabs.tsx @@ -0,0 +1,66 @@ +"use client" + +import * as React from "react" +import * as TabsPrimitive from "@radix-ui/react-tabs" + +import { cn } from "@/lib/utils" + +function Tabs({ + className, + ...props +}: React.ComponentProps) { + return ( + + ) +} + +function TabsList({ + className, + ...props +}: React.ComponentProps) { + return ( + + ) +} + +function TabsTrigger({ + className, + ...props +}: React.ComponentProps) { + return ( + + ) +} + +function TabsContent({ + className, + ...props +}: React.ComponentProps) { + return ( + + ) +} + +export { Tabs, TabsList, TabsTrigger, TabsContent } diff --git a/tests/unit/dashboard-tabs.test.tsx b/tests/unit/dashboard-tabs.test.tsx new file mode 100644 index 0000000..a7c44db --- /dev/null +++ b/tests/unit/dashboard-tabs.test.tsx @@ -0,0 +1,58 @@ +// @vitest-environment jsdom + +import React from "react"; +import { describe, it, expect, vi, beforeEach } from "vitest"; +import { createRoot } from "react-dom/client"; +import Home from "@/app/page"; +import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; + +// Mock Sentry to avoid needing full initialization in tests +vi.mock("@sentry/nextjs", () => ({ + startSpan: ( + _opts: { op: string; name: string }, + cb: (span: { setAttribute: (k: string, v: unknown) => void }) => void, + ) => cb({ setAttribute: () => {} }), +})); + +// Basic fetch mock to satisfy initial FilesPanel query +beforeEach(() => { + global.fetch = vi.fn(async (input: RequestInfo | URL) => { + const url = typeof input === "string" ? input : input.toString(); + if (url.includes("/api/files/list")) { + return { + ok: true, + json: async () => ({ + total: 0, + page: 1, + perPage: 50, + items: [], + }), + } as unknown as Response; + } + // default ok empty + return { + ok: true, + json: async () => ({}), + } as unknown as Response; + }) as unknown as typeof fetch; +}); + +function render(ui: React.ReactElement) { + const container = document.createElement("div"); + document.body.appendChild(container); + const root = createRoot(container); + const qc = new QueryClient(); + root.render({ui}); + return container; +} + +describe("Dashboard Tabs (Home)", () => { + it("renders all top-level dashboard tabs", () => { + const container = render(); + const text = container.textContent || ""; + expect(text).toContain("Files"); + expect(text).toContain("Search"); + expect(text).toContain("Document"); + expect(text).toContain("Qdrant"); + }); +}); diff --git a/tests/unit/search-panel.test.tsx b/tests/unit/search-panel.test.tsx new file mode 100644 index 0000000..9558b2c --- /dev/null +++ b/tests/unit/search-panel.test.tsx @@ -0,0 +1,62 @@ +// @vitest-environment jsdom + +import React from "react"; +import { describe, it, expect, vi, beforeEach } from "vitest"; +import { createRoot } from "react-dom/client"; +import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; +import { SearchPanel } from "@/components/search/search-panel"; + +// Mock Sentry to avoid needing full initialization in tests +vi.mock("@sentry/nextjs", () => ({ + startSpan: ( + _opts: { op: string; name: string }, + cb: (span: { setAttribute: (k: string, v: unknown) => void }) => void, + ) => cb({ setAttribute: () => {} }), +})); + +// Basic fetch mock for search endpoint +beforeEach(() => { + global.fetch = vi.fn(async (input: RequestInfo | URL) => { + const url = typeof input === "string" ? input : input.toString(); + if (url.includes("/api/search/query")) { + return { + ok: true, + json: async () => ({ + total: 0, + hits: [], + tookMs: 0, + }), + } as unknown as Response; + } + return { + ok: true, + json: async () => ({}), + } as unknown as Response; + }) as unknown as typeof fetch; +}); + +function render(ui: React.ReactElement) { + const container = document.createElement("div"); + document.body.appendChild(container); + const root = createRoot(container); + const qc = new QueryClient(); + root.render({ui}); + return container; +} + +describe("SearchPanel", () => { + it("renders search controls and idle state", () => { + const container = render( + {}} + onDownloadPath={() => {}} + onTagsPath={() => {}} + />, + ); + + const text = container.textContent || ""; + expect(text).toContain("Semantic"); + expect(text).toContain("Refresh"); + expect(text).toContain("Enter a query to search."); + }); +});