239 lines
9.8 KiB
TypeScript
239 lines
9.8 KiB
TypeScript
import React, { useState, useRef } from 'react';
|
|
import { X, Upload, Image as ImageIcon, Loader2 } from 'lucide-react';
|
|
import { useNotification } from '@/hooks/useNotification';
|
|
import { useTourStore } from '@/store/useTourStore';
|
|
import { processImageModeration } from '@/hooks/useImageModeration';
|
|
import { compressImage } from '../utils/image';
|
|
|
|
interface AddPhotoModalProps {
|
|
isOpen: boolean;
|
|
onClose: () => void;
|
|
tourId: string;
|
|
onSuccess?: () => void;
|
|
isPublicView?: boolean;
|
|
}
|
|
|
|
export const AddPhotoModal: React.FC<AddPhotoModalProps> = ({ isOpen, onClose, tourId, onSuccess, isPublicView }) => {
|
|
const [selectedFiles, setSelectedFiles] = useState<File[]>([]);
|
|
const [previews, setPreviews] = useState<string[]>([]);
|
|
const [isUploading, setIsUploading] = useState(false);
|
|
const [isProcessing, setIsProcessing] = useState(false);
|
|
const fileInputRef = useRef<HTMLInputElement>(null);
|
|
const notify = useNotification();
|
|
const fetchTour = useTourStore(state => state.fetchTour);
|
|
|
|
if (!isOpen) return null;
|
|
|
|
const handleFileChange = async (e: React.ChangeEvent<HTMLInputElement>) => {
|
|
if (e.target.files) {
|
|
const files = Array.from(e.target.files);
|
|
const newValidFiles: File[] = [];
|
|
const newValidPreviews: string[] = [];
|
|
|
|
setIsProcessing(true);
|
|
notify({ title: 'Đang kiểm duyệt...', message: 'Đang kiểm tra và lọc hình ảnh của bạn...', type: 'info' });
|
|
|
|
try {
|
|
for (const file of files) {
|
|
// 1. Kiểm tra cơ bản: Tệp có dung lượng hay không
|
|
if (!file || file.size === 0) {
|
|
notify({ title: 'Lỗi tệp', message: `Tệp ${file.name} trống hoặc không khả dụng.`, type: 'error' });
|
|
continue;
|
|
}
|
|
|
|
// Nén ảnh trước
|
|
const compressedFile = await compressImage(file);
|
|
|
|
// 2. Chạy kiểm duyệt hình ảnh
|
|
const moderationResult = await processImageModeration(compressedFile);
|
|
if (moderationResult.blocked) {
|
|
notify({ title: 'Ảnh bị từ chối', message: `Ảnh ${file.name} chứa nội dung không phù hợp và bị chặn.`, type: 'error' });
|
|
continue;
|
|
}
|
|
|
|
const processedFile = moderationResult.file;
|
|
const previewUrl = URL.createObjectURL(processedFile);
|
|
|
|
// 3. Kiểm tra tính toàn vẹn: Thử load ảnh vào bộ nhớ để xác nhận file "tồn tại" và đọc được
|
|
const isValidImage = await new Promise<boolean>((resolve) => {
|
|
const img = new Image();
|
|
img.onload = () => resolve(true);
|
|
img.onerror = () => resolve(false);
|
|
img.src = previewUrl;
|
|
});
|
|
|
|
if (isValidImage) {
|
|
newValidFiles.push(processedFile);
|
|
newValidPreviews.push(previewUrl);
|
|
} else {
|
|
URL.revokeObjectURL(previewUrl); // Thu hồi ngay nếu không hợp lệ
|
|
notify({ title: 'Lỗi ảnh', message: `Không thể đọc nội dung ảnh: ${file.name}. Vui lòng kiểm tra lại file.`, type: 'error' });
|
|
}
|
|
}
|
|
|
|
setSelectedFiles(prev => [...prev, ...newValidFiles]);
|
|
setPreviews(prev => [...prev, ...newValidPreviews]);
|
|
} catch (err) {
|
|
console.error('File checking error:', err);
|
|
notify({ title: 'Lỗi', message: 'Lỗi trong quá trình kiểm duyệt ảnh.', type: 'error' });
|
|
} finally {
|
|
setIsProcessing(false);
|
|
}
|
|
}
|
|
};
|
|
|
|
const removeFile = (index: number) => {
|
|
// Thu hồi URL khi xóa khỏi danh sách chờ để giải phóng bộ nhớ
|
|
URL.revokeObjectURL(previews[index]);
|
|
setSelectedFiles(prev => prev.filter((_, i) => i !== index));
|
|
setPreviews(prev => prev.filter((_, i) => i !== index));
|
|
};
|
|
|
|
const handleSubmit = async (e: React.FormEvent) => {
|
|
e.preventDefault();
|
|
if (selectedFiles.length === 0) return;
|
|
|
|
setIsUploading(true);
|
|
try {
|
|
// Lấy tọa độ hiện tại của người dùng làm dự phòng nếu ảnh EXIF không có GPS
|
|
const location = await Promise.race([
|
|
new Promise<GeolocationPosition | null>((resolve) => {
|
|
if (!navigator.geolocation) {
|
|
resolve(null);
|
|
} else {
|
|
navigator.geolocation.getCurrentPosition(
|
|
(pos) => resolve(pos),
|
|
() => resolve(null),
|
|
{ timeout: 4000, enableHighAccuracy: true }
|
|
);
|
|
}
|
|
}),
|
|
new Promise<null>((resolve) => setTimeout(() => resolve(null), 4500))
|
|
]);
|
|
|
|
const formData = new FormData();
|
|
selectedFiles.forEach(file => {
|
|
formData.append('images', file);
|
|
});
|
|
|
|
if (location) {
|
|
formData.append('latitude', location.coords.latitude.toString());
|
|
formData.append('longitude', location.coords.longitude.toString());
|
|
}
|
|
|
|
const response = await fetch(`/api/v1/tours/${tourId}/photos`, {
|
|
method: 'POST',
|
|
headers: {
|
|
'Authorization': `Bearer ${localStorage.getItem('token')}`
|
|
},
|
|
body: formData
|
|
});
|
|
|
|
if (!response.ok) throw new Error('Upload failed');
|
|
|
|
notify({
|
|
title: 'Thành công',
|
|
message: `Đã tải lên ${selectedFiles.length} ảnh.`,
|
|
type: 'success'
|
|
});
|
|
|
|
// Giải phóng bộ nhớ sau khi hoàn tất
|
|
previews.forEach(url => URL.revokeObjectURL(url));
|
|
setSelectedFiles([]);
|
|
setPreviews([]);
|
|
|
|
// Refresh tour data (applies to both authenticated and public users)
|
|
// This ensures newly uploaded photos appear immediately without requiring a page reload
|
|
fetchTour(tourId);
|
|
if (onSuccess) onSuccess();
|
|
|
|
// For public users: redirect to landing page after upload (after data refresh)
|
|
// For authenticated users: close modal and show updated tour
|
|
if (isPublicView) {
|
|
onClose();
|
|
// Give time for tour data to refresh before redirecting
|
|
setTimeout(() => {
|
|
localStorage.setItem('fromPublicUpload', 'true');
|
|
window.location.href = '/';
|
|
}, 1500);
|
|
} else {
|
|
onClose();
|
|
}
|
|
} catch (error) {
|
|
notify({
|
|
title: 'Lỗi',
|
|
message: 'Không thể tải ảnh lên. Vui lòng thử lại.',
|
|
type: 'error'
|
|
});
|
|
} finally {
|
|
setIsUploading(false);
|
|
}
|
|
};
|
|
|
|
|
|
return (
|
|
<div className="fixed inset-0 z-[2500] flex items-center justify-center p-4">
|
|
<div className="absolute inset-0 bg-gray-900/60 backdrop-blur-sm" onClick={onClose} />
|
|
<div className="relative w-full max-w-lg bg-[var(--surface)] rounded-3xl shadow-2xl overflow-hidden p-8 max-h-[90vh] flex flex-col animate-in zoom-in-95 duration-200">
|
|
<div className="flex justify-between items-center mb-6">
|
|
<h2 className="text-2xl font-bold text-[var(--text-primary)] flex items-center gap-2">
|
|
<ImageIcon className="w-6 h-6 text-blue-600" /> Tải ảnh lên
|
|
</h2>
|
|
<button onClick={onClose} className="p-2 hover:bg-[var(--background)] rounded-full transition-colors text-[var(--text-muted)]">
|
|
<X className="w-6 h-6" />
|
|
</button>
|
|
</div>
|
|
|
|
<form onSubmit={handleSubmit} className="flex-1 flex flex-col min-h-0">
|
|
<div
|
|
onClick={() => fileInputRef.current?.click()}
|
|
className="border-2 border-dashed border-[var(--border)] rounded-[32px] p-10 flex flex-col items-center justify-center cursor-pointer hover:bg-[var(--background)]/50 hover:border-blue-200 transition-all mb-6 group"
|
|
>
|
|
<input type="file" ref={fileInputRef} className="hidden" multiple accept="image/*" onChange={handleFileChange} />
|
|
<div className="w-16 h-16 rounded-2xl bg-blue-50 flex items-center justify-center text-blue-600 mb-4 group-hover:scale-110 transition-transform shadow-inner">
|
|
<Upload className="w-8 h-8" />
|
|
</div>
|
|
<p className="text-sm font-black text-[var(--text-secondary)]">Nhấn để chọn ảnh</p>
|
|
<p className="text-xs text-[var(--text-muted)] mt-1 font-medium">Hỗ trợ JPG, PNG, WEBP</p>
|
|
</div>
|
|
|
|
{previews.length > 0 && (
|
|
<div className="flex-1 overflow-y-auto mb-6 pr-2">
|
|
<p className="text-[10px] font-black text-[var(--text-muted)] uppercase tracking-widest mb-3">Đã chọn {previews.length} tệp</p>
|
|
<div className="grid grid-cols-3 gap-3">
|
|
{previews.map((src, idx) => (
|
|
<div key={idx} className="relative aspect-square rounded-2xl overflow-hidden border border-[var(--border)] shadow-sm group">
|
|
<img src={src} className="w-full h-full object-cover" alt="preview" />
|
|
<button
|
|
type="button"
|
|
onClick={() => removeFile(idx)}
|
|
className="absolute top-1.5 right-1.5 p-1.5 bg-red-500/80 backdrop-blur-sm text-white rounded-full opacity-0 group-hover:opacity-100 transition-opacity"
|
|
>
|
|
<X className="w-3 h-3" />
|
|
</button>
|
|
</div>
|
|
))}
|
|
</div>
|
|
</div>
|
|
)}
|
|
|
|
<button
|
|
disabled={isUploading || isProcessing || selectedFiles.length === 0}
|
|
className="w-full py-4 bg-blue-600 hover:bg-blue-700 disabled:bg-gray-300 text-white font-black uppercase tracking-widest rounded-2xl flex items-center justify-center gap-2 shadow-lg shadow-blue-100 transition-all active:scale-95"
|
|
>
|
|
{isUploading ? (
|
|
<Loader2 className="w-5 h-5 animate-spin" />
|
|
) : isProcessing ? (
|
|
<>
|
|
<Loader2 className="w-5 h-5 animate-spin" />
|
|
Đang xử lý ảnh...
|
|
</>
|
|
) : (
|
|
'Xác nhận tải lên'
|
|
)}
|
|
</button>
|
|
</form>
|
|
</div>
|
|
</div>
|
|
);
|
|
}; |