feat: cho phép người dùng chỉnh sửa thông tin ảnh đã upload

This commit is contained in:
2026-06-20 07:37:01 +07:00
parent 4373ed684a
commit 9da8a9494d
18 changed files with 987 additions and 298 deletions
+235 -20
View File
@@ -42,7 +42,9 @@ import {
Share2,
Tag as TagIcon,
Trash2,
FileText
FileText,
Edit,
Download
} from 'lucide-react';
import L from 'leaflet';
@@ -508,6 +510,78 @@ export const TourDetailPage = ({
return null;
}
}, []);
const currentUser = useMemo(() => {
const userStr = localStorage.getItem('user');
if (!userStr) return null;
try {
return JSON.parse(userStr);
} catch (e) {
return null;
}
}, []);
const [isEditingPhoto, setIsEditingPhoto] = useState(false);
const [editPhotoTitle, setEditPhotoTitle] = useState('');
const [editPhotoDescription, setEditPhotoDescription] = useState('');
const [editPhotoLat, setEditPhotoLat] = useState<number | ''>('');
const [editPhotoLng, setEditPhotoLng] = useState<number | ''>('');
const [isSavingPhotoEdit, setIsSavingPhotoEdit] = useState(false);
useEffect(() => {
if (selectedPhotoForDisplay) {
setEditPhotoTitle(selectedPhotoForDisplay.metadata?.title || '');
setEditPhotoDescription(selectedPhotoForDisplay.metadata?.description || '');
setEditPhotoLat(selectedPhotoForDisplay.metadata?.lat ?? '');
setEditPhotoLng(selectedPhotoForDisplay.metadata?.lng ?? '');
setIsEditingPhoto(false);
}
}, [selectedPhotoForDisplay]);
const handleSavePhotoEdit = async () => {
if (!selectedPhotoForDisplay) return;
if (editPhotoLat !== '' && (isNaN(editPhotoLat) || editPhotoLat < -90 || editPhotoLat > 90)) {
notify({ title: 'Lỗi', message: 'Vĩ độ không hợp lệ (-90 đến 90).', type: 'error' });
return;
}
if (editPhotoLng !== '' && (isNaN(editPhotoLng) || editPhotoLng < -180 || editPhotoLng > 180)) {
notify({ title: 'Lỗi', message: 'Kinh độ không hợp lệ (-180 đến 180).', type: 'error' });
return;
}
setIsSavingPhotoEdit(true);
try {
const response = await fetch(`/api/v1/photos/${selectedPhotoForDisplay.id}`, {
method: 'PATCH',
headers: {
'Content-Type': 'application/json',
'Authorization': `Bearer ${localStorage.getItem('token')}`
},
body: JSON.stringify({
title: editPhotoTitle,
description: editPhotoDescription,
latitude: editPhotoLat === '' ? undefined : editPhotoLat,
longitude: editPhotoLng === '' ? undefined : editPhotoLng
})
});
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' });
setSelectedPhotoForDisplay((prev: any) => prev ? ({ ...prev, metadata: updatedPhoto.metadata }) : null);
if (currentTour) {
fetchTour(currentTour.id);
}
setIsEditingPhoto(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 {
setIsSavingPhotoEdit(false);
}
};
const [joinRequests, setJoinRequests] = useState<any[]>([]);
const [titleInput, setTitleInput] = useState(currentTour?.title ?? '');
const [descriptionInput, setDescriptionInput] = useState(currentTour?.description ?? '');
@@ -1912,25 +1986,166 @@ export const TourDetailPage = ({
{/* 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 items-center justify-center">
<img
src={selectedPhotoForDisplay.imageUrl}
alt="Selected Tour Photo"
className="max-w-full max-h-[calc(100vh-200px)] object-contain rounded-xl shadow-md"
/>
{/* Optional: Add delete button for the large photo */}
{currentUserId === selectedPhotoForDisplay.uploaderId && !isPublicView && (
<button
onClick={(e) => {
e.stopPropagation();
handleDeletePhoto(selectedPhotoForDisplay.id);
}}
className="absolute top-4 right-4 p-2 bg-red-500/80 backdrop-blur-sm text-white rounded-full shadow-lg hover:bg-red-600 transition-colors"
title="Xóa ảnh này"
>
<Trash2 className="w-5 h-5" />
</button>
)}
<div className="relative w-full h-full flex flex-col items-center justify-center">
<div className="relative w-full flex justify-center">
<img
src={selectedPhotoForDisplay.imageUrl}
alt="Selected Tour Photo"
className="max-w-full max-h-[calc(100vh-350px)] object-contain rounded-xl shadow-md"
/>
{/* Optional: Add delete button for the large photo */}
{currentUserId === selectedPhotoForDisplay.uploaderId && !isPublicView && (
<button
onClick={(e) => {
e.stopPropagation();
handleDeletePhoto(selectedPhotoForDisplay.id);
}}
className="absolute top-4 right-4 p-2 bg-red-500/80 backdrop-blur-sm text-white rounded-full shadow-lg hover:bg-red-600 transition-colors"
title="Xóa ảnh này"
>
<Trash2 className="w-5 h-5" />
</button>
)}
</div>
<div className="mt-6 w-full flex flex-col gap-4 px-2">
{isEditingPhoto ? (
<div className="space-y-3 bg-gray-50 border border-gray-150 p-4 rounded-2xl w-full text-left">
<h4 className="text-xs font-black uppercase tracking-wider text-blue-600">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={editPhotoTitle}
onChange={(e) => setEditPhotoTitle(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"> tả</label>
<textarea
value={editPhotoDescription}
onChange={(e) => setEditPhotoDescription(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"> đ</label>
<input
type="number"
step="any"
value={editPhotoLat}
onChange={(e) => setEditPhotoLat(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={editPhotoLng}
onChange={(e) => setEditPhotoLng(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-end gap-2 mt-2">
<button
onClick={() => setIsEditingPhoto(false)}
disabled={isSavingPhotoEdit}
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={handleSavePhotoEdit}
disabled={isSavingPhotoEdit}
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"
>
{isSavingPhotoEdit ? (
<>
<Loader2 className="w-3.5 h-3.5 animate-spin" />
Đang lưu...
</>
) : (
'Lưu lại'
)}
</button>
</div>
</div>
) : (
<div className="flex flex-col gap-3 w-full text-left">
<div className="flex justify-between items-start gap-4">
<div className="flex-1">
{selectedPhotoForDisplay.metadata?.title ? (
<h3 className="text-base font-extrabold text-gray-900 break-words">
{selectedPhotoForDisplay.metadata.title}
</h3>
) : (
<span className="text-xs text-gray-400 italic block mb-1">Chưa tiêu đ</span>
)}
{selectedPhotoForDisplay.metadata?.description ? (
<p className="text-xs text-gray-600 leading-relaxed mt-1 break-words">
{selectedPhotoForDisplay.metadata.description}
</p>
) : (
<span className="text-[11px] text-gray-400 italic block mt-1">Chưa tả</span>
)}
</div>
{!isPublicView && (currentUser?.isAdmin || currentUserId === selectedPhotoForDisplay.uploaderId) && (
<button
onClick={() => setIsEditingPhoto(true)}
className="p-2 bg-gray-100 hover:bg-gray-200 border border-gray-200 rounded-xl text-gray-500 hover:text-gray-700 transition-all shrink-0"
title="Chỉnh sửa thông tin"
>
<Edit className="w-4 h-4" />
</button>
)}
</div>
<div className="flex flex-wrap items-center justify-between gap-4 border-t border-gray-100 pt-3">
<div className="space-y-1">
<div className="flex items-center gap-2 text-gray-900 font-bold text-xs">
<MapPin className="w-4 h-4 text-blue-500" />
Tải lên bởi: {selectedPhotoForDisplay.uploader?.name || 'Thành viên'}
{selectedPhotoForDisplay.metadata?.lat && selectedPhotoForDisplay.metadata?.lng && (
<span className="text-[11px] text-gray-400 font-normal ml-1">
({selectedPhotoForDisplay.metadata.lat.toFixed(4)}, {selectedPhotoForDisplay.metadata.lng.toFixed(4)})
</span>
)}
</div>
<div className="flex items-center gap-2 text-gray-450 text-[10px] font-bold uppercase tracking-wider">
<Calendar className="w-3.5 h-3.5" />
{new Date(selectedPhotoForDisplay.capturedAt).toLocaleDateString('vi-VN', { weekday: 'long', year: 'numeric', month: 'long', day: 'numeric' })}
</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" /> Tải nh gốc
</a>
)}
</div>
</div>
)}
</div>
</div>
) : (
<div className="text-center text-gray-400">