diff --git a/app/home/[id]/WorkingSpacePageClient.tsx b/app/home/[id]/WorkingSpacePageClient.tsx index 7ef2e35b..be350815 100644 --- a/app/home/[id]/WorkingSpacePageClient.tsx +++ b/app/home/[id]/WorkingSpacePageClient.tsx @@ -2,15 +2,12 @@ import { Calendar, Check, - ExternalLink, FileText, LayoutGrid, List, - MoreVertical, Search, ChevronLeft, ChevronRight, - Trash2, X, } from "lucide-react"; import { useMediaQuery } from "react-responsive"; @@ -24,6 +21,7 @@ import { z } from "zod"; import MaxWContainer from "@/components/ui/MaxWContainer"; import CreateTableBtn from "@/components/home-components/CreateTableBtn"; import CreateNoteBtn from "@/components/home-components/CreateNoteBtn"; +import PdfSettings from "@/components/home-components/PdfSettings"; import TableSettings from "@/components/home-components/TableSettings"; import NoteSettings from "@/components/home-components/NoteSettings"; import TablesNotFound from "@/components/home-components/TablesNotFound"; @@ -62,13 +60,6 @@ import { TooltipProvider, TooltipTrigger, } from "@/components/ui/tooltip"; -import { - DropdownMenu, - DropdownMenuContent, - DropdownMenuItem, - DropdownMenuSeparator, - DropdownMenuTrigger, -} from "@/components/ui/dropdown-menu"; import { cn, formatTableName } from "@/lib/utils"; import { extractTextFromTiptap as parseTiptapContentExtractText, @@ -131,6 +122,7 @@ interface PdfItem { _id: Id<"pdfs">; storageId: Id<"_storage">; title?: string; + favorite?: boolean; userId?: Id<"users">; workingSpaceId?: Id<"workingSpaces">; notesTableId?: Id<"notesTables">; @@ -899,7 +891,12 @@ export function NotesDroppableContainer({ ); const notDeletedItems = [...noteItems, ...pdfItems] .filter((item) => item && !deletedItemIds.has(item._id)) - .sort((a, b) => b.updatedAt - a.updatedAt); + .sort((a, b) => { + const aPinned = a.favorite ? 1 : 0; + const bPinned = b.favorite ? 1 : 0; + if (aPinned !== bPinned) return bPinned - aPinned; + return b.updatedAt - a.updatedAt; + }); if (!searchQuery.trim()) return notDeletedItems; const q = searchQuery.toLowerCase(); @@ -1352,90 +1349,6 @@ function ListNoteCard({ note, workspaceId, onDelete }: NoteCardProps) { ); } -function PdfCardMenu({ pdf, onDelete }: PdfCardProps) { - const [open, setOpen] = useState(false); - const [isAlertOpen, setIsAlertOpen] = useState(false); - const deletePdf = useMutation(api.pdfs.deletePdf); - const pdfSlug = generateSlug(pdf.title || "untitled-pdf"); - const pdfHref = `/home/${pdf.workingSpaceId}/pdf/${pdfSlug}?pdfId=${pdf._id}`; - - const handleDelete = useCallback(async () => { - if (onDelete) onDelete(pdf._id); - setIsAlertOpen(false); - try { - await deletePdf({ _id: pdf._id }); - } catch (error) { - console.error("Failed to delete PDF:", error); - } - }, [deletePdf, onDelete, pdf._id]); - - return ( - <> - - - - - - - - - Open upload - - - {pdf.fileUrl ? ( - - - - Original file - - - ) : null} - - { - setOpen(false); - setIsAlertOpen(true); - }} - className="text-destructive focus:text-destructive" - > - - Delete upload - - - - - - - - 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 - - - - - - ); -} - function PdfGridCard({ pdf, onDelete }: PdfCardProps) { const [isEditingTitle, setIsEditingTitle] = useState(false); const [editedTitle, setEditedTitle] = useState(pdf.title || "Untitled"); @@ -1516,7 +1429,14 @@ function PdfGridCard({ pdf, onDelete }: PdfCardProps) { {pdf.title || "Untitled"} )} - + @@ -1646,7 +1566,15 @@ function PdfListCard({ pdf, onDelete }: PdfCardProps) { )} - + + + +
+
+ {Array.from({ length: totalPages }, (_, index) => { + const pageNumber = index + 1; + const isActive = pageNumber === currentPage; + + return ( +
+ + {pageNumber} + +
+ +
+
+ ); + })} +
+
+ + ); +} + function ZoomDropdown() { const zoom = usePdf((state) => state.zoom); const updateZoom = usePdf((state) => state.updateZoom); @@ -324,15 +398,11 @@ function ZoomDropdown() { ); } -function PageNavigator({ - currentPageOverride, -}: { - currentPageOverride?: number; -}) { +function PageNavigator() { const currentPageFromStore = usePdf((state) => state.currentPage); const totalPages = usePdf((state) => state.pdfDocumentProxy.numPages); const { jumpToPage } = usePdfJump(); - const currentPage = currentPageOverride ?? currentPageFromStore; + const currentPage = currentPageFromStore; const [pageInput, setPageInput] = useState(String(currentPage)); useEffect(() => { @@ -414,58 +484,7 @@ function PdfViewerContent({ title: string; }) { const [query, setQuery] = useState(""); - const [isSearchOpen, setIsSearchOpen] = useState(true); - const zoom = usePdf((state) => state.zoom); - const viewports = usePdf((state) => state.viewports); - const currentPageFromStore = usePdf((state) => state.currentPage); - const viewportRef = usePdf((state) => state.viewportRef); - const [scrollOffset, setScrollOffset] = useState(0); - const [viewportHeight, setViewportHeight] = useState(0); - - useEffect(() => { - const viewport = viewportRef?.current; - if (!viewport) return; - - const updateHeight = () => { - setViewportHeight(viewport.clientHeight); - }; - - updateHeight(); - - const observer = new ResizeObserver(() => { - updateHeight(); - }); - - observer.observe(viewport); - - return () => observer.disconnect(); - }, [viewportRef]); - - const reactiveCurrentPage = useMemo(() => { - if (!viewports.length || !viewportHeight || zoom <= 0) { - return currentPageFromStore; - } - - const viewportCenter = scrollOffset / zoom + viewportHeight / zoom / 2; - let bestPage = 1; - let smallestDistance = Number.POSITIVE_INFINITY; - let pageStart = 0; - - for (let index = 0; index < viewports.length; index += 1) { - const pageSize = viewports[index].height + PDF_PAGE_GAP; - const pageCenter = pageStart + pageSize / 2; - const distance = Math.abs(pageCenter - viewportCenter); - - if (distance < smallestDistance) { - smallestDistance = distance; - bestPage = index + 1; - } - - pageStart += pageSize; - } - - return bestPage; - }, [currentPageFromStore, scrollOffset, viewportHeight, viewports, zoom]); + const [panelMode, setPanelMode] = useState("search"); return ( } > -
-
-
+
+
+
+
- +
- {isSearchOpen ? ( + {panelMode === "search" ? (
setIsSearchOpen(false)} + onClose={() => setPanelMode(null)} />
) : null} + {panelMode === "thumbnails" ? ( +
+ setPanelMode(null)} /> +
+ ) : null} + @@ -532,7 +579,7 @@ function PdfViewerShell({ Loading PDF... @@ -625,7 +672,10 @@ export default function PdfViewerPageClient({ pdfId }: { pdfId: Id<"pdfs"> }) { } return ( -
+
{isViewerReady && hasMeasuredViewport ? ( ; + pathname: string; + open: boolean; +} + +const PinnedUploadItem = memo( + function PinnedUploadItem({ pdf, pathname, open }: PinnedUploadItemProps) { + const [isHovered, setIsHovered] = useState(false); + const [isEditing, setIsEditing] = useState(false); + const [editedTitle, setEditedTitle] = useState(pdf.title || "Untitled"); + const updatePdf = useMutation(api.pdfs.updatePdf); + const inputRef = useRef(null); + + const pdfSlug = generateSlug(pdf.title || "untitled-pdf"); + const pdfPath = `/home/${pdf.workingSpaceId}/pdf/${pdfSlug}`; + const pdfHref = `${pdfPath}?pdfId=${pdf._id}`; + const isActive = pathname === pdfPath; + + const handleContentMouseEnter = useCallback(() => { + setIsHovered(true); + }, []); + + const handleContentMouseLeave = useCallback(() => { + setIsHovered(false); + }, []); + + const handleDoubleClick = useCallback(() => { + setIsEditing(true); + setEditedTitle(pdf.title || "Untitled"); + requestAnimationFrame(() => { + const input = inputRef.current; + if (!input) return; + input.focus(); + input.select(); + input.scrollLeft = 0; + }); + }, [pdf.title]); + + const handleInputChange = useCallback( + (e: React.ChangeEvent) => { + setEditedTitle(e.target.value); + }, + [], + ); + + const handleInputBlur = useCallback(async () => { + const currentTitle = pdf.title || "Untitled"; + const result = noteTitleSchema.safeParse(editedTitle.trim()); + + if (!result.success) { + setEditedTitle(currentTitle); + setIsEditing(false); + return; + } + + const trimmedTitle = result.data; + + if (trimmedTitle !== currentTitle) { + try { + await updatePdf({ + _id: pdf._id, + title: trimmedTitle, + }); + } catch (error) { + console.error("Error updating PDF title:", error); + setEditedTitle(currentTitle); + } + } + setIsEditing(false); + }, [editedTitle, pdf._id, pdf.title, updatePdf]); + + const handleInputKeyPress = useCallback( + (e: React.KeyboardEvent) => { + if (e.key === "Enter") { + inputRef.current?.blur(); + } else if (e.key === "Escape") { + setIsEditing(false); + setEditedTitle(pdf.title || "Untitled"); + } + }, + [pdf.title], + ); + + const textClassName = isHovered + ? "truncate flex-grow bg-gradient-to-r from-foreground from-50% via-transparent via-75% to-transparent to-100% text-transparent bg-clip-text" + : "truncate flex-grow"; + + return ( + + + + {isEditing ? ( + + ) : ( + + + + + + + {pdf.title || "Untitled"} + + + + )} + + +
+ +
+
+ ); + }, + (prevProps, nextProps) => { + return ( + prevProps.pdf.favorite === nextProps.pdf.favorite && + prevProps.pdf.title === nextProps.pdf.title && + prevProps.pathname === nextProps.pathname && + prevProps.open === nextProps.open + ); + }, +); + +interface PinnedUploadsListProps { + favoritePdfs: Doc<"pdfs">[]; + pathname: string; + open: boolean; + status: "LoadingFirstPage" | "CanLoadMore" | "LoadingMore" | "Exhausted"; + loadMore: (numItems: number) => void; +} + +const PinnedUploadsList = memo(function PinnedUploadsList({ + favoritePdfs, + pathname, + open, + status, + loadMore, +}: PinnedUploadsListProps) { + if (status === "LoadingFirstPage") { + return ( + + + Pinned Uploads + + + + + + + + + + + + + + + + ); + } + + if (favoritePdfs.length === 0) { + return null; + } + + return ( + + + Pinned Uploads + + {favoritePdfs.map((pdf) => ( + + ))} + + {favoritePdfs.length > 4 && status === "CanLoadMore" && ( + + + + )} + + {favoritePdfs.length > 4 && status === "LoadingMore" && ( + + + + )} + + ); +}); + interface WorkspaceItemProps { workingSpace: Doc<"workingSpaces">; pathname: string; @@ -982,6 +1242,11 @@ const AppSidebar = React.memo(function AppSidebar() { {}, { initialNumItems: 5 }, ); + const { + results: favoritePdfs, + status: favoritePdfsStatus, + loadMore: loadMorePdfs, + } = usePaginatedQuery(api.pdfs.getFavPdfs, {}, { initialNumItems: 5 }); const createTable = useMutation( api.notesTables.createTable, ).withOptimisticUpdate((local, args) => { @@ -1041,7 +1306,7 @@ const AppSidebar = React.memo(function AppSidebar() { setHasMoreBelow( overflow && el.scrollTop + el.clientHeight < el.scrollHeight - 8, ); - }, [results?.length, getWorkingSpaces?.length]); + }, [results?.length, favoritePdfs?.length, getWorkingSpaces?.length]); const isSidebarLoading = useMemo( () => getWorkingSpaces === undefined || User === undefined, @@ -1151,6 +1416,13 @@ const AppSidebar = React.memo(function AppSidebar() { status={status} loadMore={loadMore} /> + { setIsUploading(true); try { - const tableId = await createTable({ + const tableId = await getOrCreateTable({ name: DEFAULT_TABLE_NAME, workingSpaceId, }); @@ -252,7 +252,7 @@ export default function GlobalFolderDropUpload() { setIsWorkspaceDialogOpen(false); } }, - [createTable, generateUploadUrl, router, sendPdf, toast], + [generateUploadUrl, getOrCreateTable, router, sendPdf, toast], ); const resolveSingleWorkspaceUpload = useCallback( @@ -363,8 +363,8 @@ export default function GlobalFolderDropUpload() {

- We'll create a new table named "{DEFAULT_TABLE_NAME}"
{" "} - and add the PDFs there. + We'll use the "{DEFAULT_TABLE_NAME}" table if it already + exists, or create it and add the PDFs there.

@@ -402,7 +402,8 @@ export default function GlobalFolderDropUpload() { {pendingUpload?.folderName ?? "this folder"}
- We'll create a table named "{DEFAULT_TABLE_NAME}" there. + We'll use the "{DEFAULT_TABLE_NAME}" table there if it + already exists, or create it for these PDFs. diff --git a/components/home-components/HomeClientLayout.tsx b/components/home-components/HomeClientLayout.tsx index e1380ab0..926f3722 100644 --- a/components/home-components/HomeClientLayout.tsx +++ b/components/home-components/HomeClientLayout.tsx @@ -17,10 +17,13 @@ import BreadcrumbWithCustomSeparator from "@/components/home-components/Breadcru import GlobalFolderDropUpload from "@/components/home-components/GlobalFolderDropUpload"; import { MobileWarning } from "@/components/ui/mobile-warning"; import NoteSettings from "@/components/home-components/NoteSettings"; +import PdfSettings from "@/components/home-components/PdfSettings"; import SearchDialog from "@/components/home-components/SearchDialog"; import { usePathname, useSearchParams } from "next/navigation"; import type { Id } from "@/convex/_generated/dataModel"; import { parseSlug } from "@/lib/parseSlug"; +import { useQuery } from "@/cache/useQuery"; +import { api } from "@/convex/_generated/api"; import PublicNote from "../PublicNote"; import { motion } from "framer-motion"; import { NOISE_PNG } from "@/lib/data"; @@ -59,8 +62,13 @@ const HomeContent = memo(({ children }: { children: ReactNode }) => { const searchParams = useSearchParams(); const pathSegments = pathname.split("/").filter((segment) => segment); const noteid = searchParams.get("id") as Id<"notes">; + const pdfId = searchParams.get("pdfId") as Id<"pdfs"> | null; const noteTitle = parseSlug(`${pathSegments[2]}`); const isPdfRoute = pathSegments[2] === "pdf"; + const currentPdf = useQuery( + api.pdfs.getPdfById, + pdfId ? { _id: pdfId } : "skip", + ); const showTopFade = scrollTop > 0; @@ -82,7 +90,7 @@ const HomeContent = memo(({ children }: { children: ReactNode }) => { }} />
@@ -93,7 +101,7 @@ const HomeContent = memo(({ children }: { children: ReactNode }) => {
- {noteid && noteTitle && ( + {!isPdfRoute && noteid && noteTitle && ( { /> )} + {isPdfRoute && currentPdf && ( + + + + )}
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 ( + + + +

+ Move upload +

+ + {pdf.title || "Untitled"} + + + Select a destination table in this workspace or any other workspace. + +
+
+
+ + setQuery(event.target.value)} + placeholder="Search workspaces and tables..." + className="border-none px-0 shadow-none focus-visible:ring-0 focus-visible:ring-offset-0 text-sm bg-transparent" + /> +
+ + +
+ +
+ {moveTargets === undefined ? ( +
+ {Array.from({ length: 6 }).map((_, index) => ( +
+ ))} +
+ ) : !hasResults ? ( +
+ +

+ {debouncedQuery + ? `No targets found for "${debouncedQuery}"` + : "No workspaces found"} +

+

+ Create a workspace or table, then move this upload there. +

+
+ ) : ( +
+ {moveTargets.map((workspace) => { + const workspaceId = String(workspace._id); + const isExpanded = expandedWorkspaceIds.includes(workspaceId); + const tables = workspace.tables ?? []; + return ( +
+ + + {isExpanded && ( +
+ {tables.length === 0 ? ( +
+ No tables in this workspace yet. + { + setExpandedWorkspaceIds((prev) => + prev.includes(workspaceId) + ? prev + : [...prev, workspaceId], + ); + }} + /> +
+ ) : ( + tables.map((table: any) => { + const isCurrentTarget = + pdf.workingSpaceId === workspace._id && + pdf.notesTableId === table._id; + const isMoving = + movingTableId === String(table._id); + + 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(), })