feat: enforce 4-level coordinates resolution priority for tour photo uploads in AddPhotoModal.tsx
This commit is contained in:
@@ -3,7 +3,7 @@ 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';
|
||||
import { processAndResizeImage } from '../utils/imageProcessor';
|
||||
|
||||
interface AddPhotoModalProps {
|
||||
isOpen: boolean;
|
||||
@@ -13,8 +13,14 @@ interface AddPhotoModalProps {
|
||||
isPublicView?: boolean;
|
||||
}
|
||||
|
||||
interface PendingPhoto {
|
||||
file: File;
|
||||
latitude: number;
|
||||
longitude: number;
|
||||
}
|
||||
|
||||
export const AddPhotoModal: React.FC<AddPhotoModalProps> = ({ isOpen, onClose, tourId, onSuccess, isPublicView }) => {
|
||||
const [selectedFiles, setSelectedFiles] = useState<File[]>([]);
|
||||
const [selectedFiles, setSelectedFiles] = useState<PendingPhoto[]>([]);
|
||||
const [previews, setPreviews] = useState<string[]>([]);
|
||||
const [isUploading, setIsUploading] = useState(false);
|
||||
const [isProcessing, setIsProcessing] = useState(false);
|
||||
@@ -27,34 +33,49 @@ export const AddPhotoModal: React.FC<AddPhotoModalProps> = ({ isOpen, onClose, t
|
||||
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 {
|
||||
// Fetch device location once to serve as Priority 2 fallback for files without EXIF
|
||||
const deviceLocation = await Promise.race([
|
||||
new Promise<GeolocationPosition | null>((resolve) => {
|
||||
if (!navigator.geolocation) {
|
||||
resolve(null);
|
||||
} else {
|
||||
navigator.geolocation.getCurrentPosition(
|
||||
(pos) => resolve(pos),
|
||||
() => resolve(null),
|
||||
{ timeout: 3500, enableHighAccuracy: true }
|
||||
);
|
||||
}
|
||||
}),
|
||||
new Promise<null>((resolve) => setTimeout(() => resolve(null), 4000))
|
||||
]);
|
||||
|
||||
const newValidPhotos: PendingPhoto[] = [];
|
||||
const newValidPreviews: string[] = [];
|
||||
|
||||
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);
|
||||
// Process image: Reads EXIF data and forces 2K resizing on device memory
|
||||
const { file: processedFile, latitude: exifLat, longitude: exifLng } = await processAndResizeImage(file);
|
||||
|
||||
// 2. Chạy kiểm duyệt hình ảnh
|
||||
const moderationResult = await processImageModeration(compressedFile);
|
||||
const moderationResult = await processImageModeration(processedFile);
|
||||
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);
|
||||
const finalProcessedFile = moderationResult.file;
|
||||
const previewUrl = URL.createObjectURL(finalProcessedFile);
|
||||
|
||||
// 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
|
||||
// 3. Kiểm tra tính toàn vẹn
|
||||
const isValidImage = await new Promise<boolean>((resolve) => {
|
||||
const img = new Image();
|
||||
img.onload = () => resolve(true);
|
||||
@@ -63,15 +84,51 @@ export const AddPhotoModal: React.FC<AddPhotoModalProps> = ({ isOpen, onClose, t
|
||||
});
|
||||
|
||||
if (isValidImage) {
|
||||
newValidFiles.push(processedFile);
|
||||
// Resolve coordinates based on priority checklist:
|
||||
let finalLat: number | null = exifLat;
|
||||
let finalLng: number | null = exifLng;
|
||||
|
||||
// Priority 2: Device location
|
||||
if ((finalLat === null || finalLng === null) && deviceLocation && deviceLocation.coords) {
|
||||
finalLat = deviceLocation.coords.latitude;
|
||||
finalLng = deviceLocation.coords.longitude;
|
||||
}
|
||||
|
||||
// Priority 3: Map view state
|
||||
if (finalLat === null || finalLng === null) {
|
||||
const lastViewStateStr = localStorage.getItem('map_view_state');
|
||||
if (lastViewStateStr) {
|
||||
try {
|
||||
const lastViewState = JSON.parse(lastViewStateStr);
|
||||
if (lastViewState && Array.isArray(lastViewState.center) && lastViewState.center.length === 2) {
|
||||
finalLat = Number(lastViewState.center[0]);
|
||||
finalLng = Number(lastViewState.center[1]);
|
||||
}
|
||||
} catch (e) {
|
||||
console.error('[AddPhotoModal] Error parsing map_view_state:', e);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Priority 4: Fallback defaults
|
||||
if (finalLat === null || finalLng === null) {
|
||||
finalLat = 10.7769;
|
||||
finalLng = 106.7009;
|
||||
}
|
||||
|
||||
newValidPhotos.push({
|
||||
file: finalProcessedFile,
|
||||
latitude: finalLat,
|
||||
longitude: finalLng
|
||||
});
|
||||
newValidPreviews.push(previewUrl);
|
||||
} else {
|
||||
URL.revokeObjectURL(previewUrl); // Thu hồi ngay nếu không hợp lệ
|
||||
URL.revokeObjectURL(previewUrl);
|
||||
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]);
|
||||
setSelectedFiles(prev => [...prev, ...newValidPhotos]);
|
||||
setPreviews(prev => [...prev, ...newValidPreviews]);
|
||||
} catch (err) {
|
||||
console.error('File checking error:', err);
|
||||
@@ -95,42 +152,24 @@ export const AddPhotoModal: React.FC<AddPhotoModalProps> = ({ isOpen, onClose, t
|
||||
|
||||
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))
|
||||
]);
|
||||
// Upload each photo sequentially so we can attach its specific coordinates
|
||||
for (const item of selectedFiles) {
|
||||
const formData = new FormData();
|
||||
formData.append('latitude', item.latitude.toString());
|
||||
formData.append('longitude', item.longitude.toString());
|
||||
formData.append('images', item.file);
|
||||
|
||||
const formData = new FormData();
|
||||
selectedFiles.forEach(file => {
|
||||
formData.append('images', file);
|
||||
});
|
||||
const response = await fetch(`/api/v1/tours/${tourId}/photos`, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Authorization': `Bearer ${localStorage.getItem('token')}`
|
||||
},
|
||||
body: formData
|
||||
});
|
||||
|
||||
if (location) {
|
||||
formData.append('latitude', location.coords.latitude.toString());
|
||||
formData.append('longitude', location.coords.longitude.toString());
|
||||
if (!response.ok) throw new Error('Tải lên ảnh thất bại');
|
||||
}
|
||||
|
||||
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.`,
|
||||
|
||||
Reference in New Issue
Block a user