405 lines
13 KiB
TypeScript
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>
|
|
);
|
|
}
|