fix: lỗi các thành viên không thể upload ảnh và không có nút xóa ảnh của user
This commit is contained in:
+17
-2
@@ -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<any>(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<string | null>(viewTourId);
|
||||
const [isPublicTourView, setIsPublicTourView] = useState(!!viewTourId);
|
||||
|
||||
@@ -98,7 +99,21 @@ function App() {
|
||||
}
|
||||
|
||||
if (currentPage === 'explore') {
|
||||
return <ExploreMap onBack={handleBackFromTourDetail} onLogout={handleLogout} user={user} onViewTour={handleViewTour} />;
|
||||
return (
|
||||
<ExploreMap
|
||||
onBack={handleBackFromTourDetail}
|
||||
onLogout={handleLogout}
|
||||
user={user}
|
||||
onViewTour={handleViewTour}
|
||||
onOpenMyPhotos={() => setCurrentPage('myPhotos')}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
if (currentPage === 'myPhotos') {
|
||||
return (
|
||||
<MyPhotosPage onBack={() => setCurrentPage('explore')} />
|
||||
);
|
||||
}
|
||||
|
||||
if (currentPage === 'signup') {
|
||||
|
||||
@@ -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<AddPhotoModalProps> = ({ isOpen, onClose, tourId, onSuccess }) => {
|
||||
const [selectedFiles, setSelectedFiles] = useState<File[]>([]);
|
||||
const [previews, setPreviews] = useState<string[]>([]);
|
||||
const [isUploading, setIsUploading] = useState(false);
|
||||
const fileInputRef = useRef<HTMLInputElement>(null);
|
||||
const notify = useNotification();
|
||||
const fetchTour = useTourStore(state => state.fetchTour);
|
||||
|
||||
if (!isOpen) return null;
|
||||
|
||||
const handleFileChange = async (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
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<boolean>((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 (
|
||||
<div className="fixed inset-0 z-[2500] flex items-center justify-center p-4">
|
||||
<div className="absolute inset-0 bg-gray-900/60 backdrop-blur-sm" onClick={onClose} />
|
||||
<div className="relative w-full max-w-lg bg-white rounded-3xl shadow-2xl overflow-hidden p-8 max-h-[90vh] flex flex-col animate-in zoom-in-95 duration-200">
|
||||
<div className="flex justify-between items-center mb-6">
|
||||
<h2 className="text-2xl font-bold text-gray-900 flex items-center gap-2">
|
||||
<ImageIcon className="w-6 h-6 text-blue-600" /> Tải ảnh lên
|
||||
</h2>
|
||||
<button onClick={onClose} className="p-2 hover:bg-gray-100 rounded-full transition-colors text-gray-400">
|
||||
<X className="w-6 h-6" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<form onSubmit={handleSubmit} className="flex-1 flex flex-col min-h-0">
|
||||
<div
|
||||
onClick={() => 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"
|
||||
>
|
||||
<input type="file" ref={fileInputRef} className="hidden" multiple accept="image/*" onChange={handleFileChange} />
|
||||
<div className="w-16 h-16 rounded-2xl bg-blue-50 flex items-center justify-center text-blue-600 mb-4 group-hover:scale-110 transition-transform shadow-inner">
|
||||
<Upload className="w-8 h-8" />
|
||||
</div>
|
||||
<p className="text-sm font-black text-gray-700">Nhấn để chọn ảnh</p>
|
||||
<p className="text-xs text-gray-400 mt-1 font-medium">Hỗ trợ JPG, PNG, WEBP</p>
|
||||
</div>
|
||||
|
||||
{previews.length > 0 && (
|
||||
<div className="flex-1 overflow-y-auto mb-6 pr-2">
|
||||
<p className="text-[10px] font-black text-gray-400 uppercase tracking-widest mb-3">Đã chọn {previews.length} tệp</p>
|
||||
<div className="grid grid-cols-3 gap-3">
|
||||
{previews.map((src, idx) => (
|
||||
<div key={idx} className="relative aspect-square rounded-2xl overflow-hidden border border-gray-100 shadow-sm group">
|
||||
<img src={src} className="w-full h-full object-cover" alt="preview" />
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => removeFile(idx)}
|
||||
className="absolute top-1.5 right-1.5 p-1.5 bg-red-500/80 backdrop-blur-sm text-white rounded-full opacity-0 group-hover:opacity-100 transition-opacity"
|
||||
>
|
||||
<X className="w-3 h-3" />
|
||||
</button>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<button
|
||||
disabled={isUploading || selectedFiles.length === 0}
|
||||
className="w-full py-4 bg-blue-600 hover:bg-blue-700 disabled:bg-gray-300 text-white font-black uppercase tracking-widest rounded-2xl flex items-center justify-center gap-2 shadow-lg shadow-blue-100 transition-all active:scale-95"
|
||||
>
|
||||
{isUploading ? <Loader2 className="w-5 h-5 animate-spin" /> : 'Xác nhận tải lên'}
|
||||
</button>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -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 */}
|
||||
<div className="flex items-center gap-2 pointer-events-auto">
|
||||
{/* Nút Ảnh của tôi */}
|
||||
{user && (
|
||||
<button
|
||||
onClick={() => {
|
||||
console.log("Đang mở Ảnh của tôi...");
|
||||
onOpenMyPhotos();
|
||||
}}
|
||||
className="bg-white p-3 md:px-4 md:py-3 rounded-2xl shadow-xl hover:bg-blue-50 text-blue-600 transition-all flex items-center gap-2 font-bold border border-blue-100"
|
||||
title="Ảnh của tôi"
|
||||
>
|
||||
<ImageIcon className="w-5 h-5" />
|
||||
<span className="hidden md:inline text-sm">Ảnh của tôi</span>
|
||||
</button>
|
||||
)}
|
||||
|
||||
{/* Nút tạo Tour mới */}
|
||||
{user && (
|
||||
<button
|
||||
|
||||
@@ -0,0 +1,299 @@
|
||||
import React, { useEffect, useState, useMemo } from 'react';
|
||||
import { ChevronLeft, Image as ImageIcon, Download, Calendar, MapPin, Loader2, Filter, X, Trash2 } from 'lucide-react';
|
||||
import { useNotification } from '@/hooks/useNotification';
|
||||
import { useConfirm } from '@/hooks/useConfirm';
|
||||
|
||||
export const MyPhotosPage = ({ onBack }: { onBack: () => void }) => {
|
||||
const [photos, setPhotos] = useState<any[]>([]);
|
||||
const [isLoading, setIsLoading] = useState(true);
|
||||
const [filterTourId, setFilterTourId] = useState<string>('');
|
||||
const [filterDate, setFilterDate] = useState<string>('');
|
||||
const [selectedPhoto, setSelectedPhoto] = useState<any | null>(null); // State cho lightbox
|
||||
const [sortOrder, setSortOrder] = useState<'newest' | 'oldest'>('newest'); // 'newest' by default
|
||||
const notify = useNotification();
|
||||
const confirm = useConfirm();
|
||||
|
||||
useEffect(() => {
|
||||
const fetchPhotos = async () => {
|
||||
try {
|
||||
const response = await fetch('/api/v1/users/me/photos', {
|
||||
headers: {
|
||||
'Authorization': `Bearer ${localStorage.getItem('token')}`
|
||||
}
|
||||
});
|
||||
if (!response.ok) throw new Error('Failed to fetch photos');
|
||||
const data = await response.json();
|
||||
setPhotos(data);
|
||||
} catch (error) {
|
||||
notify({ title: 'Lỗi', message: 'Không thể tải danh sách ảnh.', type: 'error' });
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
};
|
||||
fetchPhotos();
|
||||
}, []);
|
||||
|
||||
// Lấy danh sách các Tour duy nhất để hiển thị trong bộ lọc
|
||||
const uniqueTours = useMemo(() => {
|
||||
const tourMap = new Map();
|
||||
photos.forEach(p => {
|
||||
if (p.tourId && p.tour) {
|
||||
tourMap.set(p.tourId, { id: p.tourId, title: p.tour.title });
|
||||
}
|
||||
});
|
||||
return Array.from(tourMap.values());
|
||||
}, [photos]);
|
||||
|
||||
// Logic lọc ảnh tại Frontend
|
||||
const filteredPhotos = useMemo(() => {
|
||||
let sortedPhotos = photos.filter(p => {
|
||||
const matchTour = !filterTourId || p.tourId === filterTourId;
|
||||
// So sánh ngày định dạng YYYY-MM-DD
|
||||
const photoDate = p.capturedAt ? p.capturedAt.split('T')[0] : '';
|
||||
const matchDate = !filterDate || photoDate === filterDate;
|
||||
return matchTour && matchDate;
|
||||
});
|
||||
|
||||
// Sắp xếp ảnh
|
||||
if (sortOrder === 'newest') {
|
||||
sortedPhotos.sort((a, b) => new Date(b.capturedAt).getTime() - new Date(a.capturedAt).getTime());
|
||||
} else { // 'oldest'
|
||||
sortedPhotos.sort((a, b) => new Date(a.capturedAt).getTime() - new Date(b.capturedAt).getTime());
|
||||
}
|
||||
return sortedPhotos;
|
||||
}, [photos, filterTourId, filterDate, sortOrder]);
|
||||
|
||||
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ông? 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('Failed to delete photo');
|
||||
|
||||
notify({ title: 'Thành công', message: 'Ảnh đã được xóa.', type: 'success' });
|
||||
// Cập nhật lại danh sách ảnh sau khi xóa
|
||||
setPhotos(prev => prev.filter(p => p.id !== photoId));
|
||||
setSelectedPhoto(null); // Đóng lightbox
|
||||
} catch (error) {
|
||||
notify({ title: 'Lỗi', message: 'Không thể xóa ảnh. Vui lòng thử lại.', type: 'error' });
|
||||
}
|
||||
};
|
||||
|
||||
// Đóng lightbox khi nhấn ESC
|
||||
useEffect(() => {
|
||||
const handleEsc = (event: KeyboardEvent) => event.key === 'Escape' && setSelectedPhoto(null);
|
||||
window.addEventListener('keydown', handleEsc);
|
||||
return () => window.removeEventListener('keydown', handleEsc);
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-gray-50 flex flex-col">
|
||||
{/* Header */}
|
||||
<div className="sticky top-0 z-30 bg-white/80 backdrop-blur-md border-b border-gray-100 px-4 py-4 flex items-center gap-4">
|
||||
<button onClick={onBack} className="p-2 hover:bg-gray-100 rounded-full transition-colors">
|
||||
<ChevronLeft className="w-6 h-6 text-gray-600" />
|
||||
</button>
|
||||
<div>
|
||||
<h1 className="text-xl font-black text-gray-900">Ảnh của tôi</h1>
|
||||
<p className="text-[10px] font-bold text-gray-400 uppercase tracking-widest">Kho lưu trữ ảnh gốc cá nhân</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Filter Bar - Thanh công cụ lọc */}
|
||||
<div className="bg-white border-b border-gray-100 px-6 py-4 flex flex-wrap items-center gap-4 sticky top-[73px] z-20 shadow-sm">
|
||||
<div className="flex items-center gap-2 text-gray-500">
|
||||
<Filter className="w-4 h-4" />
|
||||
<span className="text-xs font-bold uppercase tracking-wider text-gray-400">Bộ lọc:</span>
|
||||
</div>
|
||||
|
||||
{/* Lọc theo Tour */}
|
||||
<div className="relative min-w-[160px]">
|
||||
<select
|
||||
value={filterTourId}
|
||||
onChange={(e) => setFilterTourId(e.target.value)}
|
||||
className="w-full pl-3 pr-8 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 appearance-none cursor-pointer text-gray-700"
|
||||
>
|
||||
<option value="">Tất cả chuyến đi</option>
|
||||
{uniqueTours.map(t => (
|
||||
<option key={t.id} value={t.id}>{t.title}</option>
|
||||
))}
|
||||
</select>
|
||||
<div className="absolute right-3 top-1/2 -translate-y-1/2 pointer-events-none text-gray-400">
|
||||
<ChevronLeft className="w-3 h-3 -rotate-90" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Lọc theo Thời gian */}
|
||||
<div className="relative">
|
||||
<input
|
||||
type="date"
|
||||
value={filterDate}
|
||||
onChange={(e) => 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"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Lọc theo Sắp xếp */}
|
||||
<div className="relative min-w-[120px]">
|
||||
<select
|
||||
value={sortOrder}
|
||||
onChange={(e) => setSortOrder(e.target.value as 'newest' | 'oldest')}
|
||||
className="w-full pl-3 pr-8 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 appearance-none cursor-pointer text-gray-700"
|
||||
>
|
||||
<option value="newest">Mới nhất</option>
|
||||
<option value="oldest">Cũ nhất</option>
|
||||
</select>
|
||||
<div className="absolute right-3 top-1/2 -translate-y-1/2 pointer-events-none text-gray-400">
|
||||
<ChevronLeft className="w-3 h-3 -rotate-90" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
{/* Reset Filters - Nút xóa nhanh lọc */}
|
||||
{(filterTourId || filterDate) && (
|
||||
<button
|
||||
onClick={() => { setFilterTourId(''); setFilterDate(''); }}
|
||||
className="flex items-center gap-1.5 px-3 py-2 text-xs font-bold text-red-500 hover:bg-red-50 rounded-xl transition-all"
|
||||
>
|
||||
<X className="w-3.5 h-3.5" />
|
||||
Xóa lọc
|
||||
</button>
|
||||
)}
|
||||
|
||||
<div className="ml-auto">
|
||||
<p className="text-[10px] font-black text-gray-400 uppercase tracking-tighter">
|
||||
Kết quả: <span className="text-blue-600">{filteredPhotos.length}</span> / {photos.length} ảnh
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex-1 p-6 max-w-5xl mx-auto w-full">
|
||||
{isLoading ? (
|
||||
<div className="flex flex-col items-center justify-center py-20 text-gray-400">
|
||||
<Loader2 className="w-10 h-10 animate-spin mb-4" />
|
||||
<p className="font-bold">Đang tải kho ảnh...</p>
|
||||
</div>
|
||||
) : filteredPhotos.length > 0 ? (
|
||||
<div className="grid grid-cols-2 md:grid-cols-3 lg:grid-cols-4 gap-4">
|
||||
{filteredPhotos.map((photo) => (
|
||||
<div
|
||||
key={photo.id}
|
||||
onClick={() => 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 */}
|
||||
<div className="aspect-square relative overflow-hidden bg-gray-100">
|
||||
<img
|
||||
src={photo.imageUrl || photo.originalUrl}
|
||||
alt="My memory"
|
||||
className="w-full h-full object-cover transition-transform duration-700 group-hover:scale-110"
|
||||
/>
|
||||
{!photo.imageUrl && (
|
||||
<div className="absolute inset-0 bg-black/40 flex items-center justify-center">
|
||||
<span className="text-[10px] font-black text-white uppercase bg-red-500 px-2 py-1 rounded-lg">Tour đã xóa</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Overlay Actions */}
|
||||
<div className="absolute inset-0 bg-black/20 opacity-0 group-hover:opacity-100 transition-opacity flex items-center justify-center gap-2">
|
||||
{photo.originalUrl && (
|
||||
<a
|
||||
onClick={(e) => e.stopPropagation()} // Ngăn chặn mở lightbox khi bấm tải xuống
|
||||
href={photo.originalUrl}
|
||||
download
|
||||
className="p-3 bg-white text-blue-600 rounded-2xl shadow-xl hover:bg-blue-600 hover:text-white transition-all transform hover:scale-110"
|
||||
title="Tải xuống ảnh gốc"
|
||||
>
|
||||
<Download className="w-5 h-5" />
|
||||
</a>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Info */}
|
||||
<div className="p-3">
|
||||
<div className="flex items-center gap-1.5 mb-1 text-gray-400">
|
||||
<MapPin className="w-3 h-3" />
|
||||
<span className="text-[10px] font-bold truncate">
|
||||
{photo.tour?.title || 'Không rõ hành trình'}
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-1.5 text-gray-300">
|
||||
<Calendar className="w-3 h-3" />
|
||||
<span className="text-[9px] font-medium italic">
|
||||
{new Date(photo.capturedAt).toLocaleDateString('vi-VN')}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
<div className="py-32 text-center bg-white rounded-[40px] border-2 border-dashed border-gray-100">
|
||||
<ImageIcon className="w-16 h-16 text-gray-200 mx-auto mb-4" />
|
||||
<h3 className="text-xl font-bold text-gray-400">Chưa có ảnh nào</h3>
|
||||
<p className="text-sm text-gray-300">Hãy tham gia các chuyến đi và lưu lại khoảnh khắc nhé!</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Lightbox - Xem ảnh toàn màn hình */}
|
||||
{selectedPhoto && (
|
||||
<div
|
||||
className="fixed inset-0 z-[7000] flex items-center justify-center bg-black/95 backdrop-blur-md p-4 animate-in fade-in duration-300"
|
||||
onClick={() => setSelectedPhoto(null)}
|
||||
>
|
||||
<button
|
||||
onClick={() => setSelectedPhoto(null)}
|
||||
className="absolute top-6 right-6 p-3 bg-white/10 hover:bg-white/20 text-white rounded-full transition-all z-10"
|
||||
>
|
||||
<X className="w-6 h-6" />
|
||||
</button>
|
||||
|
||||
{/* Nút xóa ảnh */}
|
||||
<button
|
||||
onClick={(e) => { e.stopPropagation(); handleDeletePhoto(selectedPhoto.id); }}
|
||||
className="absolute top-6 left-6 p-3 bg-red-500/10 hover:bg-red-500/20 text-white rounded-full transition-all z-10"
|
||||
>
|
||||
<Trash2 className="w-6 h-6" />
|
||||
</button>
|
||||
|
||||
<div className="relative max-w-5xl w-full max-h-[90vh] flex flex-col items-center" onClick={(e) => e.stopPropagation()}>
|
||||
<img
|
||||
src={selectedPhoto.originalUrl || selectedPhoto.imageUrl}
|
||||
alt="Fullscreen view"
|
||||
className="max-w-full max-h-[75vh] object-contain rounded-2xl shadow-2xl animate-in zoom-in-95 duration-300"
|
||||
/>
|
||||
|
||||
<div className="mt-6 text-center text-white">
|
||||
<h2 className="text-xl font-bold">{selectedPhoto.tour?.title || 'Không rõ hành trình'}</h2>
|
||||
<p className="text-sm opacity-60 italic mt-1">{new Date(selectedPhoto.capturedAt).toLocaleDateString('vi-VN', { weekday: 'long', year: 'numeric', month: 'long', day: 'numeric' })}</p>
|
||||
|
||||
{selectedPhoto.originalUrl && (
|
||||
<a
|
||||
href={selectedPhoto.originalUrl}
|
||||
download
|
||||
className="mt-6 inline-flex items-center gap-2 px-8 py-3.5 bg-blue-600 hover:bg-blue-700 text-white rounded-2xl font-black uppercase tracking-widest text-xs transition-all shadow-lg shadow-blue-900/20 active:scale-95"
|
||||
>
|
||||
<Download className="w-4 h-4" />
|
||||
Tải xuống ảnh gốc
|
||||
</a>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -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<string | null>(null);
|
||||
const [editingLocation, setEditingLocation] = useState<any>(null);
|
||||
const [selectedMember, setSelectedMember] = useState<any>(null);
|
||||
const [isMemberDetailOpen, setIsMemberDetailOpen] = useState(false);
|
||||
const [selectedPhoto, setSelectedPhoto] = useState<any | null>(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<any[]>([]);
|
||||
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' && (
|
||||
<div className="grid grid-cols-3 gap-1.5 animate-in fade-in">
|
||||
{[1, 2, 3, 4, 5, 6].map((i) => (
|
||||
<div key={i} className="aspect-square bg-gray-200 rounded-xl overflow-hidden relative group border border-white">
|
||||
<div className="absolute inset-0 bg-black/0 group-hover:bg-black/20 transition-colors" />
|
||||
<img
|
||||
src={`https://picsum.photos/seed/${i + 10}/400/400`}
|
||||
alt="Tour photo"
|
||||
className="w-full h-full object-cover group-hover:scale-105 transition-transform duration-500"
|
||||
/>
|
||||
<div className="grid grid-cols-3 gap-2 animate-in fade-in">
|
||||
{currentTour?.photos && currentTour.photos.length > 0 ? (
|
||||
currentTour.photos.map((photo: any) => {
|
||||
const isUploader = currentUserId === photo.uploaderId;
|
||||
return (
|
||||
<div
|
||||
key={photo.id}
|
||||
onClick={() => setSelectedPhoto(photo)}
|
||||
className="aspect-square bg-gray-200 rounded-2xl overflow-hidden relative group border border-white shadow-sm cursor-pointer"
|
||||
>
|
||||
<div className="absolute inset-0 bg-black/0 group-hover:bg-black/20 transition-colors z-10" />
|
||||
<img
|
||||
src={photo.imageUrl}
|
||||
alt="Tour photo"
|
||||
className="w-full h-full object-cover group-hover:scale-110 transition-transform duration-700"
|
||||
/>
|
||||
{isUploader && !isPublicView && (
|
||||
<button
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
handleDeletePhoto(photo.id);
|
||||
}}
|
||||
className="absolute top-2 right-2 p-1.5 bg-red-500/80 backdrop-blur-sm text-white rounded-lg opacity-0 group-hover:opacity-100 transition-opacity z-20 hover:bg-red-600"
|
||||
>
|
||||
<Trash2 className="w-4 h-4" />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
})
|
||||
) : (
|
||||
<div className="col-span-3 py-24 text-center bg-white rounded-[40px] border-2 border-dashed border-gray-100 animate-in zoom-in-95">
|
||||
<div className="w-20 h-20 bg-gray-50 rounded-3xl flex items-center justify-center mx-auto mb-6">
|
||||
<ImageIcon className="w-10 h-10 text-gray-200" />
|
||||
</div>
|
||||
<h4 className="text-lg font-bold text-gray-400">Khoảnh khắc trống</h4>
|
||||
<p className="text-sm text-gray-300 mt-2 max-w-[200px] mx-auto">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!</p>
|
||||
</div>
|
||||
))}
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
@@ -1172,13 +1241,14 @@ export const TourDetailPage = ({ onBack, tourId, isPublicView = false }: { onBac
|
||||
</div>
|
||||
|
||||
{/* Floating Action Button (Mobile) */}
|
||||
{canEdit && !isPublicView && ( // Hide floating action button in public view
|
||||
{((activeTab === 'plan' && canEdit) || (activeTab === 'photo' && canUploadPhoto)) && !isPublicView && (
|
||||
<div className="fixed bottom-6 left-1/2 -translate-x-1/2 z-40">
|
||||
<button
|
||||
onClick={() => {
|
||||
setTargetLegId(null); // Reset khi nhấn nút floating tổng quát
|
||||
setEditingLocation(null);
|
||||
if (activeTab === 'plan') setIsAddLocationOpen(true);
|
||||
if (activeTab === 'photo' && canUploadPhoto) setIsAddPhotoOpen(true);
|
||||
}}
|
||||
className="bg-blue-600 text-white px-6 py-3 rounded-full shadow-lg hover:bg-blue-700 transition-all flex items-center font-bold">
|
||||
{activeTab === 'plan' ? 'Thêm địa điểm' : activeTab === 'expense' ? 'Thêm chi phí' : 'Đăng ảnh'}
|
||||
@@ -1213,6 +1283,16 @@ export const TourDetailPage = ({ onBack, tourId, isPublicView = false }: { onBac
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Add Photo Modal */}
|
||||
{currentTour && (
|
||||
<AddPhotoModal
|
||||
isOpen={isAddPhotoOpen}
|
||||
onClose={() => setIsAddPhotoOpen(false)}
|
||||
tourId={currentTour.id}
|
||||
onSuccess={() => fetchTour(currentTour.id)}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Member Detail Popover */}
|
||||
{isMemberDetailOpen && selectedMember && (
|
||||
<div className="fixed inset-0 z-[2100] flex items-center justify-center p-4">
|
||||
|
||||
@@ -34,6 +34,10 @@ export default defineConfig(({ mode }) => {
|
||||
target: 'http://localhost:3001',
|
||||
changeOrigin: true,
|
||||
},
|
||||
'/uploads': {
|
||||
target: 'http://localhost:3001',
|
||||
changeOrigin: true,
|
||||
},
|
||||
'/socket.io': {
|
||||
target: 'http://localhost:3001',
|
||||
ws: true,
|
||||
|
||||
Reference in New Issue
Block a user