Skip to content
Closed
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
2 changes: 1 addition & 1 deletion api/modules/statements/services.py
Original file line number Diff line number Diff line change
Expand Up @@ -344,7 +344,7 @@ async def send_statement_to_queue(
project_id: str,
organization_id: str,
) -> None:
url = f"{request.url.scheme}://{request.headers.get('host')}/api/projects/{project_id}/statements/{statement.id}"
url = f"{request.url.scheme}://{request.headers.get('host')}/projects/{project_id}/statements/{statement.id}"
await qstash.message.publish_json(
url=url,
method="POST",
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -69,7 +69,7 @@ export function ProjectItem({ project }: { project: Project }) {
};

return (
<SidebarMenuItem className="items-center">
<SidebarMenuItem className="items-center" data-project-id={project.id}>
<ContextMenu>
<SidebarMenuButton
asChild
Expand Down
16 changes: 14 additions & 2 deletions app/o/[org-slug]/(sidebar)/layout.tsx
Original file line number Diff line number Diff line change
@@ -1,14 +1,20 @@
import { headers as _headers } from "next/headers";
import {
SidebarInset,
SidebarProvider,
SidebarTrigger,
} from "@/components/animate-ui/components/radix/sidebar";
import { AppSidebar } from "./components/sidebar";
import { GlobalDropProvider } from "@/components/providers/global-drop-provider";
import { getProjects } from "@/server/get-projects";
import { auth } from "@/utils/auth";

export default function OrganizationLayout({
export default async function OrganizationLayout({
children,
}: LayoutProps<"/o/[org-slug]">) {
// const { "org-slug": orgSlug } = await params;
const headers = await _headers();
const projectsData = getProjects({ headers });
const activeOrg = await auth.api.getFullOrganization({ headers });

return (
<SidebarProvider>
Expand All @@ -19,6 +25,12 @@ export default function OrganizationLayout({
{children}
</main>
</SidebarInset>
{activeOrg?.id && (
<GlobalDropProvider
projectsPromise={projectsData}
organizationId={activeOrg.id}
/>
)}
</SidebarProvider>
);
}
39 changes: 19 additions & 20 deletions components/animate-ui/components/base/checkbox.tsx
Original file line number Diff line number Diff line change
@@ -1,44 +1,43 @@
import * as React from 'react';

import { cva, type VariantProps } from "class-variance-authority";
import type * as React from "react";
import {
Checkbox as CheckboxPrimitive,
CheckboxIndicator as CheckboxIndicatorPrimitive,
Checkbox as CheckboxPrimitive,
type CheckboxProps as CheckboxPrimitiveProps,
} from '@/components/animate-ui/primitives/base/checkbox';
import { cn } from '@/utils/cn';
import { cva, type VariantProps } from 'class-variance-authority';
} from "@/components/animate-ui/primitives/base/checkbox";
import { cn } from "@/utils/cn";

const checkboxVariants = cva(
'peer shrink-0 flex items-center justify-center outline-none focus-visible:ring-[3px] focus-visible:ring-ring/50 aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 disabled:cursor-not-allowed disabled:opacity-50 transition-colors duration-500 focus-visible:ring-offset-2 [&[data-checked],&[data-indeterminate]]:bg-primary [&[data-checked],&[data-indeterminate]]:text-primary-foreground',
"peer flex shrink-0 items-center justify-center outline-none transition-colors duration-500 focus-visible:ring-[3px] focus-visible:ring-ring/50 focus-visible:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50 aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 [&[data-checked],&[data-indeterminate]]:bg-primary [&[data-checked],&[data-indeterminate]]:text-primary-foreground",
{
variants: {
variant: {
default: 'bg-background border',
accent: 'bg-input',
default: "border bg-background",
accent: "bg-input",
},
size: {
default: 'size-5 rounded-sm',
sm: 'size-4.5 rounded-[5px]',
lg: 'size-6 rounded-[7px]',
default: "size-5 rounded-sm",
sm: "size-4.5 rounded-[5px]",
lg: "size-6 rounded-[7px]",
},
},
defaultVariants: {
variant: 'default',
size: 'default',
variant: "default",
size: "default",
},
},
}
);

const checkboxIndicatorVariants = cva('', {
const checkboxIndicatorVariants = cva("", {
variants: {
size: {
default: 'size-3.5',
sm: 'size-3',
lg: 'size-4',
default: "size-3.5",
sm: "size-3",
lg: "size-4",
},
},
defaultVariants: {
size: 'default',
size: "default",
},
});

Expand Down
278 changes: 278 additions & 0 deletions components/global-file-drop-handler.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,278 @@
"use client";

import { useCallback, useEffect, useRef, useState } from "react";
import { CrossProjectConfirmation } from "@/components/ui/cross-project-confirmation";
import { ProjectSelectorDialog } from "@/components/ui/project-selector-dialog";
import { validatePDFFile } from "@/lib/file-validation";
import { useToasts } from "@/stores/toast-store";
import type { Project } from "@/types";

type GlobalFileDropHandlerProps = {
projects: Project[];
currentProject?: Project | null;
organizationId: string;
apiKey: string;
baseUrl: string;
};

export function GlobalFileDropHandler({
projects,
currentProject,
organizationId,
apiKey,
baseUrl,
}: GlobalFileDropHandlerProps) {
const [isDragging, setIsDragging] = useState(false);
const [dragCounter, setDragCounter] = useState(0);
const [showProjectSelector, setShowProjectSelector] = useState(false);
const [showCrossProjectConfirmation, setShowCrossProjectConfirmation] =
useState(false);
const [pendingFile, setPendingFile] = useState<File | null>(null);
const [targetProject, setTargetProject] = useState<Project | null>(null);

const { addToast, updateToast, autoRemoveToast } = useToasts();
const workerRef = useRef<Worker | null>(null);
const dropZoneRef = useRef<HTMLDivElement>(null);

// Initialize worker
useEffect(() => {
workerRef.current = new Worker("/upload-worker.js");

workerRef.current.onmessage = (event) => {
const { type, payload } = event.data;

switch (type) {
case "UPLOAD_PROGRESS":
updateToast(payload.toastId, {
status: payload.status,
message: payload.message,
});
break;
case "STATUS_UPDATE":
updateToast(payload.toastId, {
status: payload.status,
message: payload.message,
});
if (payload.status === "completed" || payload.status === "failed") {
autoRemoveToast(payload.toastId);
}
break;
case "UPLOAD_ERROR":
updateToast(payload.toastId, {
status: "failed",
message: payload.error,
});
autoRemoveToast(payload.toastId);
break;
default:
console.warn("Unknown message type:", type);
break;
}
};

return () => {
workerRef.current?.terminate();
};
}, [updateToast, autoRemoveToast]);

const startUpload = useCallback(
(file: File, project: Project) => {
const toastId = addToast(
`Uploading ${file.name}...`,
"pending",
file.name
);

workerRef.current?.postMessage({
type: "START_UPLOAD",
payload: {
file,
projectId: project.id,
organizationId,
apiKey,
baseUrl,
toastId,
},
});
},
[addToast, organizationId, apiKey, baseUrl]
);

const handleFilesDrop = useCallback(
async (files: File[], droppedProject?: Project | null) => {
if (files.length > 1) {
addToast("Only one file at a time is allowed", "failed");
return;
}

const file = files[0];
if (!file) return;

try {
const validation = await validatePDFFile(file);
if (!validation.valid) {
addToast(validation.error?.message || "Invalid file", "failed");
return;
}

let projectToUse: Project | null = null;

if (droppedProject) {
if (currentProject && droppedProject.id !== currentProject.id) {
setTargetProject(droppedProject);
setPendingFile(file);
setShowCrossProjectConfirmation(true);
return;
}
projectToUse = droppedProject;
} else if (currentProject) {
projectToUse = currentProject;
} else {
setPendingFile(file);
setShowProjectSelector(true);
return;
}

if (projectToUse) {
startUpload(file, projectToUse);
}
} catch (error) {
addToast("Error validating file", "failed");
console.error("File validation error:", error);
}
},
[addToast, currentProject, startUpload]
);

// Global drag and drop handlers
useEffect(() => {
const handleDragEnter = (e: DragEvent) => {
e.preventDefault();
e.stopPropagation();

setDragCounter((prev) => prev + 1);

if (e.dataTransfer?.types.includes("Files")) {
setIsDragging(true);
}
};

const handleDragLeave = (e: DragEvent) => {
e.preventDefault();
e.stopPropagation();

setDragCounter((prev) => {
const newCounter = prev - 1;
if (newCounter <= 0) {
setIsDragging(false);
return 0;
}
return newCounter;
});
};

const handleDragOver = (e: DragEvent) => {
e.preventDefault();
e.stopPropagation();
};

const handleDrop = async (e: DragEvent) => {
e.preventDefault();
e.stopPropagation();

setIsDragging(false);
setDragCounter(0);

const files = e.dataTransfer?.files;
if (!files || files.length === 0) return;

const dropTarget = e.target as HTMLElement;
const projectElement = dropTarget.closest("[data-project-id]");
const droppedProjectId = projectElement?.getAttribute("data-project-id");

let targetProjectForDrop: Project | null = null;
if (droppedProjectId) {
targetProjectForDrop =
projects.find((p) => p.id === droppedProjectId) || null;
}

await handleFilesDrop(Array.from(files), targetProjectForDrop);
};

document.addEventListener("dragenter", handleDragEnter);
document.addEventListener("dragleave", handleDragLeave);
document.addEventListener("dragover", handleDragOver);
document.addEventListener("drop", handleDrop);

return () => {
document.removeEventListener("dragenter", handleDragEnter);
document.removeEventListener("dragleave", handleDragLeave);
document.removeEventListener("dragover", handleDragOver);
document.removeEventListener("drop", handleDrop);
};
}, [projects, handleFilesDrop]);

const handleProjectSelection = (project: Project) => {
if (pendingFile) {
startUpload(pendingFile, project);
setPendingFile(null);
}
setShowProjectSelector(false);
};

const handleCrossProjectConfirmation = () => {
if (pendingFile && targetProject) {
startUpload(pendingFile, targetProject);
setPendingFile(null);
setTargetProject(null);
}
};

return (
<>
{isDragging && (
<div
className="fixed inset-0 z-50 flex items-center justify-center bg-blue-500/20 backdrop-blur-sm"
ref={dropZoneRef}
style={{ pointerEvents: "none" }}
>
<div className="max-w-md rounded-lg border-2 border-blue-500 border-dashed bg-background/90 p-8 text-center backdrop-blur-sm">
<div className="mb-4 text-6xl">πŸ“„</div>
<h3 className="mb-2 font-semibold text-lg">Drop PDF File Here</h3>
<p className="text-muted-foreground text-sm">
{currentProject
? `Upload to ${currentProject.name}`
: "Choose a project to upload to"}
</p>
</div>
</div>
)}

<ProjectSelectorDialog
filename={pendingFile?.name || ""}
isOpen={showProjectSelector}
onClose={() => {
setShowProjectSelector(false);
setPendingFile(null);
}}
onSelectProject={handleProjectSelection}
projects={projects}
/>

{targetProject && currentProject && (
<CrossProjectConfirmation
currentProject={currentProject}
filename={pendingFile?.name || ""}
isOpen={showCrossProjectConfirmation}
onClose={() => {
setShowCrossProjectConfirmation(false);
setPendingFile(null);
setTargetProject(null);
}}
onConfirm={handleCrossProjectConfirmation}
targetProject={targetProject}
/>
)}
</>
);
}
Loading