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:
Dương Cầm
2026-02-02 22:34:39 +07:00
parent acc2a06098
commit 36db0f70e2
3 changed files with 139 additions and 16 deletions

View File

@@ -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");
}
}
};

View File

@@ -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
View 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";
}