Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2,628 changes: 2,628 additions & 0 deletions pnpm-lock.yaml

Large diffs are not rendered by default.

151 changes: 89 additions & 62 deletions src/App.tsx
Original file line number Diff line number Diff line change
@@ -1,52 +1,79 @@
import React, { useState, useCallback } from 'react';
import React, { useState, useCallback, useEffect } from 'react';
import { Image, Trash2 } from 'lucide-react';
import { CompressionOptions } from './components/CompressionOptions';
import { DropZone } from './components/DropZone';
import { ImageList } from './components/ImageList';
import { DownloadAll } from './components/DownloadAll';
import { ResizeOptions } from './components/ResizeOptions';
import { ProcessingOptions } from './components/ProcessingOptions';
import { useImageQueue } from './hooks/useImageQueue';
import { DEFAULT_QUALITY_SETTINGS } from './utils/formatDefaults';
import { DEFAULT_RESIZE_OPTIONS } from './types';
import type { ImageFile, OutputType, CompressionOptions as CompressionOptionsType } from './types';

export function App() {
const [images, setImages] = useState<ImageFile[]>([]);
const [outputType, setOutputType] = useState<OutputType>('webp');
const [outputType, setOutputType] = useState<OutputType>('avif');
const [options, setOptions] = useState<CompressionOptionsType>({
quality: DEFAULT_QUALITY_SETTINGS.webp,
quality: DEFAULT_QUALITY_SETTINGS.webp, // or whichever default
});
const [resizeOptions, setResizeOptions] = useState(DEFAULT_RESIZE_OPTIONS);
const [processLevel, setProcessLevel] = useState(2); // processing intensity

// Update each "processing" image's elapsed time every 2 seconds
useEffect(() => {
const timer = setInterval(() => {
setImages((prev) =>
prev.map((img) => {
if (img.status === 'processing' && img.startTime) {
return {
...img,
elapsed: ((Date.now() - img.startTime) / 1000 + 1).toFixed(0)
};
} else if (img.status === 'processing' && !img.startTime) {
return { ...img, startTime: Date.now() };
}
return img;
})
);
}, 2000);
return () => clearInterval(timer);
}, []);

const { addToQueue } = useImageQueue(options, outputType, setImages);
const { addToQueue } = useImageQueue(
options,
outputType,
setImages,
resizeOptions,
processLevel
);

const handleOutputTypeChange = useCallback((type: OutputType) => {
setOutputType(type);
if (type !== 'png') {
setOptions({ quality: DEFAULT_QUALITY_SETTINGS[type] });
}
}, []);

const handleFilesDrop = useCallback((newImages: ImageFile[]) => {
// First add all images to state
setImages((prev) => [...prev, ...newImages]);

// Use requestAnimationFrame to wait for render to complete
requestAnimationFrame(() => {
// Then add to queue after UI has updated
newImages.forEach(image => addToQueue(image.id));
});
}, [addToQueue]);
const handleFilesDrop = useCallback(
(newImages: ImageFile[]) => {
setImages((prev) => [...prev, ...newImages]);
requestAnimationFrame(() => {
newImages.forEach((image) => addToQueue(image.id));
});
},
[addToQueue]
);

const handleRemoveImage = useCallback((id: string) => {
setImages((prev) => {
const image = prev.find(img => img.id === id);
const image = prev.find((img) => img.id === id);
if (image?.preview) {
URL.revokeObjectURL(image.preview);
}
return prev.filter(img => img.id !== id);
return prev.filter((img) => img.id !== id);
});
}, []);

const handleClearAll = useCallback(() => {
images.forEach(image => {
images.forEach((image) => {
if (image.preview) {
URL.revokeObjectURL(image.preview);
}
Expand All @@ -55,65 +82,65 @@ export function App() {
}, [images]);

const handleDownloadAll = useCallback(async () => {
const completedImages = images.filter((img) => img.status === "complete");

const completedImages = images.filter((img) => img.status === 'complete');
for (const image of completedImages) {
if (image.blob && image.outputType) {
const link = document.createElement("a");
const link = document.createElement('a');
link.href = URL.createObjectURL(image.blob);
link.download = `${image.file.name.split(".")[0]}.${image.outputType}`;
link.download = `${image.file.name.split('.')[0]}.${image.outputType}`;
link.click();
URL.revokeObjectURL(link.href);
}

await new Promise((resolve) => setTimeout(resolve, 100));
}
}, [images]);

const completedImages = images.filter(img => img.status === 'complete').length;
const completedImages = images.filter((img) => img.status === 'complete').length;

return (
<div className="min-h-screen bg-gray-50">
<div className="max-w-4xl mx-auto px-4 py-12">
<div className="text-center mb-8">
<div className="flex items-center justify-center gap-2 mb-4">
<Image className="w-8 h-8 text-blue-500" />
<h1 className="text-3xl font-bold text-gray-900">Squish</h1>
</div>
<p className="text-gray-600">
Compress and convert your images to AVIF, JPEG, JPEG XL, PNG, or WebP
</p>
<div className="max-w-5xl mx-auto p-4">
<div className="flex items-center justify-between mb-4">
<h1 className="text-xl font-bold flex items-center gap-2">
<Image className="w-5 h-5" />
Squoosh Playground
</h1>
<button
onClick={handleClearAll}
className="text-red-500 hover:underline flex items-center gap-1"
>
<Trash2 className="w-4 h-4" />
Clear All
</button>
</div>

<div className="space-y-6">
<CompressionOptions
options={options}
outputType={outputType}
onOptionsChange={setOptions}
onOutputTypeChange={handleOutputTypeChange}
/>

<DropZone onFilesDrop={handleFilesDrop} />

{completedImages > 0 && (
<DownloadAll onDownloadAll={handleDownloadAll} count={completedImages} />
)}

<ImageList
images={images}
onRemove={handleRemoveImage}
<CompressionOptions
options={options}
outputType={outputType}
onOptionsChange={setOptions}
onOutputTypeChange={handleOutputTypeChange}
/>

<ResizeOptions
options={resizeOptions}
onOptionsChange={setResizeOptions}
/>

<ProcessingOptions
processLevel={processLevel}
onChange={setProcessLevel}
/>

<DropZone onFilesDrop={handleFilesDrop} />

{completedImages > 0 && (
<DownloadAll
onDownloadAll={handleDownloadAll}
count={completedImages}
/>
)}

{images.length > 0 && (
<button
onClick={handleClearAll}
className="w-full flex items-center justify-center gap-2 px-4 py-2 bg-blue-500 text-white rounded-lg hover:bg-blue-600 transition-colors"
>
<Trash2 className="w-5 h-5" />
Clear All
</button>
)}
</div>
<ImageList images={images} onRemove={handleRemoveImage} />
</div>
</div>
);
Expand Down
3 changes: 2 additions & 1 deletion src/components/CompressionOptions.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -39,10 +39,11 @@ export function CompressionOptions({

{outputType !== 'png' && (
<div>
<label className="block text-sm font-medium text-gray-700 mb-2">
<label htmlFor='imageQualityRangeInput' className="block text-sm font-medium text-gray-700 mb-2">
Quality: {options.quality}%
</label>
<input
id='imageQualityRangeInput'
type="range"
min="1"
max="100"
Expand Down
2 changes: 1 addition & 1 deletion src/components/DownloadAll.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,7 @@ export function DownloadAll({ onDownloadAll, count }: DownloadAllProps) {
return (
<button
onClick={onDownloadAll}
className="w-full flex items-center justify-center gap-2 px-4 py-2 bg-green-500 text-white rounded-lg hover:bg-green-600 transition-colors"
className="w-full flex items-center justify-center gap-2 px-4 py-2 mt-4 bg-green-500 text-white rounded-lg hover:bg-green-600 transition-colors"
>
<Download className="w-5 h-5" />
Download All ({count} {count === 1 ? 'image' : 'images'})
Expand Down
30 changes: 23 additions & 7 deletions src/components/DropZone.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -37,11 +37,30 @@ export function DropZone({ onFilesDrop }: DropZoneProps) {
e.target.value = '';
}, [onFilesDrop]);

const handleKeyDown = useCallback((e: React.KeyboardEvent) => {
if (e.key === 'Enter' || e.key === ' ') {
e.preventDefault();
const fileInput = document.getElementById('fileInput') as HTMLInputElement | null;
fileInput?.click();
}
}, []);

// New handler to trigger file input click when entire zone is clicked.
const handleClick = useCallback(() => {
const fileInput = document.getElementById('fileInput') as HTMLInputElement | null;
fileInput?.click();
}, []);

return (
<div
className="border-2 border-dashed border-gray-300 rounded-lg p-12 text-center hover:border-blue-500 transition-colors"
className="border-2 border-dashed border-gray-300 rounded-lg p-12 text-center hover:border-blue-500 transition-colors cursor-pointer" // Added cursor-pointer
onDrop={handleDrop}
onDragOver={handleDragOver}
onClick={handleClick} // Added onClick handler
tabIndex={0}
onKeyDown={handleKeyDown}
role="button"
aria-label="Upload images by dropping or selecting files"
>
<input
type="file"
Expand All @@ -51,10 +70,7 @@ export function DropZone({ onFilesDrop }: DropZoneProps) {
accept="image/*,.jxl"
onChange={handleFileInput}
/>
<label
htmlFor="fileInput"
className="cursor-pointer flex flex-col items-center gap-4"
>
<div className="flex flex-col items-center gap-4">
<Upload className="w-12 h-12 text-gray-400" />
<div>
<p className="text-lg font-medium text-gray-700">
Expand All @@ -64,7 +80,7 @@ export function DropZone({ onFilesDrop }: DropZoneProps) {
Supports JPEG, PNG, WebP, AVIF, and JXL
</p>
</div>
</label>
</div>
</div>
);
}
}
18 changes: 15 additions & 3 deletions src/components/ImageList.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -54,16 +54,28 @@ export function ImageList({ images, onRemove }: ImageListProps) {
{image.status === 'pending' && (
<span>Ready to process</span>
)}

{image.status === 'processing' && (
<span className="flex flex-col items-start gap-1 text-sm text-gray-500">
<span className="flex items-center gap-2">
<Loader2 className="w-4 h-4 animate-spin" />
Processing...
Processing ({image.progress ? `${image.progress}%` : '...'})
</span>
)}
{image.elapsed && (
<span className="text-xs">
{image.elapsed} sec elapsed
</span>
)}
</span>
)}
{image.status === 'complete' && (
<span className="flex items-center gap-2 text-green-600">
<CheckCircle className="w-4 h-4" />
Complete
Completed {image.elapsed && (<span className="text-xs">
in {image.elapsed} seconds.
</span>
)}

</span>
)}
{image.status === 'error' && (
Expand Down
26 changes: 26 additions & 0 deletions src/components/ProcessingOptions.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,26 @@
import React from 'react';

interface ProcessingOptionsProps {
processLevel: number;
onChange: (level: number) => void;
}

export function ProcessingOptions({ processLevel, onChange }: ProcessingOptionsProps) {
return (
<div className="bg-white p-6 rounded-lg shadow-sm mt-6">
<label className="block text-sm font-medium text-gray-700 mb-2">
Processing Intensity (1: light, 12: aggressive)
</label>
<input
type="range"
min="1"
max="12"
step="1"
value={processLevel}
onChange={(e) => onChange(Number(e.target.value))}
className="w-full"
/>
<p className="text-sm text-gray-600">Current Level: {processLevel}</p>
</div>
);
}
Loading