Skip to content

feat: add batch processing with ZIP download to 6 PDF tools | NSoC'26#35

Open
ayushlad9108 wants to merge 4 commits into
JhaSourav07:mainfrom
ayushlad9108:feature/batch-processing
Open

feat: add batch processing with ZIP download to 6 PDF tools | NSoC'26#35
ayushlad9108 wants to merge 4 commits into
JhaSourav07:mainfrom
ayushlad9108:feature/batch-processing

Conversation

@ayushlad9108
Copy link
Copy Markdown

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 function crash caused by the previous version using a Map method not yet supported in current Chrome builds.

NSoC'26

@vercel
Copy link
Copy Markdown
Contributor

vercel Bot commented Apr 19, 2026

@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.

Copy link
Copy Markdown
Contributor

Copilot AI left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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-dist to 4.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.

Comment thread src/hooks/useBatchProcess.js Outdated
Comment on lines +10 to +90
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,
};
Copy link

Copilot AI Apr 19, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Copilot uses AI. Check for mistakes.
Comment on lines +118 to +143
{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>
) : (
Copy link

Copilot AI Apr 19, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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).

Suggested change
{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>
);
})() : (

Copilot uses AI. Check for mistakes.
Comment on lines +25 to +41
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 */ }
}
};

Copy link

Copilot AI Apr 19, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Copilot uses AI. Check for mistakes.
progress={batch.progress}
error={batch.error}
done={batch.done}
onRun={() => batch.runBatch({ position, fontSize, prefix, startNumber })}
Copy link

Copilot AI Apr 19, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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).

Suggested change
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();
}
}}

Copilot uses AI. Check for mistakes.
Comment thread src/pages/LockPdf/LockPdf.jsx Outdated
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 })}
Copy link

Copilot AI Apr 19, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Suggested change
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 });
}}

Copilot uses AI. Check for mistakes.
Comment thread .env
@@ -0,0 +1 @@
VITE_WALLETCONNECT_PROJECT_ID=placeholder_project_id
Copy link

Copilot AI Apr 19, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Suggested change
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

Copilot uses AI. Check for mistakes.
Comment on lines +31 to +61
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}
/>
Copy link

Copilot AI Apr 19, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Copilot uses AI. Check for mistakes.
Comment thread src/hooks/useBatchProcess.js Outdated
Comment on lines +74 to +75
a.click();
URL.revokeObjectURL(url);
Copy link

Copilot AI Apr 19, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Suggested change
a.click();
URL.revokeObjectURL(url);
document.body.appendChild(a);
a.click();
setTimeout(() => {
URL.revokeObjectURL(url);
document.body.removeChild(a);
}, 0);

Copilot uses AI. Check for mistakes.
Comment on lines +93 to +119
{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"
/>
Copy link

Copilot AI Apr 19, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Copilot uses AI. Check for mistakes.
Comment thread src/pages/Watermark/Watermark.jsx Outdated
)}
</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} />
Copy link

Copilot AI Apr 19, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Suggested change
<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}
/>

Copilot uses AI. Check for mistakes.
@ayushlad9108
Copy link
Copy Markdown
Author

Addressed all review comments:

  • Added canProcess/onAfterEach guards to useBatchProcess for subscription enforcement
  • UpgradeButton shown in batch mode for all 6 tools
  • runDisabled prevents CTA when locked or password invalid
  • URL.revokeObjectURL cleanup via useEffect + useRef
  • Rotate batch now uses rotatePdf service instead of inline pdf-lib
  • ZIP download revoke moved to setTimeout
  • Drop zone disabled mid-run
  • pdfjs-dist pinned to exact 4.4.168
  • .env removed from tracking, .env.example added

@JhaSourav07
Copy link
Copy Markdown
Owner

Hey @ayushlad9108,
can you resolve the merge conflicts ?

@ayushlad9108
Copy link
Copy Markdown
Author

@JhaSourav07 Please check!

Comment thread .github/workflows/ci.yml
Copy link
Copy Markdown
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I am not sure why you did this

@JhaSourav07
Copy link
Copy Markdown
Owner

@ayushlad9108

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.

Copy link
Copy Markdown
Contributor

Copilot AI left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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 .env files to the repository, even with placeholder values. .gitignore won’t stop an already-tracked .env from being committed/updated, and it risks accidental secret leakage later. Remove .env from the repo (and from git history if needed) and rely on .env.example for 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.

Comment on lines +62 to 66
setError(null); setFile(selectedFile); setPreviewUrl(null);
};

const clearFile = () => {
setFile(null);
setError(null);
setPreviewUrl(null);
};
const clearFile = () => { setFile(null); setError(null); setPreviewUrl(null); };

Copy link

Copilot AI Apr 20, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Copilot uses AI. Check for mistakes.
Comment on lines +25 to +127
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"
Copy link

Copilot AI Apr 20, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Copilot uses AI. Check for mistakes.
Comment on lines +37 to +58
<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>
Copy link

Copilot AI Apr 20, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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).

Copilot uses AI. Check for mistakes.
Comment on lines +137 to +142
{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>
))}
Copy link

Copilot AI Apr 20, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Copilot uses AI. Check for mistakes.
Comment on lines +46 to 55
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;
Copy link

Copilot AI Apr 20, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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).

Copilot uses AI. Check for mistakes.
Comment on lines +45 to +50
useEffect(() => {
return () => {
if (batchPreviewRef.current) URL.revokeObjectURL(batchPreviewRef.current);
if (previewUrl) URL.revokeObjectURL(previewUrl);
};
}, []); // eslint-disable-line
Copy link

Copilot AI Apr 20, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Copilot uses AI. Check for mistakes.
Comment on lines +66 to +88
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);
Copy link

Copilot AI Apr 20, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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).

Copilot uses AI. Check for mistakes.
Comment on lines +13 to +52
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;

Copy link

Copilot AI Apr 20, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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).

Copilot uses AI. Check for mistakes.
Comment on lines +145 to 155
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;
Copy link

Copilot AI Apr 20, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Copilot uses AI. Check for mistakes.
Comment on lines +39 to +46
const batch = useBatchProcess(
(f, opts) => addPageNumbers(f, opts),
(name) => `numbered_${name}`,
{
canProcess: (f) => !hasReachedGlobalLimit && (isPremium || f.size <= mbToBytes(LIMIT_MB)),
onAfterEach: incrementUsage,
}
);
Copy link

Copilot AI Apr 20, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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).

Copilot uses AI. Check for mistakes.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

[Feature] Batch Processing — Apply Same Operation to Multiple PDFs at Once | Level 3 | NSoC'26

3 participants