file-browser/src/app/page.tsx

405 lines
13 KiB
TypeScript

"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<string | undefined>(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<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),
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 (
<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); }} />
</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">
<ModeToggle />
</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); }}
/>
<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>
<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}
<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={() => {
// Optionally refresh file list to reflect updated tags when surface supports it
if (!searching) filesQuery.refetch();
}}
/>
</div>
);
}