From 67a1c47396383da5a65d5bd26eed3e8b180b842a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Sebastian=20Kr=C3=BCger?= Date: Mon, 17 Nov 2025 13:13:52 +0100 Subject: [PATCH] feat: add batch conversion support with multi-file upload and ZIP download MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Major improvements: - Updated FileUpload component to support multiple file selection - Added drag-and-drop for multiple files - Individual file removal - "Add More Files" button when files are selected - Modified FileConverter to handle batch conversions - Sequential conversion of multiple files - Progress tracking for each file individually - All files share same output format and settings - Added batch download functionality - Single file: direct download - Multiple files: ZIP archive download - Uses jszip library for ZIP creation - Enhanced UI/UX - Show count of selected files in convert button - Display all conversion jobs with individual previews - "Download All" button for completed conversions - Conversion status messages show success/failure counts Dependencies added: - jszip@3.10.1 for ZIP file creation 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude --- components/converter/FileConverter.tsx | 293 ++++++++++++++++--------- components/converter/FileUpload.tsx | 101 +++++---- lib/utils/fileUtils.ts | 19 ++ package.json | 1 + pnpm-lock.yaml | 85 +++++++ 5 files changed, 362 insertions(+), 137 deletions(-) diff --git a/components/converter/FileConverter.tsx b/components/converter/FileConverter.tsx index b09dad7..8ffadd3 100644 --- a/components/converter/FileConverter.tsx +++ b/components/converter/FileConverter.tsx @@ -19,35 +19,39 @@ import { import { convertWithFFmpeg } from '@/lib/converters/ffmpegService'; import { convertWithImageMagick } from '@/lib/converters/imagemagickService'; import { addToHistory } from '@/lib/storage/history'; +import { downloadBlobsAsZip, generateOutputFilename } from '@/lib/utils/fileUtils'; import type { ConversionJob, ConversionFormat, ConversionOptions } from '@/types/conversion'; export function FileConverter() { const { addToast } = useToast(); - const [selectedFile, setSelectedFile] = React.useState(); + const [selectedFiles, setSelectedFiles] = React.useState([]); const [inputFormat, setInputFormat] = React.useState(); const [outputFormat, setOutputFormat] = React.useState(); const [compatibleFormats, setCompatibleFormats] = React.useState([]); - const [conversionJob, setConversionJob] = React.useState(); + const [conversionJobs, setConversionJobs] = React.useState([]); const [conversionOptions, setConversionOptions] = React.useState({}); - // Detect input format when file is selected + // Detect input format when files are selected React.useEffect(() => { - if (!selectedFile) { + if (selectedFiles.length === 0) { setInputFormat(undefined); setOutputFormat(undefined); setCompatibleFormats([]); - setConversionJob(undefined); + setConversionJobs([]); return; } + // Use first file to detect format (assume all files same format for batch) + const firstFile = selectedFiles[0]; + // Try to detect format from extension - const ext = selectedFile.name.split('.').pop()?.toLowerCase(); + const ext = firstFile.name.split('.').pop()?.toLowerCase(); let format = ext ? getFormatByExtension(ext) : undefined; // Fallback to MIME type if (!format) { - format = getFormatByMimeType(selectedFile.type); + format = getFormatByMimeType(firstFile.type); } if (format) { @@ -60,119 +64,189 @@ export function FileConverter() { setOutputFormat(compatible[0]); } - addToast(`Detected format: ${format.name}`, 'success'); + addToast(`Detected format: ${format.name} (${selectedFiles.length} file${selectedFiles.length > 1 ? 's' : ''})`, 'success'); } else { addToast('Could not detect file format', 'error'); setInputFormat(undefined); setCompatibleFormats([]); } - }, [selectedFile]); + }, [selectedFiles]); const handleConvert = async () => { - if (!selectedFile || !inputFormat || !outputFormat) { - addToast('Please select a file and output format', 'error'); + if (selectedFiles.length === 0 || !inputFormat || !outputFormat) { + addToast('Please select files and output format', 'error'); return; } - // Create conversion job - const job: ConversionJob = { + // Create conversion jobs for all files + const jobs: ConversionJob[] = selectedFiles.map((file) => ({ id: Math.random().toString(36).substring(7), - inputFile: selectedFile, + inputFile: file, inputFormat, outputFormat, options: conversionOptions, - status: 'loading', + status: 'pending', progress: 0, startTime: Date.now(), - }; + })); - setConversionJob(job); + setConversionJobs(jobs); - try { - // Update status to processing - setConversionJob((prev) => prev && { ...prev, status: 'processing', progress: 10 }); + // Convert files sequentially + for (let i = 0; i < jobs.length; i++) { + const job = jobs[i]; - // Call appropriate converter - let result; + try { + // Update job to loading + setConversionJobs((prev) => + prev.map((j, idx) => idx === i ? { ...j, status: 'loading' as const } : j) + ); - switch (outputFormat.converter) { - case 'ffmpeg': - result = await convertWithFFmpeg(selectedFile, outputFormat.extension, conversionOptions, (progress) => { - setConversionJob((prev) => prev && { ...prev, progress }); - }); - break; + // Update job to processing + setConversionJobs((prev) => + prev.map((j, idx) => idx === i ? { ...j, status: 'processing' as const, progress: 10 } : j) + ); - case 'imagemagick': - result = await convertWithImageMagick( - selectedFile, - outputFormat.extension, - conversionOptions, - (progress) => { - setConversionJob((prev) => prev && { ...prev, progress }); - } + // Call appropriate converter + let result; + + switch (outputFormat.converter) { + case 'ffmpeg': + result = await convertWithFFmpeg(job.inputFile, outputFormat.extension, conversionOptions, (progress) => { + setConversionJobs((prev) => + prev.map((j, idx) => idx === i ? { ...j, progress } : j) + ); + }); + break; + + case 'imagemagick': + result = await convertWithImageMagick( + job.inputFile, + outputFormat.extension, + conversionOptions, + (progress) => { + setConversionJobs((prev) => + prev.map((j, idx) => idx === i ? { ...j, progress } : j) + ); + } + ); + break; + + default: + throw new Error(`Unknown converter: ${outputFormat.converter}`); + } + + // Update job with result + if (result.success && result.blob) { + setConversionJobs((prev) => + prev.map((j, idx) => idx === i ? { + ...j, + status: 'completed' as const, + progress: 100, + result: result.blob, + endTime: Date.now(), + } : j) ); - break; - default: - throw new Error(`Unknown converter: ${outputFormat.converter}`); + // Add to history + addToHistory({ + inputFileName: job.inputFile.name, + inputFormat: inputFormat.name, + outputFormat: outputFormat.name, + outputFileName: `output.${outputFormat.extension}`, + fileSize: result.blob.size, + result: result.blob, + }); + } else { + setConversionJobs((prev) => + prev.map((j, idx) => idx === i ? { + ...j, + status: 'error' as const, + error: result.error || 'Unknown error', + endTime: Date.now(), + } : j) + ); + } + } catch (error) { + const errorMessage = error instanceof Error ? error.message : 'Unknown error'; + + setConversionJobs((prev) => + prev.map((j, idx) => idx === i ? { + ...j, + status: 'error' as const, + error: errorMessage, + endTime: Date.now(), + } : j) + ); } + } - // Update job with result - if (result.success && result.blob) { - setConversionJob((prev) => prev && { - ...prev, - status: 'completed', - progress: 100, - result: result.blob, - endTime: Date.now(), - }); - - addToast('Conversion completed successfully!', 'success'); - - // Add to history - addToHistory({ - inputFileName: selectedFile.name, - inputFormat: inputFormat.name, - outputFormat: outputFormat.name, - outputFileName: `output.${outputFormat.extension}`, - fileSize: result.blob.size, - result: result.blob, - }); - } else { - setConversionJob((prev) => prev && { - ...prev, - status: 'error', - error: result.error || 'Unknown error', - endTime: Date.now(), - }); - - addToast(result.error || 'Conversion failed', 'error'); - } - } catch (error) { - const errorMessage = error instanceof Error ? error.message : 'Unknown error'; - - setConversionJob((prev) => prev && { - ...prev, - status: 'error', - error: errorMessage, - endTime: Date.now(), - }); - - addToast(`Conversion failed: ${errorMessage}`, 'error'); + // Show completion message + const successCount = jobs.filter(j => j.status === 'completed').length; + if (successCount === jobs.length) { + addToast(`All ${jobs.length} files converted successfully!`, 'success'); + } else if (successCount > 0) { + addToast(`${successCount}/${jobs.length} files converted successfully`, 'info'); + } else { + addToast('All conversions failed', 'error'); } }; const handleReset = () => { - setSelectedFile(undefined); + setSelectedFiles([]); setInputFormat(undefined); setOutputFormat(undefined); setCompatibleFormats([]); - setConversionJob(undefined); + setConversionJobs([]); setConversionOptions({}); }; - const isConvertDisabled = - !selectedFile || !outputFormat || conversionJob?.status === 'loading' || conversionJob?.status === 'processing'; + const handleFileSelect = (files: File[]) => { + setSelectedFiles((prev) => [...prev, ...files]); + }; + + const handleFileRemove = (index: number) => { + setSelectedFiles((prev) => prev.filter((_, i) => i !== index)); + }; + + const handleDownloadAll = async () => { + if (!outputFormat) return; + + const completedJobs = conversionJobs.filter(job => job.status === 'completed' && job.result); + + if (completedJobs.length === 0) { + addToast('No files to download', 'error'); + return; + } + + if (completedJobs.length === 1) { + // Just download the single file + const job = completedJobs[0]; + const filename = generateOutputFilename(job.inputFile.name, outputFormat.extension); + const url = URL.createObjectURL(job.result!); + const a = document.createElement('a'); + a.href = url; + a.download = filename; + document.body.appendChild(a); + a.click(); + document.body.removeChild(a); + URL.revokeObjectURL(url); + return; + } + + // Download multiple files as ZIP + const files = completedJobs.map(job => ({ + blob: job.result!, + filename: generateOutputFilename(job.inputFile.name, outputFormat.extension), + })); + + await downloadBlobsAsZip(files, `converted-files.zip`); + addToast(`Downloaded ${files.length} files as ZIP`, 'success'); + }; + + const isConverting = conversionJobs.some(job => job.status === 'loading' || job.status === 'processing'); + const isConvertDisabled = selectedFiles.length === 0 || !outputFormat || isConverting; + const completedCount = conversionJobs.filter(job => job.status === 'completed').length; return (
@@ -187,15 +261,15 @@ export function FileConverter() { {/* File upload */} - {/* File Info */} - {selectedFile && inputFormat && ( - + {/* File Info - show first file */} + {selectedFiles.length > 0 && inputFormat && ( + )} {/* Format selection */} @@ -221,7 +295,7 @@ export function FileConverter() { selectedFormat={outputFormat} onFormatSelect={setOutputFormat} label="Output Format" - disabled={conversionJob?.status === 'processing' || conversionJob?.status === 'loading'} + disabled={isConverting} />
)} @@ -233,7 +307,7 @@ export function FileConverter() { outputFormat={outputFormat} options={conversionOptions} onOptionsChange={setConversionOptions} - disabled={conversionJob?.status === 'processing' || conversionJob?.status === 'loading'} + disabled={isConverting} /> )} @@ -246,9 +320,9 @@ export function FileConverter() { className="flex-1" size="lg" > - {conversionJob?.status === 'loading' || conversionJob?.status === 'processing' + {isConverting ? 'Converting...' - : 'Convert'} + : `Convert ${selectedFiles.length} File${selectedFiles.length > 1 ? 's' : ''}`} + + + )} + + {/* Conversion previews */} + {conversionJobs.length > 0 && ( +
+ {conversionJobs.map((job) => ( + + ))} +
+ )} ); } diff --git a/components/converter/FileUpload.tsx b/components/converter/FileUpload.tsx index 350693d..d30eaf0 100644 --- a/components/converter/FileUpload.tsx +++ b/components/converter/FileUpload.tsx @@ -7,9 +7,9 @@ import { formatFileSize } from '@/lib/utils/fileUtils'; import { Button } from '@/components/ui/Button'; export interface FileUploadProps { - onFileSelect: (file: File) => void; - onFileRemove: () => void; - selectedFile?: File; + onFileSelect: (files: File[]) => void; + onFileRemove: (index: number) => void; + selectedFiles?: File[]; accept?: string; maxSizeMB?: number; disabled?: boolean; @@ -18,7 +18,7 @@ export interface FileUploadProps { export function FileUpload({ onFileSelect, onFileRemove, - selectedFile, + selectedFiles = [], accept, maxSizeMB = 500, disabled = false, @@ -54,26 +54,36 @@ export function FileUpload({ const files = Array.from(e.dataTransfer.files); if (files.length > 0) { - handleFile(files[0]); + handleFiles(files); } }; const handleFileInput = (e: React.ChangeEvent) => { const files = Array.from(e.target.files || []); if (files.length > 0) { - handleFile(files[0]); + handleFiles(files); } }; - const handleFile = (file: File) => { - // Check file size + const handleFiles = (files: File[]) => { + // Check file sizes const maxBytes = maxSizeMB * 1024 * 1024; - if (file.size > maxBytes) { - alert(`File size exceeds ${maxSizeMB}MB limit. Please choose a smaller file.`); - return; + const validFiles = files.filter(file => { + if (file.size > maxBytes) { + alert(`${file.name} exceeds ${maxSizeMB}MB limit and will be skipped.`); + return false; + } + return true; + }); + + if (validFiles.length > 0) { + onFileSelect(validFiles); } - onFileSelect(file); + // Reset input + if (fileInputRef.current) { + fileInputRef.current.value = ''; + } }; const handleClick = () => { @@ -82,45 +92,58 @@ export function FileUpload({ } }; - const handleRemove = (e: React.MouseEvent) => { + const handleRemove = (index: number) => (e: React.MouseEvent) => { e.stopPropagation(); - onFileRemove(); - if (fileInputRef.current) { - fileInputRef.current.value = ''; - } + onFileRemove(index); }; return ( -
+
- {selectedFile ? ( -
-
-
-

{selectedFile.name}

-

- {formatFileSize(selectedFile.size)} -

+ {selectedFiles.length > 0 ? ( +
+ {selectedFiles.map((file, index) => ( +
+
+
+

{file.name}

+

+ {formatFileSize(file.size)} +

+
+ +
- -
+ ))} + + {/* Add more files button */} +
) : (

- Drop your file here or click to browse + Drop your files here or click to browse

- Maximum file size: {maxSizeMB}MB + Maximum file size: {maxSizeMB}MB per file

)} diff --git a/lib/utils/fileUtils.ts b/lib/utils/fileUtils.ts index 187031c..75826aa 100644 --- a/lib/utils/fileUtils.ts +++ b/lib/utils/fileUtils.ts @@ -93,3 +93,22 @@ export function validateFileType(file: File, allowedTypes: string[]): boolean { return file.type === type; }); } + +/** + * Download multiple blobs as a ZIP file + */ +export async function downloadBlobsAsZip(files: Array<{ blob: Blob; filename: string }>, zipFilename: string): Promise { + const JSZip = (await import('jszip')).default; + const zip = new JSZip(); + + // Add all files to ZIP + files.forEach(({ blob, filename }) => { + zip.file(filename, blob); + }); + + // Generate ZIP blob + const zipBlob = await zip.generateAsync({ type: 'blob' }); + + // Download ZIP + downloadBlob(zipBlob, zipFilename); +} diff --git a/package.json b/package.json index 11cefb8..36fa80f 100644 --- a/package.json +++ b/package.json @@ -14,6 +14,7 @@ "@imagemagick/magick-wasm": "^0.0.30", "clsx": "^2.1.1", "fuse.js": "^7.1.0", + "jszip": "^3.10.1", "lucide-react": "^0.553.0", "next": "^16.0.0", "react": "^19.0.0", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 1c25381..e4713e6 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -23,6 +23,9 @@ importers: fuse.js: specifier: ^7.1.0 version: 7.1.0 + jszip: + specifier: ^3.10.1 + version: 3.10.1 lucide-react: specifier: ^0.553.0 version: 0.553.0(react@19.2.0) @@ -852,6 +855,9 @@ packages: convert-source-map@2.0.0: resolution: {integrity: sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg==} + core-util-is@1.0.3: + resolution: {integrity: sha512-ZQBvi1DcpJ4GDqanjucZ2Hj3wEO5pZDS89BWbkcrvdxksJorwUDDZamX9ldFkp9aw2lmBDLgkObEA4DWNJ9FYQ==} + cross-spawn@7.0.6: resolution: {integrity: sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==} engines: {node: '>= 8'} @@ -1240,6 +1246,9 @@ packages: resolution: {integrity: sha512-Hs59xBNfUIunMFgWAbGX5cq6893IbWg4KnrjbYwX3tx0ztorVgTDA6B2sxf8ejHJ4wz8BqGUMYlnzNBer5NvGg==} engines: {node: '>= 4'} + immediate@3.0.6: + resolution: {integrity: sha512-XXOFtyqDjNDAQxVfYxuF7g9Il/IbWmmlQg2MYKOH8ExIT1qg6xc4zyS3HaEEATgs1btfzxq15ciUiY7gjSXRGQ==} + import-fresh@3.3.1: resolution: {integrity: sha512-TR3KfrTZTYLPB6jUjfx6MF9WcWrHL9su5TObK4ZkYgBdWKPOFoSoQIdEuTuR82pmtxH2spWG9h6etwfr1pLBqQ==} engines: {node: '>=6'} @@ -1248,6 +1257,9 @@ packages: resolution: {integrity: sha512-JmXMZ6wuvDmLiHEml9ykzqO6lwFbof0GG4IkcGaENdCRDDmMVnny7s5HsIgHCbaq0w2MyPhDqkhTUgS2LU2PHA==} engines: {node: '>=0.8.19'} + inherits@2.0.4: + resolution: {integrity: sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==} + internal-slot@1.1.0: resolution: {integrity: sha512-4gd7VpWNQNB4UKKCFFVcp1AVv+FMOgs9NKzjHKusc8jTMhd5eL1NqQqOpE0KzMds804/yHlglp3uxgluOqAPLw==} engines: {node: '>= 0.4'} @@ -1355,6 +1367,9 @@ packages: resolution: {integrity: sha512-mfcwb6IzQyOKTs84CQMrOwW4gQcaTOAWJ0zzJCl2WSPDrWk/OzDaImWFH3djXhb24g4eudZfLRozAvPGw4d9hQ==} engines: {node: '>= 0.4'} + isarray@1.0.0: + resolution: {integrity: sha512-VLghIWNM6ELQzo7zwmcg0NmTVyWKYjvIeM83yjp0wRDTmUnrM678fQbcKBo6n2CJEF0szoG//ytg+TKla89ALQ==} + isarray@2.0.5: resolution: {integrity: sha512-xHjhDr3cNBK0BzdUJSPXZntQUx/mwMS5Rw4A7lPJ90XGAO6ISP/ePDNuo0vhqOZU+UD5JoodwCAAoZQd3FeAKw==} @@ -1403,6 +1418,9 @@ packages: resolution: {integrity: sha512-ZZow9HBI5O6EPgSJLUb8n2NKgmVWTwCvHGwFuJlMjvLFqlGG6pjirPhtdsseaLZjSibD8eegzmYpUZwoIlj2cQ==} engines: {node: '>=4.0'} + jszip@3.10.1: + resolution: {integrity: sha512-xXDvecyTpGLrqFrvkrUSoxxfJI5AH7U8zxxtVclpsUtMCq4JQ290LY8AW5c7Ggnr/Y/oK+bQMbqK2qmtk3pN4g==} + keyv@4.5.4: resolution: {integrity: sha512-oxVHkHR/EJf2CNXnWxRLW6mg7JyCCUcG0DtEGmL2ctUo1PNTin1PUil+r/+4r5MpVgC/fn1kjsx7mjSujKqIpw==} @@ -1417,6 +1435,9 @@ packages: resolution: {integrity: sha512-+bT2uH4E5LGE7h/n3evcS/sQlJXCpIp6ym8OWJ5eV6+67Dsql/LaaT7qJBAt2rzfoa/5QBGBhxDix1dMt2kQKQ==} engines: {node: '>= 0.8.0'} + lie@3.3.0: + resolution: {integrity: sha512-UaiMJzeWRlEujzAuw5LokY1L5ecNQYZKfmyZ9L7wDHb/p5etKaxXhohBcrw0EYby+G/NA52vRSN4N39dxHAIwQ==} + lightningcss-android-arm64@1.30.2: resolution: {integrity: sha512-BH9sEdOCahSgmkVhBLeU7Hc9DWeZ1Eb6wNS6Da8igvUwAe0sqROHddIlvU06q3WyXVEOYDZ6ykBZQnjTbmo4+A==} engines: {node: '>= 12.0.0'} @@ -1619,6 +1640,9 @@ packages: resolution: {integrity: sha512-LaNjtRWUBY++zB5nE/NwcaoMylSPk+S+ZHNB1TzdbMJMny6dynpAGt7X/tl/QYq3TIeE6nxHppbo2LGymrG5Pw==} engines: {node: '>=10'} + pako@1.0.11: + resolution: {integrity: sha512-4hLB8Py4zZce5s4yd9XzopqwVv/yGNhV1Bl8NTmCq1763HeK2+EwVTv+leGeL13Dnh2wfbqowVPXCIO0z4taYw==} + parent-module@1.0.1: resolution: {integrity: sha512-GQ2EWRpQV8/o+Aw8YqtfZZPfNRWZYkbidE9k5rpl/hC3vtHHBfGm2Ifi6qWV+coDGkrUKZAxE3Lot5kcsRlh+g==} engines: {node: '>=6'} @@ -1661,6 +1685,9 @@ packages: resolution: {integrity: sha512-vkcDPrRZo1QZLbn5RLGPpg/WmIQ65qoWWhcGKf/b5eplkkarX0m9z8ppCat4mlOqUsWpyNuYgO3VRyrYHSzX5g==} engines: {node: '>= 0.8.0'} + process-nextick-args@2.0.1: + resolution: {integrity: sha512-3ouUOpQhtgrbOa17J7+uxOTpITYWaGP7/AhoR3+A+/1e9skrzelGi/dXzEYyvbxubEF6Wn2ypscTKiKJFFn1ag==} + prop-types@15.8.1: resolution: {integrity: sha512-oj87CgZICdulUohogVAR7AjlC0327U4el4L6eAvOqCeudMDVU0NThNaV+b9Df4dXgSP1gXMTnPdhfe/2qDH5cg==} @@ -1683,6 +1710,9 @@ packages: resolution: {integrity: sha512-tmbWg6W31tQLeB5cdIBOicJDJRR2KzXsV7uSK9iNfLWQ5bIZfxuPEHp7M8wiHyHnn0DD1i7w3Zmin0FtkrwoCQ==} engines: {node: '>=0.10.0'} + readable-stream@2.3.8: + resolution: {integrity: sha512-8p0AUk4XODgIewSi0l8Epjs+EVnWiK7NoDIEGU0HhE7+ZyY8D1IMY7odu5lRrFXGg71L15KG8QrPmum45RTtdA==} + reflect.getprototypeof@1.0.10: resolution: {integrity: sha512-00o4I+DVrefhv+nX0ulyi3biSHCPDe+yLv5o/p6d/UVlirijB8E16FtfwSAi4g3tcqrQ4lRAqQSoFEZJehYEcw==} engines: {node: '>= 0.4'} @@ -1718,6 +1748,9 @@ packages: resolution: {integrity: sha512-AURm5f0jYEOydBj7VQlVvDrjeFgthDdEF5H1dP+6mNpoXOMo1quQqJ4wvJDyRZ9+pO3kGWoOdmV08cSv2aJV6Q==} engines: {node: '>=0.4'} + safe-buffer@5.1.2: + resolution: {integrity: sha512-Gd2UZBJDkXlY7GbJxfsE8/nvKkUEU1G38c1siN6QP6a9PT9MmHB8GnpscSmMJSoF8LOIrt8ud/wPtojys4G6+g==} + safe-push-apply@1.0.0: resolution: {integrity: sha512-iKE9w/Z7xCzUMIZqdBsp6pEQvwuEebH4vdpjcDWnyzaI6yl6O9FHvVpmGelvEHNsoY6wGblkxR6Zty/h00WiSA==} engines: {node: '>= 0.4'} @@ -1750,6 +1783,9 @@ packages: resolution: {integrity: sha512-RJRdvCo6IAnPdsvP/7m6bsQqNnn1FCBX5ZNtFL98MmFF/4xAIJTIg1YbHW5DC2W5SKZanrC6i4HsJqlajw/dZw==} engines: {node: '>= 0.4'} + setimmediate@1.0.5: + resolution: {integrity: sha512-MATJdZp8sLqDl/68LfQmbP8zKPLQNV6BIZoIgrscFDQ+RsvK/BxeDQOgyxKKoh0y/8h3BqVFnCqQ/gd+reiIXA==} + sharp@0.34.5: resolution: {integrity: sha512-Ou9I5Ft9WNcCbXrU9cMgPBcCK8LiwLqcbywW3t4oDV37n1pzpuNLsYiAV8eODnjbtQlSDwZ2cUEeQz4E54Hltg==} engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} @@ -1812,6 +1848,9 @@ packages: resolution: {integrity: sha512-UXSH262CSZY1tfu3G3Secr6uGLCFVPMhIqHjlgCUtCCcgihYc/xKs9djMTMUOb2j1mVSeU8EU6NWc/iQKU6Gfg==} engines: {node: '>= 0.4'} + string_decoder@1.1.1: + resolution: {integrity: sha512-n/ShnvDi6FHbbVfviro+WojiFzv+s8MPMHBczVePfUpDJLwoLT0ht1l4YwBCbi8pJAveEEdnkHyPyTP/mzRfwg==} + strip-bom@3.0.0: resolution: {integrity: sha512-vavAMRXOgBVNF6nyEEmL3DBK19iRpDcoIwW+swQ+CbGiu7lju6t+JklA1MHweoWtadgt4ISVUsXLyDq34ddcwA==} engines: {node: '>=4'} @@ -1922,6 +1961,9 @@ packages: uri-js@4.4.1: resolution: {integrity: sha512-7rKUyy33Q1yc98pQ1DAmLtwX109F7TIfWlW1Ydo8Wl1ii1SeHieeh0HHfPeL2fMXK6z0s8ecKs9frCuLJvndBg==} + util-deprecate@1.0.2: + resolution: {integrity: sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==} + which-boxed-primitive@1.1.1: resolution: {integrity: sha512-TbX3mj8n0odCBFVlY8AxkqcHASw3L60jIuF8jFP78az3C2YhmGvqbHBpAjTRH2/xqYunrJ9g1jSyjCjpoWzIAA==} engines: {node: '>= 0.4'} @@ -2732,6 +2774,8 @@ snapshots: convert-source-map@2.0.0: {} + core-util-is@1.0.3: {} + cross-spawn@7.0.6: dependencies: path-key: 3.1.1 @@ -3267,6 +3311,8 @@ snapshots: ignore@7.0.5: {} + immediate@3.0.6: {} + import-fresh@3.3.1: dependencies: parent-module: 1.0.1 @@ -3274,6 +3320,8 @@ snapshots: imurmurhash@0.1.4: {} + inherits@2.0.4: {} + internal-slot@1.1.0: dependencies: es-errors: 1.3.0 @@ -3392,6 +3440,8 @@ snapshots: call-bound: 1.0.4 get-intrinsic: 1.3.0 + isarray@1.0.0: {} + isarray@2.0.5: {} isexe@2.0.0: {} @@ -3434,6 +3484,13 @@ snapshots: object.assign: 4.1.7 object.values: 1.2.1 + jszip@3.10.1: + dependencies: + lie: 3.3.0 + pako: 1.0.11 + readable-stream: 2.3.8 + setimmediate: 1.0.5 + keyv@4.5.4: dependencies: json-buffer: 3.0.1 @@ -3449,6 +3506,10 @@ snapshots: prelude-ls: 1.2.1 type-check: 0.4.0 + lie@3.3.0: + dependencies: + immediate: 3.0.6 + lightningcss-android-arm64@1.30.2: optional: true @@ -3637,6 +3698,8 @@ snapshots: dependencies: p-limit: 3.1.0 + pako@1.0.11: {} + parent-module@1.0.1: dependencies: callsites: 3.1.0 @@ -3669,6 +3732,8 @@ snapshots: prelude-ls@1.2.1: {} + process-nextick-args@2.0.1: {} + prop-types@15.8.1: dependencies: loose-envify: 1.4.0 @@ -3688,6 +3753,16 @@ snapshots: react@19.2.0: {} + readable-stream@2.3.8: + dependencies: + core-util-is: 1.0.3 + inherits: 2.0.4 + isarray: 1.0.0 + process-nextick-args: 2.0.1 + safe-buffer: 5.1.2 + string_decoder: 1.1.1 + util-deprecate: 1.0.2 + reflect.getprototypeof@1.0.10: dependencies: call-bind: 1.0.8 @@ -3738,6 +3813,8 @@ snapshots: has-symbols: 1.1.0 isarray: 2.0.5 + safe-buffer@5.1.2: {} + safe-push-apply@1.0.0: dependencies: es-errors: 1.3.0 @@ -3777,6 +3854,8 @@ snapshots: es-errors: 1.3.0 es-object-atoms: 1.1.1 + setimmediate@1.0.5: {} + sharp@0.34.5: dependencies: '@img/colour': 1.0.0 @@ -3902,6 +3981,10 @@ snapshots: define-properties: 1.2.1 es-object-atoms: 1.1.1 + string_decoder@1.1.1: + dependencies: + safe-buffer: 5.1.2 + strip-bom@3.0.0: {} strip-json-comments@3.1.1: {} @@ -4040,6 +4123,8 @@ snapshots: dependencies: punycode: 2.3.1 + util-deprecate@1.0.2: {} + which-boxed-primitive@1.1.1: dependencies: is-bigint: 1.1.0