added homepage and unified navigation

This commit is contained in:
nicholai 2025-09-16 09:18:08 -06:00
parent 01c47558db
commit ae1be57046
13 changed files with 1460 additions and 914 deletions

31
package-lock.json generated
View File

@ -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",

View File

@ -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",

View File

@ -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} />;
}

View File

@ -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>

View File

@ -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>
);
}

View 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;

View 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;

View 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;

View 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;

View 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;

View 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 }

View 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");
});
});

View 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.");
});
});