Skip to content

Commit cc5a5f5

Browse files
committed
content(app): image compressor
1 parent 5316449 commit cc5a5f5

File tree

4 files changed

+309
-0
lines changed

4 files changed

+309
-0
lines changed

public/apps/apps.json

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -211,6 +211,13 @@
211211
"description": "A toolkit for basic image manipulations.",
212212
"icon": "ImageIcon"
213213
},
214+
{
215+
"slug": "image-compressor",
216+
"to": "/apps/image-compressor",
217+
"title": "Image Compressor",
218+
"description": "Compress images to reduce file size while maintaining quality.",
219+
"icon": "ArrowsInLineHorizontalIcon"
220+
},
214221
{
215222
"slug": "word-counter",
216223
"to": "/apps/word-counter",

src/components/AnimatedRoutes.js

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -50,6 +50,7 @@ import MemoryGamePage from '../pages/apps/MemoryGamePage'; // Import MemoryGameP
5050
import RockPaperScissorsPage from '../pages/apps/RockPaperScissorsPage'; // Import RockPaperScissorsPage
5151
import TicTacToePage from '../pages/apps/TicTacToePage'; // Import TicTacToePage
5252
import ConnectFourPage from '../pages/apps/ConnectFourPage'; // Import ConnectFourPage
53+
import ImageCompressorPage from '../pages/apps/ImageCompressorPage'; // Import ImageCompressorPage
5354
import SettingsPage from '../pages/SettingsPage';
5455

5556
import UsefulLinksPage from '../pages/UsefulLinksPage';
@@ -487,6 +488,10 @@ function AnimatedRoutes() {
487488
path="/apps::cron"
488489
element={<Navigate to="/apps/cron-job-generator" replace />}
489490
/>
491+
<Route
492+
path="/apps::imc"
493+
element={<Navigate to="/apps/image-compressor" replace />}
494+
/>
490495
<Route
491496
path="/apps::excuse"
492497
element={<Navigate to="/apps/excuse-generator" replace />}
@@ -969,6 +974,20 @@ function AnimatedRoutes() {
969974
</motion.div>
970975
}
971976
/>
977+
<Route
978+
path="/apps/image-compressor"
979+
element={
980+
<motion.div
981+
initial="initial"
982+
animate="in"
983+
exit="out"
984+
variants={pageVariants}
985+
transition={pageTransition}
986+
>
987+
<ImageCompressorPage />
988+
</motion.div>
989+
}
990+
/>
972991
{/* D&D specific 404 page */}
973992
<Route
974993
path="/stories/*"
Lines changed: 281 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,281 @@
1+
import React, { useState, useRef, useEffect, useCallback } from 'react';
2+
import { Link } from 'react-router-dom';
3+
import { ArrowLeftIcon, ArrowsInLineHorizontalIcon } from '@phosphor-icons/react';
4+
import colors from '../../config/colors';
5+
import { useToast } from '../../hooks/useToast';
6+
import useSeo from '../../hooks/useSeo';
7+
8+
const ImageCompressorPage = () => {
9+
useSeo({
10+
title: 'Image Compressor | Fezcodex',
11+
description: 'Compress images to reduce file size while maintaining quality.',
12+
keywords: ['Fezcodex', 'image compressor', 'compress image', 'reduce image size', 'optimize image'],
13+
ogTitle: 'Image Compressor | Fezcodex',
14+
ogDescription: 'Compress images to reduce file size while maintaining quality.',
15+
ogImage: 'https://fezcode.github.io/logo512.png',
16+
twitterCard: 'summary_large_image',
17+
twitterTitle: 'Image Compressor | Fezcodex',
18+
twitterDescription: 'Compress images to reduce file size while maintaining quality.',
19+
twitterImage: 'https://fezcode.github.io/logo512.png',
20+
});
21+
22+
const { addToast } = useToast();
23+
const [originalImage, setOriginalImage] = useState(null);
24+
const [compressedImage, setCompressedImage] = useState(null);
25+
const [originalSize, setOriginalSize] = useState(0);
26+
const [compressedSize, setCompressedSize] = useState(0);
27+
const [quality, setQuality] = useState(0.7); // Default compression quality
28+
const [shouldCompress, setShouldCompress] = useState(false); // New state to control manual compression
29+
const canvasRef = useRef(null);
30+
31+
const cardStyle = {
32+
backgroundColor: colors['app-alpha-10'],
33+
borderColor: colors['app-alpha-50'],
34+
color: colors.app,
35+
};
36+
37+
const buttonStyle = `px-6 py-2 rounded-md text-lg font-arvo font-normal transition-colors duration-300 ease-in-out roll-button`;
38+
const appButtonStyle =
39+
'border-app/100 bg-app/20 hover:bg-app/40 cursor-pointer text-app hover:text-white';
40+
41+
const handleImageUpload = (e) => {
42+
const file = e.target.files[0];
43+
if (!file) return;
44+
45+
setOriginalSize(file.size);
46+
47+
const reader = new FileReader();
48+
reader.onload = (event) => {
49+
console.log('Original image loaded into FileReader. Setting originalImage state.');
50+
setOriginalImage(event.target.result);
51+
// Reset compressed image and size when a new image is uploaded
52+
setCompressedImage(null);
53+
setCompressedSize(0);
54+
};
55+
reader.readAsDataURL(file);
56+
};
57+
58+
const compressImage = useCallback(() => {
59+
if (!originalImage) {
60+
addToast({ title: 'Info', message: 'Please upload an image first.' });
61+
return;
62+
}
63+
if (!canvasRef.current) {
64+
console.warn("Canvas ref is not current. Skipping image compression.");
65+
return;
66+
}
67+
68+
const img = new Image();
69+
img.src = originalImage;
70+
img.onload = () => {
71+
console.log('Image loaded, attempting to compress...');
72+
try {
73+
const canvas = canvasRef.current;
74+
const ctx = canvas.getContext('2d');
75+
76+
canvas.width = img.width;
77+
canvas.height = img.height;
78+
ctx.drawImage(img, 0, 0, img.width, img.height);
79+
80+
const compressedDataUrl = canvas.toDataURL('image/jpeg', quality);
81+
setCompressedImage(compressedDataUrl);
82+
83+
const base64Length = compressedDataUrl.length - 'data:image/jpeg;base64,'.length;
84+
const sizeInBytes = base64Length * 0.75;
85+
setCompressedSize(sizeInBytes);
86+
87+
addToast({ title: 'Success', message: 'Image compressed successfully.' });
88+
console.log('Image compressed and state updated.');
89+
} catch (error) {
90+
console.error('Error during image compression:', error);
91+
addToast({ title: 'Error', message: 'Failed to compress image. Check console for details.', type: 'error' });
92+
setCompressedImage(null); // Ensure compressedImage is null on error
93+
setCompressedSize(0);
94+
}
95+
};
96+
img.onerror = () => {
97+
console.error('Error loading image into Image object.');
98+
addToast({ title: 'Error', message: 'Failed to load image. Please try another file.', type: 'error' });
99+
setOriginalImage(null);
100+
setCompressedImage(null);
101+
setOriginalSize(0);
102+
setCompressedSize(0);
103+
};
104+
}, [originalImage, quality, addToast, setCompressedImage, setCompressedSize, setOriginalImage, setOriginalSize]);
105+
106+
const handleDownload = () => {
107+
if (compressedImage) {
108+
const link = document.createElement('a');
109+
link.download = `compressed_image_${(quality * 100).toFixed(0)}.jpeg`;
110+
link.href = compressedImage;
111+
document.body.appendChild(link);
112+
link.click();
113+
document.body.removeChild(link);
114+
} else {
115+
addToast({ title: 'Info', message: 'No compressed image to download.' });
116+
}
117+
};
118+
119+
// Added compressImage to dependencies
120+
121+
const formatBytes = (bytes, decimals = 2) => {
122+
if (bytes === 0) return '0 Bytes';
123+
const k = 1024;
124+
const dm = decimals < 0 ? 0 : decimals;
125+
const sizes = ['Bytes', 'KB', 'MB', 'GB', 'TB'];
126+
const i = Math.floor(Math.log(bytes) / Math.log(k));
127+
return parseFloat((bytes / Math.pow(k, i)).toFixed(dm)) + ' ' + sizes[i];
128+
};
129+
130+
return (
131+
<div className="py-16 sm:py-24">
132+
<div className="mx-auto max-w-7xl px-6 lg:px-8 text-gray-300">
133+
<Link
134+
to="/apps"
135+
className="text-article hover:underline flex items-center justify-center gap-2 text-lg mb-4"
136+
>
137+
<ArrowLeftIcon size={24} /> Back to Apps
138+
</Link>
139+
<h1 className="text-4xl font-bold tracking-tight sm:text-6xl mb-4 flex items-center">
140+
<span className="codex-color">fc</span>
141+
<span className="separator-color">::</span>
142+
<span className="apps-color">apps</span>
143+
<span className="separator-color">::</span>
144+
<span className="single-app-color">imc</span>
145+
</h1>
146+
<hr className="border-gray-700" />
147+
<div className="flex justify-center items-center mt-16">
148+
<div
149+
className="group bg-transparent border rounded-lg shadow-2xl p-6 flex flex-col justify-between relative overflow-hidden h-full w-full max-w-4xl"
150+
style={cardStyle}
151+
>
152+
<div
153+
className="absolute top-0 left-0 w-full h-full opacity-10"
154+
style={{
155+
backgroundImage:
156+
'radial-gradient(circle, white 1px, transparent 1px)',
157+
backgroundSize: '10px 10px',
158+
}}
159+
></div>
160+
<div className="relative z-10 p-1">
161+
<h1 className="text-3xl font-arvo font-normal mb-4 text-app flex items-center gap-2">
162+
<ArrowsInLineHorizontalIcon size={32} /> Image Compressor
163+
</h1>
164+
<hr className="border-gray-700 mb-4" />
165+
166+
{/* Client-Side Notification */}
167+
<div
168+
className="bg-yellow-900 bg-opacity-30 border border-yellow-700 text-yellow-300 px-4 py-3 rounded relative mb-6"
169+
role="alert"
170+
>
171+
<strong className="font-bold">Client-Side Only:</strong>
172+
<span className="block sm:inline ml-2">
173+
All image processing happens directly in your browser using canvas API. No data is uploaded to any server.
174+
</span>
175+
</div>
176+
177+
<div className="mb-4">
178+
<label
179+
htmlFor="image-upload"
180+
className={`${buttonStyle} ${appButtonStyle} cursor-pointer inline-flex items-center justify-center`}
181+
>
182+
Upload Image
183+
<input
184+
id="image-upload"
185+
type="file"
186+
accept="image/*"
187+
onChange={handleImageUpload}
188+
className="hidden"
189+
/>
190+
</label>
191+
</div>
192+
193+
{originalImage && (
194+
<div className="mb-4">
195+
<label
196+
htmlFor="quality-slider"
197+
className="block text-sm font-medium text-gray-300 mb-2"
198+
>
199+
Compression Quality: {(quality * 100).toFixed(0)}%
200+
</label>
201+
<input
202+
id="quality-slider"
203+
type="range"
204+
min="0.1"
205+
max="1"
206+
step="0.05"
207+
value={quality}
208+
onChange={(e) => setQuality(parseFloat(e.target.value))}
209+
className="w-full h-2 bg-gray-200 rounded-lg appearance-none cursor-pointer dark:bg-gray-700"
210+
/>
211+
<button
212+
onClick={compressImage}
213+
className={`${buttonStyle} ${appButtonStyle} mt-4`}
214+
>
215+
Compress Image
216+
</button>
217+
</div>
218+
)}
219+
220+
{originalImage && <p>Original Image State: <span className="text-green-400">Set</span></p>}
221+
{compressedImage && <p>Compressed Image State: <span className="text-green-400">Set</span></p>}
222+
223+
<div className="grid grid-cols-1 md:grid-cols-2 gap-4 mt-4">
224+
{originalImage && (
225+
<div className="border border-gray-500 p-2 rounded-lg">
226+
<h3 className="text-xl font-bold mb-2 text-center">Original Image</h3>
227+
<img src={originalImage} alt="Original" className="max-w-full h-auto rounded-lg mx-auto" />
228+
<p className="text-center mt-2">Size: {formatBytes(originalSize)}</p>
229+
</div>
230+
)}
231+
{/* Always render canvas if original image is present to make canvasRef available */}
232+
{originalImage && (
233+
<div className="hidden"> {/* Hidden as it's for off-screen processing */}
234+
<canvas ref={canvasRef}></canvas>
235+
</div>
236+
)}
237+
238+
{compressedImage && (
239+
<div className="border border-gray-500 p-2 rounded-lg">
240+
<h3 className="text-xl font-bold mb-2 text-center">Compressed Image</h3>
241+
<img src={compressedImage} alt="Compressed" className="max-w-full h-auto rounded-lg mx-auto" />
242+
<p className="text-center mt-2">Size: {formatBytes(compressedSize)}</p>
243+
{originalSize > 0 && compressedSize > 0 && (
244+
<>
245+
{originalSize >= compressedSize ? (
246+
<p className="text-center text-green-400">
247+
Saved: {formatBytes(originalSize - compressedSize)} (
248+
{(((originalSize - compressedSize) / originalSize) * 100).toFixed(2)}%)
249+
</p>
250+
) : (
251+
<p className="text-center text-red-400">
252+
Expanded: {formatBytes(compressedSize - originalSize)} (
253+
{(((compressedSize - originalSize) / originalSize) * 100).toFixed(2)}%)
254+
</p>
255+
)}
256+
</>
257+
)}
258+
</div>
259+
)}
260+
</div>
261+
262+
{compressedImage && (
263+
<div className="flex justify-center mt-4">
264+
<button onClick={handleDownload} className={`${buttonStyle} ${appButtonStyle}`}>
265+
Download{' '}
266+
<span className={compressedSize < originalSize ? 'text-green-400' : 'text-red-600'}>
267+
{compressedSize < originalSize ? 'Compressed' : 'Expanded'}
268+
</span>{' '}
269+
Image
270+
</button>
271+
</div>
272+
)}
273+
</div>
274+
</div>
275+
</div>
276+
</div>
277+
</div>
278+
);
279+
};
280+
281+
export default ImageCompressorPage;

src/utils/appIcons.js

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -29,6 +29,7 @@ import {
2929
BrainIcon,
3030
HandshakeIcon,
3131
XCircleIcon,
32+
ArrowsInLineHorizontalIcon,
3233
} from '@phosphor-icons/react';
3334

3435
export const appIcons = {
@@ -62,4 +63,5 @@ export const appIcons = {
6263
BrainIcon,
6364
HandshakeIcon,
6465
XCircleIcon,
66+
ArrowsInLineHorizontalIcon,
6567
};

0 commit comments

Comments
 (0)