diff --git a/backend/public/downloads/yotrip-latest.apk b/backend/public/downloads/yotrip-latest.apk index e2d76a5..13bd94c 100644 Binary files a/backend/public/downloads/yotrip-latest.apk and b/backend/public/downloads/yotrip-latest.apk differ diff --git a/frontend/dist/index.html b/frontend/dist/index.html index 9bf0764..fd7e497 100644 --- a/frontend/dist/index.html +++ b/frontend/dist/index.html @@ -21,7 +21,7 @@ - + diff --git a/frontend/src/components/AddPhotoModal.tsx b/frontend/src/components/AddPhotoModal.tsx index 6191c6a..384d223 100644 --- a/frontend/src/components/AddPhotoModal.tsx +++ b/frontend/src/components/AddPhotoModal.tsx @@ -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 = ({ isOpen, onClose, tourId, onSuccess, isPublicView }) => { - const [selectedFiles, setSelectedFiles] = useState([]); + const [selectedFiles, setSelectedFiles] = useState([]); const [previews, setPreviews] = useState([]); const [isUploading, setIsUploading] = useState(false); const [isProcessing, setIsProcessing] = useState(false); @@ -27,34 +33,49 @@ export const AddPhotoModal: React.FC = ({ isOpen, onClose, t const handleFileChange = async (e: React.ChangeEvent) => { 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((resolve) => { + if (!navigator.geolocation) { + resolve(null); + } else { + navigator.geolocation.getCurrentPosition( + (pos) => resolve(pos), + () => resolve(null), + { timeout: 3500, enableHighAccuracy: true } + ); + } + }), + new Promise((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((resolve) => { const img = new Image(); img.onload = () => resolve(true); @@ -63,15 +84,51 @@ export const AddPhotoModal: React.FC = ({ 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 = ({ 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((resolve) => { - if (!navigator.geolocation) { - resolve(null); - } else { - navigator.geolocation.getCurrentPosition( - (pos) => resolve(pos), - () => resolve(null), - { timeout: 4000, enableHighAccuracy: true } - ); - } - }), - new Promise((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.`,