Added PDF merge preview feature before Download#27
Conversation
|
@sahasra2212 is attempting to deploy a commit to the jhasourav07's projects Team on Vercel. A member of the Team first needs to authorize it. |
There was a problem hiding this comment.
Pull request overview
This PR updates the Merge PDF tool flow to generate an in-app merged PDF preview (via a Blob URL + iframe) before the user downloads the final file, instead of auto-downloading immediately.
Changes:
- Replace auto-download after merge with a “Generate Preview” step and an embedded iframe preview + manual download.
- Allow adding more PDFs after initial selection and reset the merged preview when new files are added.
- Simplify the Merge page UI and state handling around selected items and thumbnail rendering.
💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.
| try { | ||
| setIsProcessing(true); | ||
| setError(null); | ||
| setDone(false); | ||
| const mergedBlob = await mergePdfs(items.map((it) => it.file)); | ||
| const url = URL.createObjectURL(mergedBlob); | ||
| const link = document.createElement("a"); | ||
| link.href = url; | ||
| link.download = `QuickPDF_Merged_${Date.now()}.pdf`; | ||
| document.body.appendChild(link); | ||
| link.click(); | ||
| document.body.removeChild(link); | ||
| URL.revokeObjectURL(url); | ||
| await incrementUsage(); | ||
| const blob = await mergePdfs(items.map(i => i.file)); | ||
| const url = URL.createObjectURL(blob); | ||
|
|
||
| setMergedPreviewUrl(url); | ||
| setDone(true); | ||
| } catch { | ||
| setError("An error occurred while merging the PDFs. Please try again."); | ||
| alert("Error merging PDFs"); | ||
| } finally { |
There was a problem hiding this comment.
Error handling in handleMerge uses a blocking alert() and discards the actual error. The rest of the app surfaces errors inline via component state; consider restoring an error state and rendering it in the page so failures are non-blocking and consistent.
| {item.thumb ? ( | ||
| <img src={item.thumb} alt="" /> | ||
| ) : ( | ||
| <Loader2 className="animate-spin" /> | ||
| )} | ||
| <p className="text-xs truncate">{item.name}</p> | ||
| </div> | ||
| ))} | ||
| </div> | ||
|
|
||
| {/* Inline add-more zone */} | ||
| {/* Drop more */} | ||
| <div | ||
| onClick={() => fileInputRef.current?.click()} | ||
| className="flex items-center justify-center h-24 border-2 border-dashed border-white/10 rounded-2xl text-zinc-600 hover:border-white/25 hover:text-zinc-400 transition-all cursor-pointer text-sm gap-2" | ||
| className="border-2 border-dashed border-white/20 p-6 text-center text-zinc-400 cursor-pointer mb-6" | ||
| > | ||
| <Plus className="w-4 h-4" /> Drop more PDFs or click to add | ||
| + Drop more PDFs or click to add | ||
| </div> | ||
| </motion.div> | ||
| </> | ||
| )} | ||
|
|
||
| {/* Sticky bottom bar */} | ||
| {/* Button */} | ||
| {items.length > 0 && ( | ||
| <div className="fixed bottom-0 inset-x-0 z-40 bg-black/90 backdrop-blur-md border-t border-white/[0.08] px-4 py-3 flex items-center gap-4"> | ||
| <div className="flex-1 min-w-0"> | ||
| <p className="text-xs text-zinc-500">{items.length} PDF{items.length !== 1 ? "s" : ""} selected</p> | ||
| {items.length < 2 | ||
| ? <p className="text-xs text-zinc-600">Add at least 2 PDFs to merge</p> | ||
| : <p className="text-xs text-white font-medium">Order matches the grid — drag cards to rearrange</p> | ||
| } | ||
| </div> | ||
|
|
||
| <button | ||
| onClick={clearAll} | ||
| className="shrink-0 text-zinc-500 hover:text-white transition-colors text-xs underline underline-offset-4 whitespace-nowrap" | ||
| > | ||
| Clear all | ||
| </button> | ||
|
|
||
| {isLocked ? ( | ||
| <UpgradeButton | ||
| reason={lockReason} | ||
| limitLabel={lockLabel} | ||
| isWalletConnected={isWalletConnected} | ||
| isPremium={isPremium} | ||
| className="shrink-0 h-11 px-5 text-sm" | ||
| /> | ||
| <Button onClick={handleMerge} disabled={isProcessing}> | ||
| {isProcessing ? ( | ||
| <> | ||
| <Loader2 className="animate-spin mr-2" /> | ||
| Generating... | ||
| </> | ||
| ) : done ? ( | ||
| <> | ||
| <CheckCircle2 className="mr-2 text-green-500" /> | ||
| Preview Ready! | ||
| </> | ||
| ) : ( | ||
| <Button | ||
| onClick={handleMerge} | ||
| disabled={items.length < 2 || isProcessing} | ||
| className="shrink-0 h-11 px-6 text-sm font-bold rounded-xl bg-white text-black hover:bg-zinc-200 transition-all active:scale-[0.98] shadow-xl disabled:opacity-40" | ||
| > | ||
| {isProcessing | ||
| ? <><Loader2 className="animate-spin mr-2 w-4 h-4" />Merging…</> | ||
| : done | ||
| ? <><CheckCircle2 className="mr-2 w-4 h-4 text-emerald-500" />Downloaded!</> | ||
| : <><Download className="mr-2 w-4 h-4" />Merge & Download</> | ||
| } | ||
| </Button> | ||
| <> | ||
| <Download className="mr-2" /> | ||
| Generate Preview | ||
| </> | ||
| )} | ||
| </div> | ||
| </Button> | ||
| )} | ||
|
|
||
| {/* Preview modal */} | ||
| {previewItem && ( | ||
| <PreviewModal item={previewItem} onClose={() => setPreviewItem(null)} /> | ||
| {/* Preview */} | ||
| {mergedPreviewUrl && ( | ||
| <div className="mt-6"> | ||
| <iframe | ||
| src={mergedPreviewUrl} | ||
| className="w-full h-[400px] bg-white rounded" | ||
| /> |
There was a problem hiding this comment.
Accessibility: thumbnails render with alt="" and the preview iframe has no title. Use a descriptive alt (e.g., the file name) and add a title to the iframe so screen readers can identify the content.
| <a href={mergedPreviewUrl} download="merged.pdf"> | ||
| <Button className="mt-3"> | ||
| <Download className="mr-2" /> | ||
| Download PDF | ||
| </Button> | ||
| </a> |
There was a problem hiding this comment.
The download UI nests a (Button renders a native button) inside an , which is invalid HTML and creates nested interactive elements. Prefer making the Button trigger a programmatic download, or render a styled element instead of Button for the download link.
| <a href={mergedPreviewUrl} download="merged.pdf"> | |
| <Button className="mt-3"> | |
| <Download className="mr-2" /> | |
| Download PDF | |
| </Button> | |
| </a> | |
| <Button | |
| className="mt-3" | |
| onClick={() => { | |
| const link = document.createElement("a"); | |
| link.href = mergedPreviewUrl; | |
| link.download = "merged.pdf"; | |
| document.body.appendChild(link); | |
| link.click(); | |
| document.body.removeChild(link); | |
| }} | |
| > | |
| <Download className="mr-2" /> | |
| Download PDF | |
| </Button> |
| import React, { useState, useRef, useCallback, useEffect } from "react"; | ||
| import { useFileStore } from "../../hooks/useFileStore"; | ||
| import { | ||
| Layers, X, Download, Loader2, Trash2, GripVertical, | ||
| Plus, Eye, EyeOff, CheckCircle2, FileText, | ||
| Layers, X, Download, Loader2, Trash2, | ||
| Plus, CheckCircle2, FileText, | ||
| } from "lucide-react"; | ||
| import { motion, AnimatePresence } from "framer-motion"; | ||
| import { Button } from "../../components/ui/Button"; | ||
| import { UpgradeButton } from "../../components/ui/UpgradeButton"; | ||
| import { mergePdfs } from "../../services/pdf.service"; | ||
| import { Dropzone } from "../../components/pdf/Dropzone"; | ||
| import { formatFileSize } from "../../utils/formatters"; | ||
| import { useSubscription } from "../../hooks/useSubscription"; | ||
| import { FREE_LIMITS } from "../../config/limits"; | ||
| import { Button } from "../../components/ui/Button"; |
There was a problem hiding this comment.
Several imports are unused now (useFileStore, Layers, X, Trash2, Plus, FileText, motion, AnimatePresence). This increases bundle size and may fail lint/CI. Please remove unused imports or reintroduce the related functionality.
| {items.length > 0 && ( | ||
| <div className="fixed bottom-0 inset-x-0 z-40 bg-black/90 backdrop-blur-md border-t border-white/[0.08] px-4 py-3 flex items-center gap-4"> | ||
| <div className="flex-1 min-w-0"> | ||
| <p className="text-xs text-zinc-500">{items.length} PDF{items.length !== 1 ? "s" : ""} selected</p> | ||
| {items.length < 2 | ||
| ? <p className="text-xs text-zinc-600">Add at least 2 PDFs to merge</p> | ||
| : <p className="text-xs text-white font-medium">Order matches the grid — drag cards to rearrange</p> | ||
| } | ||
| </div> | ||
|
|
||
| <button | ||
| onClick={clearAll} | ||
| className="shrink-0 text-zinc-500 hover:text-white transition-colors text-xs underline underline-offset-4 whitespace-nowrap" | ||
| > | ||
| Clear all | ||
| </button> | ||
|
|
||
| {isLocked ? ( | ||
| <UpgradeButton | ||
| reason={lockReason} | ||
| limitLabel={lockLabel} | ||
| isWalletConnected={isWalletConnected} | ||
| isPremium={isPremium} | ||
| className="shrink-0 h-11 px-5 text-sm" | ||
| /> | ||
| <Button onClick={handleMerge} disabled={isProcessing}> | ||
| {isProcessing ? ( | ||
| <> | ||
| <Loader2 className="animate-spin mr-2" /> | ||
| Generating... | ||
| </> | ||
| ) : done ? ( | ||
| <> | ||
| <CheckCircle2 className="mr-2 text-green-500" /> | ||
| Preview Ready! | ||
| </> | ||
| ) : ( | ||
| <Button | ||
| onClick={handleMerge} | ||
| disabled={items.length < 2 || isProcessing} | ||
| className="shrink-0 h-11 px-6 text-sm font-bold rounded-xl bg-white text-black hover:bg-zinc-200 transition-all active:scale-[0.98] shadow-xl disabled:opacity-40" | ||
| > | ||
| {isProcessing | ||
| ? <><Loader2 className="animate-spin mr-2 w-4 h-4" />Merging…</> | ||
| : done | ||
| ? <><CheckCircle2 className="mr-2 w-4 h-4 text-emerald-500" />Downloaded!</> | ||
| : <><Download className="mr-2 w-4 h-4" />Merge & Download</> | ||
| } | ||
| </Button> | ||
| <> | ||
| <Download className="mr-2" /> | ||
| Generate Preview | ||
| </> | ||
| )} | ||
| </div> | ||
| </Button> |
There was a problem hiding this comment.
The “Generate Preview” button remains enabled when fewer than 2 PDFs are selected; clicking it becomes a no-op due to the early return in handleMerge. Disable the button (and/or show helper text) until at least 2 PDFs are selected to avoid confusing UX.
| const addFiles = useCallback(async (files) => { | ||
| const valid = files.filter(f => f.type === "application/pdf"); | ||
|
|
||
| const newItems = valid.map(file => ({ | ||
| id: makeId(), | ||
| file, | ||
| name: file.name, | ||
| size: file.size, | ||
| thumb: null, | ||
| numPages: null, | ||
| loadingThumb: true, | ||
| loading: true | ||
| })); |
There was a problem hiding this comment.
addFiles filters strictly on file.type === "application/pdf". Drag-and-dropped PDFs sometimes have an empty/unknown MIME type, so valid PDFs can be ignored with no feedback. Consider accepting by extension as a fallback and/or showing an inline message when files are skipped.
| {/* Grid */} | ||
| <div className="grid grid-cols-2 sm:grid-cols-3 gap-4 mb-6"> | ||
| {items.map(item => ( | ||
| <div key={item.id} className="bg-zinc-800 p-2 rounded"> | ||
| {item.thumb ? ( | ||
| <img src={item.thumb} alt="" /> | ||
| ) : ( | ||
| <Loader2 className="animate-spin" /> | ||
| )} | ||
| <p className="text-xs truncate">{item.name}</p> | ||
| </div> | ||
| ))} | ||
| </div> |
There was a problem hiding this comment.
The item grid removed reordering/removal controls and per-file preview, which conflicts with the PR description (“Reordered files using drag-and-drop”) and the previous Merge UX implied by the deleted code. If those features are still desired, they should be preserved while adding the merged-output preview step.
| ) : ( | ||
| <Loader2 className="animate-spin" /> |
There was a problem hiding this comment.
If renderFirstPage fails, thumb stays null but loading is set to false; the grid currently renders a spinner whenever thumb is falsy, so failures will show an infinite loading indicator. Use item.loading to decide when to show the spinner, and render a placeholder state when thumbnail generation fails.
| ) : ( | |
| <Loader2 className="animate-spin" /> | |
| ) : item.loading ? ( | |
| <div className="flex items-center justify-center h-24"> | |
| <Loader2 className="animate-spin" /> | |
| </div> | |
| ) : ( | |
| <div className="flex flex-col items-center justify-center h-24 text-zinc-400"> | |
| <FileText className="mb-1" /> | |
| <span className="text-xs">Preview unavailable</span> | |
| </div> |
| // ─── Main Component ──────────────────────────────────────────────────────────── | ||
| export function Merge() { | ||
| const [items, setItems] = useFileStore("Merge_items", []); | ||
| const [items, setItems] = useState([]); |
There was a problem hiding this comment.
Merge now uses local component state for selected PDFs, but the rest of the tools persist selected files via useFileStore (IndexedDB) so refresh/navigation doesn’t wipe progress. Consider switching back to useFileStore for consistency and to preserve the previous behavior for Merge as well.
| const [items, setItems] = useState([]); | |
| const [items, setItems] = useFileStore("merge-items", []); |
| // merge | ||
| const handleMerge = async () => { | ||
| if (items.length < 2) return; | ||
|
|
||
| function clearAll() { | ||
| setItems([]); | ||
| setError(null); | ||
| setIsProcessing(true); | ||
| setDone(false); | ||
| } | ||
|
|
||
| // ── drag-and-drop reorder ── | ||
| function handleDragStart(id) { dragId.current = id; } | ||
| function handleDragEnter(id) { overId.current = id; } | ||
| function handleDragEnd() { | ||
| if (!dragId.current || !overId.current || dragId.current === overId.current) return; | ||
| setItems((prev) => { | ||
| const arr = [...prev]; | ||
| const from = arr.findIndex((it) => it.id === dragId.current); | ||
| const to = arr.findIndex((it) => it.id === overId.current); | ||
| const [moved] = arr.splice(from, 1); | ||
| arr.splice(to, 0, moved); | ||
| return arr; | ||
| }); | ||
| dragId.current = null; | ||
| overId.current = null; | ||
| } | ||
|
|
||
| // ── merge ── | ||
| const handleMerge = async () => { | ||
| if (items.length < 2) return; | ||
| try { | ||
| setIsProcessing(true); | ||
| setError(null); | ||
| setDone(false); | ||
| const mergedBlob = await mergePdfs(items.map((it) => it.file)); | ||
| const url = URL.createObjectURL(mergedBlob); | ||
| const link = document.createElement("a"); | ||
| link.href = url; | ||
| link.download = `QuickPDF_Merged_${Date.now()}.pdf`; | ||
| document.body.appendChild(link); | ||
| link.click(); | ||
| document.body.removeChild(link); | ||
| URL.revokeObjectURL(url); | ||
| await incrementUsage(); | ||
| const blob = await mergePdfs(items.map(i => i.file)); | ||
| const url = URL.createObjectURL(blob); | ||
|
|
||
| setMergedPreviewUrl(url); | ||
| setDone(true); | ||
| } catch { |
There was a problem hiding this comment.
The subscription/free-tier gating and usage tracking for Merge appears to have been removed (no useSubscription/FREE_LIMITS checks and no incrementUsage call after a successful merge). This likely breaks global request limits and file-count limits for non-premium users; other tools consistently enforce these.
|
Kindly address copilot comments |
#Merge PDF Preview Feature
#Changes Made
#Tested
Closes #25