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.`,