Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
29 changes: 29 additions & 0 deletions frontend/src/app/dashboard/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,8 @@
import ChatSessionSidebar from "@/components/chat/ChatSessionSidebar";
import ChatPanel from "@/components/chat/ChatPanel";
import CompareView from "@/components/document/CompareView";
import DashboardDropOverlay from "@/components/document/DashboardDropOverlay";
import { useDashboardDrop } from "@/hooks/useDashboardDrop";

function PDFViewerSkeleton() {
return (
Expand Down Expand Up @@ -48,7 +50,7 @@
});

// Lazy-load the graph panel β€” it pulls in @xyflow/react which is sizeable
const KnowledgeGraph = dynamic(

Check warning on line 53 in frontend/src/app/dashboard/page.tsx

View workflow job for this annotation

GitHub Actions / βš›οΈ Frontend β€” TypeScript & Build

'KnowledgeGraph' is assigned a value but never used
() => import("@/components/graph/KnowledgeGraph"),
{ ssr: false },
);
Expand Down Expand Up @@ -150,6 +152,29 @@
})();
}, [user, loadDocuments]);

// ── Full-page drag-and-drop ──────────────────────────────────────────────
const handlePageDrop = useCallback(
async (files: File[]) => {
for (const file of files) {
const formData = new FormData();
formData.append("file", file);
try {
await api.postForm("/api/v1/documents/upload", formData);
} catch (err) {
const message = err instanceof Error ? err.message : "Upload failed";
console.error(`Upload failed for ${file.name}:`, message);
}
}
void loadDocuments();
},
[loadDocuments],
);

const { isDraggingOver, dropZoneProps } = useDashboardDrop({
onDrop: handlePageDrop,
disabled: !user,
});

// Ingest status change toast notification handler
useEffect(() => {
const prev = prevDocsRef.current;
Expand Down Expand Up @@ -213,6 +238,10 @@

return (
<div className="h-screen flex flex-col overflow-hidden min-w-[375px]">
<DashboardDropOverlay
isDraggingOver={isDraggingOver}
dropZoneProps={dropZoneProps}
/>
<Header
sidebarOpen={sidebarOpen}
onToggleSidebar={() => setSidebarOpen(!sidebarOpen)}
Expand Down
59 changes: 59 additions & 0 deletions frontend/src/components/document/DashboardDropOverlay.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,59 @@
"use client";

import { UploadCloud } from "lucide-react";

interface DashboardDropOverlayProps {
isDraggingOver: boolean;
dropZoneProps: {
onDragEnter: (e: React.DragEvent) => void;
onDragOver: (e: React.DragEvent) => void;
onDragLeave: (e: React.DragEvent) => void;
onDrop: (e: React.DragEvent) => void;
};
}

export default function DashboardDropOverlay({
isDraggingOver,
dropZoneProps,
}: DashboardDropOverlayProps) {
if (!isDraggingOver) return null;

return (
<div
aria-label="Drop files here to upload"
aria-live="assertive"
role="region"
className="fixed inset-0 z-[100] flex items-center justify-center"
{...dropZoneProps}
>
{/* Blurred backdrop */}
<div className="absolute inset-0 bg-background/80 backdrop-blur-sm animate-in fade-in duration-150" />

{/* Animated dashed border card */}
<div
className={[
"relative z-10 flex flex-col items-center justify-center gap-4",
"rounded-2xl border-2 border-dashed border-primary",
"bg-primary/5 px-16 py-14 shadow-2xl",
"animate-in zoom-in-95 fade-in duration-200",
].join(" ")}
>
{/* Pulsing icon ring */}
<div className="relative flex items-center justify-center">
<span className="absolute h-20 w-20 rounded-full bg-primary/15 animate-ping" />
<span className="absolute h-16 w-16 rounded-full bg-primary/20" />
<UploadCloud className="relative w-10 h-10 text-primary animate-bounce" />
</div>

<div className="text-center space-y-1">
<p className="text-lg font-semibold text-foreground">
Drop files to upload
</p>
<p className="text-sm text-muted-foreground">
PDF, DOCX, TXT, MD supported
</p>
</div>
</div>
</div>
);
}
138 changes: 138 additions & 0 deletions frontend/src/hooks/useDashboardDrop.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,138 @@
/**
* useDashboardDrop
*
* Tracks when the user drags files over the browser window and exposes
* an `isDraggingOver` flag so the dashboard can render a full-page
* drop-zone overlay. The actual file handling is delegated to the
* caller via `onDrop`.
*/
import { useCallback, useEffect, useRef, useState } from "react";

const ACCEPTED_EXTENSIONS = [".pdf", ".docx", ".txt", ".md"];
const ACCEPTED_MIME_TYPES = [
"application/pdf",
"application/vnd.openxmlformats-officedocument.wordprocessingml.document",
"text/plain",
"text/markdown",
];

function hasAcceptedFile(transfer: DataTransfer): boolean {
// DataTransferItemList gives MIME type before the drop
if (transfer.items && transfer.items.length > 0) {
return Array.from(transfer.items).some(
(item) => item.kind === "file" && ACCEPTED_MIME_TYPES.includes(item.type),
);
}
// Fallback: FileList (available after drop)
if (transfer.files && transfer.files.length > 0) {
return Array.from(transfer.files).some((file) => {
const ext = "." + file.name.split(".").pop()?.toLowerCase();
return ACCEPTED_EXTENSIONS.includes(ext);
});
}
return false;
}

interface UseDashboardDropOptions {
onDrop: (files: File[]) => void;
disabled?: boolean;
}

interface UseDashboardDropResult {
isDraggingOver: boolean;
dropZoneProps: {
onDragEnter: (e: React.DragEvent) => void;
onDragOver: (e: React.DragEvent) => void;
onDragLeave: (e: React.DragEvent) => void;
onDrop: (e: React.DragEvent) => void;
};
}

export function useDashboardDrop({
onDrop,
disabled = false,
}: UseDashboardDropOptions): UseDashboardDropResult {
const [isDraggingOver, setIsDraggingOver] = useState(false);
// Counter tracks nested dragenter/dragleave pairs so we don't flicker
const dragCounter = useRef(0);

const handleWindowDragEnter = useCallback(
(e: DragEvent) => {
if (disabled) return;
if (!e.dataTransfer || !hasAcceptedFile(e.dataTransfer)) return;
dragCounter.current += 1;
if (dragCounter.current === 1) setIsDraggingOver(true);
},
[disabled],
);

const handleWindowDragLeave = useCallback(
(e: DragEvent) => {

Check warning on line 70 in frontend/src/hooks/useDashboardDrop.ts

View workflow job for this annotation

GitHub Actions / βš›οΈ Frontend β€” TypeScript & Build

'e' is defined but never used
if (disabled) return;
dragCounter.current = Math.max(0, dragCounter.current - 1);
if (dragCounter.current === 0) setIsDraggingOver(false);
},
[disabled],
);

const handleWindowDrop = useCallback((e: DragEvent) => {

Check warning on line 78 in frontend/src/hooks/useDashboardDrop.ts

View workflow job for this annotation

GitHub Actions / βš›οΈ Frontend β€” TypeScript & Build

'e' is defined but never used
dragCounter.current = 0;
setIsDraggingOver(false);
}, []);

// Attach window-level listeners so ANY drag over the page triggers the overlay
useEffect(() => {
window.addEventListener("dragenter", handleWindowDragEnter);
window.addEventListener("dragleave", handleWindowDragLeave);
window.addEventListener("drop", handleWindowDrop);
return () => {
window.removeEventListener("dragenter", handleWindowDragEnter);
window.removeEventListener("dragleave", handleWindowDragLeave);
window.removeEventListener("drop", handleWindowDrop);
};
}, [handleWindowDragEnter, handleWindowDragLeave, handleWindowDrop]);

// Reset when disabled
useEffect(() => {
if (disabled) {
dragCounter.current = 0;
const id = setTimeout(() => setIsDraggingOver(false), 0);
return () => clearTimeout(id);
}
}, [disabled]);

// Props spread onto the overlay <div> itself
const dropZoneProps = {
onDragEnter: (e: React.DragEvent) => {
e.preventDefault();
e.stopPropagation();
},
onDragOver: (e: React.DragEvent) => {
e.preventDefault();
e.stopPropagation();
e.dataTransfer.dropEffect = "copy";
},
onDragLeave: (e: React.DragEvent) => {
e.preventDefault();
e.stopPropagation();
},
onDrop: (e: React.DragEvent) => {
e.preventDefault();
e.stopPropagation();
dragCounter.current = 0;
setIsDraggingOver(false);
if (disabled) return;

const files = Array.from(e.dataTransfer.files).filter((file) => {
const ext = "." + file.name.split(".").pop()?.toLowerCase();
return ACCEPTED_EXTENSIONS.includes(ext);
});

if (files.length > 0) {
onDrop(files);
}
},
};

return { isDraggingOver, dropZoneProps };
}
Loading