299 lines
13 KiB
TypeScript
299 lines
13 KiB
TypeScript
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>
|
|
);
|
|
}; |