From 2fabdb79df52853b6f0f10a23de79a1a1cee420d Mon Sep 17 00:00:00 2001 From: 3dtours Date: Tue, 16 Jun 2026 18:54:55 +0700 Subject: [PATCH] =?UTF-8?q?fix:=20l=E1=BB=97i=20c=C3=A1c=20th=C3=A0nh=20vi?= =?UTF-8?q?=C3=AAn=20kh=C3=B4ng=20th=E1=BB=83=20upload=20=E1=BA=A3nh=20v?= =?UTF-8?q?=C3=A0=20kh=C3=B4ng=20c=C3=B3=20n=C3=BAt=20x=C3=B3a=20=E1=BA=A3?= =?UTF-8?q?nh=20c=E1=BB=A7a=20user?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- frontend/src/App.tsx | 19 +- frontend/src/components/AddPhotoModal.tsx | 168 +++++++ frontend/src/pages/ExploreMap.tsx | 17 +- frontend/src/pages/MyPhotosPage.tsx | 299 ++++++++++++ frontend/src/pages/TourDetailPage.tsx | 104 +++- frontend/vite.config.ts | 4 + package-lock.json | 556 +++++++++++++++++++++- 7 files changed, 1151 insertions(+), 16 deletions(-) create mode 100644 frontend/src/components/AddPhotoModal.tsx create mode 100644 frontend/src/pages/MyPhotosPage.tsx diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index 42f5527..05eb4e4 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -3,6 +3,7 @@ import { LandingPage } from './pages/LandingPage'; import { ExploreMap } from './pages/ExploreMap'; import { TourDetailPage } from './pages/TourDetailPage'; import { SignupPage } from './pages/SignupPage'; +import { MyPhotosPage } from './pages/MyPhotosPage'; import { useTourStore } from './store/useTourStore'; import { ConfirmProvider } from './hooks/useConfirm'; import { NotificationProvider } from './hooks/useNotification'; @@ -12,7 +13,7 @@ function App() { const viewTourId = params.get('viewTour'); const [user, setUser] = useState(null); - const [currentPage, setCurrentPage] = useState<'landing' | 'explore' | 'tourDetail' | 'signup'>(viewTourId ? 'tourDetail' : 'landing'); + const [currentPage, setCurrentPage] = useState<'landing' | 'explore' | 'tourDetail' | 'signup' | 'myPhotos'>(viewTourId ? 'tourDetail' : 'landing'); const [currentTourId, setCurrentTourId] = useState(viewTourId); const [isPublicTourView, setIsPublicTourView] = useState(!!viewTourId); @@ -98,7 +99,21 @@ function App() { } if (currentPage === 'explore') { - return ; + return ( + setCurrentPage('myPhotos')} + /> + ); + } + + if (currentPage === 'myPhotos') { + return ( + setCurrentPage('explore')} /> + ); } if (currentPage === 'signup') { diff --git a/frontend/src/components/AddPhotoModal.tsx b/frontend/src/components/AddPhotoModal.tsx new file mode 100644 index 0000000..abe6817 --- /dev/null +++ b/frontend/src/components/AddPhotoModal.tsx @@ -0,0 +1,168 @@ +import React, { useState, useRef } from 'react'; +import { X, Upload, Image as ImageIcon, Loader2 } from 'lucide-react'; +import { useNotification } from '@/hooks/useNotification'; +import { useTourStore } from '@/store/useTourStore'; + +interface AddPhotoModalProps { + isOpen: boolean; + onClose: () => void; + tourId: string; + onSuccess?: () => void; +} + +export const AddPhotoModal: React.FC = ({ isOpen, onClose, tourId, onSuccess }) => { + const [selectedFiles, setSelectedFiles] = useState([]); + const [previews, setPreviews] = useState([]); + const [isUploading, setIsUploading] = useState(false); + const fileInputRef = useRef(null); + const notify = useNotification(); + const fetchTour = useTourStore(state => state.fetchTour); + + if (!isOpen) return null; + + const handleFileChange = async (e: React.ChangeEvent) => { + if (e.target.files) { + const files = Array.from(e.target.files); + const newValidFiles: File[] = []; + 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; + } + + const previewUrl = URL.createObjectURL(file); + + // 2. 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 + const isValidImage = await new Promise((resolve) => { + const img = new Image(); + img.onload = () => resolve(true); + img.onerror = () => resolve(false); + img.src = previewUrl; + }); + + if (isValidImage) { + newValidFiles.push(file); + newValidPreviews.push(previewUrl); + } else { + URL.revokeObjectURL(previewUrl); // Thu hồi ngay nếu không hợp lệ + 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]); + setPreviews(prev => [...prev, ...newValidPreviews]); + } + }; + + const removeFile = (index: number) => { + // Thu hồi URL khi xóa khỏi danh sách chờ để giải phóng bộ nhớ + URL.revokeObjectURL(previews[index]); + setSelectedFiles(prev => prev.filter((_, i) => i !== index)); + setPreviews(prev => prev.filter((_, i) => i !== index)); + }; + + const handleSubmit = async (e: React.FormEvent) => { + e.preventDefault(); + if (selectedFiles.length === 0) return; + + setIsUploading(true); + try { + 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 (!response.ok) throw new Error('Upload failed'); + + notify({ + title: 'Thành công', + message: `Đã tải lên ${selectedFiles.length} ảnh.`, + type: 'success' + }); + + fetchTour(tourId); + if (onSuccess) onSuccess(); + onClose(); + // Giải phóng bộ nhớ sau khi hoàn tất + previews.forEach(url => URL.revokeObjectURL(url)); + setSelectedFiles([]); + setPreviews([]); + } catch (error) { + notify({ + title: 'Lỗi', + message: 'Không thể tải ảnh lên. Vui lòng thử lại.', + type: 'error' + }); + } finally { + setIsUploading(false); + } + }; + + return ( +
+
+
+
+

+ Tải ảnh lên +

+ +
+ +
+
fileInputRef.current?.click()} + className="border-2 border-dashed border-gray-200 rounded-[32px] p-10 flex flex-col items-center justify-center cursor-pointer hover:bg-blue-50/50 hover:border-blue-200 transition-all mb-6 group" + > + +
+ +
+

Nhấn để chọn ảnh

+

Hỗ trợ JPG, PNG, WEBP

+
+ + {previews.length > 0 && ( +
+

Đã chọn {previews.length} tệp

+
+ {previews.map((src, idx) => ( +
+ preview + +
+ ))} +
+
+ )} + + +
+
+
+ ); +}; \ No newline at end of file diff --git a/frontend/src/pages/ExploreMap.tsx b/frontend/src/pages/ExploreMap.tsx index 2502d56..20be22e 100644 --- a/frontend/src/pages/ExploreMap.tsx +++ b/frontend/src/pages/ExploreMap.tsx @@ -56,7 +56,7 @@ function MapTracker() { return null; } -export const ExploreMap = ({ onBack, onLogout, user, onViewTour }: { onBack: () => void, onLogout?: () => void, user?: any, onViewTour: (id: string) => void }) => { +export const ExploreMap = ({ onBack, onLogout, user, onViewTour, onOpenMyPhotos }: { onBack: () => void, onLogout?: () => void, user?: any, onViewTour: (id: string) => void, onOpenMyPhotos: () => void }) => { // Tối ưu hóa việc lấy dữ liệu từ store bằng selectors để tránh re-render thừa const publicTours = useTourStore(state => state.publicTours); const fetchPublicTours = useTourStore(state => state.fetchPublicTours); @@ -311,6 +311,21 @@ export const ExploreMap = ({ onBack, onLogout, user, onViewTour }: { onBack: () {/* Nhóm bên phải: Các thao tác người dùng */}
+ {/* Nút Ảnh của tôi */} + {user && ( + + )} + {/* Nút tạo Tour mới */} {user && ( +
+

Ảnh của tôi

+

Kho lưu trữ ảnh gốc cá nhân

+
+
+ + {/* Filter Bar - Thanh công cụ lọc */} +
+
+ + Bộ lọc: +
+ + {/* Lọc theo Tour */} +
+ +
+ +
+
+ + {/* Lọc theo Thời gian */} +
+ setFilterDate(e.target.value)} + className="pl-3 pr-3 py-2 bg-gray-50 border border-gray-100 rounded-xl text-xs font-bold outline-none focus:ring-2 focus:ring-blue-500 transition-all text-gray-700 cursor-pointer" + /> +
+ + {/* Lọc theo Sắp xếp */} +
+ +
+ +
+
+ + + {/* Reset Filters - Nút xóa nhanh lọc */} + {(filterTourId || filterDate) && ( + + )} + +
+

+ Kết quả: {filteredPhotos.length} / {photos.length} ảnh +

+
+
+ +
+ {isLoading ? ( +
+ +

Đang tải kho ảnh...

+
+ ) : filteredPhotos.length > 0 ? ( +
+ {filteredPhotos.map((photo) => ( +
setSelectedPhoto(photo)} + className="group relative bg-white rounded-3xl overflow-hidden shadow-sm border border-gray-100 transition-all hover:shadow-xl hover:-translate-y-1 cursor-pointer" + > + {/* Image Preview */} + + + {/* Info */} +
+
+ + + {photo.tour?.title || 'Không rõ hành trình'} + +
+
+ + + {new Date(photo.capturedAt).toLocaleDateString('vi-VN')} + +
+
+
+ ))} +
+ ) : ( +
+ +

Chưa có ảnh nào

+

Hãy tham gia các chuyến đi và lưu lại khoảnh khắc nhé!

+
+ )} +
+ + {/* Lightbox - Xem ảnh toàn màn hình */} + {selectedPhoto && ( +
setSelectedPhoto(null)} + > + + + {/* Nút xóa ảnh */} + + +
e.stopPropagation()}> + Fullscreen view + +
+

{selectedPhoto.tour?.title || 'Không rõ hành trình'}

+

{new Date(selectedPhoto.capturedAt).toLocaleDateString('vi-VN', { weekday: 'long', year: 'numeric', month: 'long', day: 'numeric' })}

+ + {selectedPhoto.originalUrl && ( + + + Tải xuống ảnh gốc + + )} +
+
+
+ )} +
+ ); +}; \ No newline at end of file diff --git a/frontend/src/pages/TourDetailPage.tsx b/frontend/src/pages/TourDetailPage.tsx index 787f09a..492366f 100644 --- a/frontend/src/pages/TourDetailPage.tsx +++ b/frontend/src/pages/TourDetailPage.tsx @@ -8,6 +8,7 @@ import { AddMemberModal } from '../components/AddMemberModal'; import { useConfirm } from '@/hooks/useConfirm'; import { useNotification } from '@/hooks/useNotification'; import { CommentModal } from '@/components/CommentModal'; +import { AddPhotoModal } from '@/components/AddPhotoModal'; import { MapContainer, TileLayer, Marker, Popup, useMapEvents, Polyline, useMap } from 'react-leaflet'; import _MarkerClusterGroup from 'react-leaflet-cluster'; const MarkerClusterGroup = (_MarkerClusterGroup as any).default || _MarkerClusterGroup; @@ -15,6 +16,7 @@ import { Map as MapIcon, Wallet, Image as ImageIcon, + Upload, Calendar, Users, ChevronLeft, @@ -32,7 +34,8 @@ import { X, MessageSquare, Share2, - Tag as TagIcon + Tag as TagIcon, + Trash2 } from 'lucide-react'; import L from 'leaflet'; @@ -180,10 +183,21 @@ export const TourDetailPage = ({ onBack, tourId, isPublicView = false }: { onBac const [viewMode, setViewMode] = useState<'map' | 'timeline'>('timeline'); const [isAddLocationOpen, setIsAddLocationOpen] = useState(false); const [isAddMemberOpen, setIsAddMemberOpen] = useState(false); + const [isAddPhotoOpen, setIsAddPhotoOpen] = useState(false); const [targetLegId, setTargetLegId] = useState(null); const [editingLocation, setEditingLocation] = useState(null); const [selectedMember, setSelectedMember] = useState(null); const [isMemberDetailOpen, setIsMemberDetailOpen] = useState(false); + const [selectedPhoto, setSelectedPhoto] = useState(null); + const currentUserId = useMemo(() => { + const token = localStorage.getItem('token'); + if (!token) return null; + try { + return JSON.parse(atob(token.split('.')[1])).sub; + } catch (e) { + return null; + } + }, []); const [joinRequests, setJoinRequests] = useState([]); const [titleInput, setTitleInput] = useState(currentTour?.title ?? ''); const [descriptionInput, setDescriptionInput] = useState(currentTour?.description ?? ''); @@ -223,6 +237,32 @@ export const TourDetailPage = ({ onBack, tourId, isPublicView = false }: { onBac } }; + const handleDeletePhoto = async (photoId: string) => { + const isConfirmed = await confirm({ + title: 'Xóa ảnh này?', + message: 'Bạn có chắc chắn muốn xóa ảnh này khỏi chuyến đi? Hành động này không thể hoàn tác.' + }); + + if (!isConfirmed) return; + + try { + const response = await fetch(`/api/v1/photos/${photoId}`, { + method: 'DELETE', + headers: { + 'Authorization': `Bearer ${localStorage.getItem('token')}` + } + }); + + if (!response.ok) throw new Error('Không thể xóa ảnh'); + + notify({ title: 'Thành công', message: 'Đã xóa ảnh.', type: 'success' }); + fetchTour(tourId); + setSelectedPhoto(null); + } catch (error) { + notify({ title: 'Lỗi', message: 'Không thể xóa ảnh. Vui lòng thử lại.', type: 'error' }); + } + }; + const fetchTour = useTourStore(state => state.fetchTour); const fetchPublicTourDetails = useTourStore(state => state.fetchPublicTourDetails); // New action @@ -288,6 +328,7 @@ export const TourDetailPage = ({ onBack, tourId, isPublicView = false }: { onBac // Nếu là public view, không có quyền chỉnh sửa const canEdit = isPublicView ? false : ['OWNER', 'MANAGER'].includes(userRole || ''); const canInvite = isPublicView ? false : ['OWNER', 'MANAGER', 'MEMBER'].includes(userRole || ''); + const canUploadPhoto = isPublicView ? false : ['OWNER', 'MANAGER', 'MEMBER', 'MEMBER_NO_FINANCE'].includes(userRole || ''); const isOwner = isPublicView ? false : userRole === 'OWNER'; const canShare = isPublicView || ['OWNER', 'MANAGER', 'MEMBER'].includes(userRole || ''); @@ -944,17 +985,45 @@ export const TourDetailPage = ({ onBack, tourId, isPublicView = false }: { onBac )} {activeTab === 'photo' && ( -
- {[1, 2, 3, 4, 5, 6].map((i) => ( -
-
- Tour photo +
+ {currentTour?.photos && currentTour.photos.length > 0 ? ( + currentTour.photos.map((photo: any) => { + const isUploader = currentUserId === photo.uploaderId; + return ( +
setSelectedPhoto(photo)} + className="aspect-square bg-gray-200 rounded-2xl overflow-hidden relative group border border-white shadow-sm cursor-pointer" + > +
+ Tour photo + {isUploader && !isPublicView && ( + + )} +
+ ); + }) + ) : ( +
+
+ +
+

Khoảnh khắc trống

+

Hãy là người đầu tiên chia sẻ những hình ảnh tuyệt vời của chuyến đi này!

- ))} + )}
)} @@ -1172,13 +1241,14 @@ export const TourDetailPage = ({ onBack, tourId, isPublicView = false }: { onBac
{/* Floating Action Button (Mobile) */} - {canEdit && !isPublicView && ( // Hide floating action button in public view + {((activeTab === 'plan' && canEdit) || (activeTab === 'photo' && canUploadPhoto)) && !isPublicView && (