diff --git a/components/home-components/MovePdfDialog.tsx b/components/home-components/MovePdfDialog.tsx
new file mode 100644
index 00000000..c4750f08
--- /dev/null
+++ b/components/home-components/MovePdfDialog.tsx
@@ -0,0 +1,388 @@
+"use client";
+
+import { useEffect, useMemo, useRef, useState } from "react";
+import { useMutation } from "convex/react";
+import {
+ ChevronRight,
+ FileSymlink,
+ Folder,
+ FolderOpen,
+ Plus,
+ Search,
+} from "lucide-react";
+import { api } from "@/convex/_generated/api";
+import type { Id } from "@/convex/_generated/dataModel";
+import { useQuery } from "@/cache/useQuery";
+import { useToast } from "@/hooks/use-toast";
+import { cn } from "@/lib/utils";
+import { generateSlug } from "@/lib/generateSlug";
+import { Button } from "@/components/ui/button";
+import { Input } from "@/components/ui/input";
+import LoadingAnimation from "@/components/ui/LoadingAnimation";
+import {
+ Dialog,
+ DialogContent,
+ DialogDescription,
+ DialogHeader,
+ DialogTitle,
+} from "@/components/ui/dialog";
+import CreateTableBtn from "./CreateTableBtn";
+import IntentPrefetchLink from "../IntentPrefetchLink";
+
+interface MovePdfDialogProps {
+ open: boolean;
+ onOpenChange: (open: boolean) => void;
+ pdf: {
+ _id: Id<"pdfs">;
+ title?: string;
+ workingSpaceId?: Id<"workingSpaces">;
+ notesTableId?: Id<"notesTables">;
+ };
+}
+
+function HighlightedText({ text, query }: { text: string; query: string }) {
+ if (!query) return <>{text}>;
+
+ const index = text.toLowerCase().indexOf(query.toLowerCase());
+ if (index === -1) return <>{text}>;
+
+ return (
+ <>
+ {text.slice(0, index)}
+
+ {text.slice(index, index + query.length)}
+
+ {text.slice(index + query.length)}
+ >
+ );
+}
+
+export default function MovePdfDialog({
+ open,
+ onOpenChange,
+ pdf,
+}: MovePdfDialogProps) {
+ const { toast } = useToast();
+ const searchInputRef = useRef
(null);
+ const [query, setQuery] = useState("");
+ const [debouncedQuery, setDebouncedQuery] = useState("");
+ const [expandedWorkspaceIds, setExpandedWorkspaceIds] = useState(
+ [],
+ );
+ const [isCreatingWorkspace, setIsCreatingWorkspace] = useState(false);
+ const [movingTableId, setMovingTableId] = useState(null);
+
+ const moveTargets = useQuery(api.notes.getWorkspaceTree, {
+ searchQuery: debouncedQuery || undefined,
+ }) as any[] | undefined;
+
+ const movePdf = useMutation(api.pdfs.movePdf).withOptimisticUpdate(
+ (local, args) => {
+ const currentPdf = local.getQuery(api.pdfs.getPdfById, {
+ _id: args._id,
+ });
+
+ if (!currentPdf) return;
+
+ local.setQuery(
+ api.pdfs.getPdfById,
+ { _id: args._id },
+ {
+ ...currentPdf,
+ workingSpaceId: args.targetWorkingSpaceId,
+ notesTableId: args.targetNotesTableId,
+ updatedAt: Date.now(),
+ },
+ );
+ },
+ );
+ const createWorkingSpace = useMutation(api.workingSpaces.createWorkingSpace);
+
+ useEffect(() => {
+ const timer = setTimeout(() => {
+ setDebouncedQuery(query);
+ }, 250);
+
+ return () => clearTimeout(timer);
+ }, [query]);
+
+ useEffect(() => {
+ if (!open) return;
+
+ setQuery("");
+ setDebouncedQuery("");
+ setExpandedWorkspaceIds(
+ pdf.workingSpaceId ? [String(pdf.workingSpaceId)] : [],
+ );
+ setIsCreatingWorkspace(false);
+ setMovingTableId(null);
+
+ const timer = setTimeout(() => {
+ searchInputRef.current?.focus();
+ }, 20);
+
+ return () => clearTimeout(timer);
+ }, [open, pdf.workingSpaceId]);
+
+ useEffect(() => {
+ if (!debouncedQuery || !moveTargets) return;
+ setExpandedWorkspaceIds(
+ moveTargets.map((workspace) => String(workspace._id)),
+ );
+ }, [debouncedQuery, moveTargets]);
+
+ const hasResults = useMemo(
+ () => (moveTargets?.length ?? 0) > 0,
+ [moveTargets],
+ );
+
+ const toggleWorkspace = (workspaceId: string) => {
+ setExpandedWorkspaceIds((prev) =>
+ prev.includes(workspaceId)
+ ? prev.filter((id) => id !== workspaceId)
+ : [...prev, workspaceId],
+ );
+ };
+
+ const handleCreateWorkspace = async () => {
+ try {
+ setIsCreatingWorkspace(true);
+ const workspaceId = await createWorkingSpace({ name: "Untitled" });
+ setExpandedWorkspaceIds((prev) =>
+ prev.includes(String(workspaceId))
+ ? prev
+ : [...prev, String(workspaceId)],
+ );
+ } catch (error) {
+ console.error("Failed to create workspace:", error);
+ } finally {
+ setIsCreatingWorkspace(false);
+ }
+ };
+
+ const handleMove = async (
+ targetWorkingSpaceId: Id<"workingSpaces">,
+ targetNotesTableId: Id<"notesTables">,
+ ) => {
+ try {
+ setMovingTableId(String(targetNotesTableId));
+ const result = await movePdf({
+ _id: pdf._id,
+ targetWorkingSpaceId,
+ targetNotesTableId,
+ });
+ onOpenChange(false);
+
+ const pdfSlug = generateSlug(result.title || pdf.title || "untitled-pdf");
+
+ toast({
+ variant: "default",
+ title: "Upload moved successfully",
+ description: "Jump straight to the file in its new location.",
+ action: (
+
+ ),
+ });
+ } catch (error) {
+ console.error("Failed to move PDF:", error);
+ toast({
+ variant: "destructive",
+ title: "Failed to move upload",
+ description: "Try again later",
+ });
+ } finally {
+ setMovingTableId(null);
+ }
+ };
+
+ return (
+
+ );
+}
diff --git a/components/home-components/PdfSettings.tsx b/components/home-components/PdfSettings.tsx
new file mode 100644
index 00000000..fbef192a
--- /dev/null
+++ b/components/home-components/PdfSettings.tsx
@@ -0,0 +1,339 @@
+"use client";
+
+import { useCallback, useEffect, useRef, useState } from "react";
+import { useMutation } from "convex/react";
+import {
+ Download,
+ ExternalLink,
+ FileDown,
+ FileOutput,
+ Pin,
+} from "lucide-react";
+import { FaEllipsis, FaEllipsisVertical, FaRegTrashCan } from "react-icons/fa6";
+import type { Id } from "@/convex/_generated/dataModel";
+import { api } from "@/convex/_generated/api";
+import { useQuery } from "@/cache/useQuery";
+import { cn } from "@/lib/utils";
+import {
+ AlertDialog,
+ AlertDialogAction,
+ AlertDialogCancel,
+ AlertDialogContent,
+ AlertDialogDescription,
+ AlertDialogFooter,
+ AlertDialogHeader,
+ AlertDialogTitle,
+} from "@/components/ui/alert-dialog";
+import { Button } from "@/components/ui/button";
+import {
+ DropdownMenu,
+ DropdownMenuContent,
+ DropdownMenuGroup,
+ DropdownMenuItem,
+ DropdownMenuSeparator,
+ DropdownMenuSub,
+ DropdownMenuSubContent,
+ DropdownMenuSubTrigger,
+ DropdownMenuTrigger,
+} from "@/components/ui/dropdown-menu";
+import { Input } from "@/components/ui/input";
+import { Label } from "@/components/ui/label";
+import {
+ Tooltip,
+ TooltipContent,
+ TooltipProvider,
+ TooltipTrigger,
+} from "@/components/ui/tooltip";
+import MovePdfDialog from "./MovePdfDialog";
+
+interface PdfSettingsProps {
+ pdfId: Id<"pdfs">;
+ pdfTitle?: string;
+ iconVariant: "vertical_icon" | "horizontal_icon";
+ btnClassName?: string;
+ dropdownMenuContentAlign: "end" | "start";
+ tooltipContentAlign: "end" | "start";
+ onDelete?: (pdfId: Id<"pdfs">) => void;
+}
+
+const PDF_TITLE_MAX_LENGTH = 55;
+
+const formatPdfTimestamp = (timestamp?: number) => {
+ if (!timestamp) return "";
+
+ return new Date(timestamp).toLocaleString(undefined, {
+ month: "short",
+ day: "numeric",
+ year: "numeric",
+ hour: "numeric",
+ minute: "2-digit",
+ });
+};
+
+export default function PdfSettings({
+ pdfId,
+ pdfTitle,
+ iconVariant,
+ btnClassName,
+ dropdownMenuContentAlign,
+ tooltipContentAlign,
+ onDelete,
+}: PdfSettingsProps) {
+ const [inputValue, setInputValue] = useState(pdfTitle || "Untitled");
+ const [open, setOpen] = useState(false);
+ const [isAlertOpen, setIsAlertOpen] = useState(false);
+ const [isTooltipOpen, setIsTooltipOpen] = useState(false);
+ const [isMoveDialogOpen, setIsMoveDialogOpen] = useState(false);
+ const inputRef = useRef(null);
+
+ const pdf = useQuery(api.pdfs.getPdfById, { _id: pdfId });
+ const updatePdf = useMutation(api.pdfs.updatePdf);
+ const deletePdf = useMutation(api.pdfs.deletePdf);
+
+ useEffect(() => {
+ setInputValue(pdfTitle || "Untitled");
+ }, [pdfTitle]);
+
+ useEffect(() => {
+ if (open) {
+ setTimeout(() => {
+ inputRef.current?.focus();
+ inputRef.current?.select();
+ }, 10);
+ }
+ }, [open]);
+
+ const handleBlur = useCallback(async () => {
+ const trimmedValue = inputValue.trim();
+ const isValid =
+ trimmedValue.length > 0 && trimmedValue.length <= PDF_TITLE_MAX_LENGTH;
+
+ if (!isValid) {
+ setInputValue(pdfTitle || "Untitled");
+ return;
+ }
+
+ if (trimmedValue !== (pdfTitle || "Untitled")) {
+ try {
+ await updatePdf({ _id: pdfId, title: trimmedValue });
+ } catch (error) {
+ console.error("Error updating PDF title:", error);
+ setInputValue(pdfTitle || "Untitled");
+ }
+ }
+
+ setInputValue(trimmedValue);
+ }, [inputValue, pdfId, pdfTitle, updatePdf]);
+
+ const handleDelete = useCallback(async () => {
+ if (onDelete) onDelete(pdfId);
+ setIsAlertOpen(false);
+ try {
+ await deletePdf({ _id: pdfId });
+ } catch (error) {
+ console.error("Failed to delete PDF:", error);
+ }
+ }, [deletePdf, onDelete, pdfId]);
+
+ const handleFavoritePin = useCallback(async () => {
+ if (!pdf) return;
+ await updatePdf({
+ _id: pdfId,
+ favorite: !pdf.favorite,
+ });
+ }, [pdf, pdfId, updatePdf]);
+
+ const handleDownloadOriginal = useCallback(() => {
+ if (!pdf?.fileUrl) return;
+
+ const link = document.createElement("a");
+ link.href = pdf.fileUrl;
+ link.download = `${pdf.title || "upload"}.pdf`;
+ link.target = "_blank";
+ link.rel = "noreferrer";
+ document.body.appendChild(link);
+ link.click();
+ document.body.removeChild(link);
+ }, [pdf]);
+
+ if (!pdf) return null;
+
+ const createdAtText = formatPdfTimestamp(pdf.createdAt);
+ const updatedAtText = formatPdfTimestamp(pdf.updatedAt);
+
+ return (
+ <>
+
+
+
+
+
+
+
+
+
+ Rename, Pin, Move, Download, Delete
+
+
+
+
+
+
+
+ setInputValue(event.target.value)}
+ onBlur={() => void handleBlur()}
+ onKeyDown={(event) => {
+ if (event.key === "Enter") {
+ event.preventDefault();
+ void handleBlur();
+ setOpen(false);
+ }
+ }}
+ placeholder="Rename your upload"
+ className="text-foreground h-8"
+ ref={inputRef}
+ />
+
+
+
+
+
+
+
+
+
+
+ Download
+
+
+
+
+ PDF file
+
+ {pdf.fileUrl ? (
+
+
+
+ Open original
+
+
+ ) : null}
+
+
+
+
+
+
+
+
+
Last updated {updatedAtText}
+
Created {createdAtText}
+
+
+
+
+
+
+
+
+ Confirm Upload Deletion
+
+ Are you sure you want to delete this upload? This action cannot be
+ undone.
+
+
+
+
+ Cancel
+
+ void handleDelete()}
+ className="bg-destructive hover:bg-destructive/90 text-destructive-foreground border-none"
+ >
+ Delete
+
+
+
+
+
+
+ >
+ );
+}
diff --git a/components/home-components/PdfSettingsSidebar.tsx b/components/home-components/PdfSettingsSidebar.tsx
new file mode 100644
index 00000000..afed556b
--- /dev/null
+++ b/components/home-components/PdfSettingsSidebar.tsx
@@ -0,0 +1,132 @@
+"use client";
+
+import type { Id } from "@/convex/_generated/dataModel";
+import {
+ AlertDialog,
+ AlertDialogAction,
+ AlertDialogCancel,
+ AlertDialogContent,
+ AlertDialogDescription,
+ AlertDialogFooter,
+ AlertDialogHeader,
+ AlertDialogTitle,
+} from "@/components/ui/alert-dialog";
+import {
+ Tooltip,
+ TooltipContent,
+ TooltipProvider,
+ TooltipTrigger,
+} from "@/components/ui/tooltip";
+import { useState } from "react";
+import { useMutation } from "convex/react";
+import { api } from "@/convex/_generated/api";
+import { useQuery } from "@/cache/useQuery";
+import { Button } from "@/components/ui/button";
+import { Pin, PinOff, X } from "lucide-react";
+import { cn } from "@/lib/utils";
+
+interface PdfSettingsSidebarProps {
+ pdfId: Id<"pdfs">;
+ containerClassName?: string;
+}
+
+export default function PdfSettingsSidebar({
+ pdfId,
+ containerClassName,
+}: PdfSettingsSidebarProps) {
+ const [isAlertOpen, setIsAlertOpen] = useState(false);
+ const updatePdf = useMutation(api.pdfs.updatePdf);
+ const deletePdf = useMutation(api.pdfs.deletePdf);
+ const pdf = useQuery(api.pdfs.getPdfById, { _id: pdfId });
+
+ const handleFavoritePin = async () => {
+ if (!pdf) return;
+ await updatePdf({
+ _id: pdfId,
+ favorite: !pdf.favorite,
+ });
+ };
+
+ const handleDelete = async (event: React.MouseEvent) => {
+ event.preventDefault();
+ try {
+ await deletePdf({ _id: pdfId });
+ } catch (error) {
+ console.error("Failed to delete PDF:", error);
+ } finally {
+ setIsAlertOpen(false);
+ }
+ };
+
+ return (
+ <>
+
+
+
+
+
+
+
+ {pdf?.favorite ? "Unpin upload" : "Pin upload"}
+
+
+
+
+
+
+
+
+ Delete upload
+
+
+
+
+
+
+
+
+ Confirm Upload Deletion
+
+ Are you sure you want to delete this upload? This action cannot be
+ undone.
+
+
+
+
+ Cancel
+
+
+ Delete
+
+
+
+
+ >
+ );
+}
diff --git a/convex/pdfs.ts b/convex/pdfs.ts
index 1bd958c7..cd9ff14d 100644
--- a/convex/pdfs.ts
+++ b/convex/pdfs.ts
@@ -1,6 +1,7 @@
import { mutation, query } from "./_generated/server";
import { v } from "convex/values";
import { getAuthUserId } from "@convex-dev/auth/server";
+import { paginationOptsValidator } from "convex/server";
export const generateUploadUrl = mutation({
args: {},
@@ -49,6 +50,7 @@ export const sendPdf = mutation({
workingSpaceId: args.workingSpaceId,
notesTableId: args.notesTableId,
title: args.title,
+ favorite: false,
createdAt: Date.now(),
updatedAt: Date.now(),
});
@@ -116,10 +118,28 @@ export const getPdfById = query({
},
});
+export const getFavPdfs = query({
+ args: { paginationOpts: paginationOptsValidator },
+ handler: async (ctx, { paginationOpts }) => {
+ const userId = await getAuthUserId(ctx);
+ if (!userId) {
+ throw new Error("Unauthenticated");
+ }
+
+ return await ctx.db
+ .query("pdfs")
+ .withIndex("by_userId", (q) => q.eq("userId", userId))
+ .filter((q) => q.eq(q.field("favorite"), true))
+ .order("desc")
+ .paginate(paginationOpts);
+ },
+});
+
export const updatePdf = mutation({
args: {
_id: v.id("pdfs"),
- title: v.string(),
+ title: v.optional(v.string()),
+ favorite: v.optional(v.boolean()),
},
handler: async (ctx, args) => {
const userId = await getAuthUserId(ctx);
@@ -133,12 +153,59 @@ export const updatePdf = mutation({
}
await ctx.db.patch(args._id, {
- title: args.title,
+ title: args.title ?? pdf.title,
+ favorite: args.favorite ?? pdf.favorite,
updatedAt: Date.now(),
});
},
});
+export const movePdf = mutation({
+ args: {
+ _id: v.id("pdfs"),
+ targetWorkingSpaceId: v.id("workingSpaces"),
+ targetNotesTableId: v.id("notesTables"),
+ },
+ handler: async (ctx, args) => {
+ const userId = await getAuthUserId(ctx);
+ if (!userId) {
+ throw new Error("Unauthenticated");
+ }
+
+ const pdf = await ctx.db.get(args._id);
+ if (!pdf || pdf.userId !== userId) {
+ throw new Error("PDF not found or not authorized");
+ }
+
+ const targetWorkspace = await ctx.db.get(args.targetWorkingSpaceId);
+ if (!targetWorkspace || targetWorkspace.userId !== userId) {
+ throw new Error("Target workspace not found or not authorized");
+ }
+
+ const targetTable = await ctx.db.get(args.targetNotesTableId);
+ if (!targetTable) {
+ throw new Error("Target table not found");
+ }
+
+ if (targetTable.workingSpaceId !== args.targetWorkingSpaceId) {
+ throw new Error("Target table does not belong to this workspace");
+ }
+
+ await ctx.db.patch(args._id, {
+ workingSpaceId: args.targetWorkingSpaceId,
+ notesTableId: args.targetNotesTableId,
+ updatedAt: Date.now(),
+ });
+
+ return {
+ pdfId: args._id,
+ workingSpaceId: args.targetWorkingSpaceId,
+ notesTableId: args.targetNotesTableId,
+ title: pdf.title,
+ };
+ },
+});
+
export const deletePdf = mutation({
args: {
_id: v.id("pdfs"),
diff --git a/convex/schema.ts b/convex/schema.ts
index 07fb85bc..467d7f82 100644
--- a/convex/schema.ts
+++ b/convex/schema.ts
@@ -63,6 +63,7 @@ export default defineSchema({
workingSpaceId: v.optional(v.id("workingSpaces")),
notesTableId: v.optional(v.id("notesTables")),
title: v.optional(v.string()),
+ favorite: v.optional(v.boolean()),
createdAt: v.number(),
updatedAt: v.number(),
})