fix: bố cục lại cách hiển thị thông tin ảnh
This commit is contained in:
Vendored
+45
@@ -1732,6 +1732,42 @@ let PhotoController = class PhotoController {
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
async toggleLikePhoto(id, req) {
|
||||||
|
const userId = req.user.id;
|
||||||
|
const photo = await this.prisma.photo.findUnique({
|
||||||
|
where: { id }
|
||||||
|
});
|
||||||
|
if (!photo) {
|
||||||
|
throw new common_1.NotFoundException('Không tìm thấy ảnh.');
|
||||||
|
}
|
||||||
|
const currentMetadata = photo.metadata || {};
|
||||||
|
const likedUserIds = Array.isArray(currentMetadata.likedUserIds)
|
||||||
|
? currentMetadata.likedUserIds
|
||||||
|
: [];
|
||||||
|
const index = likedUserIds.indexOf(userId);
|
||||||
|
let updatedLikedUserIds = [...likedUserIds];
|
||||||
|
if (index > -1) {
|
||||||
|
updatedLikedUserIds.splice(index, 1);
|
||||||
|
}
|
||||||
|
else {
|
||||||
|
updatedLikedUserIds.push(userId);
|
||||||
|
}
|
||||||
|
const updatedMetadata = {
|
||||||
|
...currentMetadata,
|
||||||
|
likedUserIds: updatedLikedUserIds
|
||||||
|
};
|
||||||
|
return this.prisma.photo.update({
|
||||||
|
where: { id },
|
||||||
|
data: {
|
||||||
|
metadata: updatedMetadata
|
||||||
|
},
|
||||||
|
include: {
|
||||||
|
uploader: {
|
||||||
|
select: { id: true, name: true }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
};
|
};
|
||||||
__decorate([
|
__decorate([
|
||||||
(0, common_1.Post)('upload-anonymous'),
|
(0, common_1.Post)('upload-anonymous'),
|
||||||
@@ -1760,6 +1796,15 @@ __decorate([
|
|||||||
__metadata("design:paramtypes", [String, Object, Object]),
|
__metadata("design:paramtypes", [String, Object, Object]),
|
||||||
__metadata("design:returntype", Promise)
|
__metadata("design:returntype", Promise)
|
||||||
], PhotoController.prototype, "updatePhoto", null);
|
], PhotoController.prototype, "updatePhoto", null);
|
||||||
|
__decorate([
|
||||||
|
(0, common_1.Post)(':id/toggle-like'),
|
||||||
|
(0, common_1.UseGuards)(jwt_auth_guard_1.JwtAuthGuard),
|
||||||
|
__param(0, (0, common_1.Param)('id', common_1.ParseUUIDPipe)),
|
||||||
|
__param(1, (0, common_1.Req)()),
|
||||||
|
__metadata("design:type", Function),
|
||||||
|
__metadata("design:paramtypes", [String, Object]),
|
||||||
|
__metadata("design:returntype", Promise)
|
||||||
|
], PhotoController.prototype, "toggleLikePhoto", null);
|
||||||
PhotoController = __decorate([
|
PhotoController = __decorate([
|
||||||
(0, common_1.Controller)('photos'),
|
(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),
|
(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
@@ -1723,6 +1723,52 @@ class PhotoController {
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Post(':id/toggle-like')
|
||||||
|
@UseGuards(JwtAuthGuard)
|
||||||
|
async toggleLikePhoto(@Param('id', ParseUUIDPipe) id: string, @Req() req: any) {
|
||||||
|
const userId = req.user.id;
|
||||||
|
const photo = await this.prisma.photo.findUnique({
|
||||||
|
where: { id }
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!photo) {
|
||||||
|
throw new NotFoundException('Không tìm thấy ảnh.');
|
||||||
|
}
|
||||||
|
|
||||||
|
const currentMetadata = (photo.metadata as any) || {};
|
||||||
|
const likedUserIds = Array.isArray(currentMetadata.likedUserIds)
|
||||||
|
? currentMetadata.likedUserIds
|
||||||
|
: [];
|
||||||
|
|
||||||
|
const index = likedUserIds.indexOf(userId);
|
||||||
|
let updatedLikedUserIds = [...likedUserIds];
|
||||||
|
|
||||||
|
if (index > -1) {
|
||||||
|
// Unlike
|
||||||
|
updatedLikedUserIds.splice(index, 1);
|
||||||
|
} else {
|
||||||
|
// Like
|
||||||
|
updatedLikedUserIds.push(userId);
|
||||||
|
}
|
||||||
|
|
||||||
|
const updatedMetadata = {
|
||||||
|
...currentMetadata,
|
||||||
|
likedUserIds: updatedLikedUserIds
|
||||||
|
};
|
||||||
|
|
||||||
|
return this.prisma.photo.update({
|
||||||
|
where: { id },
|
||||||
|
data: {
|
||||||
|
metadata: updatedMetadata
|
||||||
|
},
|
||||||
|
include: {
|
||||||
|
uploader: {
|
||||||
|
select: { id: true, name: true }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@UseGuards(JwtAuthGuard)
|
@UseGuards(JwtAuthGuard)
|
||||||
|
|||||||
Vendored
+1
-1
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
+211
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" />
|
<meta name="viewport" content="width=device-width, initial-scale=1.0, viewport-fit=cover" />
|
||||||
<link rel="icon" type="image/x-icon" href="/favicon.ico" />
|
<link rel="icon" type="image/x-icon" href="/favicon.ico" />
|
||||||
<title>Travel Planner</title>
|
<title>Travel Planner</title>
|
||||||
<script type="module" crossorigin src="/assets/index-BT1r-9nH.js"></script>
|
<script type="module" crossorigin src="/assets/index-DChzoIDA.js"></script>
|
||||||
<link rel="stylesheet" crossorigin href="/assets/index-B4mnZ6Vc.css">
|
<link rel="stylesheet" crossorigin href="/assets/index-CHBet_N8.css">
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
<div id="root"></div>
|
<div id="root"></div>
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import React, { useState, useEffect, useRef } from 'react';
|
import React, { useState, useEffect, useRef } from 'react';
|
||||||
import { X, Send, MessageSquare, User, Loader2, Calendar, MapPin, Download, Edit } from 'lucide-react';
|
import { X, Send, MessageSquare, User, Loader2, Calendar, MapPin, Download, Edit, Heart } from 'lucide-react';
|
||||||
import { io } from 'socket.io-client';
|
import { io } from 'socket.io-client';
|
||||||
import { CoordinateSelectModal } from './CoordinateSelectModal';
|
import { CoordinateSelectModal } from './CoordinateSelectModal';
|
||||||
|
|
||||||
@@ -60,6 +60,33 @@ export const PublicPhotoModal: React.FC<PublicPhotoModalProps> = ({
|
|||||||
const [editLng, setEditLng] = useState<number | ''>('');
|
const [editLng, setEditLng] = useState<number | ''>('');
|
||||||
const [isSavingEdit, setIsSavingEdit] = useState(false);
|
const [isSavingEdit, setIsSavingEdit] = useState(false);
|
||||||
const [isMapOpen, setIsMapOpen] = useState(false);
|
const [isMapOpen, setIsMapOpen] = useState(false);
|
||||||
|
const [resolvedAddress, setResolvedAddress] = useState<string>('');
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const lat = photo?.metadata?.lat;
|
||||||
|
const lng = photo?.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 độ');
|
||||||
|
}
|
||||||
|
}, [photo?.id, photo?.metadata?.lat, photo?.metadata?.lng]);
|
||||||
|
|
||||||
const checkCurrentUser = () => {
|
const checkCurrentUser = () => {
|
||||||
const userStr = localStorage.getItem('user');
|
const userStr = localStorage.getItem('user');
|
||||||
@@ -131,6 +158,57 @@ export const PublicPhotoModal: React.FC<PublicPhotoModalProps> = ({
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const likedUserIds = photo.metadata && Array.isArray((photo.metadata as any).likedUserIds)
|
||||||
|
? (photo.metadata as any).likedUserIds
|
||||||
|
: [];
|
||||||
|
const isLiked = currentUser && likedUserIds.includes(currentUser.id);
|
||||||
|
const likeCount = likedUserIds.length;
|
||||||
|
|
||||||
|
const handleToggleLike = async () => {
|
||||||
|
let token = localStorage.getItem('token');
|
||||||
|
let userObj = currentUser;
|
||||||
|
|
||||||
|
if (!token) {
|
||||||
|
try {
|
||||||
|
const guestRes = await fetch('/api/v1/auth/create-guest', { method: 'POST' });
|
||||||
|
if (guestRes.ok) {
|
||||||
|
const guestData = await guestRes.json();
|
||||||
|
token = guestData.access_token;
|
||||||
|
userObj = guestData.user;
|
||||||
|
localStorage.setItem('token', token!);
|
||||||
|
localStorage.setItem('user', JSON.stringify(userObj));
|
||||||
|
if (onLoginSuccess) {
|
||||||
|
onLoginSuccess(userObj);
|
||||||
|
}
|
||||||
|
setCurrentUser(userObj);
|
||||||
|
} else {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
console.error('Không thể tạo phiên khách:', e);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const res = await fetch(`/api/v1/photos/${photo.id}/toggle-like`, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: {
|
||||||
|
'Authorization': `Bearer ${token}`
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
if (res.ok) {
|
||||||
|
const updatedPhoto = await res.json();
|
||||||
|
if (onUpdatePhoto) {
|
||||||
|
onUpdatePhoto(updatedPhoto);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Lỗi khi thích ảnh:', error);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
|
||||||
const fetchComments = async () => {
|
const fetchComments = async () => {
|
||||||
setIsLoading(true);
|
setIsLoading(true);
|
||||||
@@ -296,6 +374,18 @@ export const PublicPhotoModal: React.FC<PublicPhotoModalProps> = ({
|
|||||||
|
|
||||||
{/* Left Side: Photo Detail */}
|
{/* Left Side: Photo Detail */}
|
||||||
<div className="relative w-full md:w-3/5 h-2/5 md:h-full bg-slate-950 flex items-center justify-center overflow-hidden group">
|
<div className="relative w-full md:w-3/5 h-2/5 md:h-full bg-slate-950 flex items-center justify-center overflow-hidden group">
|
||||||
|
{/* Like Button Overlay */}
|
||||||
|
<button
|
||||||
|
onClick={handleToggleLike}
|
||||||
|
className="absolute top-4 left-4 z-40 flex items-center gap-1.5 bg-slate-950/70 hover:bg-slate-900/80 border border-slate-700/50 text-slate-200 hover: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-slate-350 hover:text-rose-450'
|
||||||
|
}`} />
|
||||||
|
<span>{likeCount}</span>
|
||||||
|
</button>
|
||||||
|
|
||||||
<img
|
<img
|
||||||
src={photo.imageUrl}
|
src={photo.imageUrl}
|
||||||
alt="Public Map Upload"
|
alt="Public Map Upload"
|
||||||
@@ -453,27 +543,27 @@ export const PublicPhotoModal: React.FC<PublicPhotoModalProps> = ({
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Photo Metadata */}
|
{/* Photo Metadata */}
|
||||||
<div className="flex flex-wrap items-center gap-4 text-xs text-slate-300">
|
<div className="flex flex-wrap items-center justify-between gap-4 border-t border-slate-800/80 pt-3 text-xs text-slate-350">
|
||||||
<span className="flex items-center gap-1.5 font-semibold text-emerald-400">
|
<div className="space-y-1 text-left">
|
||||||
<User className="w-4 h-4" />
|
<div className="flex items-center gap-1.5 text-slate-400">
|
||||||
{photo.uploader?.name || 'Ẩn danh'}
|
<Calendar className="w-3.5 h-3.5" />
|
||||||
</span>
|
Ngày chụp: {new Date(photo.capturedAt).toLocaleDateString('vi-VN', {
|
||||||
<span className="flex items-center gap-1.5 text-slate-400">
|
day: '2-digit',
|
||||||
<Calendar className="w-4 h-4" />
|
month: '2-digit',
|
||||||
{new Date(photo.capturedAt).toLocaleDateString('vi-VN', {
|
year: 'numeric',
|
||||||
day: '2-digit',
|
hour: '2-digit',
|
||||||
month: '2-digit',
|
minute: '2-digit'
|
||||||
year: 'numeric',
|
})}
|
||||||
hour: '2-digit',
|
</div>
|
||||||
minute: '2-digit'
|
<div className="flex items-center gap-1.5 text-slate-400" title={photo.metadata?.lat && photo.metadata?.lng ? `${photo.metadata.lat.toFixed(6)}, ${photo.metadata.lng.toFixed(6)}` : ''}>
|
||||||
})}
|
<MapPin className="w-3.5 h-3.5 text-rose-500" />
|
||||||
</span>
|
Địa điểm: {resolvedAddress}
|
||||||
{photo.metadata?.lat && photo.metadata?.lng && (
|
</div>
|
||||||
<span className="flex items-center gap-1.5 text-slate-400">
|
<div className="flex items-center gap-1.5 font-semibold text-emerald-400">
|
||||||
<MapPin className="w-4 h-4 text-rose-500" />
|
<User className="w-3.5 h-3.5" />
|
||||||
{photo.metadata.lat.toFixed(4)}, {photo.metadata.lng.toFixed(4)}
|
Người đăng: {photo.uploader?.name || 'Ẩn danh'}
|
||||||
</span>
|
</div>
|
||||||
)}
|
</div>
|
||||||
{photo.originalUrl && (
|
{photo.originalUrl && (
|
||||||
<a
|
<a
|
||||||
href={photo.originalUrl}
|
href={photo.originalUrl}
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import React, { useEffect, useState, useMemo } from 'react';
|
import React, { useEffect, useState, useMemo } from 'react';
|
||||||
import { ChevronLeft, Image as ImageIcon, Download, Calendar, MapPin, Loader2, Filter, X, Trash2, Edit } from 'lucide-react';
|
import { ChevronLeft, Image as ImageIcon, Download, Calendar, MapPin, Loader2, Filter, X, Trash2, Edit, Heart } from 'lucide-react';
|
||||||
import { useNotification } from '@/hooks/useNotification';
|
import { useNotification } from '@/hooks/useNotification';
|
||||||
import { useConfirm } from '@/hooks/useConfirm';
|
import { useConfirm } from '@/hooks/useConfirm';
|
||||||
import { CoordinateSelectModal } from '../components/CoordinateSelectModal';
|
import { CoordinateSelectModal } from '../components/CoordinateSelectModal';
|
||||||
@@ -21,6 +21,33 @@ export const MyPhotosPage = ({ onBack }: { onBack: () => void }) => {
|
|||||||
const [editLng, setEditLng] = useState<number | ''>('');
|
const [editLng, setEditLng] = useState<number | ''>('');
|
||||||
const [isSavingEdit, setIsSavingEdit] = useState(false);
|
const [isSavingEdit, setIsSavingEdit] = useState(false);
|
||||||
const [isMapOpen, setIsMapOpen] = useState(false);
|
const [isMapOpen, setIsMapOpen] = useState(false);
|
||||||
|
const [resolvedAddress, setResolvedAddress] = useState<string>('');
|
||||||
|
|
||||||
|
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(() => {
|
useEffect(() => {
|
||||||
if (selectedPhotoForDisplay) {
|
if (selectedPhotoForDisplay) {
|
||||||
@@ -75,6 +102,45 @@ export const MyPhotosPage = ({ onBack }: { onBack: () => void }) => {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const currentUser = useMemo(() => {
|
||||||
|
const userStr = localStorage.getItem('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');
|
||||||
|
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(() => {
|
useEffect(() => {
|
||||||
const fetchPhotos = async () => {
|
const fetchPhotos = async () => {
|
||||||
try {
|
try {
|
||||||
@@ -277,24 +343,105 @@ export const MyPhotosPage = ({ onBack }: { onBack: () => void }) => {
|
|||||||
<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]">
|
<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 ? (
|
{selectedPhotoForDisplay ? (
|
||||||
<div className="relative w-full h-full flex flex-col items-center justify-center">
|
<div className="relative w-full h-full flex flex-col items-center justify-center">
|
||||||
<img
|
<div className="relative overflow-hidden rounded-xl shadow-md max-w-full max-h-[calc(100vh-350px)] group">
|
||||||
src={selectedPhotoForDisplay.imageUrl || selectedPhotoForDisplay.originalUrl}
|
<img
|
||||||
alt="Selected Photo"
|
src={selectedPhotoForDisplay.imageUrl || selectedPhotoForDisplay.originalUrl}
|
||||||
className="max-w-full max-h-[calc(100vh-350px)] object-contain rounded-xl shadow-md"
|
alt="Selected Photo"
|
||||||
/>
|
className="max-w-full max-h-[calc(100vh-350px)] object-contain"
|
||||||
<button
|
/>
|
||||||
onClick={(e) => {
|
|
||||||
e.stopPropagation();
|
{/* Overlays (Only show when not editing) */}
|
||||||
handleDeletePhoto(selectedPhotoForDisplay.id);
|
{!isEditing && (
|
||||||
}}
|
<>
|
||||||
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-all active:scale-90"
|
{/* Like (Heart) button overlay */}
|
||||||
title="Xóa ảnh này"
|
<button
|
||||||
>
|
onClick={handleToggleLike}
|
||||||
<Trash2 className="w-5 h-5" />
|
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"
|
||||||
</button>
|
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>
|
||||||
|
|
||||||
<div className="mt-6 w-full flex flex-col gap-4 px-2">
|
{/* Delete button overlay */}
|
||||||
{isEditing ? (
|
<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/95 via-black/50 to-transparent p-6 text-white flex flex-col gap-2">
|
||||||
|
<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">
|
<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>
|
<h4 className="text-xs font-black uppercase tracking-wider text-blue-605">Chỉnh sửa thông tin ảnh</h4>
|
||||||
|
|
||||||
@@ -382,64 +529,8 @@ export const MyPhotosPage = ({ onBack }: { onBack: () => void }) => {
|
|||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</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 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-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>
|
||||||
) : (
|
) : (
|
||||||
<div className="text-center text-gray-400 py-20">
|
<div className="text-center text-gray-400 py-20">
|
||||||
|
|||||||
@@ -45,7 +45,8 @@ import {
|
|||||||
Trash2,
|
Trash2,
|
||||||
FileText,
|
FileText,
|
||||||
Edit,
|
Edit,
|
||||||
Download
|
Download,
|
||||||
|
Heart
|
||||||
} from 'lucide-react';
|
} from 'lucide-react';
|
||||||
import L from 'leaflet';
|
import L from 'leaflet';
|
||||||
|
|
||||||
@@ -529,6 +530,69 @@ export const TourDetailPage = ({
|
|||||||
const [editPhotoLng, setEditPhotoLng] = useState<number | ''>('');
|
const [editPhotoLng, setEditPhotoLng] = useState<number | ''>('');
|
||||||
const [isSavingPhotoEdit, setIsSavingPhotoEdit] = useState(false);
|
const [isSavingPhotoEdit, setIsSavingPhotoEdit] = useState(false);
|
||||||
const [isMapOpen, setIsMapOpen] = useState(false);
|
const [isMapOpen, setIsMapOpen] = useState(false);
|
||||||
|
const [resolvedAddress, setResolvedAddress] = useState<string>('');
|
||||||
|
|
||||||
|
const likedUserIds = selectedPhotoForDisplay && selectedPhotoForDisplay.metadata && Array.isArray(selectedPhotoForDisplay.metadata.likedUserIds)
|
||||||
|
? selectedPhotoForDisplay.metadata.likedUserIds
|
||||||
|
: [];
|
||||||
|
const isPhotoLiked = currentUser && likedUserIds.includes(currentUser.id);
|
||||||
|
const photoLikeCount = likedUserIds.length;
|
||||||
|
|
||||||
|
const handleToggleLikePhoto = async () => {
|
||||||
|
if (!selectedPhotoForDisplay) return;
|
||||||
|
try {
|
||||||
|
const response = await fetch(`/api/v1/photos/${selectedPhotoForDisplay.id}/toggle-like`, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: {
|
||||||
|
'Authorization': `Bearer ${localStorage.getItem('token')}`
|
||||||
|
}
|
||||||
|
});
|
||||||
|
if (response.ok) {
|
||||||
|
const data = await response.json();
|
||||||
|
setSelectedPhotoForDisplay((prev: any) => {
|
||||||
|
if (!prev) return null;
|
||||||
|
return {
|
||||||
|
...prev,
|
||||||
|
metadata: {
|
||||||
|
...prev.metadata,
|
||||||
|
likedUserIds: data.likedUserIds
|
||||||
|
}
|
||||||
|
};
|
||||||
|
});
|
||||||
|
if (currentTour) {
|
||||||
|
fetchTour(currentTour.id);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error toggling like:', error);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
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(() => {
|
useEffect(() => {
|
||||||
if (selectedPhotoForDisplay) {
|
if (selectedPhotoForDisplay) {
|
||||||
@@ -1989,29 +2053,107 @@ export const TourDetailPage = ({
|
|||||||
<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]">
|
<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 ? (
|
{selectedPhotoForDisplay ? (
|
||||||
<div className="relative w-full h-full flex flex-col 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">
|
<div className="relative overflow-hidden rounded-xl shadow-md max-w-full max-h-[calc(100vh-350px)] group">
|
||||||
<img
|
<img
|
||||||
src={selectedPhotoForDisplay.imageUrl}
|
src={selectedPhotoForDisplay.imageUrl}
|
||||||
alt="Selected Tour Photo"
|
alt="Selected Tour Photo"
|
||||||
className="max-w-full max-h-[calc(100vh-350px)] object-contain rounded-xl shadow-md"
|
className="max-w-full max-h-[calc(100vh-350px)] object-contain"
|
||||||
/>
|
/>
|
||||||
{/* Optional: Add delete button for the large photo */}
|
|
||||||
{currentUserId === selectedPhotoForDisplay.uploaderId && !isPublicView && (
|
{/* Overlays (Only show when not editing) */}
|
||||||
<button
|
{!isEditingPhoto && (
|
||||||
onClick={(e) => {
|
<>
|
||||||
e.stopPropagation();
|
{/* Like (Heart) button overlay */}
|
||||||
handleDeletePhoto(selectedPhotoForDisplay.id);
|
<button
|
||||||
}}
|
onClick={handleToggleLikePhoto}
|
||||||
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"
|
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="Xóa ảnh này"
|
title={isPhotoLiked ? "Bỏ thích" : "Thích"}
|
||||||
>
|
>
|
||||||
<Trash2 className="w-5 h-5" />
|
<Heart className={`w-4 h-4 transition-colors ${
|
||||||
</button>
|
isPhotoLiked ? 'text-rose-500 fill-rose-500 animate-in zoom-in-75' : 'text-gray-300 hover:text-rose-450'
|
||||||
|
}`} />
|
||||||
|
<span>{photoLikeCount}</span>
|
||||||
|
</button>
|
||||||
|
|
||||||
|
{/* Delete button overlay (if owner) */}
|
||||||
|
{currentUserId === selectedPhotoForDisplay.uploaderId && !isPublicView && (
|
||||||
|
<button
|
||||||
|
onClick={(e) => {
|
||||||
|
e.stopPropagation();
|
||||||
|
handleDeletePhoto(selectedPhotoForDisplay.id);
|
||||||
|
}}
|
||||||
|
className="absolute top-4 right-4 z-10 p-2 bg-black/60 hover:bg-red-650 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/95 via-black/50 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 */}
|
||||||
|
{!isPublicView && (currentUser?.isAdmin || currentUserId === selectedPhotoForDisplay.uploaderId) && (
|
||||||
|
<button
|
||||||
|
onClick={() => setIsEditingPhoto(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>
|
||||||
|
<div className="text-[10px] text-gray-400">
|
||||||
|
Tải lên bởi: {selectedPhotoForDisplay.uploader?.name || 'Thành viên'}
|
||||||
|
</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>
|
</div>
|
||||||
|
|
||||||
<div className="mt-6 w-full flex flex-col gap-4 px-2">
|
{isEditingPhoto && (
|
||||||
{isEditingPhoto ? (
|
<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 text-left">
|
<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>
|
<h4 className="text-xs font-black uppercase tracking-wider text-blue-600">Chỉnh sửa thông tin ảnh</h4>
|
||||||
|
|
||||||
@@ -2099,66 +2241,8 @@ export const TourDetailPage = ({
|
|||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</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>
|
||||||
) : (
|
) : (
|
||||||
<div className="text-center text-gray-400">
|
<div className="text-center text-gray-400">
|
||||||
|
|||||||
Reference in New Issue
Block a user