mirror of
https://github.com/duongcamcute/tech-gadget-manager.git
synced 2026-06-27 22:36:08 +00:00
✨ Tính năng: Tự động resize và convert ảnh sang WebP khi upload
- Thêm src/lib/imageUtils.ts với optimizeImage() - Resize ảnh về tối đa 800x800px, convert sang WebP - Giảm dung lượng 70-90% so với ảnh gốc - Áp dụng cho SmartAddForm và ItemDetailDialog - Backup không bị ảnh hưởng (ảnh đã optimize lưu trong DB)
This commit is contained in:
@@ -21,6 +21,7 @@ import { IconSelect } from "@/components/ui/IconSelect";
|
||||
import { getColorHex } from "@/lib/utils/colors";
|
||||
import { ITEM_TYPES, LOCATION_ICONS, ITEM_ICONS } from "@/lib/constants/options";
|
||||
import { TECH_SUGGESTIONS } from "@/lib/constants";
|
||||
import { optimizeImage, formatBytes, getBase64Size } from "@/lib/imageUtils";
|
||||
|
||||
const formatCurrency = (amount: number | null | undefined) => {
|
||||
if (!amount) return "---";
|
||||
@@ -461,16 +462,23 @@ function EditMode({ item, locations, onCancel, onClose }: { item: any, locations
|
||||
}, [purchaseDate, warrantyMonths, form]);
|
||||
const watchedColor = form.watch("color");
|
||||
|
||||
const handleImageChange = (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
const handleImageChange = async (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
const file = e.target.files?.[0];
|
||||
if (file) {
|
||||
const reader = new FileReader();
|
||||
reader.onloadend = () => {
|
||||
const res = reader.result as string;
|
||||
setImgPreview(res);
|
||||
form.setValue("image", res);
|
||||
};
|
||||
reader.readAsDataURL(file);
|
||||
try {
|
||||
// Optimize image: resize to 800x800 max, convert to WebP
|
||||
const optimized = await optimizeImage(file);
|
||||
setImgPreview(optimized);
|
||||
form.setValue("image", optimized);
|
||||
|
||||
// Log size reduction
|
||||
const originalSize = file.size;
|
||||
const newSize = getBase64Size(optimized);
|
||||
console.log(`Image optimized: ${formatBytes(originalSize)} → ${formatBytes(newSize)} (${Math.round((1 - newSize / originalSize) * 100)}% smaller)`);
|
||||
} catch (err) {
|
||||
console.error("Image optimization failed:", err);
|
||||
toast("Lỗi xử lý ảnh", "error");
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
|
||||
@@ -23,6 +23,8 @@ import { Check, ChevronsUpDown } from "lucide-react";
|
||||
import { cn, buildLocationTree } from "@/lib/utils";
|
||||
import { LOCATION_ICONS, ITEM_ICONS } from "@/lib/constants/options";
|
||||
|
||||
import { optimizeImage, formatBytes, getBase64Size } from "@/lib/imageUtils";
|
||||
|
||||
const COLORS = [
|
||||
{ name: 'Đen', hex: '#000000', class: 'bg-black' },
|
||||
{ name: 'Trắng', hex: '#ffffff', class: 'bg-white border border-gray-200' },
|
||||
@@ -201,16 +203,23 @@ export function SmartAddForm({ locations, onSuccess }: SmartAddFormProps) {
|
||||
}
|
||||
};
|
||||
|
||||
const handleImageChange = (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
const handleImageChange = async (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
const file = e.target.files?.[0];
|
||||
if (file) {
|
||||
const reader = new FileReader();
|
||||
reader.onloadend = () => {
|
||||
const res = reader.result as string;
|
||||
setImgPreview(res);
|
||||
form.setValue("image", res);
|
||||
};
|
||||
reader.readAsDataURL(file);
|
||||
try {
|
||||
// Optimize image: resize to 800x800 max, convert to WebP
|
||||
const optimized = await optimizeImage(file);
|
||||
setImgPreview(optimized);
|
||||
form.setValue("image", optimized);
|
||||
|
||||
// Log size reduction
|
||||
const originalSize = file.size;
|
||||
const newSize = getBase64Size(optimized);
|
||||
console.log(`Image optimized: ${formatBytes(originalSize)} → ${formatBytes(newSize)} (${Math.round((1 - newSize / originalSize) * 100)}% smaller)`);
|
||||
} catch (err) {
|
||||
console.error("Image optimization failed:", err);
|
||||
toast("Lỗi xử lý ảnh", "error");
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
|
||||
106
src/lib/imageUtils.ts
Normal file
106
src/lib/imageUtils.ts
Normal file
@@ -0,0 +1,106 @@
|
||||
/**
|
||||
* Image optimization utilities for client-side processing
|
||||
* Resize and convert images to WebP format before upload
|
||||
*/
|
||||
|
||||
export interface ImageOptimizeOptions {
|
||||
maxWidth?: number;
|
||||
maxHeight?: number;
|
||||
quality?: number; // 0-1, default 0.8
|
||||
}
|
||||
|
||||
const DEFAULT_OPTIONS: Required<ImageOptimizeOptions> = {
|
||||
maxWidth: 800,
|
||||
maxHeight: 800,
|
||||
quality: 0.8,
|
||||
};
|
||||
|
||||
/**
|
||||
* Optimize an image file by resizing and converting to WebP
|
||||
* @param file - The image file to optimize
|
||||
* @param options - Optional settings for max dimensions and quality
|
||||
* @returns Promise<string> - Base64 data URL of the optimized image
|
||||
*/
|
||||
export async function optimizeImage(
|
||||
file: File,
|
||||
options?: ImageOptimizeOptions
|
||||
): Promise<string> {
|
||||
const opts = { ...DEFAULT_OPTIONS, ...options };
|
||||
|
||||
return new Promise((resolve, reject) => {
|
||||
// Create image element to load the file
|
||||
const img = new Image();
|
||||
const url = URL.createObjectURL(file);
|
||||
|
||||
img.onload = () => {
|
||||
URL.revokeObjectURL(url); // Clean up
|
||||
|
||||
// Calculate new dimensions maintaining aspect ratio
|
||||
let { width, height } = img;
|
||||
|
||||
if (width > opts.maxWidth || height > opts.maxHeight) {
|
||||
const ratio = Math.min(opts.maxWidth / width, opts.maxHeight / height);
|
||||
width = Math.round(width * ratio);
|
||||
height = Math.round(height * ratio);
|
||||
}
|
||||
|
||||
// Create canvas and draw resized image
|
||||
const canvas = document.createElement("canvas");
|
||||
canvas.width = width;
|
||||
canvas.height = height;
|
||||
|
||||
const ctx = canvas.getContext("2d");
|
||||
if (!ctx) {
|
||||
reject(new Error("Could not get canvas context"));
|
||||
return;
|
||||
}
|
||||
|
||||
// Use high quality rendering
|
||||
ctx.imageSmoothingEnabled = true;
|
||||
ctx.imageSmoothingQuality = "high";
|
||||
ctx.drawImage(img, 0, 0, width, height);
|
||||
|
||||
// Convert to WebP (fallback to JPEG if WebP not supported)
|
||||
let dataUrl: string;
|
||||
try {
|
||||
dataUrl = canvas.toDataURL("image/webp", opts.quality);
|
||||
// Check if browser actually supports WebP
|
||||
if (!dataUrl.startsWith("data:image/webp")) {
|
||||
dataUrl = canvas.toDataURL("image/jpeg", opts.quality);
|
||||
}
|
||||
} catch {
|
||||
dataUrl = canvas.toDataURL("image/jpeg", opts.quality);
|
||||
}
|
||||
|
||||
resolve(dataUrl);
|
||||
};
|
||||
|
||||
img.onerror = () => {
|
||||
URL.revokeObjectURL(url);
|
||||
reject(new Error("Failed to load image"));
|
||||
};
|
||||
|
||||
img.src = url;
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Get file size from base64 string (approximate)
|
||||
* @param base64 - Base64 data URL
|
||||
* @returns Size in bytes
|
||||
*/
|
||||
export function getBase64Size(base64: string): number {
|
||||
// Remove data URL prefix
|
||||
const base64String = base64.split(",")[1] || base64;
|
||||
// Base64 encodes 3 bytes in 4 chars, so multiply by 3/4
|
||||
return Math.round((base64String.length * 3) / 4);
|
||||
}
|
||||
|
||||
/**
|
||||
* Format bytes to human readable string
|
||||
*/
|
||||
export function formatBytes(bytes: number): string {
|
||||
if (bytes < 1024) return bytes + " B";
|
||||
if (bytes < 1024 * 1024) return (bytes / 1024).toFixed(1) + " KB";
|
||||
return (bytes / (1024 * 1024)).toFixed(1) + " MB";
|
||||
}
|
||||
Reference in New Issue
Block a user