"use client"; import * as React from "react"; 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 { ModeToggle } from "@/components/theme/mode-toggle"; 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; }>; }; type SearchResult = { total: number; tookMs: number; hits: Array<{ score: number; bm25Score?: number; vectorScore?: number; doc: { id: string; name: string; path: string; parentPath?: string; 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) { const res = await fetch("/api/search/query", { method: "POST", headers: { "content-type": "application/json" }, body: JSON.stringify({ q, semantic, page, perPage }), }); if (!res.ok) throw new Error(`Search failed: ${res.status}`); const data = (await res.json()) as SearchResult; return data; } export default function Home() { const queryClient = useQueryClient(); const [path, setPath] = React.useState(undefined); const [page, setPage] = React.useState(1); const [perPage] = React.useState(50); const [q, setQ] = React.useState(""); const [semantic, setSemantic] = React.useState(false); const searching = q.trim().length > 0; // Editor state const [editPath, setEditPath] = React.useState(null); // Tags dialog state const [tagsPath, setTagsPath] = React.useState(null); const filesQuery = useQuery({ queryKey: ["files", path, page, perPage], queryFn: () => fetchFiles(path, page, perPage), enabled: !searching, }); const searchQuery = useQuery({ queryKey: ["search", q, semantic, page, perPage], queryFn: () => executeSearch(q.trim(), semantic, page, perPage), enabled: searching, }); const files: FileRow[] = React.useMemo(() => { if (searching) { const hits = searchQuery.data?.hits ?? []; return hits.map((h) => ({ id: h.doc.id, name: h.doc.name, path: h.doc.path, parentPath: h.doc.parentPath, sizeBytes: h.doc.sizeBytes, mimeType: h.doc.mimeType, etag: h.doc.etag, })); } 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, searchQuery.data, searching]); 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 handleOpen(item: FileRow) { // Placeholder for preview behavior; for now, download handleDownload(item); } function handleEdit(item: FileRow) { setEditPath(item.path); } async function postJSON(url: string, body: unknown) { const res = await fetch(url, { method: "POST", headers: { "content-type": "application/json" }, body: JSON.stringify(body), }); if (!res.ok) { const payload = await res.json().catch(() => ({})); throw new Error(payload?.message || `Request failed (${res.status})`); } return res.json(); } // 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); } function handleUploaded() { if (!searching) { filesQuery.refetch(); } } return (
{/* Sidebar */} {/* Main content */}
{ setPage(1); setPath(p); }} />
{ setPage(1); setQ(e.target.value); }} />
setSemantic(Boolean(v))} />
{searching ? (
{searchQuery.isLoading ? "Searching…" : `Found ${searchQuery.data?.total ?? 0} in ${searchQuery.data?.tookMs ?? 0}ms`}
) : null}
{/* Editor dialog */} !v && setEditPath(null)}> Markdown Editor
{editPath ? : null}
{/* Tags dialog */} !v && setTagsPath(null)} initialTags={[]} onSaved={() => { // Optionally refresh file list to reflect updated tags when surface supports it if (!searching) filesQuery.refetch(); }} />
); }