feat: enforce 4-level coordinates resolution priority for tour photo uploads in AddPhotoModal.tsx
This commit is contained in:
Binary file not shown.
Vendored
+1
-1
@@ -21,7 +21,7 @@
|
|||||||
<meta name="twitter:title" content="Travel Planner - Lên kế hoạch chuyến đi của bạn" />
|
<meta name="twitter:title" content="Travel Planner - Lên kế hoạch chuyến đi của bạn" />
|
||||||
<meta name="twitter:description" content="Khám phá những hành trình tuyệt vời và chia sẻ khoảnh khắc đẹp với cộng đồng du lịch." />
|
<meta name="twitter:description" content="Khám phá những hành trình tuyệt vời và chia sẻ khoảnh khắc đẹp với cộng đồng du lịch." />
|
||||||
<meta name="twitter:image" content="https://yotrip.labz.io.vn/background.avif" />
|
<meta name="twitter:image" content="https://yotrip.labz.io.vn/background.avif" />
|
||||||
<script type="module" crossorigin src="/assets/index-BDb3Pmxn.js"></script>
|
<script type="module" crossorigin src="/assets/index-CqagDF1K.js"></script>
|
||||||
<link rel="modulepreload" crossorigin href="/assets/rolldown-runtime-Cyuzqnbw.js">
|
<link rel="modulepreload" crossorigin href="/assets/rolldown-runtime-Cyuzqnbw.js">
|
||||||
<link rel="modulepreload" crossorigin href="/assets/vendor-others-VIU5qAPG.js">
|
<link rel="modulepreload" crossorigin href="/assets/vendor-others-VIU5qAPG.js">
|
||||||
<link rel="modulepreload" crossorigin href="/assets/vendor-editor-DOXkNaRS.js">
|
<link rel="modulepreload" crossorigin href="/assets/vendor-editor-DOXkNaRS.js">
|
||||||
|
|||||||
@@ -3,7 +3,7 @@ import { X, Upload, Image as ImageIcon, Loader2 } from 'lucide-react';
|
|||||||
import { useNotification } from '@/hooks/useNotification';
|
import { useNotification } from '@/hooks/useNotification';
|
||||||
import { useTourStore } from '@/store/useTourStore';
|
import { useTourStore } from '@/store/useTourStore';
|
||||||
import { processImageModeration } from '@/hooks/useImageModeration';
|
import { processImageModeration } from '@/hooks/useImageModeration';
|
||||||
import { compressImage } from '../utils/image';
|
import { processAndResizeImage } from '../utils/imageProcessor';
|
||||||
|
|
||||||
interface AddPhotoModalProps {
|
interface AddPhotoModalProps {
|
||||||
isOpen: boolean;
|
isOpen: boolean;
|
||||||
@@ -13,8 +13,14 @@ interface AddPhotoModalProps {
|
|||||||
isPublicView?: boolean;
|
isPublicView?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
interface PendingPhoto {
|
||||||
|
file: File;
|
||||||
|
latitude: number;
|
||||||
|
longitude: number;
|
||||||
|
}
|
||||||
|
|
||||||
export const AddPhotoModal: React.FC<AddPhotoModalProps> = ({ isOpen, onClose, tourId, onSuccess, isPublicView }) => {
|
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 [previews, setPreviews] = useState<string[]>([]);
|
||||||
const [isUploading, setIsUploading] = useState(false);
|
const [isUploading, setIsUploading] = useState(false);
|
||||||
const [isProcessing, setIsProcessing] = 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>) => {
|
const handleFileChange = async (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||||
if (e.target.files) {
|
if (e.target.files) {
|
||||||
const files = Array.from(e.target.files);
|
const files = Array.from(e.target.files);
|
||||||
const newValidFiles: File[] = [];
|
|
||||||
const newValidPreviews: string[] = [];
|
|
||||||
|
|
||||||
setIsProcessing(true);
|
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' });
|
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 {
|
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) {
|
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) {
|
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' });
|
notify({ title: 'Lỗi tệp', message: `Tệp ${file.name} trống hoặc không khả dụng.`, type: 'error' });
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Nén ảnh trước
|
// Process image: Reads EXIF data and forces 2K resizing on device memory
|
||||||
const compressedFile = await compressImage(file);
|
const { file: processedFile, latitude: exifLat, longitude: exifLng } = await processAndResizeImage(file);
|
||||||
|
|
||||||
// 2. Chạy kiểm duyệt hình ảnh
|
// 2. Chạy kiểm duyệt hình ảnh
|
||||||
const moderationResult = await processImageModeration(compressedFile);
|
const moderationResult = await processImageModeration(processedFile);
|
||||||
if (moderationResult.blocked) {
|
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' });
|
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;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
const processedFile = moderationResult.file;
|
const finalProcessedFile = moderationResult.file;
|
||||||
const previewUrl = URL.createObjectURL(processedFile);
|
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 isValidImage = await new Promise<boolean>((resolve) => {
|
||||||
const img = new Image();
|
const img = new Image();
|
||||||
img.onload = () => resolve(true);
|
img.onload = () => resolve(true);
|
||||||
@@ -63,15 +84,51 @@ export const AddPhotoModal: React.FC<AddPhotoModalProps> = ({ isOpen, onClose, t
|
|||||||
});
|
});
|
||||||
|
|
||||||
if (isValidImage) {
|
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);
|
newValidPreviews.push(previewUrl);
|
||||||
} else {
|
} 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' });
|
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]);
|
setPreviews(prev => [...prev, ...newValidPreviews]);
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error('File checking error:', err);
|
console.error('File checking error:', err);
|
||||||
@@ -95,42 +152,24 @@ export const AddPhotoModal: React.FC<AddPhotoModalProps> = ({ isOpen, onClose, t
|
|||||||
|
|
||||||
setIsUploading(true);
|
setIsUploading(true);
|
||||||
try {
|
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
|
// Upload each photo sequentially so we can attach its specific coordinates
|
||||||
const location = await Promise.race([
|
for (const item of selectedFiles) {
|
||||||
new Promise<GeolocationPosition | null>((resolve) => {
|
const formData = new FormData();
|
||||||
if (!navigator.geolocation) {
|
formData.append('latitude', item.latitude.toString());
|
||||||
resolve(null);
|
formData.append('longitude', item.longitude.toString());
|
||||||
} else {
|
formData.append('images', item.file);
|
||||||
navigator.geolocation.getCurrentPosition(
|
|
||||||
(pos) => resolve(pos),
|
|
||||||
() => resolve(null),
|
|
||||||
{ timeout: 4000, enableHighAccuracy: true }
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}),
|
|
||||||
new Promise<null>((resolve) => setTimeout(() => resolve(null), 4500))
|
|
||||||
]);
|
|
||||||
|
|
||||||
const formData = new FormData();
|
const response = await fetch(`/api/v1/tours/${tourId}/photos`, {
|
||||||
selectedFiles.forEach(file => {
|
method: 'POST',
|
||||||
formData.append('images', file);
|
headers: {
|
||||||
});
|
'Authorization': `Bearer ${localStorage.getItem('token')}`
|
||||||
|
},
|
||||||
|
body: formData
|
||||||
|
});
|
||||||
|
|
||||||
if (location) {
|
if (!response.ok) throw new Error('Tải lên ảnh thất bại');
|
||||||
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({
|
notify({
|
||||||
title: 'Thành công',
|
title: 'Thành công',
|
||||||
message: `Đã tải lên ${selectedFiles.length} ảnh.`,
|
message: `Đã tải lên ${selectedFiles.length} ảnh.`,
|
||||||
|
|||||||
Reference in New Issue
Block a user