added homepage and unified navigation
This commit is contained in:
parent
01c47558db
commit
ae1be57046
31
package-lock.json
generated
31
package-lock.json
generated
@ -16,6 +16,7 @@
|
|||||||
"@radix-ui/react-progress": "^1.1.7",
|
"@radix-ui/react-progress": "^1.1.7",
|
||||||
"@radix-ui/react-scroll-area": "^1.2.10",
|
"@radix-ui/react-scroll-area": "^1.2.10",
|
||||||
"@radix-ui/react-slot": "^1.2.3",
|
"@radix-ui/react-slot": "^1.2.3",
|
||||||
|
"@radix-ui/react-tabs": "^1.1.13",
|
||||||
"@radix-ui/react-tooltip": "^1.2.8",
|
"@radix-ui/react-tooltip": "^1.2.8",
|
||||||
"@sentry/nextjs": "^10.11.0",
|
"@sentry/nextjs": "^10.11.0",
|
||||||
"@tanstack/react-query": "^5.87.4",
|
"@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": {
|
"node_modules/@radix-ui/react-tooltip": {
|
||||||
"version": "1.2.8",
|
"version": "1.2.8",
|
||||||
"resolved": "https://registry.npmjs.org/@radix-ui/react-tooltip/-/react-tooltip-1.2.8.tgz",
|
"resolved": "https://registry.npmjs.org/@radix-ui/react-tooltip/-/react-tooltip-1.2.8.tgz",
|
||||||
|
|||||||
@ -22,6 +22,7 @@
|
|||||||
"@radix-ui/react-progress": "^1.1.7",
|
"@radix-ui/react-progress": "^1.1.7",
|
||||||
"@radix-ui/react-scroll-area": "^1.2.10",
|
"@radix-ui/react-scroll-area": "^1.2.10",
|
||||||
"@radix-ui/react-slot": "^1.2.3",
|
"@radix-ui/react-slot": "^1.2.3",
|
||||||
|
"@radix-ui/react-tabs": "^1.1.13",
|
||||||
"@radix-ui/react-tooltip": "^1.2.8",
|
"@radix-ui/react-tooltip": "^1.2.8",
|
||||||
"@sentry/nextjs": "^10.11.0",
|
"@sentry/nextjs": "^10.11.0",
|
||||||
"@tanstack/react-query": "^5.87.4",
|
"@tanstack/react-query": "^5.87.4",
|
||||||
|
|||||||
@ -1,280 +1,13 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import * as React from "react";
|
import * as React from "react";
|
||||||
import { useQuery } from "@tanstack/react-query";
|
import { DocViewer } from "@/components/doc/doc-viewer";
|
||||||
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";
|
|
||||||
|
|
||||||
type PageProps = {
|
type PageProps = {
|
||||||
params: { path?: string[] };
|
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) {
|
export default function DocPage({ params }: PageProps) {
|
||||||
const path = "/" + (params.path?.join("/") ?? "");
|
const path = "/" + (params.path?.join("/") ?? "");
|
||||||
const [activeTab, setActiveTab] = React.useState<"content" | "versions" | "diff" | "tags" | "history">("content");
|
return <DocViewer path={path} />;
|
||||||
|
|
||||||
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<string | null>(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 (
|
|
||||||
<div className="h-screen flex flex-col">
|
|
||||||
<header className="border-b p-3 flex items-center gap-3">
|
|
||||||
<Breadcrumbs path={path} onNavigate={() => {}} />
|
|
||||||
<div className="ml-auto flex items-center gap-2">
|
|
||||||
<Button variant="secondary" size="sm" onClick={() => contentQuery.refetch()}>
|
|
||||||
{contentQuery.isFetching ? "Refreshing..." : "Refresh"}
|
|
||||||
</Button>
|
|
||||||
<Button size="sm" onClick={() => setEditorOpen(true)}>
|
|
||||||
Edit
|
|
||||||
</Button>
|
|
||||||
<Button variant="secondary" size="sm" onClick={downloadCurrent}>
|
|
||||||
Download
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
</header>
|
|
||||||
|
|
||||||
{/* Tabs */}
|
|
||||||
<div className="border-b px-3 py-2 flex items-center gap-2">
|
|
||||||
{(["content", "versions", "diff", "tags", "history"] as const).map((t) => (
|
|
||||||
<Button
|
|
||||||
key={t}
|
|
||||||
variant={activeTab === t ? "default" : "ghost"}
|
|
||||||
size="sm"
|
|
||||||
onClick={() => setActiveTab(t)}
|
|
||||||
>
|
|
||||||
{t === "content" ? "Content" : t[0].toUpperCase() + t.slice(1)}
|
|
||||||
</Button>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Tab Content */}
|
|
||||||
<div className="flex-1 overflow-auto">
|
|
||||||
{activeTab === "content" ? (
|
|
||||||
<div className="grid grid-cols-[1fr_280px] gap-3 p-3 h-full">
|
|
||||||
<div className="border rounded-md overflow-hidden">
|
|
||||||
<CodeMirror
|
|
||||||
value={contentQuery.data?.content ?? ""}
|
|
||||||
height="calc(100vh - 200px)"
|
|
||||||
extensions={[markdown()]}
|
|
||||||
editable={false}
|
|
||||||
theme="dark"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
<aside className="border rounded-md p-2">
|
|
||||||
<div className="text-sm font-semibold mb-2">Outline</div>
|
|
||||||
<div className="space-y-1 text-sm">
|
|
||||||
{outline.length === 0 ? <div className="text-muted-foreground">No headings</div> : null}
|
|
||||||
{outline.map((h, idx) => (
|
|
||||||
<div key={`${h.text}-${idx}`} style={{ paddingLeft: (h.level - 1) * 12 }}>
|
|
||||||
• {h.text}
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
</aside>
|
|
||||||
</div>
|
|
||||||
) : null}
|
|
||||||
|
|
||||||
{activeTab === "versions" ? (
|
|
||||||
<div className="p-3 text-sm text-muted-foreground">
|
|
||||||
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.
|
|
||||||
</div>
|
|
||||||
) : null}
|
|
||||||
|
|
||||||
{activeTab === "diff" ? (
|
|
||||||
<div className="p-3 space-y-3">
|
|
||||||
<div className="flex items-end gap-2">
|
|
||||||
<div className="flex-1">
|
|
||||||
<label className="text-xs text-muted-foreground">Compare with path</label>
|
|
||||||
<Input
|
|
||||||
placeholder="/remote.php/dav/files/admin/path/to/other.md"
|
|
||||||
value={comparePath}
|
|
||||||
onChange={(e) => setComparePath(e.target.value)}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
<Button onClick={runDiff} disabled={diffLoading || !comparePath}>
|
|
||||||
{diffLoading ? "Loading…" : "Load & Diff"}
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
{diffError ? <div className="text-sm text-destructive">{diffError}</div> : null}
|
|
||||||
{diffRows ? (
|
|
||||||
<pre className="text-xs border rounded-md p-2 overflow-auto max-h-[60vh]">
|
|
||||||
{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 (
|
|
||||||
<div key={i} className={cls}>
|
|
||||||
{prefix} {r.text}
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
})}
|
|
||||||
</pre>
|
|
||||||
) : null}
|
|
||||||
</div>
|
|
||||||
) : null}
|
|
||||||
|
|
||||||
{activeTab === "tags" ? (
|
|
||||||
<div className="p-3">
|
|
||||||
<Button onClick={() => setTagsOpen(true)}>Manage Tags</Button>
|
|
||||||
<TagsDialog
|
|
||||||
path={path}
|
|
||||||
open={tagsOpen}
|
|
||||||
onOpenChange={(v) => setTagsOpen(v)}
|
|
||||||
initialTags={[]}
|
|
||||||
onSaved={() => {
|
|
||||||
toast.success("Tags updated");
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
) : null}
|
|
||||||
|
|
||||||
{activeTab === "history" ? (
|
|
||||||
<div className="p-3">
|
|
||||||
<div className="text-sm font-semibold mb-2">Tag History</div>
|
|
||||||
{historyQuery.isLoading ? <div className="text-sm text-muted-foreground">Loading…</div> : null}
|
|
||||||
{historyQuery.data?.history && historyQuery.data.history.length > 0 ? (
|
|
||||||
<div className="text-sm space-y-2">
|
|
||||||
{historyQuery.data.history.map((h) => (
|
|
||||||
<div key={h.id} className="border rounded-md p-2">
|
|
||||||
<div className="text-xs text-muted-foreground">{new Date(h.at).toLocaleString()} • {h.actor ?? "unknown"}</div>
|
|
||||||
<div className="mt-1">Tags: {(h.to ?? []).join(", ")}</div>
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
) : (
|
|
||||||
<div className="text-sm text-muted-foreground">No tag history</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
) : null}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Editor dialog */}
|
|
||||||
<Dialog open={editorOpen} onOpenChange={(v) => setEditorOpen(v)}>
|
|
||||||
<DialogContent className="max-w-4xl h-[80vh]">
|
|
||||||
<DialogHeader>
|
|
||||||
<DialogTitle>Edit Document</DialogTitle>
|
|
||||||
</DialogHeader>
|
|
||||||
<div className="h-[calc(80vh-60px)]">
|
|
||||||
{editorOpen ? <MarkdownEditor path={path} /> : null}
|
|
||||||
</div>
|
|
||||||
</DialogContent>
|
|
||||||
</Dialog>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|||||||
502
src/app/page.tsx
502
src/app/page.tsx
@ -1,445 +1,125 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import * as React from "react";
|
import * as React from "react";
|
||||||
import { useRouter } from "next/navigation";
|
import * as Sentry from "@sentry/nextjs";
|
||||||
import { SidebarTree } from "@/components/sidebar/sidebar-tree";
|
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
|
||||||
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 { ModeToggle } from "@/components/theme/mode-toggle";
|
import { ModeToggle } from "@/components/theme/mode-toggle";
|
||||||
import { SearchResultsList } from "@/components/search/results-list";
|
import SignOutButton from "@/components/auth/signout-button";
|
||||||
import { FiltersBar } from "@/components/search/filters-bar";
|
import { FilesPanel } from "@/components/files/files-panel";
|
||||||
import { CommandPalette } from "@/components/search/command-palette";
|
import { SearchPanel } from "@/components/search/search-panel";
|
||||||
import type { SearchResult, SearchHit, FacetFilters } from "@/types/search";
|
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 = {
|
type DashboardTab = "files" | "search" | "document" | "qdrant";
|
||||||
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<SearchResult> {
|
|
||||||
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 default function Home() {
|
export default function Home() {
|
||||||
const router = useRouter();
|
const [activeTab, setActiveTab] = React.useState<DashboardTab>("files");
|
||||||
const queryClient = useQueryClient();
|
const [selectedPath, setSelectedPath] = React.useState<string | null>(null);
|
||||||
const [path, setPath] = React.useState<string | undefined>(undefined);
|
|
||||||
const [page, setPage] = React.useState(1);
|
|
||||||
const [perPage] = React.useState(50);
|
|
||||||
const [selectedFolders, setSelectedFolders] = React.useState<string[]>([]);
|
|
||||||
|
|
||||||
const [q, setQ] = React.useState("");
|
|
||||||
const [semantic, setSemantic] = React.useState(false);
|
|
||||||
const [filters, setFilters] = React.useState<FacetFilters>({});
|
|
||||||
const [paletteOpen, setPaletteOpen] = React.useState(false);
|
|
||||||
const searching = q.trim().length > 0;
|
|
||||||
|
|
||||||
// Editor state
|
|
||||||
const [editPath, setEditPath] = React.useState<string | null>(null);
|
|
||||||
|
|
||||||
// Tags dialog state
|
|
||||||
const [tagsPath, setTagsPath] = React.useState<string | null>(null);
|
const [tagsPath, setTagsPath] = React.useState<string | null>(null);
|
||||||
|
|
||||||
const filesQuery = useQuery({
|
const handleTabChange = (next: string) => {
|
||||||
queryKey: ["files", path, page, perPage],
|
const from = activeTab;
|
||||||
queryFn: () => fetchFiles(path, page, perPage),
|
const to = (next as DashboardTab) || "files";
|
||||||
enabled: !searching,
|
Sentry.startSpan(
|
||||||
});
|
{ op: "ui.click", name: "Dashboard Tab Switch" },
|
||||||
|
(span) => {
|
||||||
|
span.setAttribute("from", from);
|
||||||
|
span.setAttribute("to", to);
|
||||||
|
setActiveTab(to);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
const searchQuery = useQuery<SearchResult>({
|
const selectDocument = (path: string | null) => {
|
||||||
queryKey: ["search", q, semantic, page, perPage, filters],
|
if (!path) {
|
||||||
queryFn: () => executeSearch(q.trim(), semantic, page, perPage, filters),
|
setSelectedPath(null);
|
||||||
enabled: searching,
|
setActiveTab("document");
|
||||||
});
|
return;
|
||||||
|
|
||||||
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})`);
|
|
||||||
}
|
}
|
||||||
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 onDownloadPath = (p: string) => {
|
||||||
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 url = new URL("/api/files/download", window.location.origin);
|
const url = new URL("/api/files/download", window.location.origin);
|
||||||
url.searchParams.set("path", p);
|
url.searchParams.set("path", p);
|
||||||
window.open(url.toString(), "_blank", "noopener,noreferrer");
|
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 (
|
return (
|
||||||
<div className="h-screen grid grid-cols-[280px_1fr]">
|
<div className="h-screen flex flex-col">
|
||||||
{/* Sidebar */}
|
{/* Top header */}
|
||||||
<aside className="border-r h-full">
|
<header className="border-b p-3 flex items-center gap-3">
|
||||||
<SidebarTree
|
<div className="text-sm font-medium">Dashboard</div>
|
||||||
path={path}
|
<div className="ml-auto flex items-center gap-2">
|
||||||
onNavigate={(p) => { setPage(1); setPath(p); }}
|
<ModeToggle />
|
||||||
canCheckFolders={true}
|
<SignOutButton size="sm" variant="ghost" />
|
||||||
onSelectionChange={(paths) => setSelectedFolders(paths)}
|
</div>
|
||||||
/>
|
</header>
|
||||||
</aside>
|
|
||||||
|
|
||||||
{/* Main content */}
|
{/* Tabs container */}
|
||||||
<main className="h-full flex flex-col">
|
<div className="flex-1 flex flex-col min-h-0">
|
||||||
<header className="border-b p-3 flex items-center gap-3">
|
<Tabs value={activeTab} onValueChange={handleTabChange} className="flex-1 flex flex-col min-h-0">
|
||||||
<Breadcrumbs path={path} onNavigate={(p) => { setPage(1); setPath(p); }} />
|
<div className="border-b px-3 py-2 flex items-center justify-between">
|
||||||
<div className="ml-auto">
|
<TabsList>
|
||||||
<ModeToggle />
|
<TabsTrigger value="files">Files</TabsTrigger>
|
||||||
|
<TabsTrigger value="search">Search</TabsTrigger>
|
||||||
|
<TabsTrigger value="document">Document</TabsTrigger>
|
||||||
|
<TabsTrigger value="qdrant">Qdrant</TabsTrigger>
|
||||||
|
</TabsList>
|
||||||
</div>
|
</div>
|
||||||
</header>
|
|
||||||
|
|
||||||
<section className="p-3 flex items-center gap-2 border-b">
|
<TabsContent value="files" className="flex-1 min-h-0">
|
||||||
<div className="flex items-center gap-2 w-full">
|
<FilesPanel
|
||||||
<Input
|
onOpen={(file) => selectDocument(file.path)}
|
||||||
placeholder="Search files..."
|
onDownload={(file) => onDownloadPath(file.path)}
|
||||||
value={q}
|
onTagsPath={(p) => setTagsPath(p)}
|
||||||
onChange={(e) => { setPage(1); setQ(e.target.value); }}
|
|
||||||
/>
|
/>
|
||||||
<div className="flex items-center gap-2 px-2">
|
</TabsContent>
|
||||||
<Checkbox
|
|
||||||
id="semantic"
|
|
||||||
checked={semantic}
|
|
||||||
onCheckedChange={(v: boolean) => setSemantic(Boolean(v))}
|
|
||||||
/>
|
|
||||||
<label htmlFor="semantic" className="text-sm">Semantic</label>
|
|
||||||
</div>
|
|
||||||
<Button
|
|
||||||
variant="secondary"
|
|
||||||
onClick={() => {
|
|
||||||
// Force refetch for the active mode
|
|
||||||
if (searching) searchQuery.refetch();
|
|
||||||
else filesQuery.refetch();
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
Refresh
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
<UploadDialog currentPath={path} onUploaded={handleUploaded} />
|
|
||||||
</section>
|
|
||||||
|
|
||||||
{searching ? (
|
<TabsContent value="search" className="flex-1 min-h-0">
|
||||||
<section className="p-3 border-b">
|
<SearchPanel
|
||||||
<FiltersBar value={filters} onChange={setFilters} />
|
onOpenPath={(p) => selectDocument(p)}
|
||||||
</section>
|
onDownloadPath={onDownloadPath}
|
||||||
) : null}
|
onTagsPath={(p) => setTagsPath(p)}
|
||||||
|
|
||||||
<section className="p-3 flex-1 overflow-auto">
|
|
||||||
{searching ? (
|
|
||||||
<div className="text-xs text-muted-foreground mb-2">
|
|
||||||
{searchQuery.isLoading
|
|
||||||
? "Searching…"
|
|
||||||
: `Found ${searchQuery.data?.total ?? 0} in ${searchQuery.data?.tookMs ?? 0}ms`}
|
|
||||||
</div>
|
|
||||||
) : null}
|
|
||||||
{searching ? (
|
|
||||||
<SearchResultsList
|
|
||||||
q={q.trim()}
|
|
||||||
hits={searchHits}
|
|
||||||
onOpenPath={openByPath}
|
|
||||||
onDownloadPath={downloadByPath}
|
|
||||||
onTagsPath={tagsByPath}
|
|
||||||
/>
|
/>
|
||||||
) : (
|
</TabsContent>
|
||||||
<FileTable
|
|
||||||
items={files}
|
|
||||||
onOpen={handleOpen}
|
|
||||||
onDownload={handleDownload}
|
|
||||||
onEdit={handleEdit}
|
|
||||||
onRename={handleRename}
|
|
||||||
onCopy={handleCopy}
|
|
||||||
onDelete={handleDelete}
|
|
||||||
onTags={handleTags}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
</section>
|
|
||||||
</main>
|
|
||||||
|
|
||||||
{/* Editor dialog */}
|
<TabsContent value="document" className="flex-1 min-h-0">
|
||||||
<Dialog open={!!editPath} onOpenChange={(v) => !v && setEditPath(null)}>
|
{selectedPath ? (
|
||||||
<DialogContent className="max-w-4xl h-[80vh]">
|
<DocViewer path={selectedPath} />
|
||||||
<DialogHeader>
|
) : (
|
||||||
<DialogTitle>Markdown Editor</DialogTitle>
|
<div className="h-full flex items-center justify-center">
|
||||||
</DialogHeader>
|
<div className="text-sm text-muted-foreground">
|
||||||
<div className="h-[calc(80vh-60px)]">
|
Select a file from Files or Search to view the document here.
|
||||||
{editPath ? <MarkdownEditor path={editPath} /> : null}
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</DialogContent>
|
)}
|
||||||
</Dialog>
|
</TabsContent>
|
||||||
|
|
||||||
{/* Tags dialog */}
|
<TabsContent value="qdrant" className="flex-1 min-h-0">
|
||||||
|
<QdrantPanel />
|
||||||
|
</TabsContent>
|
||||||
|
</Tabs>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Tags dialog (shared) */}
|
||||||
<TagsDialog
|
<TagsDialog
|
||||||
path={tagsPath ?? ""}
|
path={tagsPath ?? ""}
|
||||||
open={!!tagsPath}
|
open={!!tagsPath}
|
||||||
onOpenChange={(v) => !v && setTagsPath(null)}
|
onOpenChange={(v) => !v && setTagsPath(null)}
|
||||||
initialTags={[]}
|
initialTags={[]}
|
||||||
onSaved={() => {
|
onSaved={() => {
|
||||||
// Optionally refresh file list to reflect updated tags when surface supports it
|
// Panels handle their own refetch; we only need UX feedback here.
|
||||||
if (!searching) filesQuery.refetch();
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
{/* Command palette (Cmd/Ctrl+K) */}
|
|
||||||
<CommandPalette
|
|
||||||
open={paletteOpen}
|
|
||||||
onOpenChange={setPaletteOpen}
|
|
||||||
onSubmit={(text) => {
|
|
||||||
setPage(1);
|
|
||||||
setQ(text);
|
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@ -1,244 +1,12 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import * as React from "react";
|
import * as React from "react";
|
||||||
import { useQuery } from "@tanstack/react-query";
|
import { QdrantPanel } from "@/components/qdrant/qdrant-panel";
|
||||||
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<string, unknown>;
|
|
||||||
vector?: number[] | Record<string, number>;
|
|
||||||
};
|
|
||||||
|
|
||||||
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;
|
|
||||||
}
|
|
||||||
|
|
||||||
export default function QdrantPage() {
|
export default function QdrantPage() {
|
||||||
const [selected, setSelected] = React.useState<string | null>(null);
|
|
||||||
const [limit, setLimit] = React.useState(100);
|
|
||||||
const [withVector, setWithVector] = React.useState(false);
|
|
||||||
const [offset, setOffset] = React.useState<string | number | null>(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 (
|
return (
|
||||||
<div className="h-screen flex flex-col">
|
<div className="h-screen flex flex-col">
|
||||||
<header className="border-b p-3">
|
<QdrantPanel />
|
||||||
<h1 className="text-lg font-semibold">Qdrant Collections</h1>
|
|
||||||
</header>
|
|
||||||
<div className="flex-1 grid grid-cols-[320px_1fr]">
|
|
||||||
<aside className="border-r p-3">
|
|
||||||
<div className="text-sm font-medium mb-2">Collections</div>
|
|
||||||
<div className="space-y-1">
|
|
||||||
{collectionsQuery.isLoading && <div className="text-xs text-muted-foreground">Loading…</div>}
|
|
||||||
{collectionsQuery.error && (
|
|
||||||
<div className="text-xs text-destructive">
|
|
||||||
{(collectionsQuery.error as Error).message}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
{collectionsQuery.data?.collections?.map((c) => (
|
|
||||||
<button
|
|
||||||
key={c.name}
|
|
||||||
className={`w-full text-left px-2 py-1 rounded hover:bg-accent ${
|
|
||||||
selected === c.name ? "bg-accent" : ""
|
|
||||||
}`}
|
|
||||||
onClick={() => {
|
|
||||||
setSelected(c.name);
|
|
||||||
setOffset(null);
|
|
||||||
}}
|
|
||||||
title={`${c.points_count ?? 0} pts`}
|
|
||||||
>
|
|
||||||
<div className="font-medium">{c.name}</div>
|
|
||||||
<div className="text-xs text-muted-foreground">
|
|
||||||
pts: {c.points_count ?? "—"} vec: {c.vectors_count ?? "—"}
|
|
||||||
</div>
|
|
||||||
</button>
|
|
||||||
))}
|
|
||||||
{collectionsQuery.data && collectionsQuery.data.collections.length === 0 && (
|
|
||||||
<div className="text-xs text-muted-foreground">No collections</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</aside>
|
|
||||||
|
|
||||||
<main className="p-3 flex flex-col">
|
|
||||||
<div className="flex items-center gap-2 mb-3">
|
|
||||||
<div className="text-sm">Collection:</div>
|
|
||||||
<Input
|
|
||||||
value={selected ?? ""}
|
|
||||||
onChange={(e) => {
|
|
||||||
setSelected(e.target.value || null);
|
|
||||||
setOffset(null);
|
|
||||||
}}
|
|
||||||
placeholder="collection name"
|
|
||||||
className="max-w-xs"
|
|
||||||
/>
|
|
||||||
<div className="flex items-center gap-2">
|
|
||||||
<div className="text-sm">Limit</div>
|
|
||||||
<Input
|
|
||||||
type="number"
|
|
||||||
value={limit}
|
|
||||||
onChange={(e) => setLimit(Math.max(1, Math.min(1000, Number(e.target.value) || 100)))}
|
|
||||||
className="w-24"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
<Button
|
|
||||||
variant={withVector ? "default" : "outline"}
|
|
||||||
onClick={() => setWithVector((v) => !v)}
|
|
||||||
>
|
|
||||||
{withVector ? "Vectors: on" : "Vectors: off"}
|
|
||||||
</Button>
|
|
||||||
<Button
|
|
||||||
variant="secondary"
|
|
||||||
onClick={() => {
|
|
||||||
setOffset(null);
|
|
||||||
pointsQuery.refetch();
|
|
||||||
}}
|
|
||||||
disabled={!selected || pointsQuery.isFetching}
|
|
||||||
>
|
|
||||||
{pointsQuery.isFetching ? "Loading…" : "Refresh"}
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="flex-1 grid grid-cols-2 gap-3">
|
|
||||||
{/* Points list */}
|
|
||||||
<div className="border rounded flex flex-col">
|
|
||||||
<div className="border-b px-3 py-2 text-sm font-medium">Points</div>
|
|
||||||
<ScrollArea className="flex-1">
|
|
||||||
<div className="p-3 space-y-3">
|
|
||||||
{pointsQuery.isLoading && <div className="text-xs text-muted-foreground">Loading…</div>}
|
|
||||||
{pointsQuery.error && (
|
|
||||||
<div className="text-xs text-destructive">
|
|
||||||
{(pointsQuery.error as Error).message}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
{(pointsQuery.data?.points ?? []).map((p, idx) => (
|
|
||||||
<div key={`${p.id}-${idx}`} className="p-2 rounded border">
|
|
||||||
<div className="text-xs font-mono">id: {String(p.id)}</div>
|
|
||||||
<pre className="text-xs overflow-auto mt-1">
|
|
||||||
{pretty(p.payload)}
|
|
||||||
</pre>
|
|
||||||
{withVector && p.vector && (
|
|
||||||
<pre className="text-[10px] overflow-auto mt-1 text-muted-foreground">
|
|
||||||
{Array.isArray(p.vector) ? `[${p.vector.slice(0, 8).join(", ")} …]` : pretty(p.vector)}
|
|
||||||
</pre>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
{(pointsQuery.data?.points ?? []).length === 0 && !pointsQuery.isLoading && !pointsQuery.error && (
|
|
||||||
<div className="text-xs text-muted-foreground">No points</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</ScrollArea>
|
|
||||||
<div className="border-t p-2 flex items-center justify-between">
|
|
||||||
<div className="text-xs text-muted-foreground">
|
|
||||||
Offset: {pointsQuery.data?.next_page_offset != null ? String(pointsQuery.data.next_page_offset) : "—"}
|
|
||||||
</div>
|
|
||||||
<div className="flex items-center gap-2">
|
|
||||||
<Button
|
|
||||||
size="sm"
|
|
||||||
variant="outline"
|
|
||||||
onClick={() => setOffset(null)}
|
|
||||||
disabled={!selected || pointsQuery.isFetching}
|
|
||||||
>
|
|
||||||
Reset
|
|
||||||
</Button>
|
|
||||||
<Button
|
|
||||||
size="sm"
|
|
||||||
onClick={() => setOffset(pointsQuery.data?.next_page_offset ?? null)}
|
|
||||||
disabled={!selected || pointsQuery.isFetching || !pointsQuery.data?.next_page_offset}
|
|
||||||
>
|
|
||||||
Next
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Embedding scatter */}
|
|
||||||
<div className="border rounded p-3 flex flex-col">
|
|
||||||
<div className="text-sm font-medium mb-2">Embedding Visualization</div>
|
|
||||||
<div className="flex-1 min-h-[420px]">
|
|
||||||
<EmbeddingScatter
|
|
||||||
points={(pointsQuery.data?.points as unknown as EmbeddingPoint[]) ?? []}
|
|
||||||
onOpenPoint={(p) => {
|
|
||||||
// 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);
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</main>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
84
src/components/auth/signout-button.tsx
Normal file
84
src/components/auth/signout-button.tsx
Normal file
@ -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 (
|
||||||
|
<Button
|
||||||
|
variant={variant}
|
||||||
|
size={size}
|
||||||
|
className={className}
|
||||||
|
onClick={handleClick}
|
||||||
|
disabled={pending}
|
||||||
|
>
|
||||||
|
{pending ? "Signing out…" : "Sign out"}
|
||||||
|
</Button>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export default SignOutButton;
|
||||||
309
src/components/doc/doc-viewer.tsx
Normal file
309
src/components/doc/doc-viewer.tsx
Normal file
@ -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<string | null>(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 (
|
||||||
|
<div className="h-screen flex flex-col">
|
||||||
|
<header className="border-b p-3 flex items-center gap-3">
|
||||||
|
<Breadcrumbs path={path} onNavigate={() => {}} />
|
||||||
|
<div className="ml-auto flex items-center gap-2">
|
||||||
|
<Button variant="secondary" size="sm" onClick={() => contentQuery.refetch()}>
|
||||||
|
{contentQuery.isFetching ? "Refreshing..." : "Refresh"}
|
||||||
|
</Button>
|
||||||
|
<Button size="sm" onClick={() => setEditorOpen(true)}>
|
||||||
|
Edit
|
||||||
|
</Button>
|
||||||
|
<Button variant="secondary" size="sm" onClick={downloadCurrent}>
|
||||||
|
Download
|
||||||
|
</Button>
|
||||||
|
<SignOutButton size="sm" variant="ghost" />
|
||||||
|
</div>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
{/* Tabs */}
|
||||||
|
<div className="border-b px-3 py-2 flex items-center gap-2">
|
||||||
|
{(["content", "versions", "diff", "tags", "history"] as const).map((t) => (
|
||||||
|
<Button
|
||||||
|
key={t}
|
||||||
|
variant={activeTab === t ? "default" : "ghost"}
|
||||||
|
size="sm"
|
||||||
|
onClick={() => setActiveTab(t)}
|
||||||
|
>
|
||||||
|
{t === "content" ? "Content" : t[0].toUpperCase() + t.slice(1)}
|
||||||
|
</Button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Tab Content */}
|
||||||
|
<div className="flex-1 overflow-auto">
|
||||||
|
{activeTab === "content" ? (
|
||||||
|
<div className="grid grid-cols-[1fr_280px] gap-3 p-3 h-full">
|
||||||
|
<div className="border rounded-md overflow-hidden">
|
||||||
|
<CodeMirror
|
||||||
|
value={contentQuery.data?.content ?? ""}
|
||||||
|
height="calc(100vh - 200px)"
|
||||||
|
extensions={[markdown()]}
|
||||||
|
editable={false}
|
||||||
|
theme="dark"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<aside className="border rounded-md p-2">
|
||||||
|
<div className="text-sm font-semibold mb-2">Outline</div>
|
||||||
|
<div className="space-y-1 text-sm">
|
||||||
|
{outline.length === 0 ? (
|
||||||
|
<div className="text-muted-foreground">No headings</div>
|
||||||
|
) : null}
|
||||||
|
{outline.map((h, idx) => (
|
||||||
|
<div key={`${h.text}-${idx}`} style={{ paddingLeft: (h.level - 1) * 12 }}>
|
||||||
|
• {h.text}
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</aside>
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
|
||||||
|
{activeTab === "versions" ? (
|
||||||
|
<div className="p-3 text-sm text-muted-foreground">
|
||||||
|
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.
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
|
||||||
|
{activeTab === "diff" ? (
|
||||||
|
<div className="p-3 space-y-3">
|
||||||
|
<div className="flex items-end gap-2">
|
||||||
|
<div className="flex-1">
|
||||||
|
<label className="text-xs text-muted-foreground">Compare with path</label>
|
||||||
|
<Input
|
||||||
|
placeholder="/remote.php/dav/files/admin/path/to/other.md"
|
||||||
|
value={comparePath}
|
||||||
|
onChange={(e) => setComparePath(e.target.value)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<Button onClick={runDiff} disabled={diffLoading || !comparePath}>
|
||||||
|
{diffLoading ? "Loading…" : "Load & Diff"}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
{diffError ? <div className="text-sm text-destructive">{diffError}</div> : null}
|
||||||
|
{diffRows ? (
|
||||||
|
<pre className="text-xs border rounded-md p-2 overflow-auto max-h-[60vh]">
|
||||||
|
{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 (
|
||||||
|
<div key={i} className={cls}>
|
||||||
|
{prefix} {r.text}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</pre>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
|
||||||
|
{activeTab === "tags" ? (
|
||||||
|
<div className="p-3">
|
||||||
|
<Button onClick={() => setTagsOpen(true)}>Manage Tags</Button>
|
||||||
|
<TagsDialog
|
||||||
|
path={path}
|
||||||
|
open={tagsOpen}
|
||||||
|
onOpenChange={(v) => setTagsOpen(v)}
|
||||||
|
initialTags={[]}
|
||||||
|
onSaved={() => {
|
||||||
|
toast.success("Tags updated");
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
|
||||||
|
{activeTab === "history" ? (
|
||||||
|
<div className="p-3">
|
||||||
|
<div className="text-sm font-semibold mb-2">Tag History</div>
|
||||||
|
{historyQuery.isLoading ? (
|
||||||
|
<div className="text-sm text-muted-foreground">Loading…</div>
|
||||||
|
) : null}
|
||||||
|
{historyQuery.data?.history && historyQuery.data.history.length > 0 ? (
|
||||||
|
<div className="text-sm space-y-2">
|
||||||
|
{historyQuery.data.history.map((h) => (
|
||||||
|
<div key={h.id} className="border rounded-md p-2">
|
||||||
|
<div className="text-xs text-muted-foreground">
|
||||||
|
{new Date(h.at).toLocaleString()} • {h.actor ?? "unknown"}
|
||||||
|
</div>
|
||||||
|
<div className="mt-1">Tags: {(h.to ?? []).join(", ")}</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="text-sm text-muted-foreground">No tag history</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Editor dialog */}
|
||||||
|
<Dialog open={editorOpen} onOpenChange={(v) => setEditorOpen(v)}>
|
||||||
|
<DialogContent className="max-w-4xl h-[80vh]">
|
||||||
|
<DialogHeader>
|
||||||
|
<DialogTitle>Edit Document</DialogTitle>
|
||||||
|
</DialogHeader>
|
||||||
|
<div className="h-[calc(80vh-60px)]">
|
||||||
|
{editorOpen ? <MarkdownEditor path={path} /> : null}
|
||||||
|
</div>
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export default DocViewer;
|
||||||
351
src/components/files/files-panel.tsx
Normal file
351
src/components/files/files-panel.tsx
Normal file
@ -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<string | undefined>(undefined);
|
||||||
|
const [page, setPage] = React.useState(1);
|
||||||
|
const [perPage] = React.useState(50);
|
||||||
|
const [selectedFolders, setSelectedFolders] = React.useState<string[]>([]);
|
||||||
|
|
||||||
|
// Editor state
|
||||||
|
const [editPath, setEditPath] = React.useState<string | null>(null);
|
||||||
|
|
||||||
|
// Tags dialog state
|
||||||
|
const [tagsPath, setTagsPath] = React.useState<string | null>(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 (
|
||||||
|
<div className="h-full grid grid-cols-[280px_1fr]">
|
||||||
|
{/* Sidebar */}
|
||||||
|
<aside className="border-r h-full">
|
||||||
|
<SidebarTree
|
||||||
|
path={path}
|
||||||
|
onNavigate={(p) => {
|
||||||
|
setPage(1);
|
||||||
|
setPath(p);
|
||||||
|
}}
|
||||||
|
canCheckFolders={true}
|
||||||
|
onSelectionChange={(paths) => setSelectedFolders(paths)}
|
||||||
|
/>
|
||||||
|
</aside>
|
||||||
|
|
||||||
|
{/* Main content */}
|
||||||
|
<main className="h-full flex flex-col">
|
||||||
|
<header className="border-b p-3 flex items-center gap-3">
|
||||||
|
<Breadcrumbs
|
||||||
|
path={path}
|
||||||
|
onNavigate={(p) => {
|
||||||
|
setPage(1);
|
||||||
|
setPath(p);
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<div className="ml-auto flex items-center gap-2">
|
||||||
|
<Button
|
||||||
|
variant="secondary"
|
||||||
|
size="sm"
|
||||||
|
onClick={() => {
|
||||||
|
filesQuery.refetch();
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{filesQuery.isFetching ? "Refreshing…" : "Refresh"}
|
||||||
|
</Button>
|
||||||
|
<UploadDialog currentPath={path} onUploaded={handleUploaded} />
|
||||||
|
</div>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<section className="p-3 flex-1 overflow-auto">
|
||||||
|
<FileTable
|
||||||
|
items={files}
|
||||||
|
onOpen={handleOpen}
|
||||||
|
onDownload={handleDownload}
|
||||||
|
onEdit={handleEdit}
|
||||||
|
onRename={handleRename}
|
||||||
|
onCopy={handleCopy}
|
||||||
|
onDelete={handleDelete}
|
||||||
|
onTags={handleTags}
|
||||||
|
/>
|
||||||
|
</section>
|
||||||
|
</main>
|
||||||
|
|
||||||
|
{/* Editor dialog */}
|
||||||
|
<Dialog open={!!editPath} onOpenChange={(v) => !v && setEditPath(null)}>
|
||||||
|
<DialogContent className="max-w-4xl h-[80vh]">
|
||||||
|
<DialogHeader>
|
||||||
|
<DialogTitle>Markdown Editor</DialogTitle>
|
||||||
|
</DialogHeader>
|
||||||
|
<div className="h-[calc(80vh-60px)]">
|
||||||
|
{editPath ? <MarkdownEditor path={editPath} /> : null}
|
||||||
|
</div>
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
|
|
||||||
|
{/* Tags dialog */}
|
||||||
|
<TagsDialog
|
||||||
|
path={tagsPath ?? ""}
|
||||||
|
open={!!tagsPath}
|
||||||
|
onOpenChange={(v) => !v && setTagsPath(null)}
|
||||||
|
initialTags={[]}
|
||||||
|
onSaved={() => {
|
||||||
|
filesQuery.refetch();
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export default FilesPanel;
|
||||||
258
src/components/qdrant/qdrant-panel.tsx
Normal file
258
src/components/qdrant/qdrant-panel.tsx
Normal file
@ -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<string, unknown>;
|
||||||
|
vector?: number[] | Record<string, number>;
|
||||||
|
};
|
||||||
|
|
||||||
|
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<string | null>(null);
|
||||||
|
const [limit, setLimit] = React.useState(100);
|
||||||
|
const [withVector, setWithVector] = React.useState(false);
|
||||||
|
const [offset, setOffset] = React.useState<string | number | null>(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 (
|
||||||
|
<div className="h-full flex flex-col">
|
||||||
|
<div className="flex-1 grid grid-cols-[320px_1fr]">
|
||||||
|
<aside className="border-r p-3">
|
||||||
|
<div className="text-sm font-medium mb-2">Collections</div>
|
||||||
|
<div className="space-y-1">
|
||||||
|
{collectionsQuery.isLoading && (
|
||||||
|
<div className="text-xs text-muted-foreground">Loading…</div>
|
||||||
|
)}
|
||||||
|
{collectionsQuery.error && (
|
||||||
|
<div className="text-xs text-destructive">
|
||||||
|
{(collectionsQuery.error as Error).message}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{collectionsQuery.data?.collections?.map((c) => (
|
||||||
|
<button
|
||||||
|
key={c.name}
|
||||||
|
className={`w-full text-left px-2 py-1 rounded hover:bg-accent ${
|
||||||
|
selected === c.name ? "bg-accent" : ""
|
||||||
|
}`}
|
||||||
|
onClick={() => {
|
||||||
|
setSelected(c.name);
|
||||||
|
setOffset(null);
|
||||||
|
}}
|
||||||
|
title={`${c.points_count ?? 0} pts`}
|
||||||
|
>
|
||||||
|
<div className="font-medium">{c.name}</div>
|
||||||
|
<div className="text-xs text-muted-foreground">
|
||||||
|
pts: {c.points_count ?? "—"} vec: {c.vectors_count ?? "—"}
|
||||||
|
</div>
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
{collectionsQuery.data && collectionsQuery.data.collections.length === 0 && (
|
||||||
|
<div className="text-xs text-muted-foreground">No collections</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</aside>
|
||||||
|
|
||||||
|
<main className="p-3 flex flex-col">
|
||||||
|
<div className="flex items-center gap-2 mb-3">
|
||||||
|
<div className="text-sm">Collection:</div>
|
||||||
|
<Input
|
||||||
|
value={selected ?? ""}
|
||||||
|
onChange={(e) => {
|
||||||
|
setSelected(e.target.value || null);
|
||||||
|
setOffset(null);
|
||||||
|
}}
|
||||||
|
placeholder="collection name"
|
||||||
|
className="max-w-xs"
|
||||||
|
/>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<div className="text-sm">Limit</div>
|
||||||
|
<Input
|
||||||
|
type="number"
|
||||||
|
value={limit}
|
||||||
|
onChange={(e) =>
|
||||||
|
setLimit(Math.max(1, Math.min(1000, Number(e.target.value) || 100)))
|
||||||
|
}
|
||||||
|
className="w-24"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<Button variant={withVector ? "default" : "outline"} onClick={() => setWithVector((v) => !v)}>
|
||||||
|
{withVector ? "Vectors: on" : "Vectors: off"}
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
variant="secondary"
|
||||||
|
onClick={() => {
|
||||||
|
setOffset(null);
|
||||||
|
pointsQuery.refetch();
|
||||||
|
}}
|
||||||
|
disabled={!selected || pointsQuery.isFetching}
|
||||||
|
>
|
||||||
|
{pointsQuery.isFetching ? "Loading…" : "Refresh"}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex-1 grid grid-cols-2 gap-3">
|
||||||
|
{/* Points list */}
|
||||||
|
<div className="border rounded flex flex-col">
|
||||||
|
<div className="border-b px-3 py-2 text-sm font-medium">Points</div>
|
||||||
|
<ScrollArea className="flex-1">
|
||||||
|
<div className="p-3 space-y-3">
|
||||||
|
{pointsQuery.isLoading && (
|
||||||
|
<div className="text-xs text-muted-foreground">Loading…</div>
|
||||||
|
)}
|
||||||
|
{pointsQuery.error && (
|
||||||
|
<div className="text-xs text-destructive">
|
||||||
|
{(pointsQuery.error as Error).message}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{(pointsQuery.data?.points ?? []).map((p, idx) => (
|
||||||
|
<div key={`${p.id}-${idx}`} className="p-2 rounded border">
|
||||||
|
<div className="text-xs font-mono">id: {String(p.id)}</div>
|
||||||
|
<pre className="text-xs overflow-auto mt-1">{pretty(p.payload)}</pre>
|
||||||
|
{withVector && p.vector && (
|
||||||
|
<pre className="text-[10px] overflow-auto mt-1 text-muted-foreground">
|
||||||
|
{Array.isArray(p.vector)
|
||||||
|
? `[${p.vector.slice(0, 8).join(", ")} …]`
|
||||||
|
: pretty(p.vector)}
|
||||||
|
</pre>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
{(pointsQuery.data?.points ?? []).length === 0 &&
|
||||||
|
!pointsQuery.isLoading &&
|
||||||
|
!pointsQuery.error && (
|
||||||
|
<div className="text-xs text-muted-foreground">No points</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</ScrollArea>
|
||||||
|
<div className="border-t p-2 flex items-center justify-between">
|
||||||
|
<div className="text-xs text-muted-foreground">
|
||||||
|
Offset:{" "}
|
||||||
|
{pointsQuery.data?.next_page_offset != null
|
||||||
|
? String(pointsQuery.data.next_page_offset)
|
||||||
|
: "—"}
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<Button
|
||||||
|
size="sm"
|
||||||
|
variant="outline"
|
||||||
|
onClick={() => setOffset(null)}
|
||||||
|
disabled={!selected || pointsQuery.isFetching}
|
||||||
|
>
|
||||||
|
Reset
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
size="sm"
|
||||||
|
onClick={() => setOffset(pointsQuery.data?.next_page_offset ?? null)}
|
||||||
|
disabled={
|
||||||
|
!selected || pointsQuery.isFetching || !pointsQuery.data?.next_page_offset
|
||||||
|
}
|
||||||
|
>
|
||||||
|
Next
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Embedding scatter */}
|
||||||
|
<div className="border rounded p-3 flex flex-col">
|
||||||
|
<div className="text-sm font-medium mb-2">Embedding Visualization</div>
|
||||||
|
<div className="flex-1 min-h-[420px]">
|
||||||
|
<EmbeddingScatter
|
||||||
|
points={(pointsQuery.data?.points as unknown as EmbeddingPoint[]) ?? []}
|
||||||
|
onOpenPoint={(p) => {
|
||||||
|
// 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);
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</main>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export default QdrantPanel;
|
||||||
145
src/components/search/search-panel.tsx
Normal file
145
src/components/search/search-panel.tsx
Normal file
@ -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<SearchResult> {
|
||||||
|
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<FacetFilters>({});
|
||||||
|
const [page, setPage] = React.useState(1);
|
||||||
|
const [perPage] = React.useState(50);
|
||||||
|
|
||||||
|
const searching = q.trim().length > 0;
|
||||||
|
|
||||||
|
const searchQuery = useQuery<SearchResult>({
|
||||||
|
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 (
|
||||||
|
<div className="flex flex-col h-full">
|
||||||
|
{/* Controls */}
|
||||||
|
<section className="p-3 flex items-center gap-2 border-b">
|
||||||
|
<div className="flex items-center gap-2 w-full">
|
||||||
|
<Input
|
||||||
|
placeholder="Search files..."
|
||||||
|
value={q}
|
||||||
|
onChange={(e) => {
|
||||||
|
setPage(1);
|
||||||
|
setQ(e.target.value);
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<div className="flex items-center gap-2 px-2">
|
||||||
|
<Checkbox
|
||||||
|
id="semantic"
|
||||||
|
checked={semantic}
|
||||||
|
onCheckedChange={(v: boolean) => setSemantic(Boolean(v))}
|
||||||
|
/>
|
||||||
|
<label htmlFor="semantic" className="text-sm">
|
||||||
|
Semantic
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
<Button
|
||||||
|
variant="secondary"
|
||||||
|
onClick={() => {
|
||||||
|
if (searching) searchQuery.refetch();
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Refresh
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
{searching ? (
|
||||||
|
<section className="p-3 border-b">
|
||||||
|
<FiltersBar value={filters} onChange={setFilters} />
|
||||||
|
</section>
|
||||||
|
) : (
|
||||||
|
<section className="p-3 border-b text-sm text-muted-foreground">
|
||||||
|
Enter a query to search.
|
||||||
|
</section>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<section className="p-3 flex-1 overflow-auto">
|
||||||
|
{searching ? (
|
||||||
|
<>
|
||||||
|
<div className="text-xs text-muted-foreground mb-2">
|
||||||
|
{searchQuery.isLoading
|
||||||
|
? "Searching…"
|
||||||
|
: `Found ${searchQuery.data?.total ?? 0} in ${searchQuery.data?.tookMs ?? 0}ms`}
|
||||||
|
</div>
|
||||||
|
<SearchResultsList
|
||||||
|
q={q.trim()}
|
||||||
|
hits={hits}
|
||||||
|
onOpenPath={handleOpenPath}
|
||||||
|
onDownloadPath={handleDownloadPath}
|
||||||
|
onTagsPath={handleTagsPath}
|
||||||
|
/>
|
||||||
|
</>
|
||||||
|
) : null}
|
||||||
|
</section>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export default SearchPanel;
|
||||||
66
src/components/ui/tabs.tsx
Normal file
66
src/components/ui/tabs.tsx
Normal file
@ -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<typeof TabsPrimitive.Root>) {
|
||||||
|
return (
|
||||||
|
<TabsPrimitive.Root
|
||||||
|
data-slot="tabs"
|
||||||
|
className={cn("flex flex-col gap-2", className)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function TabsList({
|
||||||
|
className,
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof TabsPrimitive.List>) {
|
||||||
|
return (
|
||||||
|
<TabsPrimitive.List
|
||||||
|
data-slot="tabs-list"
|
||||||
|
className={cn(
|
||||||
|
"bg-muted text-muted-foreground inline-flex h-9 w-fit items-center justify-center rounded-lg p-[3px]",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function TabsTrigger({
|
||||||
|
className,
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof TabsPrimitive.Trigger>) {
|
||||||
|
return (
|
||||||
|
<TabsPrimitive.Trigger
|
||||||
|
data-slot="tabs-trigger"
|
||||||
|
className={cn(
|
||||||
|
"data-[state=active]:bg-background dark:data-[state=active]:text-foreground focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:outline-ring dark:data-[state=active]:border-input dark:data-[state=active]:bg-input/30 text-foreground dark:text-muted-foreground inline-flex h-[calc(100%-1px)] flex-1 items-center justify-center gap-1.5 rounded-md border border-transparent px-2 py-1 text-sm font-medium whitespace-nowrap transition-[color,box-shadow] focus-visible:ring-[3px] focus-visible:outline-1 disabled:pointer-events-none disabled:opacity-50 data-[state=active]:shadow-sm [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function TabsContent({
|
||||||
|
className,
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof TabsPrimitive.Content>) {
|
||||||
|
return (
|
||||||
|
<TabsPrimitive.Content
|
||||||
|
data-slot="tabs-content"
|
||||||
|
className={cn("flex-1 outline-none", className)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export { Tabs, TabsList, TabsTrigger, TabsContent }
|
||||||
58
tests/unit/dashboard-tabs.test.tsx
Normal file
58
tests/unit/dashboard-tabs.test.tsx
Normal file
@ -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(<QueryClientProvider client={qc}>{ui}</QueryClientProvider>);
|
||||||
|
return container;
|
||||||
|
}
|
||||||
|
|
||||||
|
describe("Dashboard Tabs (Home)", () => {
|
||||||
|
it("renders all top-level dashboard tabs", () => {
|
||||||
|
const container = render(<Home />);
|
||||||
|
const text = container.textContent || "";
|
||||||
|
expect(text).toContain("Files");
|
||||||
|
expect(text).toContain("Search");
|
||||||
|
expect(text).toContain("Document");
|
||||||
|
expect(text).toContain("Qdrant");
|
||||||
|
});
|
||||||
|
});
|
||||||
62
tests/unit/search-panel.test.tsx
Normal file
62
tests/unit/search-panel.test.tsx
Normal file
@ -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(<QueryClientProvider client={qc}>{ui}</QueryClientProvider>);
|
||||||
|
return container;
|
||||||
|
}
|
||||||
|
|
||||||
|
describe("SearchPanel", () => {
|
||||||
|
it("renders search controls and idle state", () => {
|
||||||
|
const container = render(
|
||||||
|
<SearchPanel
|
||||||
|
onOpenPath={() => {}}
|
||||||
|
onDownloadPath={() => {}}
|
||||||
|
onTagsPath={() => {}}
|
||||||
|
/>,
|
||||||
|
);
|
||||||
|
|
||||||
|
const text = container.textContent || "";
|
||||||
|
expect(text).toContain("Semantic");
|
||||||
|
expect(text).toContain("Refresh");
|
||||||
|
expect(text).toContain("Enter a query to search.");
|
||||||
|
});
|
||||||
|
});
|
||||||
Loading…
x
Reference in New Issue
Block a user