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-scroll-area": "^1.2.10",
|
||||
"@radix-ui/react-slot": "^1.2.3",
|
||||
"@radix-ui/react-tabs": "^1.1.13",
|
||||
"@radix-ui/react-tooltip": "^1.2.8",
|
||||
"@sentry/nextjs": "^10.11.0",
|
||||
"@tanstack/react-query": "^5.87.4",
|
||||
@ -4456,6 +4457,36 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/@radix-ui/react-tabs": {
|
||||
"version": "1.1.13",
|
||||
"resolved": "https://registry.npmjs.org/@radix-ui/react-tabs/-/react-tabs-1.1.13.tgz",
|
||||
"integrity": "sha512-7xdcatg7/U+7+Udyoj2zodtI9H/IIopqo+YOIcZOq1nJwXWBZ9p8xiu5llXlekDbZkca79a/fozEYQXIA4sW6A==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@radix-ui/primitive": "1.1.3",
|
||||
"@radix-ui/react-context": "1.1.2",
|
||||
"@radix-ui/react-direction": "1.1.1",
|
||||
"@radix-ui/react-id": "1.1.1",
|
||||
"@radix-ui/react-presence": "1.1.5",
|
||||
"@radix-ui/react-primitive": "2.1.3",
|
||||
"@radix-ui/react-roving-focus": "1.1.11",
|
||||
"@radix-ui/react-use-controllable-state": "1.2.2"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@types/react": "*",
|
||||
"@types/react-dom": "*",
|
||||
"react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc",
|
||||
"react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"@types/react": {
|
||||
"optional": true
|
||||
},
|
||||
"@types/react-dom": {
|
||||
"optional": true
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/@radix-ui/react-tooltip": {
|
||||
"version": "1.2.8",
|
||||
"resolved": "https://registry.npmjs.org/@radix-ui/react-tooltip/-/react-tooltip-1.2.8.tgz",
|
||||
|
||||
@ -22,6 +22,7 @@
|
||||
"@radix-ui/react-progress": "^1.1.7",
|
||||
"@radix-ui/react-scroll-area": "^1.2.10",
|
||||
"@radix-ui/react-slot": "^1.2.3",
|
||||
"@radix-ui/react-tabs": "^1.1.13",
|
||||
"@radix-ui/react-tooltip": "^1.2.8",
|
||||
"@sentry/nextjs": "^10.11.0",
|
||||
"@tanstack/react-query": "^5.87.4",
|
||||
|
||||
@ -1,280 +1,13 @@
|
||||
"use client";
|
||||
|
||||
import * as React from "react";
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import CodeMirror from "@uiw/react-codemirror";
|
||||
import { markdown } from "@codemirror/lang-markdown";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Dialog, DialogContent, DialogHeader, DialogTitle } from "@/components/ui/dialog";
|
||||
import { MarkdownEditor } from "@/components/editor/markdown-editor";
|
||||
import { toast } from "sonner";
|
||||
import { TagsDialog } from "@/components/files/tags-dialog";
|
||||
import { Breadcrumbs } from "@/components/navigation/breadcrumbs";
|
||||
import { DocViewer } from "@/components/doc/doc-viewer";
|
||||
|
||||
type PageProps = {
|
||||
params: { path?: string[] };
|
||||
};
|
||||
|
||||
async function fetchContent(path: string) {
|
||||
const url = new URL("/api/files/content", window.location.origin);
|
||||
url.searchParams.set("path", path);
|
||||
const res = await fetch(url.toString(), { cache: "no-store" });
|
||||
if (!res.ok) {
|
||||
const payload = await res.json().catch(() => ({}));
|
||||
throw new Error(payload?.message || `Failed to load content (${res.status})`);
|
||||
}
|
||||
return (await res.json()) as { ok: boolean; content?: string; mimeType?: string };
|
||||
}
|
||||
|
||||
async function fetchTagHistory(path: string) {
|
||||
const url = new URL("/api/files/tags/history", window.location.origin);
|
||||
url.searchParams.set("path", path);
|
||||
const res = await fetch(url.toString(), { cache: "no-store" });
|
||||
if (!res.ok) {
|
||||
const payload = await res.json().catch(() => ({}));
|
||||
throw new Error(payload?.message || `Failed to load tag history (${res.status})`);
|
||||
}
|
||||
return (await res.json()) as { ok: boolean; history?: Array<{ id: string; path: string; actor?: string; action: string; from?: string[]; to: string[]; at: string }> };
|
||||
}
|
||||
|
||||
function buildOutline(md?: string) {
|
||||
if (!md) return [] as Array<{ level: number; text: string; line: number }>;
|
||||
const lines = md.split(/\r?\n/);
|
||||
const out: Array<{ level: number; text: string; line: number }> = [];
|
||||
lines.forEach((ln, i) => {
|
||||
const m = /^(#{1,6})\s+(.*)$/.exec(ln);
|
||||
if (m) {
|
||||
out.push({ level: m[1].length, text: m[2].trim(), line: i });
|
||||
}
|
||||
});
|
||||
return out;
|
||||
}
|
||||
|
||||
function simpleUnifiedDiff(a: string, b: string) {
|
||||
// naive line-by-line diff for small files
|
||||
const aLines = a.split(/\r?\n/);
|
||||
const bLines = b.split(/\r?\n/);
|
||||
const max = Math.max(aLines.length, bLines.length);
|
||||
const rows: Array<{ type: "ctx" | "del" | "add"; text: string }> = [];
|
||||
for (let i = 0; i < max; i++) {
|
||||
const A = aLines[i];
|
||||
const B = bLines[i];
|
||||
if (A === B) {
|
||||
if (A !== undefined) rows.push({ type: "ctx", text: A });
|
||||
} else {
|
||||
if (A !== undefined) rows.push({ type: "del", text: A });
|
||||
if (B !== undefined) rows.push({ type: "add", text: B });
|
||||
}
|
||||
}
|
||||
return rows;
|
||||
}
|
||||
|
||||
export default function DocPage({ params }: PageProps) {
|
||||
const path = "/" + (params.path?.join("/") ?? "");
|
||||
const [activeTab, setActiveTab] = React.useState<"content" | "versions" | "diff" | "tags" | "history">("content");
|
||||
|
||||
const [editorOpen, setEditorOpen] = React.useState(false);
|
||||
const [tagsOpen, setTagsOpen] = React.useState(false);
|
||||
|
||||
// Content
|
||||
const contentQuery = useQuery({
|
||||
queryKey: ["doc-content", path],
|
||||
queryFn: () => fetchContent(path),
|
||||
});
|
||||
|
||||
const outline = React.useMemo(() => buildOutline(contentQuery.data?.content || ""), [contentQuery.data?.content]);
|
||||
|
||||
// Diff
|
||||
const [comparePath, setComparePath] = React.useState("");
|
||||
const [diffRows, setDiffRows] = React.useState<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>
|
||||
);
|
||||
return <DocViewer path={path} />;
|
||||
}
|
||||
|
||||
502
src/app/page.tsx
502
src/app/page.tsx
@ -1,445 +1,125 @@
|
||||
"use client";
|
||||
|
||||
import * as React from "react";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { SidebarTree } from "@/components/sidebar/sidebar-tree";
|
||||
import { Breadcrumbs } from "@/components/navigation/breadcrumbs";
|
||||
import { FileTable, type FileRow } from "@/components/files/file-table";
|
||||
import { UploadDialog } from "@/components/files/upload-dialog";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query";
|
||||
import { Checkbox } from "@/components/ui/checkbox";
|
||||
import { Dialog, DialogContent, DialogHeader, DialogTitle } from "@/components/ui/dialog";
|
||||
import { MarkdownEditor } from "@/components/editor/markdown-editor";
|
||||
import { toast } from "sonner";
|
||||
import { TagsDialog } from "@/components/files/tags-dialog";
|
||||
import * as Sentry from "@sentry/nextjs";
|
||||
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
|
||||
import { ModeToggle } from "@/components/theme/mode-toggle";
|
||||
import { SearchResultsList } from "@/components/search/results-list";
|
||||
import { FiltersBar } from "@/components/search/filters-bar";
|
||||
import { CommandPalette } from "@/components/search/command-palette";
|
||||
import type { SearchResult, SearchHit, FacetFilters } from "@/types/search";
|
||||
import SignOutButton from "@/components/auth/signout-button";
|
||||
import { FilesPanel } from "@/components/files/files-panel";
|
||||
import { SearchPanel } from "@/components/search/search-panel";
|
||||
import { DocViewer } from "@/components/doc/doc-viewer";
|
||||
import { QdrantPanel } from "@/components/qdrant/qdrant-panel";
|
||||
import { TagsDialog } from "@/components/files/tags-dialog";
|
||||
import { normalizePath } from "@/lib/paths";
|
||||
|
||||
type FilesListResponse = {
|
||||
total: number;
|
||||
page: number;
|
||||
perPage: number;
|
||||
items: Array<{
|
||||
id: string;
|
||||
name: string;
|
||||
path: string;
|
||||
parentPath?: string;
|
||||
kind: "File";
|
||||
sizeBytes: number;
|
||||
mimeType: string;
|
||||
etag?: string;
|
||||
}>;
|
||||
};
|
||||
|
||||
|
||||
async function fetchFiles(path?: string, page = 1, perPage = 50) {
|
||||
const url = new URL("/api/files/list", window.location.origin);
|
||||
if (path) url.searchParams.set("path", path);
|
||||
url.searchParams.set("page", String(page));
|
||||
url.searchParams.set("perPage", String(perPage));
|
||||
const res = await fetch(url.toString(), { cache: "no-store" });
|
||||
if (!res.ok) throw new Error(`Failed to load files: ${res.status}`);
|
||||
const data = (await res.json()) as FilesListResponse;
|
||||
return data;
|
||||
}
|
||||
|
||||
async function executeSearch(
|
||||
q: string,
|
||||
semantic: boolean,
|
||||
page: number,
|
||||
perPage: number,
|
||||
filters: FacetFilters,
|
||||
): Promise<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;
|
||||
}
|
||||
type DashboardTab = "files" | "search" | "document" | "qdrant";
|
||||
|
||||
export default function Home() {
|
||||
const router = useRouter();
|
||||
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[]>([]);
|
||||
|
||||
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 [activeTab, setActiveTab] = React.useState<DashboardTab>("files");
|
||||
const [selectedPath, setSelectedPath] = React.useState<string | null>(null);
|
||||
const [tagsPath, setTagsPath] = React.useState<string | null>(null);
|
||||
|
||||
const filesQuery = useQuery({
|
||||
queryKey: ["files", path, page, perPage],
|
||||
queryFn: () => fetchFiles(path, page, perPage),
|
||||
enabled: !searching,
|
||||
});
|
||||
const handleTabChange = (next: string) => {
|
||||
const from = activeTab;
|
||||
const to = (next as DashboardTab) || "files";
|
||||
Sentry.startSpan(
|
||||
{ op: "ui.click", name: "Dashboard Tab Switch" },
|
||||
(span) => {
|
||||
span.setAttribute("from", from);
|
||||
span.setAttribute("to", to);
|
||||
setActiveTab(to);
|
||||
},
|
||||
);
|
||||
};
|
||||
|
||||
const searchQuery = useQuery<SearchResult>({
|
||||
queryKey: ["search", q, semantic, page, perPage, filters],
|
||||
queryFn: () => executeSearch(q.trim(), semantic, page, perPage, filters),
|
||||
enabled: searching,
|
||||
});
|
||||
|
||||
const searchHits: SearchHit[] = React.useMemo(() => {
|
||||
return (searchQuery.data?.hits ?? []) as SearchHit[];
|
||||
}, [searchQuery.data]);
|
||||
|
||||
const files: FileRow[] = React.useMemo(() => {
|
||||
const items = filesQuery.data?.items ?? [];
|
||||
return items.map((it) => ({
|
||||
id: it.id,
|
||||
name: it.name,
|
||||
path: it.path,
|
||||
parentPath: it.parentPath,
|
||||
sizeBytes: it.sizeBytes,
|
||||
mimeType: it.mimeType,
|
||||
etag: it.etag,
|
||||
}));
|
||||
}, [filesQuery.data]);
|
||||
|
||||
function handleDownload(item: FileRow) {
|
||||
const url = new URL("/api/files/download", window.location.origin);
|
||||
url.searchParams.set("path", item.path);
|
||||
// open in a new tab to trigger save/open dialog
|
||||
window.open(url.toString(), "_blank", "noopener,noreferrer");
|
||||
}
|
||||
|
||||
function encodeDocPath(p: string) {
|
||||
// Convert an absolute path like "/a/b c/d.md" -> "/doc/a/b%20c/d.md"
|
||||
const segments = p.replace(/^\/+/, "").split("/");
|
||||
const encoded = segments.map((s) => encodeURIComponent(s)).join("/");
|
||||
return `/doc/${encoded}`;
|
||||
}
|
||||
|
||||
function navigateToDoc(p: string) {
|
||||
router.push(encodeDocPath(p));
|
||||
}
|
||||
|
||||
function handleOpen(item: FileRow) {
|
||||
// Navigate to the Document View for the selected file
|
||||
navigateToDoc(item.path);
|
||||
}
|
||||
|
||||
function handleEdit(item: FileRow) {
|
||||
setEditPath(item.path);
|
||||
}
|
||||
|
||||
async function postJSON(url: string, body: unknown) {
|
||||
const res = await fetch(url, {
|
||||
method: "POST",
|
||||
headers: { "content-type": "application/json" },
|
||||
body: JSON.stringify(body),
|
||||
});
|
||||
if (!res.ok) {
|
||||
const payload = await res.json().catch(() => ({}));
|
||||
throw new Error(payload?.message || `Request failed (${res.status})`);
|
||||
const selectDocument = (path: string | null) => {
|
||||
if (!path) {
|
||||
setSelectedPath(null);
|
||||
setActiveTab("document");
|
||||
return;
|
||||
}
|
||||
return res.json();
|
||||
}
|
||||
const normalized = normalizePath(path);
|
||||
Sentry.startSpan(
|
||||
{ op: "ui.action", name: "Open Document" },
|
||||
(span) => {
|
||||
span.setAttribute("path", normalized);
|
||||
setSelectedPath(normalized);
|
||||
setActiveTab("document");
|
||||
},
|
||||
);
|
||||
};
|
||||
|
||||
// Optimistic delete mutation
|
||||
const deleteMutation = useMutation({
|
||||
mutationFn: async (file: FileRow) => {
|
||||
return postJSON("/api/files/delete", { path: file.path });
|
||||
},
|
||||
onMutate: async (file: FileRow) => {
|
||||
// Cancel any outgoing refetches (so they don't overwrite our optimistic update)
|
||||
await queryClient.cancelQueries({ queryKey: ["files", path, page, perPage] });
|
||||
// Snapshot previous value
|
||||
const prev = queryClient.getQueryData(["files", path, page, perPage]) as
|
||||
| { total: number; page: number; perPage: number; items: FileRow[] }
|
||||
| undefined;
|
||||
|
||||
if (prev) {
|
||||
// Optimistically remove the item
|
||||
const nextItems = prev.items.filter((x) => x.id !== file.id);
|
||||
queryClient.setQueryData(["files", path, page, perPage], {
|
||||
...prev,
|
||||
total: Math.max(0, (prev.total ?? nextItems.length) - 1),
|
||||
items: nextItems,
|
||||
});
|
||||
}
|
||||
|
||||
return { prev };
|
||||
},
|
||||
onError: (error, _file, ctx) => {
|
||||
if (ctx?.prev) {
|
||||
queryClient.setQueryData(["files", path, page, perPage], ctx.prev);
|
||||
}
|
||||
const message = error instanceof Error ? error.message : String(error);
|
||||
toast.error(message);
|
||||
},
|
||||
onSuccess: () => {
|
||||
toast.success("Deleted");
|
||||
},
|
||||
onSettled: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ["files", path, page, perPage] });
|
||||
},
|
||||
});
|
||||
|
||||
// Optimistic rename mutation
|
||||
const renameMutation = useMutation({
|
||||
mutationFn: async (vars: { item: FileRow; to: string; newName: string }) => {
|
||||
return postJSON("/api/files/rename", { from: vars.item.path, to: vars.to });
|
||||
},
|
||||
onMutate: async ({ item, to, newName }) => {
|
||||
await queryClient.cancelQueries({ queryKey: ["files", path, page, perPage] });
|
||||
const prev = queryClient.getQueryData(["files", path, page, perPage]) as
|
||||
| { total: number; page: number; perPage: number; items: FileRow[] }
|
||||
| undefined;
|
||||
if (prev) {
|
||||
const nextItems = prev.items.map((x) =>
|
||||
x.id === item.id ? { ...x, name: newName, path: to } : x,
|
||||
);
|
||||
queryClient.setQueryData(["files", path, page, perPage], {
|
||||
...prev,
|
||||
items: nextItems,
|
||||
});
|
||||
}
|
||||
return { prev };
|
||||
},
|
||||
onError: (error, _vars, ctx) => {
|
||||
if (ctx?.prev) {
|
||||
queryClient.setQueryData(["files", path, page, perPage], ctx.prev);
|
||||
}
|
||||
const message = error instanceof Error ? error.message : String(error);
|
||||
toast.error(message);
|
||||
},
|
||||
onSuccess: () => {
|
||||
toast.success("Renamed");
|
||||
},
|
||||
onSettled: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ["files", path, page, perPage] });
|
||||
},
|
||||
});
|
||||
|
||||
// Optimistic copy mutation
|
||||
const copyMutation = useMutation({
|
||||
mutationFn: async (vars: { item: FileRow; to: string; newName: string }) => {
|
||||
return postJSON("/api/files/copy", { from: vars.item.path, to: vars.to });
|
||||
},
|
||||
onMutate: async ({ item, to, newName }) => {
|
||||
await queryClient.cancelQueries({ queryKey: ["files", path, page, perPage] });
|
||||
const prev = queryClient.getQueryData(["files", path, page, perPage]) as
|
||||
| { total: number; page: number; perPage: number; items: FileRow[] }
|
||||
| undefined;
|
||||
if (prev) {
|
||||
const temp: FileRow = {
|
||||
...item,
|
||||
id: `temp-${Date.now()}`,
|
||||
name: newName,
|
||||
path: to,
|
||||
};
|
||||
const nextItems = [temp, ...(prev.items ?? [])];
|
||||
queryClient.setQueryData(["files", path, page, perPage], {
|
||||
...prev,
|
||||
total: (prev.total ?? nextItems.length),
|
||||
items: nextItems,
|
||||
});
|
||||
}
|
||||
return { prev };
|
||||
},
|
||||
onError: (error, _vars, ctx) => {
|
||||
if (ctx?.prev) {
|
||||
queryClient.setQueryData(["files", path, page, perPage], ctx.prev);
|
||||
}
|
||||
const message = error instanceof Error ? error.message : String(error);
|
||||
toast.error(message);
|
||||
},
|
||||
onSuccess: () => {
|
||||
toast.success("Copied");
|
||||
},
|
||||
onSettled: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ["files", path, page, perPage] });
|
||||
},
|
||||
});
|
||||
|
||||
async function handleRename(item: FileRow) {
|
||||
const newName = window.prompt("New name (or full destination path):", item.name);
|
||||
if (!newName) return;
|
||||
const to =
|
||||
newName.startsWith("/")
|
||||
? newName
|
||||
: `${item.parentPath ?? ""}/${newName}`.replace(/\/+/g, "/");
|
||||
await renameMutation.mutateAsync({ item, to, newName });
|
||||
}
|
||||
|
||||
async function handleCopy(item: FileRow) {
|
||||
const newPath = window.prompt("Copy to path (absolute or name in same folder):", `${item.parentPath ?? ""}/${item.name}`);
|
||||
if (!newPath) return;
|
||||
const to =
|
||||
newPath.startsWith("/")
|
||||
? newPath
|
||||
: `${item.parentPath ?? ""}/${newPath}`.replace(/\/+/g, "/");
|
||||
const newName = newPath.startsWith("/")
|
||||
? newPath.split("/").filter(Boolean).pop() ?? item.name
|
||||
: newPath;
|
||||
await copyMutation.mutateAsync({ item, to, newName });
|
||||
}
|
||||
|
||||
async function handleDelete(item: FileRow) {
|
||||
const yes = window.confirm(`Delete "${item.name}"? This cannot be undone.`);
|
||||
if (!yes) return;
|
||||
await deleteMutation.mutateAsync(item);
|
||||
}
|
||||
|
||||
function handleTags(item: FileRow) {
|
||||
setTagsPath(item.path);
|
||||
}
|
||||
|
||||
// Helpers for search results list (path-only operations)
|
||||
function downloadByPath(p: string) {
|
||||
const onDownloadPath = (p: string) => {
|
||||
const url = new URL("/api/files/download", window.location.origin);
|
||||
url.searchParams.set("path", p);
|
||||
window.open(url.toString(), "_blank", "noopener,noreferrer");
|
||||
}
|
||||
function openByPath(p: string) {
|
||||
navigateToDoc(p);
|
||||
}
|
||||
function tagsByPath(p: string) {
|
||||
setTagsPath(p);
|
||||
}
|
||||
|
||||
function handleUploaded() {
|
||||
if (!searching) {
|
||||
filesQuery.refetch();
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="h-screen 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>
|
||||
<div className="h-screen flex flex-col">
|
||||
{/* Top header */}
|
||||
<header className="border-b p-3 flex items-center gap-3">
|
||||
<div className="text-sm font-medium">Dashboard</div>
|
||||
<div className="ml-auto flex items-center gap-2">
|
||||
<ModeToggle />
|
||||
<SignOutButton size="sm" variant="ghost" />
|
||||
</div>
|
||||
</header>
|
||||
|
||||
{/* 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">
|
||||
<ModeToggle />
|
||||
{/* Tabs container */}
|
||||
<div className="flex-1 flex flex-col min-h-0">
|
||||
<Tabs value={activeTab} onValueChange={handleTabChange} className="flex-1 flex flex-col min-h-0">
|
||||
<div className="border-b px-3 py-2 flex items-center justify-between">
|
||||
<TabsList>
|
||||
<TabsTrigger value="files">Files</TabsTrigger>
|
||||
<TabsTrigger value="search">Search</TabsTrigger>
|
||||
<TabsTrigger value="document">Document</TabsTrigger>
|
||||
<TabsTrigger value="qdrant">Qdrant</TabsTrigger>
|
||||
</TabsList>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<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); }}
|
||||
<TabsContent value="files" className="flex-1 min-h-0">
|
||||
<FilesPanel
|
||||
onOpen={(file) => selectDocument(file.path)}
|
||||
onDownload={(file) => onDownloadPath(file.path)}
|
||||
onTagsPath={(p) => setTagsPath(p)}
|
||||
/>
|
||||
<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={() => {
|
||||
// Force refetch for the active mode
|
||||
if (searching) searchQuery.refetch();
|
||||
else filesQuery.refetch();
|
||||
}}
|
||||
>
|
||||
Refresh
|
||||
</Button>
|
||||
</div>
|
||||
<UploadDialog currentPath={path} onUploaded={handleUploaded} />
|
||||
</section>
|
||||
</TabsContent>
|
||||
|
||||
{searching ? (
|
||||
<section className="p-3 border-b">
|
||||
<FiltersBar value={filters} onChange={setFilters} />
|
||||
</section>
|
||||
) : null}
|
||||
|
||||
<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 value="search" className="flex-1 min-h-0">
|
||||
<SearchPanel
|
||||
onOpenPath={(p) => selectDocument(p)}
|
||||
onDownloadPath={onDownloadPath}
|
||||
onTagsPath={(p) => setTagsPath(p)}
|
||||
/>
|
||||
) : (
|
||||
<FileTable
|
||||
items={files}
|
||||
onOpen={handleOpen}
|
||||
onDownload={handleDownload}
|
||||
onEdit={handleEdit}
|
||||
onRename={handleRename}
|
||||
onCopy={handleCopy}
|
||||
onDelete={handleDelete}
|
||||
onTags={handleTags}
|
||||
/>
|
||||
)}
|
||||
</section>
|
||||
</main>
|
||||
</TabsContent>
|
||||
|
||||
{/* 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>
|
||||
<TabsContent value="document" className="flex-1 min-h-0">
|
||||
{selectedPath ? (
|
||||
<DocViewer path={selectedPath} />
|
||||
) : (
|
||||
<div className="h-full flex items-center justify-center">
|
||||
<div className="text-sm text-muted-foreground">
|
||||
Select a file from Files or Search to view the document here.
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</TabsContent>
|
||||
|
||||
{/* Tags dialog */}
|
||||
<TabsContent value="qdrant" className="flex-1 min-h-0">
|
||||
<QdrantPanel />
|
||||
</TabsContent>
|
||||
</Tabs>
|
||||
</div>
|
||||
|
||||
{/* Tags dialog (shared) */}
|
||||
<TagsDialog
|
||||
path={tagsPath ?? ""}
|
||||
open={!!tagsPath}
|
||||
onOpenChange={(v) => !v && setTagsPath(null)}
|
||||
initialTags={[]}
|
||||
onSaved={() => {
|
||||
// Optionally refresh file list to reflect updated tags when surface supports it
|
||||
if (!searching) filesQuery.refetch();
|
||||
}}
|
||||
/>
|
||||
{/* Command palette (Cmd/Ctrl+K) */}
|
||||
<CommandPalette
|
||||
open={paletteOpen}
|
||||
onOpenChange={setPaletteOpen}
|
||||
onSubmit={(text) => {
|
||||
setPage(1);
|
||||
setQ(text);
|
||||
// Panels handle their own refetch; we only need UX feedback here.
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
|
||||
@ -1,244 +1,12 @@
|
||||
"use client";
|
||||
|
||||
import * as React from "react";
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { ScrollArea } from "@/components/ui/scroll-area";
|
||||
import { EmbeddingScatter, type EmbeddingPoint } from "@/components/qdrant/embedding-scatter";
|
||||
|
||||
type CollectionRow = {
|
||||
name: string;
|
||||
vectors_count?: number;
|
||||
points_count?: number;
|
||||
};
|
||||
|
||||
type CollectionsResponse = {
|
||||
collections: CollectionRow[];
|
||||
};
|
||||
|
||||
type ScrollPoint = {
|
||||
id: string | number;
|
||||
payload?: Record<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;
|
||||
}
|
||||
import { QdrantPanel } from "@/components/qdrant/qdrant-panel";
|
||||
|
||||
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 (
|
||||
<div className="h-screen flex flex-col">
|
||||
<header className="border-b p-3">
|
||||
<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>
|
||||
<QdrantPanel />
|
||||
</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