feat: add batch conversion support with multi-file upload and ZIP download

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 <noreply@anthropic.com>
This commit is contained in:
2025-11-17 13:13:52 +01:00
parent a43a336965
commit 67a1c47396
5 changed files with 362 additions and 137 deletions

View File

@@ -19,35 +19,39 @@ import {
import { convertWithFFmpeg } from '@/lib/converters/ffmpegService'; import { convertWithFFmpeg } from '@/lib/converters/ffmpegService';
import { convertWithImageMagick } from '@/lib/converters/imagemagickService'; import { convertWithImageMagick } from '@/lib/converters/imagemagickService';
import { addToHistory } from '@/lib/storage/history'; import { addToHistory } from '@/lib/storage/history';
import { downloadBlobsAsZip, generateOutputFilename } from '@/lib/utils/fileUtils';
import type { ConversionJob, ConversionFormat, ConversionOptions } from '@/types/conversion'; import type { ConversionJob, ConversionFormat, ConversionOptions } from '@/types/conversion';
export function FileConverter() { export function FileConverter() {
const { addToast } = useToast(); const { addToast } = useToast();
const [selectedFile, setSelectedFile] = React.useState<File | undefined>(); const [selectedFiles, setSelectedFiles] = React.useState<File[]>([]);
const [inputFormat, setInputFormat] = React.useState<ConversionFormat | undefined>(); const [inputFormat, setInputFormat] = React.useState<ConversionFormat | undefined>();
const [outputFormat, setOutputFormat] = React.useState<ConversionFormat | undefined>(); const [outputFormat, setOutputFormat] = React.useState<ConversionFormat | undefined>();
const [compatibleFormats, setCompatibleFormats] = React.useState<ConversionFormat[]>([]); const [compatibleFormats, setCompatibleFormats] = React.useState<ConversionFormat[]>([]);
const [conversionJob, setConversionJob] = React.useState<ConversionJob | undefined>(); const [conversionJobs, setConversionJobs] = React.useState<ConversionJob[]>([]);
const [conversionOptions, setConversionOptions] = React.useState<ConversionOptions>({}); const [conversionOptions, setConversionOptions] = React.useState<ConversionOptions>({});
// Detect input format when file is selected // Detect input format when files are selected
React.useEffect(() => { React.useEffect(() => {
if (!selectedFile) { if (selectedFiles.length === 0) {
setInputFormat(undefined); setInputFormat(undefined);
setOutputFormat(undefined); setOutputFormat(undefined);
setCompatibleFormats([]); setCompatibleFormats([]);
setConversionJob(undefined); setConversionJobs([]);
return; return;
} }
// Use first file to detect format (assume all files same format for batch)
const firstFile = selectedFiles[0];
// Try to detect format from extension // 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; let format = ext ? getFormatByExtension(ext) : undefined;
// Fallback to MIME type // Fallback to MIME type
if (!format) { if (!format) {
format = getFormatByMimeType(selectedFile.type); format = getFormatByMimeType(firstFile.type);
} }
if (format) { if (format) {
@@ -60,119 +64,189 @@ export function FileConverter() {
setOutputFormat(compatible[0]); setOutputFormat(compatible[0]);
} }
addToast(`Detected format: ${format.name}`, 'success'); addToast(`Detected format: ${format.name} (${selectedFiles.length} file${selectedFiles.length > 1 ? 's' : ''})`, 'success');
} else { } else {
addToast('Could not detect file format', 'error'); addToast('Could not detect file format', 'error');
setInputFormat(undefined); setInputFormat(undefined);
setCompatibleFormats([]); setCompatibleFormats([]);
} }
}, [selectedFile]); }, [selectedFiles]);
const handleConvert = async () => { const handleConvert = async () => {
if (!selectedFile || !inputFormat || !outputFormat) { if (selectedFiles.length === 0 || !inputFormat || !outputFormat) {
addToast('Please select a file and output format', 'error'); addToast('Please select files and output format', 'error');
return; return;
} }
// Create conversion job // Create conversion jobs for all files
const job: ConversionJob = { const jobs: ConversionJob[] = selectedFiles.map((file) => ({
id: Math.random().toString(36).substring(7), id: Math.random().toString(36).substring(7),
inputFile: selectedFile, inputFile: file,
inputFormat, inputFormat,
outputFormat, outputFormat,
options: conversionOptions, options: conversionOptions,
status: 'loading', status: 'pending',
progress: 0, progress: 0,
startTime: Date.now(), startTime: Date.now(),
}; }));
setConversionJob(job); setConversionJobs(jobs);
try { // Convert files sequentially
// Update status to processing for (let i = 0; i < jobs.length; i++) {
setConversionJob((prev) => prev && { ...prev, status: 'processing', progress: 10 }); const job = jobs[i];
// Call appropriate converter try {
let result; // Update job to loading
setConversionJobs((prev) =>
prev.map((j, idx) => idx === i ? { ...j, status: 'loading' as const } : j)
);
switch (outputFormat.converter) { // Update job to processing
case 'ffmpeg': setConversionJobs((prev) =>
result = await convertWithFFmpeg(selectedFile, outputFormat.extension, conversionOptions, (progress) => { prev.map((j, idx) => idx === i ? { ...j, status: 'processing' as const, progress: 10 } : j)
setConversionJob((prev) => prev && { ...prev, progress }); );
});
break;
case 'imagemagick': // Call appropriate converter
result = await convertWithImageMagick( let result;
selectedFile,
outputFormat.extension, switch (outputFormat.converter) {
conversionOptions, case 'ffmpeg':
(progress) => { result = await convertWithFFmpeg(job.inputFile, outputFormat.extension, conversionOptions, (progress) => {
setConversionJob((prev) => prev && { ...prev, 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: // Add to history
throw new Error(`Unknown converter: ${outputFormat.converter}`); 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 // Show completion message
if (result.success && result.blob) { const successCount = jobs.filter(j => j.status === 'completed').length;
setConversionJob((prev) => prev && { if (successCount === jobs.length) {
...prev, addToast(`All ${jobs.length} files converted successfully!`, 'success');
status: 'completed', } else if (successCount > 0) {
progress: 100, addToast(`${successCount}/${jobs.length} files converted successfully`, 'info');
result: result.blob, } else {
endTime: Date.now(), addToast('All conversions failed', 'error');
});
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');
} }
}; };
const handleReset = () => { const handleReset = () => {
setSelectedFile(undefined); setSelectedFiles([]);
setInputFormat(undefined); setInputFormat(undefined);
setOutputFormat(undefined); setOutputFormat(undefined);
setCompatibleFormats([]); setCompatibleFormats([]);
setConversionJob(undefined); setConversionJobs([]);
setConversionOptions({}); setConversionOptions({});
}; };
const isConvertDisabled = const handleFileSelect = (files: File[]) => {
!selectedFile || !outputFormat || conversionJob?.status === 'loading' || conversionJob?.status === 'processing'; 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 ( return (
<div className="w-full max-w-4xl mx-auto space-y-6"> <div className="w-full max-w-4xl mx-auto space-y-6">
@@ -187,15 +261,15 @@ export function FileConverter() {
<CardContent className="space-y-6"> <CardContent className="space-y-6">
{/* File upload */} {/* File upload */}
<FileUpload <FileUpload
onFileSelect={setSelectedFile} onFileSelect={handleFileSelect}
onFileRemove={handleReset} onFileRemove={handleFileRemove}
selectedFile={selectedFile} selectedFiles={selectedFiles}
disabled={conversionJob?.status === 'processing' || conversionJob?.status === 'loading'} disabled={isConverting}
/> />
{/* File Info */} {/* File Info - show first file */}
{selectedFile && inputFormat && ( {selectedFiles.length > 0 && inputFormat && (
<FileInfo file={selectedFile} format={inputFormat} /> <FileInfo file={selectedFiles[0]} format={inputFormat} />
)} )}
{/* Format selection */} {/* Format selection */}
@@ -221,7 +295,7 @@ export function FileConverter() {
selectedFormat={outputFormat} selectedFormat={outputFormat}
onFormatSelect={setOutputFormat} onFormatSelect={setOutputFormat}
label="Output Format" label="Output Format"
disabled={conversionJob?.status === 'processing' || conversionJob?.status === 'loading'} disabled={isConverting}
/> />
</div> </div>
)} )}
@@ -233,7 +307,7 @@ export function FileConverter() {
outputFormat={outputFormat} outputFormat={outputFormat}
options={conversionOptions} options={conversionOptions}
onOptionsChange={setConversionOptions} onOptionsChange={setConversionOptions}
disabled={conversionJob?.status === 'processing' || conversionJob?.status === 'loading'} disabled={isConverting}
/> />
)} )}
@@ -246,9 +320,9 @@ export function FileConverter() {
className="flex-1" className="flex-1"
size="lg" size="lg"
> >
{conversionJob?.status === 'loading' || conversionJob?.status === 'processing' {isConverting
? 'Converting...' ? 'Converting...'
: 'Convert'} : `Convert ${selectedFiles.length} File${selectedFiles.length > 1 ? 's' : ''}`}
</Button> </Button>
<Button onClick={handleReset} variant="outline" size="lg"> <Button onClick={handleReset} variant="outline" size="lg">
Reset Reset
@@ -258,8 +332,31 @@ export function FileConverter() {
</CardContent> </CardContent>
</Card> </Card>
{/* Conversion preview */} {/* Download All Button */}
{conversionJob && <ConversionPreview job={conversionJob} />} {completedCount > 0 && (
<Card>
<CardContent className="pt-6">
<Button
onClick={handleDownloadAll}
className="w-full"
size="lg"
variant="default"
>
Download All ({completedCount} file{completedCount > 1 ? 's' : ''})
{completedCount > 1 && ' as ZIP'}
</Button>
</CardContent>
</Card>
)}
{/* Conversion previews */}
{conversionJobs.length > 0 && (
<div className="space-y-4">
{conversionJobs.map((job) => (
<ConversionPreview key={job.id} job={job} />
))}
</div>
)}
</div> </div>
); );
} }

View File

@@ -7,9 +7,9 @@ import { formatFileSize } from '@/lib/utils/fileUtils';
import { Button } from '@/components/ui/Button'; import { Button } from '@/components/ui/Button';
export interface FileUploadProps { export interface FileUploadProps {
onFileSelect: (file: File) => void; onFileSelect: (files: File[]) => void;
onFileRemove: () => void; onFileRemove: (index: number) => void;
selectedFile?: File; selectedFiles?: File[];
accept?: string; accept?: string;
maxSizeMB?: number; maxSizeMB?: number;
disabled?: boolean; disabled?: boolean;
@@ -18,7 +18,7 @@ export interface FileUploadProps {
export function FileUpload({ export function FileUpload({
onFileSelect, onFileSelect,
onFileRemove, onFileRemove,
selectedFile, selectedFiles = [],
accept, accept,
maxSizeMB = 500, maxSizeMB = 500,
disabled = false, disabled = false,
@@ -54,26 +54,36 @@ export function FileUpload({
const files = Array.from(e.dataTransfer.files); const files = Array.from(e.dataTransfer.files);
if (files.length > 0) { if (files.length > 0) {
handleFile(files[0]); handleFiles(files);
} }
}; };
const handleFileInput = (e: React.ChangeEvent<HTMLInputElement>) => { const handleFileInput = (e: React.ChangeEvent<HTMLInputElement>) => {
const files = Array.from(e.target.files || []); const files = Array.from(e.target.files || []);
if (files.length > 0) { if (files.length > 0) {
handleFile(files[0]); handleFiles(files);
} }
}; };
const handleFile = (file: File) => { const handleFiles = (files: File[]) => {
// Check file size // Check file sizes
const maxBytes = maxSizeMB * 1024 * 1024; const maxBytes = maxSizeMB * 1024 * 1024;
if (file.size > maxBytes) { const validFiles = files.filter(file => {
alert(`File size exceeds ${maxSizeMB}MB limit. Please choose a smaller file.`); if (file.size > maxBytes) {
return; 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 = () => { const handleClick = () => {
@@ -82,45 +92,58 @@ export function FileUpload({
} }
}; };
const handleRemove = (e: React.MouseEvent) => { const handleRemove = (index: number) => (e: React.MouseEvent) => {
e.stopPropagation(); e.stopPropagation();
onFileRemove(); onFileRemove(index);
if (fileInputRef.current) {
fileInputRef.current.value = '';
}
}; };
return ( return (
<div className="w-full"> <div className="w-full space-y-3">
<input <input
ref={fileInputRef} ref={fileInputRef}
type="file" type="file"
multiple
className="hidden" className="hidden"
accept={accept} accept={accept}
onChange={handleFileInput} onChange={handleFileInput}
disabled={disabled} disabled={disabled}
/> />
{selectedFile ? ( {selectedFiles.length > 0 ? (
<div className="border-2 border-border rounded-lg p-6 bg-card"> <div className="space-y-2">
<div className="flex items-center justify-between"> {selectedFiles.map((file, index) => (
<div className="flex-1 min-w-0"> <div key={`${file.name}-${index}`} className="border-2 border-border rounded-lg p-4 bg-card">
<p className="text-sm font-medium text-foreground truncate">{selectedFile.name}</p> <div className="flex items-center justify-between">
<p className="text-xs text-muted-foreground mt-1"> <div className="flex-1 min-w-0">
{formatFileSize(selectedFile.size)} <p className="text-sm font-medium text-foreground truncate">{file.name}</p>
</p> <p className="text-xs text-muted-foreground mt-1">
{formatFileSize(file.size)}
</p>
</div>
<Button
variant="ghost"
size="icon"
onClick={handleRemove(index)}
disabled={disabled}
className="ml-4"
>
<X className="h-4 w-4" />
<span className="sr-only">Remove file</span>
</Button>
</div>
</div> </div>
<Button ))}
variant="ghost"
size="icon" {/* Add more files button */}
onClick={handleRemove} <Button
disabled={disabled} variant="outline"
className="ml-4" onClick={handleClick}
> disabled={disabled}
<X className="h-4 w-4" /> className="w-full"
<span className="sr-only">Remove file</span> >
</Button> <Upload className="h-4 w-4 mr-2" />
</div> Add More Files
</Button>
</div> </div>
) : ( ) : (
<div <div
@@ -141,10 +164,10 @@ export function FileUpload({
> >
<Upload className="mx-auto h-12 w-12 text-muted-foreground mb-4" /> <Upload className="mx-auto h-12 w-12 text-muted-foreground mb-4" />
<p className="text-sm font-medium text-foreground mb-1"> <p className="text-sm font-medium text-foreground mb-1">
Drop your file here or click to browse Drop your files here or click to browse
</p> </p>
<p className="text-xs text-muted-foreground"> <p className="text-xs text-muted-foreground">
Maximum file size: {maxSizeMB}MB Maximum file size: {maxSizeMB}MB per file
</p> </p>
</div> </div>
)} )}

View File

@@ -93,3 +93,22 @@ export function validateFileType(file: File, allowedTypes: string[]): boolean {
return file.type === type; return file.type === type;
}); });
} }
/**
* Download multiple blobs as a ZIP file
*/
export async function downloadBlobsAsZip(files: Array<{ blob: Blob; filename: string }>, zipFilename: string): Promise<void> {
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);
}

View File

@@ -14,6 +14,7 @@
"@imagemagick/magick-wasm": "^0.0.30", "@imagemagick/magick-wasm": "^0.0.30",
"clsx": "^2.1.1", "clsx": "^2.1.1",
"fuse.js": "^7.1.0", "fuse.js": "^7.1.0",
"jszip": "^3.10.1",
"lucide-react": "^0.553.0", "lucide-react": "^0.553.0",
"next": "^16.0.0", "next": "^16.0.0",
"react": "^19.0.0", "react": "^19.0.0",

85
pnpm-lock.yaml generated
View File

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