610 lines
31 KiB
TypeScript
610 lines
31 KiB
TypeScript
import { useEffect, useState, useMemo } from 'react';
|
|
import { ChevronLeft, Image as ImageIcon, Download, Calendar, MapPin, Loader2, Filter, X, Trash2, Edit, Heart } from 'lucide-react';
|
|
import { useNotification } from '@/hooks/useNotification';
|
|
import { useConfirm } from '@/hooks/useConfirm';
|
|
import { CoordinateSelectModal } from '../components/CoordinateSelectModal';
|
|
|
|
export const MyPhotosPage = ({ onBack }: { onBack: () => void }) => {
|
|
const [photos, setPhotos] = useState<any[]>([]);
|
|
const [isLoading, setIsLoading] = useState(true);
|
|
const [selectedTourIdForPhoto, setSelectedTourIdForPhoto] = useState<string | 'all'>('all'); // Changed from filterTourId
|
|
const [filterDate, setFilterDate] = useState<string>('');
|
|
const [selectedPhotoForDisplay, setSelectedPhotoForDisplay] = useState<any | null>(null); // New state for large photo display
|
|
const [sortOrder, setSortOrder] = useState<'newest' | 'oldest'>('newest'); // 'newest' by default
|
|
const notify = useNotification();
|
|
const confirm = useConfirm();
|
|
|
|
const [isEditing, setIsEditing] = useState(false);
|
|
const [editTitle, setEditTitle] = useState('');
|
|
const [editDescription, setEditDescription] = useState('');
|
|
const [editLat, setEditLat] = useState<number | ''>('');
|
|
const [editLng, setEditLng] = useState<number | ''>('');
|
|
const [isSavingEdit, setIsSavingEdit] = useState(false);
|
|
const [isMapOpen, setIsMapOpen] = useState(false);
|
|
const [resolvedAddress, setResolvedAddress] = useState<string>('');
|
|
const [isFullscreen, setIsFullscreen] = useState(false);
|
|
|
|
useEffect(() => {
|
|
const lat = selectedPhotoForDisplay?.metadata?.lat;
|
|
const lng = selectedPhotoForDisplay?.metadata?.lng;
|
|
if (typeof lat === 'number' && typeof lng === 'number') {
|
|
setResolvedAddress('Đang xác định địa điểm...');
|
|
fetch(`https://nominatim.openstreetmap.org/reverse?format=json&lat=${lat}&lon=${lng}&accept-language=vi`)
|
|
.then(res => {
|
|
if (!res.ok) throw new Error();
|
|
return res.json();
|
|
})
|
|
.then(data => {
|
|
if (data && data.display_name) {
|
|
const shortAddress = data.display_name.split(',').slice(0, 3).join(',').trim();
|
|
setResolvedAddress(shortAddress || data.display_name);
|
|
} else {
|
|
setResolvedAddress(`${lat.toFixed(4)}, ${lng.toFixed(4)}`);
|
|
}
|
|
})
|
|
.catch(() => {
|
|
setResolvedAddress(`${lat.toFixed(4)}, ${lng.toFixed(4)}`);
|
|
});
|
|
} else {
|
|
setResolvedAddress('Chưa xác định tọa độ');
|
|
}
|
|
}, [selectedPhotoForDisplay?.id, selectedPhotoForDisplay?.metadata?.lat, selectedPhotoForDisplay?.metadata?.lng]);
|
|
|
|
useEffect(() => {
|
|
if (selectedPhotoForDisplay) {
|
|
setEditTitle(selectedPhotoForDisplay.metadata?.title || '');
|
|
setEditDescription(selectedPhotoForDisplay.metadata?.description || '');
|
|
setEditLat(selectedPhotoForDisplay.metadata?.lat ?? '');
|
|
setEditLng(selectedPhotoForDisplay.metadata?.lng ?? '');
|
|
setIsEditing(false);
|
|
}
|
|
}, [selectedPhotoForDisplay]);
|
|
|
|
const handleSaveEdit = async () => {
|
|
if (editLat !== '' && (isNaN(editLat) || editLat < -90 || editLat > 90)) {
|
|
notify({ title: 'Lỗi', message: 'Vĩ độ không hợp lệ (-90 đến 90).', type: 'error' });
|
|
return;
|
|
}
|
|
if (editLng !== '' && (isNaN(editLng) || editLng < -180 || editLng > 180)) {
|
|
notify({ title: 'Lỗi', message: 'Kinh độ không hợp lệ (-180 đến 180).', type: 'error' });
|
|
return;
|
|
}
|
|
|
|
setIsSavingEdit(true);
|
|
try {
|
|
const response = await fetch(`/api/v1/photos/${selectedPhotoForDisplay.id}`, {
|
|
method: 'PATCH',
|
|
headers: {
|
|
'Content-Type': 'application/json',
|
|
'Authorization': `Bearer ${localStorage.getItem('token') || localStorage.getItem('guest_token')}`
|
|
},
|
|
body: JSON.stringify({
|
|
title: editTitle,
|
|
description: editDescription,
|
|
latitude: editLat === '' ? undefined : editLat,
|
|
longitude: editLng === '' ? undefined : editLng
|
|
})
|
|
});
|
|
|
|
if (!response.ok) throw new Error('Failed to update photo info');
|
|
|
|
const updatedPhoto = await response.json();
|
|
notify({ title: 'Thành công', message: 'Thông tin ảnh đã được cập nhật.', type: 'success' });
|
|
|
|
// Update photos list
|
|
setPhotos(prev => prev.map(p => p.id === updatedPhoto.id ? { ...p, metadata: updatedPhoto.metadata } : p));
|
|
// Update selectedPhotoForDisplay
|
|
setSelectedPhotoForDisplay((prev: any) => prev ? ({ ...prev, metadata: updatedPhoto.metadata }) : null);
|
|
setIsEditing(false);
|
|
} catch (error) {
|
|
notify({ title: 'Lỗi', message: 'Không thể cập nhật thông tin ảnh. Vui lòng thử lại.', type: 'error' });
|
|
} finally {
|
|
setIsSavingEdit(false);
|
|
}
|
|
};
|
|
|
|
const currentUser = useMemo(() => {
|
|
const userStr = localStorage.getItem('user') || localStorage.getItem('guest_user');
|
|
if (!userStr) return null;
|
|
try {
|
|
return JSON.parse(userStr);
|
|
} catch (e) {
|
|
return null;
|
|
}
|
|
}, []);
|
|
|
|
const likedUserIds = selectedPhotoForDisplay && selectedPhotoForDisplay.metadata && Array.isArray(selectedPhotoForDisplay.metadata.likedUserIds)
|
|
? selectedPhotoForDisplay.metadata.likedUserIds
|
|
: [];
|
|
const isLiked = currentUser && likedUserIds.includes(currentUser.id);
|
|
const likeCount = likedUserIds.length;
|
|
|
|
const handleToggleLike = async () => {
|
|
if (!selectedPhotoForDisplay) return;
|
|
const token = localStorage.getItem('token') || localStorage.getItem('guest_token');
|
|
if (!token) return;
|
|
|
|
try {
|
|
const response = await fetch(`/api/v1/photos/${selectedPhotoForDisplay.id}/toggle-like`, {
|
|
method: 'POST',
|
|
headers: {
|
|
'Authorization': `Bearer ${token}`
|
|
}
|
|
});
|
|
|
|
if (response.ok) {
|
|
const updatedPhoto = await response.json();
|
|
setPhotos(prev => prev.map(p => p.id === updatedPhoto.id ? { ...p, metadata: updatedPhoto.metadata } : p));
|
|
setSelectedPhotoForDisplay((prev: any) => prev ? ({ ...prev, metadata: updatedPhoto.metadata }) : null);
|
|
}
|
|
} catch (error) {
|
|
console.error('Lỗi khi thích ảnh:', error);
|
|
}
|
|
};
|
|
|
|
useEffect(() => {
|
|
const fetchPhotos = async () => {
|
|
try {
|
|
const response = await fetch('/api/v1/users/me/photos', {
|
|
headers: {
|
|
'Authorization': `Bearer ${localStorage.getItem('token') || localStorage.getItem('guest_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 toursWithPhotos = 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 photosToFilter = photos.filter(p => {
|
|
const matchTour = selectedTourIdForPhoto === 'all' || p.tourId === selectedTourIdForPhoto;
|
|
// 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;
|
|
});
|
|
let sortedPhotos = photosToFilter;
|
|
// 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, selectedTourIdForPhoto, filterDate, sortOrder]);
|
|
|
|
// Effect để thiết lập ảnh được chọn hiển thị hoặc reset nếu ảnh hiện tại không còn trong danh sách lọc
|
|
useEffect(() => {
|
|
if (filteredPhotos.length > 0 && (!selectedPhotoForDisplay || !filteredPhotos.some(p => p.id === selectedPhotoForDisplay.id))) {
|
|
setSelectedPhotoForDisplay(filteredPhotos[0]);
|
|
} else if (filteredPhotos.length === 0) {
|
|
setSelectedPhotoForDisplay(null);
|
|
} else if (selectedPhotoForDisplay) {
|
|
// Nếu ảnh đang chọn vẫn còn trong danh sách lọc, không làm gì cả
|
|
} else {
|
|
// Nếu không có ảnh nào để hiển thị
|
|
setSelectedPhotoForDisplay(null); // No photos to display
|
|
}
|
|
}, [filteredPhotos, selectedPhotoForDisplay]);
|
|
|
|
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') || localStorage.getItem('guest_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));
|
|
setSelectedPhotoForDisplay(null); // Reset ảnh đang hiển thị
|
|
} catch (error) {
|
|
notify({ title: 'Lỗi', message: 'Không thể xóa ảnh. Vui lòng thử lại.', type: 'error' });
|
|
}
|
|
};
|
|
|
|
|
|
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>
|
|
|
|
{/* Chỉ báo Tour hiện tại */}
|
|
<div className="relative min-w-[150px]">
|
|
<div className="px-3 py-2 bg-blue-50 text-blue-600 rounded-xl text-xs font-bold border border-blue-100 truncate max-w-[200px]">
|
|
{selectedTourIdForPhoto === 'all' ? 'Tất cả hành trình' : toursWithPhotos.find(t => t.id === selectedTourIdForPhoto)?.title || 'Tour đã chọn'}
|
|
</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 */}
|
|
{(selectedTourIdForPhoto !== 'all' || filterDate) && (
|
|
<button
|
|
onClick={() => { setSelectedTourIdForPhoto('all'); 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>
|
|
) : (
|
|
<div className="animate-in fade-in">
|
|
<div className="flex flex-col md:flex-row gap-4">
|
|
{/* Left Column: Tour List */}
|
|
<div className="md:w-1/4 bg-white rounded-2xl shadow-lg border border-gray-100 p-4 flex-shrink-0">
|
|
<h3 className="text-sm font-bold text-gray-800 mb-3">Tour của bạn</h3>
|
|
<div className="space-y-2">
|
|
<button
|
|
onClick={() => { setSelectedTourIdForPhoto('all'); setSelectedPhotoForDisplay(null); }}
|
|
className={`w-full text-left px-3 py-2 rounded-xl text-xs font-bold transition-all ${
|
|
selectedTourIdForPhoto === 'all' ? 'bg-blue-600 text-white' : 'bg-gray-100 text-gray-700 hover:bg-gray-200'
|
|
}`}
|
|
>
|
|
Tất cả ảnh
|
|
</button>
|
|
{toursWithPhotos.map(tour => (
|
|
<button
|
|
key={tour.id}
|
|
onClick={() => { setSelectedTourIdForPhoto(tour.id); setSelectedPhotoForDisplay(null); }}
|
|
className={`w-full text-left px-3 py-2 rounded-xl text-xs font-bold transition-all ${
|
|
selectedTourIdForPhoto === tour.id ? 'bg-blue-600 text-white' : 'bg-gray-100 text-gray-700 hover:bg-gray-200'
|
|
}`}
|
|
>
|
|
{tour.title}
|
|
</button>
|
|
))}
|
|
</div>
|
|
</div>
|
|
|
|
{/* Right Column: Large Photo Display */}
|
|
<div className="md:flex-1 bg-white rounded-2xl shadow-lg border border-gray-100 p-4 flex flex-col items-center justify-center min-h-[300px]">
|
|
{selectedPhotoForDisplay ? (
|
|
<div className="relative w-full h-full flex flex-col items-center justify-center">
|
|
<div className="relative overflow-hidden rounded-xl shadow-md max-w-full max-h-[calc(100vh-350px)] group">
|
|
<img
|
|
src={selectedPhotoForDisplay.imageUrl || selectedPhotoForDisplay.originalUrl}
|
|
alt="Selected Photo"
|
|
className="max-w-full max-h-[calc(100vh-350px)] object-contain cursor-zoom-in"
|
|
onClick={() => setIsFullscreen(true)}
|
|
/>
|
|
|
|
{/* Overlays (Only show when not editing) */}
|
|
{!isEditing && (
|
|
<>
|
|
{/* Like (Heart) button overlay */}
|
|
<button
|
|
onClick={handleToggleLike}
|
|
className="absolute top-4 left-4 z-10 flex items-center gap-1.5 bg-black/60 hover:bg-black/75 border border-white/10 text-white font-bold py-1.5 px-3 rounded-full transition-all active:scale-95 text-[11px] backdrop-blur-md"
|
|
title={isLiked ? "Bỏ thích" : "Thích"}
|
|
>
|
|
<Heart className={`w-4 h-4 transition-colors ${
|
|
isLiked ? 'text-rose-500 fill-rose-500 animate-in zoom-in-75' : 'text-gray-300 hover:text-rose-450'
|
|
}`} />
|
|
<span>{likeCount}</span>
|
|
</button>
|
|
|
|
{/* Delete button overlay */}
|
|
<button
|
|
onClick={(e) => {
|
|
e.stopPropagation();
|
|
handleDeletePhoto(selectedPhotoForDisplay.id);
|
|
}}
|
|
className="absolute top-4 right-4 z-10 p-2 bg-black/60 hover:bg-red-600 border border-white/10 text-white rounded-full transition-all active:scale-95 backdrop-blur-md"
|
|
title="Xóa ảnh này"
|
|
>
|
|
<Trash2 className="w-4 h-4" />
|
|
</button>
|
|
|
|
{/* Bottom metadata details gradient panel overlay */}
|
|
<div className="absolute bottom-0 inset-x-0 bg-gradient-to-t from-black/60 via-black/20 to-transparent p-6 text-white flex flex-col gap-2 text-left">
|
|
<div className="flex justify-between items-start gap-4">
|
|
<div className="flex-1 min-w-0">
|
|
{selectedPhotoForDisplay.metadata?.title ? (
|
|
<h3 className="text-base font-extrabold text-white break-words drop-shadow-md">
|
|
{selectedPhotoForDisplay.metadata.title}
|
|
</h3>
|
|
) : (
|
|
<span className="text-xs text-gray-350 italic block mb-1 drop-shadow-md">Chưa có tiêu đề</span>
|
|
)}
|
|
{selectedPhotoForDisplay.metadata?.description ? (
|
|
<p className="text-xs text-gray-250 leading-relaxed mt-1 break-words drop-shadow-sm max-h-16 overflow-y-auto no-scrollbar">
|
|
{selectedPhotoForDisplay.metadata.description}
|
|
</p>
|
|
) : (
|
|
<span className="text-[11px] text-gray-350 italic block mt-1 drop-shadow-sm">Chưa có mô tả</span>
|
|
)}
|
|
</div>
|
|
|
|
{/* Edit Button Overlay */}
|
|
<button
|
|
onClick={() => setIsEditing(true)}
|
|
className="p-2 bg-white/10 hover:bg-white/20 border border-white/15 rounded-xl text-white hover:text-gray-200 transition-all shrink-0 backdrop-blur-sm"
|
|
title="Chỉnh sửa thông tin"
|
|
>
|
|
<Edit className="w-4 h-4" />
|
|
</button>
|
|
</div>
|
|
|
|
{/* Additional metadata info inside bottom overlay */}
|
|
<div className="flex flex-wrap items-center justify-between gap-4 border-t border-white/10 pt-3 text-xs text-gray-200">
|
|
<div className="space-y-1">
|
|
<div className="flex items-center gap-2 text-gray-300 text-[10px] font-bold uppercase tracking-wider">
|
|
<Calendar className="w-3.5 h-3.5" />
|
|
Ngày chụp: {new Date(selectedPhotoForDisplay.capturedAt).toLocaleDateString('vi-VN', { weekday: 'long', year: 'numeric', month: 'long', day: 'numeric' })}
|
|
</div>
|
|
<div className="flex items-center gap-2 font-bold" title={selectedPhotoForDisplay.metadata?.lat && selectedPhotoForDisplay.metadata?.lng ? `${selectedPhotoForDisplay.metadata.lat.toFixed(6)}, ${selectedPhotoForDisplay.metadata.lng.toFixed(6)}` : ''}>
|
|
<MapPin className="w-3.5 h-3.5 text-rose-500" />
|
|
Địa điểm: {resolvedAddress}
|
|
</div>
|
|
{selectedPhotoForDisplay.tour?.title && (
|
|
<div className="text-[10px] text-gray-400">
|
|
Hành trình: {selectedPhotoForDisplay.tour.title}
|
|
</div>
|
|
)}
|
|
</div>
|
|
|
|
{selectedPhotoForDisplay.originalUrl && (
|
|
<a
|
|
href={selectedPhotoForDisplay.originalUrl}
|
|
download
|
|
className="flex items-center gap-2 px-4 py-2 bg-blue-600 hover:bg-blue-700 text-white rounded-xl font-bold uppercase tracking-widest text-[9px] transition-all shadow-md active:scale-95 shrink-0"
|
|
>
|
|
<Download className="w-3.5 h-3.5 animate-pulse" /> Tải ảnh gốc
|
|
</a>
|
|
)}
|
|
</div>
|
|
</div>
|
|
</>
|
|
)}
|
|
</div>
|
|
|
|
{isEditing && (
|
|
<div className="mt-6 w-full flex flex-col gap-4 px-2">
|
|
<div className="space-y-3 bg-gray-50 border border-gray-150 p-4 rounded-2xl w-full">
|
|
<h4 className="text-xs font-black uppercase tracking-wider text-blue-605">Chỉnh sửa thông tin ảnh</h4>
|
|
|
|
<div className="space-y-2">
|
|
<div>
|
|
<label className="block text-[10px] uppercase font-bold tracking-wider text-gray-500 mb-1">Tiêu đề</label>
|
|
<input
|
|
type="text"
|
|
value={editTitle}
|
|
onChange={(e) => setEditTitle(e.target.value)}
|
|
placeholder="Nhập tiêu đề..."
|
|
className="w-full bg-white border border-gray-200 text-gray-800 rounded-xl px-3 py-2 text-xs focus:outline-none focus:ring-1 focus:ring-blue-500 focus:border-transparent"
|
|
/>
|
|
</div>
|
|
|
|
<div>
|
|
<label className="block text-[10px] uppercase font-bold tracking-wider text-gray-500 mb-1">Mô tả</label>
|
|
<textarea
|
|
value={editDescription}
|
|
onChange={(e) => setEditDescription(e.target.value)}
|
|
placeholder="Nhập mô tả..."
|
|
rows={2}
|
|
className="w-full bg-white border border-gray-200 text-gray-800 rounded-xl px-3 py-2 text-xs focus:outline-none focus:ring-1 focus:ring-blue-500 focus:border-transparent resize-none"
|
|
/>
|
|
</div>
|
|
|
|
<div className="grid grid-cols-2 gap-2">
|
|
<div>
|
|
<label className="block text-[10px] uppercase font-bold tracking-wider text-gray-500 mb-1">Vĩ độ</label>
|
|
<input
|
|
type="number"
|
|
step="any"
|
|
value={editLat}
|
|
onChange={(e) => setEditLat(e.target.value === '' ? '' : parseFloat(e.target.value))}
|
|
placeholder="Vĩ độ..."
|
|
className="w-full bg-white border border-gray-200 text-gray-800 rounded-xl px-3 py-2 text-xs focus:outline-none focus:ring-1 focus:ring-blue-500 focus:border-transparent"
|
|
/>
|
|
</div>
|
|
<div>
|
|
<label className="block text-[10px] uppercase font-bold tracking-wider text-gray-500 mb-1">Kinh độ</label>
|
|
<input
|
|
type="number"
|
|
step="any"
|
|
value={editLng}
|
|
onChange={(e) => setEditLng(e.target.value === '' ? '' : parseFloat(e.target.value))}
|
|
placeholder="Kinh độ..."
|
|
className="w-full bg-white border border-gray-200 text-gray-800 rounded-xl px-3 py-2 text-xs focus:outline-none focus:ring-1 focus:ring-blue-500 focus:border-transparent"
|
|
/>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<div className="flex justify-start">
|
|
<button
|
|
type="button"
|
|
onClick={() => setIsMapOpen(true)}
|
|
className="flex items-center gap-1.5 px-3 py-1.5 bg-gray-100 hover:bg-gray-200 border border-gray-200 text-gray-700 hover:text-gray-900 rounded-xl text-[10px] font-bold transition-all"
|
|
>
|
|
<MapPin className="w-3.5 h-3.5 text-rose-500" />
|
|
Chọn trên bản đồ
|
|
</button>
|
|
</div>
|
|
|
|
<div className="flex justify-end gap-2 mt-2">
|
|
<button
|
|
onClick={() => setIsEditing(false)}
|
|
disabled={isSavingEdit}
|
|
className="px-3 py-1.5 bg-gray-200 hover:bg-gray-300 text-gray-700 rounded-lg text-xs font-bold transition-all"
|
|
>
|
|
Hủy
|
|
</button>
|
|
<button
|
|
onClick={handleSaveEdit}
|
|
disabled={isSavingEdit}
|
|
className="flex items-center gap-1 px-4 py-1.5 bg-blue-600 hover:bg-blue-700 disabled:opacity-50 text-white rounded-lg text-xs font-bold transition-all"
|
|
>
|
|
{isSavingEdit ? (
|
|
<>
|
|
<Loader2 className="w-3.5 h-3.5 animate-spin" />
|
|
Đang lưu...
|
|
</>
|
|
) : (
|
|
'Lưu lại'
|
|
)}
|
|
</button>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
)}
|
|
</div>
|
|
) : (
|
|
<div className="text-center text-gray-400 py-20">
|
|
<ImageIcon className="w-16 h-16 mx-auto mb-4 opacity-20" />
|
|
<p className="text-lg font-bold">Chọn một tấm ảnh để xem</p>
|
|
</div>
|
|
)}
|
|
</div>
|
|
</div>
|
|
|
|
{/* Bottom Row: Thumbnails */}
|
|
<div className="mt-4 bg-white rounded-2xl shadow-lg border border-gray-100 p-4">
|
|
<div className="flex items-center justify-between mb-4 px-1">
|
|
<h3 className="text-xs font-black text-gray-400 uppercase tracking-widest">Kho ảnh ({filteredPhotos.length})</h3>
|
|
</div>
|
|
<div className="grid grid-cols-4 sm:grid-cols-6 md:grid-cols-8 lg:grid-cols-10 gap-3 max-h-[300px] overflow-y-auto pr-2 custom-scrollbar">
|
|
{filteredPhotos.map((photo: any) => (
|
|
<div
|
|
key={photo.id}
|
|
onClick={() => setSelectedPhotoForDisplay(photo)}
|
|
className={`aspect-square bg-gray-100 rounded-xl overflow-hidden relative group border-2 transition-all cursor-pointer ${
|
|
selectedPhotoForDisplay?.id === photo.id ? 'border-blue-500 scale-[0.98]' : 'border-transparent hover:border-blue-200'
|
|
}`}
|
|
>
|
|
<img
|
|
src={photo.imageUrl || photo.originalUrl}
|
|
alt="thumbnail"
|
|
className="w-full h-full object-cover"
|
|
/>
|
|
</div>
|
|
))}
|
|
</div>
|
|
</div>
|
|
</div>
|
|
) }
|
|
<div className="h-4"></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>
|
|
<CoordinateSelectModal
|
|
isOpen={isMapOpen}
|
|
onClose={() => setIsMapOpen(false)}
|
|
initialLat={typeof editLat === 'number' ? editLat : undefined}
|
|
initialLng={typeof editLng === 'number' ? editLng : undefined}
|
|
onSelect={(lat, lng) => {
|
|
setEditLat(lat);
|
|
setEditLng(lng);
|
|
}}
|
|
/>
|
|
|
|
{isFullscreen && selectedPhotoForDisplay && (
|
|
<div
|
|
className="fixed inset-0 z-[9999] bg-black/95 flex items-center justify-center cursor-zoom-out animate-in fade-in duration-200"
|
|
onClick={() => setIsFullscreen(false)}
|
|
>
|
|
<button
|
|
onClick={() => setIsFullscreen(false)}
|
|
className="fixed top-6 right-6 p-3 bg-black/50 hover:bg-black/75 border border-white/10 rounded-full text-white transition-colors z-[10000]"
|
|
>
|
|
<X className="w-6 h-6" />
|
|
</button>
|
|
<img
|
|
src={selectedPhotoForDisplay.imageUrl || selectedPhotoForDisplay.originalUrl}
|
|
alt="Fullscreen photo"
|
|
className="max-w-full max-h-full object-contain select-none animate-in zoom-in-95 duration-200"
|
|
/>
|
|
</div>
|
|
)}
|
|
</div>
|
|
);
|
|
}; |