feat: add batch processing with ZIP download to 6 PDF tools | NSoC'26#35
feat: add batch processing with ZIP download to 6 PDF tools | NSoC'26#35ayushlad9108 wants to merge 4 commits into
Conversation
|
@ayushlad9108 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 adds a reusable batch-processing mode (multi-file upload → sequential processing → ZIP download) to several PDF tool pages in QuickPDF, and adjusts pdfjs-dist to address a browser crash.
Changes:
- Added shared batch hook (
useBatchProcess) for sequential per-file processing and ZIP generation. - Added shared batch UI components (
BatchPanel,BatchToggle) and integrated them into 6 tool pages (Watermark, Compress, Grayscale, Page Numbers, Lock PDF, Rotate). - Downgraded
pdfjs-distto4.4.168.
Reviewed changes
Copilot reviewed 11 out of 12 changed files in this pull request and generated 17 comments.
Show a summary per file
| File | Description |
|---|---|
| src/pages/Watermark/Watermark.jsx | Adds batch toggle + batch panel + first-file preview in batch mode. |
| src/pages/Compress/Compress.jsx | Adds batch compression UI and ZIP download flow. |
| src/pages/Grayscale/Grayscale.jsx | Adds batch grayscale conversion UI and ZIP download flow + preview. |
| src/pages/PageNumbers/PageNumbers.jsx | Adds batch numbering UI and ZIP download flow + preview. |
| src/pages/LockPdf/LockPdf.jsx | Adds batch lock/encrypt UI and ZIP download flow. |
| src/pages/Rotate/Rotate.jsx | Adds batch fixed-angle rotation UI and ZIP download flow. |
| src/hooks/useBatchProcess.js | New reusable hook for batch processing and ZIP download. |
| src/components/pdf/BatchPanel.jsx | New shared batch UI: dropzone, file list, progress, preview, run button. |
| src/components/ui/BatchToggle.jsx | New shared “Single/Batch” toggle UI. |
| package.json | Downgrades pdfjs-dist to ^4.4.168. |
| package-lock.json | Lockfile updates from dependency change. |
| .env | Adds VITE_WALLETCONNECT_PROJECT_ID placeholder. |
💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.
| export function useBatchProcess(processFn, getOutputName) { | ||
| const [isBatchMode, setIsBatchMode] = useState(false); | ||
| const [batchFiles, setBatchFiles] = useState([]); | ||
| const [isProcessing, setIsProcessing] = useState(false); | ||
| const [progress, setProgress] = useState({ current: 0, total: 0 }); | ||
| const [error, setError] = useState(null); | ||
| const [done, setDone] = useState(false); | ||
|
|
||
| const addFiles = useCallback((incoming) => { | ||
| const pdfs = incoming.filter((f) => f.type === "application/pdf"); | ||
| if (pdfs.length === 0) { setError("Please upload valid PDF files."); return; } | ||
| setError(null); | ||
| setDone(false); | ||
| setBatchFiles((prev) => { | ||
| // deduplicate by name+size | ||
| const existing = new Set(prev.map((f) => `${f.name}-${f.size}`)); | ||
| const fresh = pdfs.filter((f) => !existing.has(`${f.name}-${f.size}`)); | ||
| return [...prev, ...fresh]; | ||
| }); | ||
| }, []); | ||
|
|
||
| const removeFile = useCallback((index) => { | ||
| setBatchFiles((prev) => prev.filter((_, i) => i !== index)); | ||
| setDone(false); | ||
| }, []); | ||
|
|
||
| const clearFiles = useCallback(() => { | ||
| setBatchFiles([]); | ||
| setDone(false); | ||
| setError(null); | ||
| setProgress({ current: 0, total: 0 }); | ||
| }, []); | ||
|
|
||
| /** | ||
| * Process all files with the given options and download as ZIP. | ||
| * Processes one file at a time to avoid memory spikes. | ||
| */ | ||
| const runBatch = useCallback(async (options = {}) => { | ||
| if (batchFiles.length === 0) return; | ||
| setIsProcessing(true); | ||
| setError(null); | ||
| setDone(false); | ||
| setProgress({ current: 0, total: batchFiles.length }); | ||
|
|
||
| const zip = new JSZip(); | ||
|
|
||
| try { | ||
| for (let i = 0; i < batchFiles.length; i++) { | ||
| const file = batchFiles[i]; | ||
| setProgress({ current: i + 1, total: batchFiles.length }); | ||
|
|
||
| // Give the browser and any WASM workers time to reset between files | ||
| await new Promise((r) => setTimeout(r, 150)); | ||
|
|
||
| const blob = await processFn(file, options); | ||
| const outputName = getOutputName(file.name); | ||
| zip.file(outputName, blob); | ||
| } | ||
|
|
||
| const zipBlob = await zip.generateAsync({ type: "blob" }); | ||
| const url = URL.createObjectURL(zipBlob); | ||
| const a = document.createElement("a"); | ||
| a.href = url; | ||
| a.download = `QuickPDF_Batch_${Date.now()}.zip`; | ||
| a.click(); | ||
| URL.revokeObjectURL(url); | ||
| setDone(true); | ||
| } catch (err) { | ||
| console.error(err); | ||
| setError("One or more files failed to process. They may be encrypted or corrupted."); | ||
| } finally { | ||
| setIsProcessing(false); | ||
| } | ||
| }, [batchFiles, processFn, getOutputName]); | ||
|
|
||
| return { | ||
| isBatchMode, setIsBatchMode, | ||
| batchFiles, addFiles, removeFile, clearFiles, | ||
| isProcessing, progress, error, done, | ||
| runBatch, | ||
| }; |
There was a problem hiding this comment.
Batch mode currently bypasses subscription enforcement: there is no way for callers to apply per-file size limits / global request limits, and there is no hook/callback to increment usage for each processed file. This means free users can run batch jobs even when hasReachedGlobalLimit is true, and usage counts won’t be updated. Consider extending useBatchProcess to accept a validator/guard (e.g., canProcess(file)), and an onAfterEach/onComplete callback so tool pages can enforce limits and increment usage consistently.
| {batch.isBatchMode ? ( | ||
| <div className="bg-[#0a0a0a] rounded-2xl border border-white/10 p-6 md:p-8 shadow-2xl"> | ||
| <div className="space-y-4 mb-6"> | ||
| <label className="block text-sm font-medium text-zinc-400">Watermark Text (applied to all files)</label> | ||
| <input | ||
| type="text" | ||
| value={watermarkText} | ||
| onChange={(e) => setWatermarkText(e.target.value)} | ||
| placeholder="e.g., CONFIDENTIAL" | ||
| className="w-full h-11 px-4 bg-black border border-white/10 text-white rounded-lg focus:ring-2 focus:ring-white/20 outline-none transition-all placeholder:text-zinc-600 uppercase" | ||
| /> | ||
| </div> | ||
| <BatchPanel | ||
| batchFiles={batch.batchFiles} | ||
| addFiles={handleBatchFilesAdded} | ||
| removeFile={(i) => { batch.removeFile(i); if (i === 0) setBatchPreviewUrl(null); }} | ||
| isProcessing={batch.isProcessing} | ||
| progress={batch.progress} | ||
| error={batch.error} | ||
| done={batch.done} | ||
| onRun={() => batch.runBatch({ watermarkText })} | ||
| runLabel="Watermark All & Download ZIP" | ||
| previewUrl={batchPreviewUrl} | ||
| /> | ||
| </div> | ||
| ) : ( |
There was a problem hiding this comment.
In batch mode, the page doesn’t enforce the same free-tier locks as single-file mode (isLocked / UpgradeButton) and doesn’t increment usage via incrementUsage. This likely allows free users to watermark unlimited files (including oversized PDFs) without consuming their request quota. Batch runs should apply the same gating rules and update usage counts (ideally per processed file).
| {batch.isBatchMode ? ( | |
| <div className="bg-[#0a0a0a] rounded-2xl border border-white/10 p-6 md:p-8 shadow-2xl"> | |
| <div className="space-y-4 mb-6"> | |
| <label className="block text-sm font-medium text-zinc-400">Watermark Text (applied to all files)</label> | |
| <input | |
| type="text" | |
| value={watermarkText} | |
| onChange={(e) => setWatermarkText(e.target.value)} | |
| placeholder="e.g., CONFIDENTIAL" | |
| className="w-full h-11 px-4 bg-black border border-white/10 text-white rounded-lg focus:ring-2 focus:ring-white/20 outline-none transition-all placeholder:text-zinc-600 uppercase" | |
| /> | |
| </div> | |
| <BatchPanel | |
| batchFiles={batch.batchFiles} | |
| addFiles={handleBatchFilesAdded} | |
| removeFile={(i) => { batch.removeFile(i); if (i === 0) setBatchPreviewUrl(null); }} | |
| isProcessing={batch.isProcessing} | |
| progress={batch.progress} | |
| error={batch.error} | |
| done={batch.done} | |
| onRun={() => batch.runBatch({ watermarkText })} | |
| runLabel="Watermark All & Download ZIP" | |
| previewUrl={batchPreviewUrl} | |
| /> | |
| </div> | |
| ) : ( | |
| {batch.isBatchMode ? (() => { | |
| const batchHasOversizedFiles = !isPro && batch.batchFiles.some( | |
| (batchFile) => batchFile?.file?.size > mbToBytes(FREE_LIMITS.pdfSizeMB) | |
| ); | |
| const batchIsLocked = isLocked || batchHasOversizedFiles; | |
| return ( | |
| <div className="bg-[#0a0a0a] rounded-2xl border border-white/10 p-6 md:p-8 shadow-2xl"> | |
| <div className="space-y-4 mb-6"> | |
| <label className="block text-sm font-medium text-zinc-400">Watermark Text (applied to all files)</label> | |
| <input | |
| type="text" | |
| value={watermarkText} | |
| onChange={(e) => setWatermarkText(e.target.value)} | |
| placeholder="e.g., CONFIDENTIAL" | |
| className="w-full h-11 px-4 bg-black border border-white/10 text-white rounded-lg focus:ring-2 focus:ring-white/20 outline-none transition-all placeholder:text-zinc-600 uppercase" | |
| /> | |
| </div> | |
| {batchIsLocked && ( | |
| <div className="mb-6 space-y-3"> | |
| <div className="p-4 bg-amber-500/10 text-amber-300 rounded-lg text-sm border border-amber-500/20"> | |
| Free-tier limits apply to batch mode as well. Upgrade to process locked or oversized files. | |
| </div> | |
| <UpgradeButton text="Upgrade to watermark batch files" /> | |
| </div> | |
| )} | |
| <BatchPanel | |
| batchFiles={batch.batchFiles} | |
| addFiles={handleBatchFilesAdded} | |
| removeFile={(i) => { batch.removeFile(i); if (i === 0) setBatchPreviewUrl(null); }} | |
| isProcessing={batch.isProcessing} | |
| progress={batch.progress} | |
| error={batch.error} | |
| done={batch.done} | |
| onRun={async () => { | |
| if (batchIsLocked) return; | |
| await batch.runBatch({ watermarkText }); | |
| for (const batchFile of batch.batchFiles) { | |
| if (batchFile?.file) { | |
| await incrementUsage(); | |
| } | |
| } | |
| }} | |
| runLabel="Watermark All & Download ZIP" | |
| previewUrl={batchPreviewUrl} | |
| /> | |
| </div> | |
| ); | |
| })() : ( |
| const [batchPreviewUrl, setBatchPreviewUrl] = useState(null); | ||
|
|
||
| const batch = useBatchProcess( | ||
| (f) => convertToGrayscale(f, () => {}), | ||
| (name) => `Grayscale_${name}` | ||
| ); | ||
|
|
||
| const handleBatchFilesAdded = async (files) => { | ||
| batch.addFiles(files); | ||
| if (files.length > 0 && !batchPreviewUrl) { | ||
| try { | ||
| const blob = await convertToGrayscale(files[0], () => {}); | ||
| setBatchPreviewUrl(URL.createObjectURL(blob)); | ||
| } catch { /* preview is optional */ } | ||
| } | ||
| }; | ||
|
|
There was a problem hiding this comment.
handleBatchFilesAdded creates a preview object URL but never revokes it, which can accumulate memory across toggles/rerenders. Add cleanup to revoke the old URL when it changes/clears and on unmount.
| progress={batch.progress} | ||
| error={batch.error} | ||
| done={batch.done} | ||
| onRun={() => batch.runBatch({ position, fontSize, prefix, startNumber })} |
There was a problem hiding this comment.
Batch mode path doesn’t apply the same paywall/limits as single-file mode (isLocked and paywallReason) and doesn’t call incrementUsage. That can let free users process a batch even after the global limit is reached and without consuming quota. Consider enforcing the same checks for batch runs and incrementing usage for each processed file (or at least for the batch).
| onRun={() => batch.runBatch({ position, fontSize, prefix, startNumber })} | |
| onRun={async () => { | |
| if (isLocked) return; | |
| await batch.runBatch({ position, fontSize, prefix, startNumber }); | |
| for (let i = 0; i < batch.batchFiles.length; i += 1) { | |
| await incrementUsage(); | |
| } | |
| }} |
| progress={batch.progress} | ||
| error={batch.error || (!password || mismatch ? "Set a matching password before adding files." : null)} | ||
| done={batch.done} | ||
| onRun={() => password && password === confirm && batch.runBatch({ password })} |
There was a problem hiding this comment.
In the batch UI, the primary CTA remains enabled whenever batchFiles.length > 0, even if the password is empty or mismatched. Since onRun short-circuits, the click becomes a no-op (and the user may not understand why). Consider disabling the batch run button until the password is valid/matching, or have onRun set a clear error when validation fails.
| onRun={() => password && password === confirm && batch.runBatch({ password })} | |
| onRun={() => { | |
| if (!password || password !== confirm) { | |
| window.alert("Set a matching password before running the batch."); | |
| return; | |
| } | |
| batch.runBatch({ password }); | |
| }} |
| @@ -0,0 +1 @@ | |||
| VITE_WALLETCONNECT_PROJECT_ID=placeholder_project_id | |||
There was a problem hiding this comment.
Committing a .env file (even with a placeholder value) is risky: it can override deploy-time environment configuration and may accidentally leak real secrets in the future. Prefer removing .env from the repo, adding it to .gitignore, and committing a .env.example (or documenting required env vars) instead.
| VITE_WALLETCONNECT_PROJECT_ID=placeholder_project_id | |
| # Set VITE_WALLETCONNECT_PROJECT_ID in a local, untracked .env file or in your deployment environment. | |
| # Example: VITE_WALLETCONNECT_PROJECT_ID=your_walletconnect_project_id |
| function handleDrop(e) { | ||
| e.preventDefault(); | ||
| addFiles(Array.from(e.dataTransfer.files)); | ||
| } | ||
|
|
||
| function handleInput(e) { | ||
| addFiles(Array.from(e.target.files)); | ||
| e.target.value = ""; | ||
| } | ||
|
|
||
| return ( | ||
| <div className="space-y-6"> | ||
| {/* Drop zone */} | ||
| <label | ||
| onDragOver={(e) => e.preventDefault()} | ||
| onDrop={handleDrop} | ||
| className="flex flex-col items-center justify-center w-full h-36 border-2 border-dashed border-white/10 rounded-2xl cursor-pointer hover:border-white/25 hover:bg-white/[0.02] transition-all" | ||
| > | ||
| <PackageOpen className="w-8 h-8 text-zinc-600 mb-2" /> | ||
| <span className="text-sm text-zinc-500"> | ||
| Drop PDFs here or <span className="text-white underline underline-offset-2">browse</span> | ||
| </span> | ||
| <span className="text-xs text-zinc-700 mt-1">Multiple files supported</span> | ||
| <input | ||
| type="file" | ||
| accept={accept} | ||
| multiple | ||
| className="hidden" | ||
| onChange={handleInput} | ||
| disabled={isProcessing} | ||
| /> |
There was a problem hiding this comment.
BatchPanel disables the file input while isProcessing, but drag-and-drop still calls addFiles unconditionally. Users can drop files mid-run, which will update the UI list but those new files won’t be included in the currently running batch (since runBatch iterates over the snapshot of batchFiles at start). Consider ignoring drops/inputs while processing (or visually disabling the drop zone) to avoid this inconsistent state.
| a.click(); | ||
| URL.revokeObjectURL(url); |
There was a problem hiding this comment.
runBatch downloads a ZIP via an object URL and then revokes it immediately after a.click(). In some browsers, revoking synchronously can cancel or corrupt the download for large blobs. Consider appending the anchor to the DOM and revoking the URL in a setTimeout/next tick (or after requestAnimationFrame) to ensure the navigation has started.
| a.click(); | |
| URL.revokeObjectURL(url); | |
| document.body.appendChild(a); | |
| a.click(); | |
| setTimeout(() => { | |
| URL.revokeObjectURL(url); | |
| document.body.removeChild(a); | |
| }, 0); |
| {batch.isBatchMode ? ( | ||
| <div className="bg-[#0a0a0a] rounded-2xl border border-white/10 p-6 md:p-8 shadow-2xl"> | ||
| <div className="space-y-3 mb-6"> | ||
| <label className="block text-sm font-medium text-zinc-400 mb-2">Compression Level (applied to all files)</label> | ||
| <div className="grid sm:grid-cols-3 gap-4"> | ||
| {options.map((opt) => ( | ||
| <button key={opt.id} onClick={() => setLevel(opt.id)} | ||
| className={`flex flex-col items-center justify-center p-4 rounded-xl border transition-all ${level === opt.id ? "bg-white/10 border-white/30 text-white" : "bg-zinc-900/30 border-white/5 text-zinc-400 hover:bg-zinc-900/60 hover:border-white/15"}`} | ||
| > | ||
| <div className="mb-2">{opt.icon}</div> | ||
| <span className="font-semibold text-sm">{opt.label}</span> | ||
| <span className="text-xs opacity-70 mt-1">{opt.desc}</span> | ||
| </button> | ||
| ))} | ||
| </div> | ||
| </div> | ||
| <BatchPanel | ||
| batchFiles={batch.batchFiles} | ||
| addFiles={batch.addFiles} | ||
| removeFile={batch.removeFile} | ||
| isProcessing={batch.isProcessing} | ||
| progress={batch.progress} | ||
| error={batch.error} | ||
| done={batch.done} | ||
| onRun={() => batch.runBatch({ quality: options.find(o => o.id === level).val })} | ||
| runLabel="Compress All & Download ZIP" | ||
| /> |
There was a problem hiding this comment.
In batch mode, the free-tier gating (isLocked / UpgradeButton) and usage tracking (incrementUsage) are not applied. As written, a user who has reached the global free limit (or selects oversized PDFs) can still batch-compress, and their usage count won’t increment. Batch processing should enforce the same limits as single-file mode (ideally per file), and increment usage accordingly.
| )} | ||
| </p> | ||
| <div className="mt-4 flex justify-center"> | ||
| <BatchToggle isBatchMode={batch.isBatchMode} onChange={(v) => { batch.setIsBatchMode(v); setBatchPreviewUrl(null); batch.clearFiles(); }} disabled={isProcessing || batch.isProcessing} /> |
There was a problem hiding this comment.
Switching modes via BatchToggle clears batch state but leaves single-file state (file, previewUrl) untouched. If a user generated a single-file preview and then enables batch mode, previewUrl stays set which still triggers the wide layout (max-w-[1600px] / grid) even though the preview pane is hidden. Consider also clearing previewUrl (and possibly file) when entering batch mode to avoid inconsistent layout/state.
| <BatchToggle isBatchMode={batch.isBatchMode} onChange={(v) => { batch.setIsBatchMode(v); setBatchPreviewUrl(null); batch.clearFiles(); }} disabled={isProcessing || batch.isProcessing} /> | |
| <BatchToggle | |
| isBatchMode={batch.isBatchMode} | |
| onChange={(v) => { | |
| batch.setIsBatchMode(v); | |
| if (v) { | |
| setPreviewUrl(null); | |
| setFile(null); | |
| } | |
| setBatchPreviewUrl(null); | |
| batch.clearFiles(); | |
| }} | |
| disabled={isProcessing || batch.isProcessing} | |
| /> |
|
Addressed all review comments:
|
…service reuse, env handling
|
Hey @ayushlad9108, |
|
@JhaSourav07 Please check! |
There was a problem hiding this comment.
I am not sure why you did this
|
Write tests for the feature u implemented and dont remove the CI file from the repo, it is IMP for the project. run npm run test and npm run lint to see if there is any error if it passes i will review your PR. |
There was a problem hiding this comment.
Pull request overview
Copilot reviewed 13 out of 15 changed files in this pull request and generated 10 comments.
Comments suppressed due to low confidence (2)
.env:2
- Do not commit
.envfiles to the repository, even with placeholder values..gitignorewon’t stop an already-tracked.envfrom being committed/updated, and it risks accidental secret leakage later. Remove.envfrom the repo (and from git history if needed) and rely on.env.examplefor documentation.
VITE_WALLETCONNECT_PROJECT_ID=placeholder_project_id
.github/workflows/ci.yml:1
- This PR deletes the GitHub Actions CI workflow, which disables lint/test/build checks on pushes/PRs. Unless there’s a replacement workflow elsewhere, CI should remain enabled; consider restoring the workflow or moving it to a new filename/location rather than removing it entirely.
💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.
| setError(null); setFile(selectedFile); setPreviewUrl(null); | ||
| }; | ||
|
|
||
| const clearFile = () => { | ||
| setFile(null); | ||
| setError(null); | ||
| setPreviewUrl(null); | ||
| }; | ||
| const clearFile = () => { setFile(null); setError(null); setPreviewUrl(null); }; | ||
|
|
There was a problem hiding this comment.
clearFile / handleFileSelected set previewUrl to null without revoking the existing object URL first. This can leak Blob URLs when a user re-uploads or clears files. Revoke the current previewUrl before clearing/replacing it.
| const batch = useBatchProcess( | ||
| (f, opts) => compressWithQuality(f, opts.quality), | ||
| (name) => `QuickPDF_Compressed_${name}`, | ||
| { | ||
| canProcess: (f) => !hasReachedGlobalLimit && (isPremium || f.size <= mbToBytes(FREE_LIMITS.compress.maxFileSizeMb)), | ||
| onAfterEach: incrementUsage, | ||
| } | ||
| ); | ||
|
|
||
| const fileTooLarge = !isPremium && file && file.size > mbToBytes(FREE_LIMITS.compress.maxFileSizeMb); | ||
| const isLocked = hasReachedGlobalLimit || fileTooLarge; | ||
| const lockReason = hasReachedGlobalLimit ? "global" : "size"; | ||
| const lockLabel = fileTooLarge ? `${FREE_LIMITS.compress.maxFileSizeMb} MB` : undefined; | ||
|
|
||
| const options = [ | ||
| { id: "low", label: "High Quality", desc: "90% Quality", icon: <ShieldCheck className="w-5 h-5" />, val: 0.9 }, | ||
| { id: "recommended", label: "Balanced", desc: "60% Quality", icon: <Gauge className="w-5 h-5" />, val: 0.6 }, | ||
| { id: "extreme", label: "Extreme", desc: "30% Quality", icon: <Flame className="w-5 h-5" />, val: 0.3 }, | ||
| ]; | ||
|
|
||
| const handleFileSelected = (selectedFiles) => { | ||
| const selectedFile = selectedFiles[0]; | ||
| if (!selectedFile) return; | ||
| if (selectedFile.type !== "application/pdf") { setError("Please upload a valid PDF file."); return; } | ||
| setError(null); setFile(selectedFile); setResult(null); | ||
| }; | ||
|
|
||
| const clearFile = () => { setFile(null); setResult(null); setError(null); }; | ||
|
|
||
| const handleCompress = async () => { | ||
| setIsProcessing(true); setError(null); | ||
| try { | ||
| const selected = options.find((opt) => opt.id === level); | ||
| const blob = await compressWithQuality(file, selected.val); | ||
| const savings = Math.round(((file.size - blob.size) / file.size) * 100); | ||
| setResult({ blob, size: blob.size, savings: savings > 0 ? savings : 0 }); | ||
| await incrementUsage(); | ||
| } catch { | ||
| setError("Compression failed. The file might be encrypted or too large."); | ||
| } finally { | ||
| setIsProcessing(false); | ||
| } | ||
| }; | ||
|
|
||
| const handleDownload = () => { | ||
| if (!result) return; | ||
| const url = URL.createObjectURL(result.blob); | ||
| const a = document.createElement("a"); | ||
| a.href = url; a.download = `QuickPDF_Compressed_${file.name}`; a.click(); | ||
| URL.revokeObjectURL(url); | ||
| }; | ||
|
|
||
| return ( | ||
| <div className="max-w-3xl mx-auto py-12 px-4 sm:px-6"> | ||
| <div className="text-center mb-10"> | ||
| <div className="inline-flex items-center justify-center w-16 h-16 rounded-2xl bg-zinc-900 border border-white/10 text-white mb-4"> | ||
| <Minimize2 className="w-8 h-8" /> | ||
| </div> | ||
| <h1 className="text-4xl font-extrabold text-white mb-4">Compress PDF</h1> | ||
| <p className="text-lg text-zinc-400"> | ||
| Trade quality for portability. Reduce file size directly in your browser. | ||
| {!isPremium && ( | ||
| <span className="block text-sm text-zinc-600 mt-1"> | ||
| Free tier: files up to {FREE_LIMITS.compress.maxFileSizeMb} MB | ||
| </span> | ||
| )} | ||
| </p> | ||
| <div className="mt-4 flex justify-center"> | ||
| <BatchToggle isBatchMode={batch.isBatchMode} onChange={(v) => { batch.setIsBatchMode(v); batch.clearFiles(); }} disabled={isProcessing || batch.isProcessing} /> | ||
| </div> | ||
| </div> | ||
|
|
||
| <div className="bg-[#0a0a0a] rounded-2xl border border-white/10 p-6 md:p-8 shadow-2xl"> | ||
| {batch.isBatchMode ? ( | ||
| <div className="bg-[#0a0a0a] rounded-2xl border border-white/10 p-6 md:p-8 shadow-2xl"> | ||
| {isLocked && ( | ||
| <div className="mb-6"> | ||
| <UpgradeButton reason={lockReason} limitLabel={lockLabel} isWalletConnected={isWalletConnected} isPremium={isPremium} className="w-full" /> | ||
| </div> | ||
| )} | ||
| <div className="space-y-3 mb-6"> | ||
| <label className="block text-sm font-medium text-zinc-400 mb-2">Compression Level (applied to all files)</label> | ||
| <div className="grid sm:grid-cols-3 gap-4"> | ||
| {options.map((opt) => ( | ||
| <button key={opt.id} onClick={() => setLevel(opt.id)} | ||
| className={`flex flex-col items-center justify-center p-4 rounded-xl border transition-all ${level === opt.id ? "bg-white/10 border-white/30 text-white" : "bg-zinc-900/30 border-white/5 text-zinc-400 hover:bg-zinc-900/60 hover:border-white/15"}`} | ||
| > | ||
| <div className="mb-2">{opt.icon}</div> | ||
| <span className="font-semibold text-sm">{opt.label}</span> | ||
| <span className="text-xs opacity-70 mt-1">{opt.desc}</span> | ||
| </button> | ||
| ))} | ||
| </div> | ||
| </div> | ||
| <BatchPanel | ||
| batchFiles={batch.batchFiles} | ||
| addFiles={batch.addFiles} | ||
| removeFile={batch.removeFile} | ||
| isProcessing={batch.isProcessing} | ||
| progress={batch.progress} | ||
| error={batch.error} | ||
| done={batch.done} | ||
| onRun={() => batch.runBatch({ quality: options.find(o => o.id === level).val })} | ||
| runLabel="Compress All & Download ZIP" |
There was a problem hiding this comment.
Batch mode passes a quality option into compressWithQuality, but compressWithQuality in pdf.service.js currently only accepts a single file argument and ignores quality. As a result, different compression levels won’t actually change output in batch mode. Either update the service to respect the quality level or adjust the UI/hook wiring so the selected level is actually applied.
| <label | ||
| onDragOver={(e) => e.preventDefault()} | ||
| onDrop={handleDrop} | ||
| className={`flex flex-col items-center justify-center w-full h-36 border-2 border-dashed rounded-2xl transition-all | ||
| ${isProcessing | ||
| ? "border-white/5 opacity-40 cursor-not-allowed" | ||
| : "border-white/10 cursor-pointer hover:border-white/25 hover:bg-white/[0.02]"}`} | ||
| > | ||
| <PackageOpen className="w-8 h-8 text-zinc-600 mb-2" /> | ||
| <span className="text-sm text-zinc-500"> | ||
| Drop PDFs here or <span className="text-white underline underline-offset-2">browse</span> | ||
| </span> | ||
| <span className="text-xs text-zinc-700 mt-1">Multiple files supported</span> | ||
| <input | ||
| type="file" | ||
| accept={accept} | ||
| multiple | ||
| className="hidden" | ||
| onChange={handleInput} | ||
| disabled={isProcessing} | ||
| /> | ||
| </label> |
There was a problem hiding this comment.
The batch drop zone is a non-focusable <label> with a hidden file input, so keyboard-only users can’t activate “browse” to add files. Make the drop zone keyboard-accessible (e.g., add tabIndex=0 + role="button" + Enter/Space handlers that trigger the file input, or use a real <button> element to open the picker).
| {POSITIONS.map(({ value, label, Icon }) => ( | ||
| <button key={value} onClick={() => setPosition(value)} | ||
| className={`flex-1 flex flex-col items-center gap-1 py-2 rounded-xl border text-xs font-medium transition-all ${position === value ? "border-white/40 bg-white/8 text-white" : "border-white/[0.06] text-zinc-500 hover:text-white"}`}> | ||
| <Icon className="w-4 h-4" />{label} | ||
| </button> | ||
| ))} |
There was a problem hiding this comment.
POSITIONS items don’t include an Icon property, but batch mode renders <Icon ... />. When Icon is undefined this will crash with “Element type is invalid”. Either add the intended icons (e.g. AlignLeft/AlignCenter/AlignRight) to the POSITIONS array entries or remove the icon rendering here.
| const batch = useBatchProcess( | ||
| (f, opts) => lockPdf(f, opts.password), | ||
| (name) => `locked_${name}`, | ||
| { | ||
| canProcess: (f) => !hasReachedGlobalLimit && (isPremium || f.size <= mbToBytes(LIMIT_MB)), | ||
| onAfterEach: incrementUsage, | ||
| } | ||
| ); | ||
|
|
||
| const LIMIT_MB = FREE_LIMITS.lockPdf.maxFileSizeMb; |
There was a problem hiding this comment.
LIMIT_MB is referenced inside the useBatchProcess guard before it’s declared, which will throw a ReferenceError on initial render. Move the LIMIT_MB declaration above useBatchProcess(...) (or inline FREE_LIMITS.lockPdf.maxFileSizeMb).
| useEffect(() => { | ||
| return () => { | ||
| if (batchPreviewRef.current) URL.revokeObjectURL(batchPreviewRef.current); | ||
| if (previewUrl) URL.revokeObjectURL(previewUrl); | ||
| }; | ||
| }, []); // eslint-disable-line |
There was a problem hiding this comment.
The unmount cleanup effect closes over the initial previewUrl value because the dependency array is empty (and exhaustive-deps is suppressed). This means the latest object URL won’t be revoked on unmount, and you also won’t revoke the previous URL when a new preview is generated. Track previewUrl via a ref or add a separate useEffect that revokes the previous URL when previewUrl changes, without disabling the lint rule.
| try { | ||
| for (let i = 0; i < batchFiles.length; i++) { | ||
| const file = batchFiles[i]; | ||
| setProgress({ current: i + 1, total: batchFiles.length }); | ||
|
|
||
| // Per-file guard (size limit etc.) | ||
| if (guards.canProcess && !guards.canProcess(file)) { | ||
| setError(`"${file.name}" exceeds the free-tier size limit and was skipped.`); | ||
| continue; | ||
| } | ||
|
|
||
| // Yield to event loop — keeps UI responsive and lets WASM workers reset | ||
| await new Promise((r) => setTimeout(r, 150)); | ||
|
|
||
| const blob = await processFn(file, options); | ||
| zip.file(getOutputName(file.name), blob); | ||
|
|
||
| // Increment usage per file | ||
| if (guards.onAfterEach) await guards.onAfterEach(); | ||
| } | ||
|
|
||
| const zipBlob = await zip.generateAsync({ type: "blob" }); | ||
| const url = URL.createObjectURL(zipBlob); |
There was a problem hiding this comment.
If every file is skipped by guards.canProcess, the loop will continue and an empty ZIP will still be generated/downloaded, while done becomes true. Consider tracking how many files were actually added to the ZIP and, if zero, avoid generating the ZIP and instead surface a clearer error (optionally also accumulating skipped filenames rather than overwriting error per file).
| export function useBatchProcess(processFn, getOutputName, guards = {}) { | ||
| const [isBatchMode, setIsBatchMode] = useState(false); | ||
| const [batchFiles, setBatchFiles] = useState([]); | ||
| const [isProcessing, setIsProcessing] = useState(false); | ||
| const [progress, setProgress] = useState({ current: 0, total: 0 }); | ||
| const [error, setError] = useState(null); | ||
| const [done, setDone] = useState(false); | ||
|
|
||
| const addFiles = useCallback((incoming) => { | ||
| if (isProcessing) return; // ignore drops mid-run | ||
| const pdfs = incoming.filter((f) => f.type === "application/pdf"); | ||
| if (pdfs.length === 0) { setError("Please upload valid PDF files."); return; } | ||
| setError(null); | ||
| setDone(false); | ||
| setBatchFiles((prev) => { | ||
| const existing = new Set(prev.map((f) => `${f.name}-${f.size}`)); | ||
| const fresh = pdfs.filter((f) => !existing.has(`${f.name}-${f.size}`)); | ||
| return [...prev, ...fresh]; | ||
| }); | ||
| }, [isProcessing]); | ||
|
|
||
| const removeFile = useCallback((index) => { | ||
| setBatchFiles((prev) => prev.filter((_, i) => i !== index)); | ||
| setDone(false); | ||
| }, []); | ||
|
|
||
| const clearFiles = useCallback(() => { | ||
| setBatchFiles([]); | ||
| setDone(false); | ||
| setError(null); | ||
| setProgress({ current: 0, total: 0 }); | ||
| }, []); | ||
|
|
||
| /** | ||
| * Process all files with the given options and download as ZIP. | ||
| * Respects canProcess guard and calls onAfterEach per file. | ||
| */ | ||
| const runBatch = useCallback(async (options = {}) => { | ||
| if (batchFiles.length === 0) return; | ||
|
|
There was a problem hiding this comment.
This new hook introduces non-trivial behavior (deduping, guards, sequential processing, ZIP generation, error handling). The repo already has Vitest coverage for other hooks; please add a useBatchProcess.test.js validating key scenarios (non-PDF rejection, dedupe, skip behavior via canProcess, onAfterEach invocation count, and that a download URL is created/revoked).
| const [batchAngle, setBatchAngle] = useState(90); | ||
| const batch = useBatchProcess( | ||
| (f, opts) => rotatePdf(f, opts.angle), | ||
| (name) => `rotated_${name}`, | ||
| { | ||
| canProcess: (f) => !hasReachedGlobalLimit && (isPremium || f.size <= mbToBytes(ROTATE_LIMIT_MB)), | ||
| onAfterEach: incrementUsage, | ||
| } | ||
| ); | ||
|
|
||
| const ROTATE_LIMIT_MB = FREE_LIMITS.rotate.maxFileSizeMb; |
There was a problem hiding this comment.
ROTATE_LIMIT_MB is referenced inside the useBatchProcess guard before it’s declared, which will throw a ReferenceError due to the temporal dead zone on initial render. Move the ROTATE_LIMIT_MB declaration above the useBatchProcess(...) call, or inline FREE_LIMITS.rotate.maxFileSizeMb in the guard.
| const batch = useBatchProcess( | ||
| (f, opts) => addPageNumbers(f, opts), | ||
| (name) => `numbered_${name}`, | ||
| { | ||
| canProcess: (f) => !hasReachedGlobalLimit && (isPremium || f.size <= mbToBytes(LIMIT_MB)), | ||
| onAfterEach: incrementUsage, | ||
| } | ||
| ); |
There was a problem hiding this comment.
LIMIT_MB is used inside the useBatchProcess guard before it’s declared. Because LIMIT_MB is a const, this will throw a ReferenceError on render. Define LIMIT_MB before calling useBatchProcess(...) (or inline FREE_LIMITS.pageNumbers.maxFileSizeMb in the guard).
Closes #34
What's added
Batch processing mode with ZIP download for 6 PDF tools: Watermark, Compress, Grayscale, Page Numbers, Lock PDF, and Rotate.
Implementation details
Reusable logic
src/hooks/useBatchProcess.js— single hook handles all batch processing and ZIP generation via JSZip (already a project dependency). Each tool passes its own service function, zero code duplication across pages.src/components/pdf/BatchPanel.jsx— shared UI component for file list, drop zone, progress bar, and download button.src/components/ui/BatchToggle.jsx— Single/Batch pill toggle added to all 6 tool headers.Memory management
Files are processed strictly one at a time with a 150ms yield between each via
setTimeout. No parallel processing to prevent memory spikes on large batches.Preview
First-file preview is shown in Watermark, Grayscale, and Page Numbers batch mode (tools where visual verification matters). Preview is generated from the first file only to keep the UI snappy, as suggested.
Bug fix
Downgraded pdfjs-dist to 4.4.168 to fix a pre-existing
getOrInsertComputed is not a functioncrash caused by the previous version using a Map method not yet supported in current Chrome builds.NSoC'26