845 lines
35 KiB
TypeScript
845 lines
35 KiB
TypeScript
import React, { useState, useEffect, useRef } from 'react';
|
|
import { X, Send, MessageSquare, User, Loader2, Calendar, MapPin, Download, Edit, Heart, Trash2 } from 'lucide-react';
|
|
import { io } from 'socket.io-client';
|
|
import { CoordinateSelectModal } from './CoordinateSelectModal';
|
|
import { useTranslation } from '../hooks/useTranslation';
|
|
import { useConfirm } from '../hooks/useConfirm';
|
|
import { useNotification } from '../hooks/useNotification';
|
|
|
|
interface Comment {
|
|
id: string;
|
|
userName: string;
|
|
content: string;
|
|
createdAt: string;
|
|
userId: string;
|
|
}
|
|
|
|
interface PublicPhotoModalProps {
|
|
isOpen: boolean;
|
|
onClose: () => void;
|
|
photo: {
|
|
id: string;
|
|
imageUrl: string;
|
|
originalUrl?: string;
|
|
capturedAt: string;
|
|
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> = ({
|
|
isOpen,
|
|
onClose,
|
|
photo,
|
|
photoGroup = [],
|
|
onSelectPhoto,
|
|
onLoginSuccess,
|
|
onUpdatePhoto
|
|
}) => {
|
|
const { t } = useTranslation();
|
|
const confirm = useConfirm();
|
|
const notify = useNotification();
|
|
const [comments, setComments] = useState<Comment[]>([]);
|
|
const [newComment, setNewComment] = useState('');
|
|
const [isLoading, setIsLoading] = useState(false);
|
|
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 [isMapOpen, setIsMapOpen] = useState(false);
|
|
const [resolvedAddress, setResolvedAddress] = useState<string>('');
|
|
const [isFullscreen, setIsFullscreen] = useState(false);
|
|
const isLoggedIn = !!localStorage.getItem('token') && !localStorage.getItem('guest_token');
|
|
|
|
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 userStr = localStorage.getItem('user') || localStorage.getItem('guest_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') || localStorage.getItem('guest_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 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') || localStorage.getItem('guest_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 () => {
|
|
setIsLoading(true);
|
|
try {
|
|
const res = await fetch(`/api/v1/public-photos/${photo.id}/comments`);
|
|
if (res.ok) {
|
|
const data = await res.json();
|
|
setComments(
|
|
data.map((c: any) => ({
|
|
id: c.id,
|
|
userName: c.user?.name || 'Ẩn danh',
|
|
content: c.content,
|
|
createdAt: c.createdAt,
|
|
userId: c.userId
|
|
}))
|
|
);
|
|
}
|
|
} catch (error) {
|
|
console.error('Lỗi khi tải bình luận:', error);
|
|
} finally {
|
|
setIsLoading(false);
|
|
}
|
|
};
|
|
|
|
useEffect(() => {
|
|
if (!isOpen || !photo.id) return;
|
|
|
|
fetchComments();
|
|
|
|
const socket = io();
|
|
socket.emit('joinPhoto', photo.id);
|
|
|
|
socket.on('photoCommentAdded', (newCommentData: any) => {
|
|
if (newCommentData.photoId === photo.id) {
|
|
setComments(prev => {
|
|
if (prev.find(c => c.id === newCommentData.id)) return prev;
|
|
return [
|
|
...prev,
|
|
{
|
|
id: newCommentData.id,
|
|
userName: newCommentData.user?.name || 'Ẩn danh',
|
|
content: newCommentData.content,
|
|
createdAt: newCommentData.createdAt,
|
|
userId: newCommentData.userId
|
|
}
|
|
];
|
|
});
|
|
}
|
|
});
|
|
|
|
socket.on('photoCommentDeleted', (deleted: any) => {
|
|
if (deleted.photoId === photo.id) {
|
|
setComments(prev => prev.filter(c => c.id !== deleted.id));
|
|
}
|
|
});
|
|
|
|
return () => {
|
|
socket.disconnect();
|
|
};
|
|
}, [isOpen, photo.id]);
|
|
|
|
useEffect(() => {
|
|
commentsEndRef.current?.scrollIntoView({ behavior: 'smooth' });
|
|
}, [comments]);
|
|
|
|
const handleSend = async () => {
|
|
if (!newComment.trim()) return;
|
|
|
|
setIsSending(true);
|
|
try {
|
|
let token = localStorage.getItem('token');
|
|
let currentUser = JSON.parse(localStorage.getItem('user') || 'null');
|
|
|
|
// Nếu chưa có token, tự động tạo tài khoản khách
|
|
if (!token) {
|
|
const guestRes = await fetch('/api/v1/auth/create-guest', { method: 'POST' });
|
|
if (!guestRes.ok) throw new Error('Không thể tạo tài khoản khách tự động.');
|
|
const guestData = await guestRes.json();
|
|
token = guestData.access_token;
|
|
currentUser = guestData.user;
|
|
localStorage.setItem('guest_token', token!);
|
|
localStorage.setItem('guest_user', JSON.stringify(currentUser));
|
|
localStorage.setItem('token', token!);
|
|
localStorage.setItem('user', JSON.stringify(currentUser));
|
|
|
|
if (onLoginSuccess) {
|
|
onLoginSuccess(currentUser);
|
|
}
|
|
}
|
|
|
|
let res = await fetch(`/api/v1/public-photos/${photo.id}/comments`, {
|
|
method: 'POST',
|
|
headers: {
|
|
'Content-Type': 'application/json',
|
|
'Authorization': `Bearer ${token}`
|
|
},
|
|
body: JSON.stringify({ content: newComment })
|
|
});
|
|
|
|
if (res.status === 401) {
|
|
console.warn('Token invalid or expired. Creating a new guest user and retrying comment...');
|
|
localStorage.removeItem('guest_token');
|
|
localStorage.removeItem('guest_user');
|
|
localStorage.removeItem('token');
|
|
localStorage.removeItem('user');
|
|
|
|
const guestRes = await fetch('/api/v1/auth/create-guest', { method: 'POST' });
|
|
if (!guestRes.ok) throw new Error('Không thể tạo lại phiên khách.');
|
|
const guestData = await guestRes.json();
|
|
token = guestData.access_token;
|
|
currentUser = guestData.user;
|
|
localStorage.setItem('guest_token', token!);
|
|
localStorage.setItem('guest_user', JSON.stringify(currentUser));
|
|
localStorage.setItem('token', token!);
|
|
localStorage.setItem('user', JSON.stringify(currentUser));
|
|
|
|
if (onLoginSuccess) {
|
|
onLoginSuccess(currentUser);
|
|
}
|
|
|
|
res = await fetch(`/api/v1/public-photos/${photo.id}/comments`, {
|
|
method: 'POST',
|
|
headers: {
|
|
'Content-Type': 'application/json',
|
|
'Authorization': `Bearer ${token}`
|
|
},
|
|
body: JSON.stringify({ content: newComment })
|
|
});
|
|
}
|
|
|
|
if (res.ok) {
|
|
setNewComment('');
|
|
fetchComments();
|
|
} else {
|
|
const err = await res.json();
|
|
console.error('Lỗi khi gửi bình luận:', err.message);
|
|
}
|
|
} catch (error) {
|
|
console.error('Lỗi khi gửi bình luận:', error);
|
|
} finally {
|
|
setIsSending(false);
|
|
}
|
|
};
|
|
|
|
const handleDeleteComment = async (commentId: string) => {
|
|
const shouldDelete = await confirm({
|
|
title: t('deleteComment') || 'Xóa bình luận',
|
|
message: t('confirmDeleteComment') || 'Bạn có chắc chắn muốn xóa bình luận này không?'
|
|
});
|
|
if (!shouldDelete) return;
|
|
|
|
try {
|
|
const token = localStorage.getItem('token') || localStorage.getItem('guest_token');
|
|
const res = await fetch(`/api/v1/public-photos/comments/${commentId}`, {
|
|
method: 'DELETE',
|
|
headers: {
|
|
'Authorization': `Bearer ${token}`
|
|
}
|
|
});
|
|
if (res.ok) {
|
|
setComments(prev => prev.filter(c => c.id !== commentId));
|
|
notify({ title: 'Thành công', message: 'Bình luận đã được xóa.', type: 'success' });
|
|
} else {
|
|
const err = await res.json();
|
|
notify({ title: 'Lỗi', message: err.message || 'Lỗi khi xóa bình luận.', type: 'error' });
|
|
}
|
|
} catch (error) {
|
|
console.error('Lỗi khi xóa bình luận:', error);
|
|
notify({ title: 'Lỗi', message: 'Không thể kết nối đến máy chủ.', type: 'error' });
|
|
}
|
|
};
|
|
|
|
const isAuthorized = currentUser?.isAdmin ||
|
|
(currentUser && photo.uploader && currentUser.id === photo.uploader.id) ||
|
|
(currentUser && photo.uploaderId && currentUser.id === photo.uploaderId);
|
|
|
|
if (!isOpen) return null;
|
|
|
|
return (
|
|
<div className="fixed inset-0 z-[5000] md:flex md:items-center md:justify-center md:p-4">
|
|
{/* Backdrop */}
|
|
<div
|
|
className="absolute inset-0 bg-slate-950/80 backdrop-blur-md animate-in fade-in duration-300"
|
|
onClick={(e) => {
|
|
e.stopPropagation();
|
|
onClose();
|
|
}}
|
|
/>
|
|
|
|
{/* Container */}
|
|
<div
|
|
className="fixed inset-0 overflow-y-auto flex flex-col bg-slate-900 text-slate-100 md:relative md:inset-auto md:overflow-hidden md:rounded-[32px] md:shadow-2xl md:border md:border-slate-800 md:w-full md:max-w-5xl md:h-[85vh] md:flex-row animate-in zoom-in-95 duration-300"
|
|
onClick={(e) => e.stopPropagation()}
|
|
>
|
|
|
|
{/* Close Button Mobile/Desktop */}
|
|
<button
|
|
onClick={(e) => {
|
|
e.stopPropagation();
|
|
onClose();
|
|
}}
|
|
className="fixed md:absolute top-[calc(1rem+env(safe-area-inset-top,0px))] right-4 md:top-4 md:right-4 z-50 p-2 bg-slate-950/60 hover:bg-slate-800/80 border border-slate-700/50 rounded-full text-slate-300 hover:text-white transition-colors"
|
|
>
|
|
<X className="w-5 h-5" />
|
|
</button>
|
|
|
|
{/* Left Side: Photo Detail */}
|
|
<div className="relative w-full md:w-3/5 md:h-full bg-slate-950 flex flex-col overflow-hidden group shrink-0">
|
|
|
|
{/* Photo wrapper for mobile view (handles top overlay name and bottom-right like) */}
|
|
<div className="relative w-full flex items-center justify-center md:absolute md:inset-0 md:flex md:items-center md:justify-center bg-slate-950">
|
|
{/* Mobile Only: Uploader details overlay */}
|
|
<div className="absolute top-[calc(0.75rem+env(safe-area-inset-top,0px))] left-4 z-40 md:hidden flex items-center gap-2 bg-slate-950/70 backdrop-blur-md px-2.5 py-1.5 rounded-full border border-slate-700/50">
|
|
<div className="w-5 h-5 rounded-full bg-emerald-500/20 flex items-center justify-center">
|
|
<User className="w-3 h-3 text-emerald-400" />
|
|
</div>
|
|
<span className="text-xs text-slate-200 max-w-[120px] truncate">
|
|
{photo.uploader?.name || 'Ẩn danh'}
|
|
</span>
|
|
</div>
|
|
|
|
{/* Like Button Overlay */}
|
|
<button
|
|
onClick={(e) => {
|
|
e.stopPropagation();
|
|
handleToggleLike();
|
|
}}
|
|
className="absolute bottom-4 right-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 md:absolute md:top-[calc(1rem+env(safe-area-inset-top,0px))] md:left-4 md:bottom-auto md:right-auto"
|
|
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>
|
|
|
|
<a
|
|
href={`${window.location.origin}/api/v1/public-photos/${photo.id}/share`}
|
|
className="w-full h-auto max-h-[85vh] object-contain cursor-zoom-in md:h-full md:max-h-none flex items-center justify-center relative"
|
|
onClick={(e) => {
|
|
e.preventDefault();
|
|
setIsFullscreen(true);
|
|
}}
|
|
>
|
|
<img
|
|
src={photo.imageUrl}
|
|
alt="Public Map Upload"
|
|
onContextMenu={(e) => { if (!isLoggedIn) e.preventDefault(); }}
|
|
onDragStart={(e) => { if (!isLoggedIn) e.preventDefault(); }}
|
|
className={`w-full h-auto max-h-[85vh] object-contain md:h-full md:max-h-none select-none ${
|
|
!isLoggedIn ? 'pointer-events-none' : ''
|
|
}`}
|
|
draggable={false}
|
|
/>
|
|
{/* Shield overlay to prevent Save Image As on right-click / long press for non-owners */}
|
|
{(!isAuthorized || !isLoggedIn) && (
|
|
<div className="absolute inset-0 bg-transparent select-none z-10" />
|
|
)}
|
|
</a>
|
|
</div>
|
|
|
|
{/* Info & Timeline overlay inside photo panel */}
|
|
<div className="relative z-20 p-6 bg-slate-900 border-b border-slate-800/60 md:absolute md:bottom-0 md:left-0 md:right-0 md:bg-gradient-to-t md:from-black/60 md:via-black/20 md:to-transparent md:border-b-0 flex flex-col gap-4">
|
|
|
|
{/* Timeline scroll */}
|
|
{photoGroup && photoGroup.length > 1 && (
|
|
<div className="flex flex-col gap-2 border-b border-slate-800/80 pb-3 relative z-20">
|
|
<span className="text-[10px] font-bold uppercase tracking-wider text-slate-400 flex items-center gap-1.5">
|
|
<span className="inline-block w-1.5 h-1.5 rounded-full bg-emerald-500 animate-ping"></span>
|
|
Lịch sử ảnh tại vị trí này ({photoGroup.length})
|
|
</span>
|
|
<div className="flex gap-3 overflow-x-auto no-scrollbar py-1 relative z-20">
|
|
{photoGroup.map((p) => {
|
|
const isActive = p.id === photo.id;
|
|
return (
|
|
<button
|
|
key={p.id}
|
|
onClick={(e) => {
|
|
e.stopPropagation();
|
|
onSelectPhoto?.(p);
|
|
}}
|
|
className={`relative w-12 h-12 rounded-xl overflow-hidden border-2 transition-all active:scale-95 shrink-0 ${
|
|
isActive ? 'border-emerald-500 scale-110 shadow-lg' : 'border-slate-700 hover:border-slate-500'
|
|
}`}
|
|
>
|
|
<img
|
|
src={p.imageUrl}
|
|
alt="Timeline thumbnail"
|
|
onContextMenu={(e) => { if (!isLoggedIn) e.preventDefault(); }}
|
|
onDragStart={(e) => { if (!isLoggedIn) e.preventDefault(); }}
|
|
className={`w-full h-full object-cover ${
|
|
!isLoggedIn ? 'pointer-events-none' : ''
|
|
}`}
|
|
/>
|
|
<div className="absolute bottom-0 inset-x-0 bg-slate-950/70 text-[8px] text-center font-bold text-slate-300 py-0.5">
|
|
{new Date(p.capturedAt).toLocaleDateString('vi-VN', { month: '2-digit', day: '2-digit' })}
|
|
</div>
|
|
</button>
|
|
);
|
|
})}
|
|
</div>
|
|
</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-base md: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-base md: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-base md: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-base md:text-xs focus:outline-none focus:ring-1 focus:ring-emerald-500"
|
|
/>
|
|
</div>
|
|
</div>
|
|
|
|
<div className="flex justify-start">
|
|
<button
|
|
type="button"
|
|
onClick={(e) => {
|
|
e.stopPropagation();
|
|
setIsMapOpen(true);
|
|
}}
|
|
className="flex items-center gap-1.5 px-3 py-1.5 bg-slate-800 hover:bg-slate-750 border border-slate-700/50 text-slate-300 hover:text-white 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>
|
|
|
|
<div className="flex justify-end gap-2 mt-2">
|
|
<button
|
|
onClick={(e) => {
|
|
e.stopPropagation();
|
|
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={(e) => {
|
|
e.stopPropagation();
|
|
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={(e) => {
|
|
e.stopPropagation();
|
|
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 justify-between gap-4 border-t border-slate-800/80 pt-3 text-xs text-slate-350">
|
|
<div className="space-y-1 text-left">
|
|
<div className="flex items-center gap-1.5 text-slate-400">
|
|
<Calendar className="w-3.5 h-3.5" />
|
|
Ngày chụp: {new Date(photo.capturedAt).toLocaleDateString('vi-VN', {
|
|
day: '2-digit',
|
|
month: '2-digit',
|
|
year: 'numeric',
|
|
hour: '2-digit',
|
|
minute: '2-digit'
|
|
})}
|
|
</div>
|
|
<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" />
|
|
Địa điểm: {resolvedAddress}
|
|
</div>
|
|
<div className="hidden md:flex items-center gap-1.5 text-xs text-emerald-400">
|
|
<User className="w-3.5 h-3.5" />
|
|
Người đăng: {photo.uploader?.name || 'Ẩn danh'}
|
|
</div>
|
|
</div>
|
|
{isAuthorized && photo.originalUrl && (
|
|
<a
|
|
href={photo.originalUrl}
|
|
download
|
|
target="_blank"
|
|
rel="noopener noreferrer"
|
|
className="flex items-center gap-1.5 bg-slate-800/80 hover:bg-slate-700/80 border border-slate-700/50 text-slate-200 hover:text-white font-bold py-1 px-3 rounded-full transition-all active:scale-95 text-[10px]"
|
|
>
|
|
<Download className="w-3.5 h-3.5 text-emerald-500" />
|
|
Tải ảnh gốc
|
|
</a>
|
|
)}
|
|
</div>
|
|
</>
|
|
)}
|
|
|
|
<style>{`
|
|
.no-scrollbar::-webkit-scrollbar {
|
|
display: none;
|
|
}
|
|
.no-scrollbar {
|
|
-ms-overflow-style: none;
|
|
scrollbar-width: none;
|
|
}
|
|
`}</style>
|
|
</div>
|
|
</div>
|
|
|
|
{/* Right Side: Comments */}
|
|
<div className="w-full md:w-2/5 md:flex-1 md:min-h-0 flex flex-col bg-slate-900 border-t md:border-t-0 md:border-l border-slate-800">
|
|
|
|
{/* Comments Header */}
|
|
<div className="hidden md:block p-6 border-b border-slate-800">
|
|
<div>
|
|
<h3 className="text-lg font-black tracking-tight text-white flex items-center gap-2">
|
|
<MessageSquare className="w-5 h-5 text-emerald-500" />
|
|
{t('commentSectionTitle')}
|
|
</h3>
|
|
<p className="text-xs text-slate-400 mt-1">Ảnh chia sẻ công khai trên bản đồ</p>
|
|
</div>
|
|
</div>
|
|
|
|
{/* Comments list scroll area */}
|
|
<div className="md:flex-1 md:overflow-y-auto p-6 space-y-4 bg-slate-900/50">
|
|
{isLoading ? (
|
|
<div className="flex flex-col items-center justify-center py-12 text-slate-500 gap-2">
|
|
<Loader2 className="w-8 h-8 animate-spin text-emerald-500" />
|
|
<span className="text-xs font-semibold">{t('loading')}</span>
|
|
</div>
|
|
) : comments.length === 0 ? (
|
|
<div className="flex flex-col items-center justify-center py-16 text-slate-500 gap-3">
|
|
<div className="p-4 bg-slate-800/40 rounded-full text-slate-600">
|
|
<MessageSquare className="w-8 h-8" />
|
|
</div>
|
|
<span className="text-sm font-semibold italic">Chưa có bình luận nào. Hãy bắt đầu cuộc trò chuyện!</span>
|
|
</div>
|
|
) : (
|
|
comments.map((c) => {
|
|
return (
|
|
<div key={c.id} className="flex gap-3 items-start animate-in fade-in slide-in-from-bottom-2 duration-200">
|
|
<div className="w-8 h-8 rounded-full bg-slate-800 flex items-center justify-center flex-shrink-0 border border-slate-700/60">
|
|
<User className="w-4.5 h-4.5 text-slate-400" />
|
|
</div>
|
|
<div className="flex-1 min-w-0">
|
|
<div className="bg-slate-800/55 p-3.5 rounded-2xl rounded-tl-none border border-slate-800 shadow-lg">
|
|
<div className="flex justify-between items-center mb-1">
|
|
<span className="text-xs font-black text-slate-200 truncate">{c.userName}</span>
|
|
<div className="flex items-center gap-2">
|
|
<span className="text-[9px] font-medium text-slate-500">
|
|
{new Date(c.createdAt).toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' })}
|
|
</span>
|
|
{(currentUser?.isAdmin ||
|
|
currentUser?.id === c.userId ||
|
|
currentUser?.id === photo.uploaderId ||
|
|
currentUser?.id === photo.uploader?.id) && (
|
|
<button
|
|
onClick={(e) => {
|
|
e.stopPropagation();
|
|
handleDeleteComment(c.id);
|
|
}}
|
|
className="text-slate-500 hover:text-rose-500 transition-colors p-0.5"
|
|
title={t('delete') || "Xóa"}
|
|
>
|
|
<Trash2 className="w-3.5 h-3.5" />
|
|
</button>
|
|
)}
|
|
</div>
|
|
</div>
|
|
<p className="text-sm text-slate-300 leading-relaxed break-words">{c.content}</p>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
);
|
|
})
|
|
)}
|
|
|
|
<div ref={commentsEndRef} />
|
|
</div>
|
|
|
|
{/* Comment Input Area */}
|
|
<div className="sticky bottom-0 md:static p-4 bg-slate-950 md:bg-slate-950/40 border-t border-slate-800/80 pb-[calc(1rem+env(safe-area-inset-bottom,0px))] md:pb-4 z-40">
|
|
<div className="relative flex items-center gap-2">
|
|
<input
|
|
type="text"
|
|
value={newComment}
|
|
onChange={(e) => setNewComment(e.target.value)}
|
|
onKeyDown={(e) => e.key === 'Enter' && !isSending && handleSend()}
|
|
placeholder="Viết bình luận công khai..."
|
|
className="flex-1 bg-slate-800/65 border border-slate-700/70 text-slate-100 placeholder-slate-500 rounded-2xl px-4 py-3 text-base md:text-sm focus:outline-none focus:ring-2 focus:ring-emerald-500 focus:border-transparent transition-all"
|
|
disabled={isSending}
|
|
/>
|
|
<button
|
|
onClick={(e) => {
|
|
e.stopPropagation();
|
|
handleSend();
|
|
}}
|
|
disabled={!newComment.trim() || isSending}
|
|
className="p-3 bg-emerald-600 hover:bg-emerald-500 disabled:bg-slate-800 disabled:opacity-40 text-white rounded-2xl transition-all active:scale-95 shadow-lg shadow-emerald-950/30 flex items-center justify-center"
|
|
>
|
|
{isSending ? (
|
|
<Loader2 className="w-4.5 h-4.5 animate-spin" />
|
|
) : (
|
|
<Send className="w-4.5 h-4.5" />
|
|
)}
|
|
</button>
|
|
</div>
|
|
</div>
|
|
|
|
</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 && (
|
|
<div
|
|
className="fixed inset-0 z-[9999] bg-black/95 flex items-end sm: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={photo.imageUrl}
|
|
alt="Fullscreen photo"
|
|
onContextMenu={(e) => { if (!isLoggedIn) e.preventDefault(); }}
|
|
onDragStart={(e) => { if (!isLoggedIn) e.preventDefault(); }}
|
|
className={`max-w-full max-h-full sm:max-w-screen-md object-contain select-none animate-in zoom-in-95 duration-200 ${
|
|
!isLoggedIn ? 'pointer-events-none' : ''
|
|
}`}
|
|
/>
|
|
</div>
|
|
)}
|
|
</div>
|
|
);
|
|
};
|