feat: cho phép người dùng chỉnh sửa thông tin ảnh đã upload
This commit is contained in:
Vendored
+48
-2
@@ -501,7 +501,13 @@ let PublicTourController = class PublicTourController {
|
||||
}
|
||||
}
|
||||
},
|
||||
photos: true,
|
||||
photos: {
|
||||
include: {
|
||||
uploader: {
|
||||
select: { id: true, name: true }
|
||||
}
|
||||
}
|
||||
},
|
||||
legs: {
|
||||
orderBy: { sequence: 'asc' },
|
||||
include: {
|
||||
@@ -850,7 +856,13 @@ let TourController = class TourController {
|
||||
}
|
||||
}
|
||||
},
|
||||
photos: true,
|
||||
photos: {
|
||||
include: {
|
||||
uploader: {
|
||||
select: { id: true, name: true }
|
||||
}
|
||||
}
|
||||
},
|
||||
legs: {
|
||||
orderBy: { sequence: 'asc' },
|
||||
include: {
|
||||
@@ -1695,6 +1707,31 @@ let PhotoController = class PhotoController {
|
||||
await this.prisma.photo.delete({ where: { id } });
|
||||
return { message: 'Ảnh đã được xóa thành công.' };
|
||||
}
|
||||
async updatePhoto(id, body, req) {
|
||||
const photo = await this.prisma.photo.findUnique({
|
||||
where: { id }
|
||||
});
|
||||
if (!photo) {
|
||||
throw new common_1.NotFoundException('Không tìm thấy ảnh.');
|
||||
}
|
||||
if (photo.uploaderId !== req.user.id && !req.user.isAdmin) {
|
||||
throw new common_1.ForbiddenException('Bạn không có quyền chỉnh sửa thông tin của bức ảnh này.');
|
||||
}
|
||||
const currentMetadata = photo.metadata || {};
|
||||
const updatedMetadata = {
|
||||
...currentMetadata,
|
||||
lat: body.latitude !== undefined ? body.latitude : currentMetadata.lat,
|
||||
lng: body.longitude !== undefined ? body.longitude : currentMetadata.lng,
|
||||
title: body.title !== undefined ? body.title : currentMetadata.title,
|
||||
description: body.description !== undefined ? body.description : currentMetadata.description,
|
||||
};
|
||||
return this.prisma.photo.update({
|
||||
where: { id },
|
||||
data: {
|
||||
metadata: updatedMetadata
|
||||
}
|
||||
});
|
||||
}
|
||||
};
|
||||
__decorate([
|
||||
(0, common_1.Post)('upload-anonymous'),
|
||||
@@ -1714,6 +1751,15 @@ __decorate([
|
||||
__metadata("design:paramtypes", [String, Object]),
|
||||
__metadata("design:returntype", Promise)
|
||||
], PhotoController.prototype, "deletePhoto", null);
|
||||
__decorate([
|
||||
(0, common_1.Patch)(':id'),
|
||||
__param(0, (0, common_1.Param)('id', common_1.ParseUUIDPipe)),
|
||||
__param(1, (0, common_1.Body)()),
|
||||
__param(2, (0, common_1.Req)()),
|
||||
__metadata("design:type", Function),
|
||||
__metadata("design:paramtypes", [String, Object, Object]),
|
||||
__metadata("design:returntype", Promise)
|
||||
], PhotoController.prototype, "updatePhoto", null);
|
||||
PhotoController = __decorate([
|
||||
(0, common_1.Controller)('photos'),
|
||||
(0, exports.Roles)(client_1.ParticipantRole.OWNER, client_1.ParticipantRole.MANAGER, client_1.ParticipantRole.MEMBER, client_1.ParticipantRole.MEMBER_NO_FINANCE),
|
||||
|
||||
Vendored
+1
-1
File diff suppressed because one or more lines are too long
+50
-2
@@ -484,7 +484,13 @@ class PublicTourController {
|
||||
}
|
||||
}
|
||||
},
|
||||
photos: true,
|
||||
photos: {
|
||||
include: {
|
||||
uploader: {
|
||||
select: { id: true, name: true }
|
||||
}
|
||||
}
|
||||
},
|
||||
legs: {
|
||||
orderBy: { sequence: 'asc' },
|
||||
include: {
|
||||
@@ -913,7 +919,13 @@ class TourController {
|
||||
}
|
||||
}
|
||||
},
|
||||
photos: true,
|
||||
photos: {
|
||||
include: {
|
||||
uploader: {
|
||||
select: { id: true, name: true }
|
||||
}
|
||||
}
|
||||
},
|
||||
legs: {
|
||||
orderBy: { sequence: 'asc' },
|
||||
include: {
|
||||
@@ -1675,6 +1687,42 @@ class PhotoController {
|
||||
await this.prisma.photo.delete({ where: { id } });
|
||||
return { message: 'Ảnh đã được xóa thành công.' };
|
||||
}
|
||||
|
||||
@Patch(':id')
|
||||
async updatePhoto(
|
||||
@Param('id', ParseUUIDPipe) id: string,
|
||||
@Body() body: { title?: string; description?: string; latitude?: number; longitude?: number },
|
||||
@Req() req: any
|
||||
) {
|
||||
const photo = await this.prisma.photo.findUnique({
|
||||
where: { id }
|
||||
});
|
||||
|
||||
if (!photo) {
|
||||
throw new NotFoundException('Không tìm thấy ảnh.');
|
||||
}
|
||||
|
||||
// Chỉ người tải lên hoặc Admin mới có quyền sửa thông tin ảnh
|
||||
if (photo.uploaderId !== req.user.id && !req.user.isAdmin) {
|
||||
throw new ForbiddenException('Bạn không có quyền chỉnh sửa thông tin của bức ảnh này.');
|
||||
}
|
||||
|
||||
const currentMetadata = (photo.metadata as any) || {};
|
||||
const updatedMetadata = {
|
||||
...currentMetadata,
|
||||
lat: body.latitude !== undefined ? body.latitude : currentMetadata.lat,
|
||||
lng: body.longitude !== undefined ? body.longitude : currentMetadata.lng,
|
||||
title: body.title !== undefined ? body.title : currentMetadata.title,
|
||||
description: body.description !== undefined ? body.description : currentMetadata.description,
|
||||
};
|
||||
|
||||
return this.prisma.photo.update({
|
||||
where: { id },
|
||||
data: {
|
||||
metadata: updatedMetadata
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@UseGuards(JwtAuthGuard)
|
||||
|
||||
BIN
Binary file not shown.
|
Before Width: | Height: | Size: 3.0 MiB |
BIN
Binary file not shown.
|
After Width: | Height: | Size: 2.2 MiB |
Binary file not shown.
|
Before Width: | Height: | Size: 790 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 459 KiB |
Vendored
+1
-1
File diff suppressed because one or more lines are too long
+211
File diff suppressed because one or more lines are too long
+2
File diff suppressed because one or more lines are too long
-211
File diff suppressed because one or more lines are too long
-2
File diff suppressed because one or more lines are too long
+1
-1
File diff suppressed because one or more lines are too long
Vendored
+2
-2
@@ -5,8 +5,8 @@
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0, viewport-fit=cover" />
|
||||
<link rel="icon" type="image/x-icon" href="/favicon.ico" />
|
||||
<title>Travel Planner</title>
|
||||
<script type="module" crossorigin src="/assets/index-Kkg5SQS3.js"></script>
|
||||
<link rel="stylesheet" crossorigin href="/assets/index-YlatkTXO.css">
|
||||
<script type="module" crossorigin src="/assets/index-BCrVtqAn.js"></script>
|
||||
<link rel="stylesheet" crossorigin href="/assets/index-D_q28YD-.css">
|
||||
</head>
|
||||
<body>
|
||||
<div id="root"></div>
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import React, { useState, useEffect, useRef } from 'react';
|
||||
import { X, Send, MessageSquare, User, Loader2, Calendar, MapPin, Download } from 'lucide-react';
|
||||
import { X, Send, MessageSquare, User, Loader2, Calendar, MapPin, Download, Edit } from 'lucide-react';
|
||||
import { io } from 'socket.io-client';
|
||||
|
||||
interface Comment {
|
||||
@@ -21,15 +21,19 @@ interface PublicPhotoModalProps {
|
||||
metadata?: {
|
||||
lat?: number;
|
||||
lng?: number;
|
||||
title?: string;
|
||||
description?: string;
|
||||
};
|
||||
uploader?: {
|
||||
id: string;
|
||||
name: string;
|
||||
};
|
||||
uploaderId?: string;
|
||||
};
|
||||
photoGroup?: any[];
|
||||
onSelectPhoto?: (photo: any) => void;
|
||||
onLoginSuccess?: (user: any) => void;
|
||||
onUpdatePhoto?: (updatedPhoto: any) => void;
|
||||
}
|
||||
|
||||
export const PublicPhotoModal: React.FC<PublicPhotoModalProps> = ({
|
||||
@@ -38,7 +42,8 @@ export const PublicPhotoModal: React.FC<PublicPhotoModalProps> = ({
|
||||
photo,
|
||||
photoGroup = [],
|
||||
onSelectPhoto,
|
||||
onLoginSuccess
|
||||
onLoginSuccess,
|
||||
onUpdatePhoto
|
||||
}) => {
|
||||
const [comments, setComments] = useState<Comment[]>([]);
|
||||
const [newComment, setNewComment] = useState('');
|
||||
@@ -46,6 +51,84 @@ export const PublicPhotoModal: React.FC<PublicPhotoModalProps> = ({
|
||||
const [isSending, setIsSending] = useState(false);
|
||||
const commentsEndRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
const [currentUser, setCurrentUser] = useState<any>(null);
|
||||
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 checkCurrentUser = () => {
|
||||
const userStr = localStorage.getItem('user');
|
||||
if (userStr) {
|
||||
try {
|
||||
setCurrentUser(JSON.parse(userStr));
|
||||
} catch (e) {}
|
||||
} else {
|
||||
setCurrentUser(null);
|
||||
}
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
checkCurrentUser();
|
||||
}, [isOpen]);
|
||||
|
||||
useEffect(() => {
|
||||
if (photo) {
|
||||
setEditTitle(photo.metadata?.title || '');
|
||||
setEditDescription(photo.metadata?.description || '');
|
||||
setEditLat(photo.metadata?.lat ?? '');
|
||||
setEditLng(photo.metadata?.lng ?? '');
|
||||
setIsEditing(false); // Reset editing mode when selected photo changes
|
||||
}
|
||||
}, [photo]);
|
||||
|
||||
const handleSaveEdit = async () => {
|
||||
if (editLat !== '' && (isNaN(editLat) || editLat < -90 || editLat > 90)) {
|
||||
alert('Vĩ độ không hợp lệ (-90 đến 90)');
|
||||
return;
|
||||
}
|
||||
if (editLng !== '' && (isNaN(editLng) || editLng < -180 || editLng > 180)) {
|
||||
alert('Kinh độ không hợp lệ (-180 đến 180)');
|
||||
return;
|
||||
}
|
||||
|
||||
setIsSavingEdit(true);
|
||||
try {
|
||||
const token = localStorage.getItem('token');
|
||||
const res = await fetch(`/api/v1/photos/${photo.id}`, {
|
||||
method: 'PATCH',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
'Authorization': `Bearer ${token}`
|
||||
},
|
||||
body: JSON.stringify({
|
||||
title: editTitle,
|
||||
description: editDescription,
|
||||
latitude: editLat === '' ? undefined : editLat,
|
||||
longitude: editLng === '' ? undefined : editLng
|
||||
})
|
||||
});
|
||||
|
||||
if (res.ok) {
|
||||
const updatedPhoto = await res.json();
|
||||
setIsEditing(false);
|
||||
if (onUpdatePhoto) {
|
||||
onUpdatePhoto(updatedPhoto);
|
||||
}
|
||||
} else {
|
||||
const err = await res.json();
|
||||
alert(err.message || 'Lỗi khi cập nhật thông tin ảnh.');
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Lỗi khi cập nhật thông tin ảnh:', error);
|
||||
alert('Không thể kết nối đến máy chủ.');
|
||||
} finally {
|
||||
setIsSavingEdit(false);
|
||||
}
|
||||
};
|
||||
|
||||
|
||||
const fetchComments = async () => {
|
||||
setIsLoading(true);
|
||||
@@ -184,6 +267,10 @@ export const PublicPhotoModal: React.FC<PublicPhotoModalProps> = ({
|
||||
}
|
||||
};
|
||||
|
||||
const isAuthorized = currentUser?.isAdmin ||
|
||||
(currentUser && photo.uploader && currentUser.id === photo.uploader.id) ||
|
||||
(currentUser && photo.uploaderId && currentUser.id === photo.uploaderId);
|
||||
|
||||
if (!isOpen) return null;
|
||||
|
||||
return (
|
||||
@@ -244,6 +331,114 @@ export const PublicPhotoModal: React.FC<PublicPhotoModalProps> = ({
|
||||
</div>
|
||||
)}
|
||||
|
||||
{isEditing ? (
|
||||
<div className="flex flex-col gap-3 bg-slate-900/95 border border-slate-800 p-4 rounded-2xl animate-in slide-in-from-bottom-2">
|
||||
<h4 className="text-xs font-black uppercase tracking-wider text-emerald-400">Chỉnh sửa thông tin ảnh</h4>
|
||||
|
||||
<div className="space-y-2">
|
||||
<div>
|
||||
<label className="block text-[9px] uppercase font-bold tracking-wider text-slate-400 mb-1">Tiêu đề</label>
|
||||
<input
|
||||
type="text"
|
||||
value={editTitle}
|
||||
onChange={(e) => setEditTitle(e.target.value)}
|
||||
placeholder="Nhập tiêu đề cho ảnh..."
|
||||
className="w-full bg-slate-850 border border-slate-700/60 text-slate-100 rounded-xl px-3 py-2 text-xs focus:outline-none focus:ring-1 focus:ring-emerald-500"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-[9px] uppercase font-bold tracking-wider text-slate-400 mb-1">Mô tả</label>
|
||||
<textarea
|
||||
value={editDescription}
|
||||
onChange={(e) => setEditDescription(e.target.value)}
|
||||
placeholder="Mô tả bức ảnh này..."
|
||||
rows={2}
|
||||
className="w-full bg-slate-850 border border-slate-700/60 text-slate-100 rounded-xl px-3 py-2 text-xs focus:outline-none focus:ring-1 focus:ring-emerald-500 resize-none"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-2 gap-2">
|
||||
<div>
|
||||
<label className="block text-[9px] uppercase font-bold tracking-wider text-slate-400 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-slate-850 border border-slate-700/60 text-slate-100 rounded-xl px-3 py-2 text-xs focus:outline-none focus:ring-1 focus:ring-emerald-500"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-[9px] uppercase font-bold tracking-wider text-slate-400 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-slate-850 border border-slate-700/60 text-slate-100 rounded-xl px-3 py-2 text-xs focus:outline-none focus:ring-1 focus:ring-emerald-500"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex justify-end gap-2 mt-2">
|
||||
<button
|
||||
onClick={() => setIsEditing(false)}
|
||||
disabled={isSavingEdit}
|
||||
className="px-3 py-1.5 bg-slate-800 hover:bg-slate-700 text-slate-300 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-emerald-600 hover:bg-emerald-500 disabled:bg-slate-800 disabled:opacity-50 text-white rounded-lg text-xs font-bold transition-all"
|
||||
>
|
||||
{isSavingEdit ? (
|
||||
<>
|
||||
<Loader2 className="w-3 h-3 animate-spin" />
|
||||
Đang lưu...
|
||||
</>
|
||||
) : (
|
||||
'Lưu lại'
|
||||
)}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
{/* Title & Description display */}
|
||||
<div className="flex justify-between items-start gap-4">
|
||||
<div className="flex-1 min-w-0">
|
||||
{photo.metadata?.title ? (
|
||||
<h4 className="text-sm font-black text-white tracking-tight leading-snug break-words">
|
||||
{photo.metadata.title}
|
||||
</h4>
|
||||
) : (
|
||||
<span className="text-[10px] text-slate-500 italic block mb-1">Chưa có tiêu đề</span>
|
||||
)}
|
||||
{photo.metadata?.description ? (
|
||||
<p className="text-xs text-slate-300 leading-relaxed mt-1 max-h-20 overflow-y-auto no-scrollbar break-words">
|
||||
{photo.metadata.description}
|
||||
</p>
|
||||
) : (
|
||||
<span className="text-[10px] text-slate-500 italic block mt-1">Chưa có mô tả</span>
|
||||
)}
|
||||
</div>
|
||||
{isAuthorized && (
|
||||
<button
|
||||
onClick={() => setIsEditing(true)}
|
||||
className="p-1.5 bg-slate-800 hover:bg-slate-700 border border-slate-700/50 rounded-xl text-slate-300 hover:text-white transition-all shrink-0"
|
||||
title="Chỉnh sửa thông tin"
|
||||
>
|
||||
<Edit className="w-3.5 h-3.5" />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Photo Metadata */}
|
||||
<div className="flex flex-wrap items-center gap-4 text-xs text-slate-300">
|
||||
<span className="flex items-center gap-1.5 font-semibold text-emerald-400">
|
||||
@@ -279,6 +474,8 @@ export const PublicPhotoModal: React.FC<PublicPhotoModalProps> = ({
|
||||
</a>
|
||||
)}
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
|
||||
<style>{`
|
||||
.no-scrollbar::-webkit-scrollbar {
|
||||
|
||||
@@ -585,6 +585,15 @@ export const ExploreMap = ({ onBack, onLogout, user, onViewTour, onOpenMyPhotos,
|
||||
photoGroup={selectedPhotoGroup}
|
||||
onSelectPhoto={(photo) => setSelectedPhoto(photo)}
|
||||
onLoginSuccess={onLoginSuccess}
|
||||
onUpdatePhoto={(updatedPhoto) => {
|
||||
setPublicPhotos((prev) =>
|
||||
prev.map((p) => (p.id === updatedPhoto.id ? updatedPhoto : p))
|
||||
);
|
||||
setSelectedPhoto(updatedPhoto);
|
||||
setSelectedPhotoGroup((prev) =>
|
||||
prev.map((p) => (p.id === updatedPhoto.id ? updatedPhoto : p))
|
||||
);
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import React, { useEffect, useState, useMemo } from 'react';
|
||||
import { ChevronLeft, Image as ImageIcon, Download, Calendar, MapPin, Loader2, Filter, X, Trash2 } from 'lucide-react';
|
||||
import { ChevronLeft, Image as ImageIcon, Download, Calendar, MapPin, Loader2, Filter, X, Trash2, Edit } from 'lucide-react';
|
||||
import { useNotification } from '@/hooks/useNotification';
|
||||
import { useConfirm } from '@/hooks/useConfirm';
|
||||
|
||||
@@ -13,6 +13,66 @@ export const MyPhotosPage = ({ onBack }: { onBack: () => void }) => {
|
||||
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);
|
||||
|
||||
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')}`
|
||||
},
|
||||
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);
|
||||
}
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
const fetchPhotos = async () => {
|
||||
try {
|
||||
@@ -231,13 +291,124 @@ export const MyPhotosPage = ({ onBack }: { onBack: () => void }) => {
|
||||
<Trash2 className="w-5 h-5" />
|
||||
</button>
|
||||
|
||||
<div className="mt-6 w-full flex items-center justify-between px-2">
|
||||
<div className="mt-6 w-full flex flex-col gap-4 px-2">
|
||||
{isEditing ? (
|
||||
<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-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 className="flex flex-col gap-3 w-full">
|
||||
<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 có 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 có mô tả</span>
|
||||
)}
|
||||
</div>
|
||||
<button
|
||||
onClick={() => setIsEditing(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">
|
||||
<div className="flex items-center gap-2 text-gray-900 font-bold text-xs">
|
||||
<MapPin className="w-4 h-4 text-blue-500" />
|
||||
{selectedPhotoForDisplay.tour?.title || 'Không rõ hành trình'}
|
||||
{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-400 text-xs font-medium uppercase tracking-wider">
|
||||
<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>
|
||||
@@ -247,13 +418,16 @@ export const MyPhotosPage = ({ onBack }: { onBack: () => void }) => {
|
||||
<a
|
||||
href={selectedPhotoForDisplay.originalUrl}
|
||||
download
|
||||
className="flex items-center gap-2 px-6 py-3 bg-blue-600 hover:bg-blue-700 text-white rounded-2xl font-black uppercase tracking-widest text-[10px] transition-all shadow-lg shadow-blue-900/20 active:scale-95"
|
||||
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-4 h-4" /> Tải xuống ảnh gốc
|
||||
<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 py-20">
|
||||
<ImageIcon className="w-16 h-16 mx-auto mb-4 opacity-20" />
|
||||
|
||||
@@ -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,11 +1986,12 @@ 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">
|
||||
<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-200px)] object-contain rounded-xl shadow-md"
|
||||
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 && (
|
||||
@@ -1932,6 +2007,146 @@ export const TourDetailPage = ({
|
||||
</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">Mô 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">Vĩ độ</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 có 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 có mô 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">
|
||||
<ImageIcon className="w-16 h-16 mx-auto mb-4" />
|
||||
|
||||
Reference in New Issue
Block a user