feat: enforce 4-level coordinates resolution priority for tour photo uploads in AddPhotoModal.tsx

This commit is contained in:
2026-06-27 21:53:52 +07:00
parent 464a4019f1
commit e5606e64e5
3 changed files with 87 additions and 48 deletions
+86 -47
View File
@@ -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.`,