feat: tíng năng trò chuyện giữa các thành viên và tin nhắn trực tiếp cho bạn bè

This commit is contained in:
2026-06-21 09:46:37 +07:00
parent 392a4d4766
commit 8d79cd76f6
15 changed files with 3932 additions and 234 deletions
+46 -7
View File
@@ -6,6 +6,7 @@ import SignupPage from './pages/SignupPage';
import { MyPhotosPage } from './pages/MyPhotosPage';
import { MyNotePage } from './pages/MyNotePage';
import { JoinTourPage } from './pages/JoinTourPage';
import { MemberDashboard } from './pages/MemberDashboard';
import { useTourStore } from './store/useTourStore';
import { ConfirmProvider } from './hooks/useConfirm';
import { NotificationProvider } from './hooks/useNotification';
@@ -15,11 +16,12 @@ function App() {
const viewTourId = params.get('viewTour');
const [user, setUser] = useState<any>(null);
const [currentPage, setCurrentPage] = useState<'landing' | 'explore' | 'tourDetail' | 'signup' | 'myPhotos' | 'notes' | 'joinTour'>(
const [currentPage, setCurrentPage] = useState<'landing' | 'dashboard' | 'explore' | 'tourDetail' | 'signup' | 'myPhotos' | 'notes' | 'joinTour'>(
viewTourId ? 'tourDetail' : (window.location.pathname === '/join-tour' || params.has('token') ? 'joinTour' : 'landing')
);
const [currentTourId, setCurrentTourId] = useState<string | null>(viewTourId);
const [isPublicTourView, setIsPublicTourView] = useState(!!viewTourId);
const [previousPage, setPreviousPage] = useState<'explore' | 'dashboard' | 'landing'>('explore');
// Lấy action từ store
const fetchTour = useTourStore(state => state.fetchTour);
@@ -50,7 +52,7 @@ function App() {
setCurrentPage('tourDetail');
} else {
if (loggedInUser) {
setCurrentPage('explore');
setCurrentPage('dashboard');
} else {
setCurrentPage('landing');
}
@@ -65,7 +67,7 @@ function App() {
if (pendingInviteToken) {
setCurrentPage('joinTour');
} else {
setCurrentPage('explore');
setCurrentPage('dashboard');
}
};
@@ -76,18 +78,19 @@ function App() {
setCurrentPage('landing');
};
const handleViewTour = (tourId: string) => {
const handleViewTour = (tourId: string, fromPage?: 'explore' | 'dashboard') => {
setCurrentTourId(tourId);
setIsPublicTourView(false); // Không phải chế độ công khai nếu đến từ bản đồ khám phá
setPreviousPage(fromPage || (currentPage === 'dashboard' ? 'dashboard' : 'explore'));
setCurrentPage('tourDetail');
};
const handleBackFromTourDetail = () => {
setCurrentTourId(null);
setIsPublicTourView(false);
// Quay về trang khám phá nếu đã đăng nhập, ngược lại quay về Landing
// Quay về trang nếu đã đăng nhập, ngược lại quay về Landing
if (user) {
setCurrentPage('explore');
setCurrentPage(previousPage);
} else {
setCurrentPage('landing');
}
@@ -105,6 +108,22 @@ function App() {
const handleSignupSuccess = () => {
// Sau khi đăng ký, ta có thể tự động đăng nhập hoặc quay lại landing/joinTour
const pendingInviteToken = localStorage.getItem('pendingInviteToken');
const token = localStorage.getItem('token');
const storedUser = localStorage.getItem('user');
if (token && storedUser) {
try {
const loggedInUser = JSON.parse(storedUser);
setUser(loggedInUser);
if (pendingInviteToken) {
setCurrentPage('joinTour');
} else {
setCurrentPage('dashboard');
}
return;
} catch (e) {}
}
if (pendingInviteToken) {
setCurrentPage('joinTour');
} else {
@@ -112,10 +131,30 @@ function App() {
}
};
const handleBackFromExplore = () => {
if (user) {
setCurrentPage('dashboard');
} else {
setCurrentPage('landing');
}
};
return (
<ConfirmProvider>
<NotificationProvider>
{(() => {
if (currentPage === 'dashboard') {
return (
<MemberDashboard
user={user}
onLogout={handleLogout}
onExploreTours={() => setCurrentPage('explore')}
onViewTour={handleViewTour}
onOpenMyPhotos={() => setCurrentPage('myPhotos')}
/>
);
}
if (currentPage === 'tourDetail') {
return (
<TourDetailPage
@@ -134,7 +173,7 @@ function App() {
if (currentPage === 'explore') {
return (
<ExploreMap
onBack={handleBackFromTourDetail}
onBack={handleBackFromExplore}
onLogout={handleLogout}
user={user}
onViewTour={handleViewTour}
+655
View File
@@ -0,0 +1,655 @@
import React, { useState, useEffect, useRef } from 'react';
import { io, Socket } from 'socket.io-client';
import { Send, MessageSquare, Loader2, Image as ImageIcon, MapPin, Download, X } from 'lucide-react';
import { useNotification } from '@/hooks/useNotification';
interface TourChatProps {
tourId: string;
}
export const TourChat: React.FC<TourChatProps> = ({ tourId }) => {
const notify = useNotification();
const [messages, setMessages] = useState<any[]>([]);
const [newMessage, setNewMessage] = useState('');
const [loading, setLoading] = useState(true);
const [selectedImage, setSelectedImage] = useState<File | null>(null);
const [imagePreview, setImagePreview] = useState<string | null>(null);
const [attachedLocation, setAttachedLocation] = useState<{ latitude: number; longitude: number } | null>(null);
const [isUploading, setIsUploading] = useState(false);
const [isLocating, setIsLocating] = useState(false);
const [participants, setParticipants] = useState<any[]>([]);
const [showMentionList, setShowMentionList] = useState(false);
const [mentionSearch, setMentionSearch] = useState('');
const [mentionIndex, setMentionIndex] = useState(0);
const [taggedUserIds, setTaggedUserIds] = useState<string[]>([]);
const messagesEndRef = useRef<HTMLDivElement>(null);
const socketRef = useRef<Socket | null>(null);
const fileInputRef = useRef<HTMLInputElement>(null);
const inputRef = useRef<HTMLInputElement>(null);
const mentionRef = useRef<HTMLDivElement>(null);
const getHeaders = () => ({
'Authorization': `Bearer ${localStorage.getItem('token')}`,
'Content-Type': 'application/json'
});
const currentUserId = (() => {
try {
const token = localStorage.getItem('token');
if (!token) return null;
const base64Url = token.split('.')[1];
const base64 = base64Url.replace(/-/g, '+').replace(/_/g, '/');
const jsonPayload = decodeURIComponent(atob(base64).split('').map(function(c) {
return '%' + ('00' + c.charCodeAt(0).toString(16)).slice(-2);
}).join(''));
const parsed = JSON.parse(jsonPayload);
return parsed.sub || parsed.id;
} catch (e) {
return null;
}
})();
// Fetch tour details to get participants (excluding current user)
useEffect(() => {
const fetchTourDetails = async () => {
try {
const res = await fetch(`/api/v1/tours/${tourId}`, { headers: getHeaders() });
if (res.ok) {
const data = await res.json();
if (data && data.participants) {
const memberList = data.participants
.map((p: any) => p.user)
.filter((u: any) => u && u.id !== currentUserId);
setParticipants(memberList);
}
}
} catch (err) {
console.error('Lỗi khi tải thông tin thành viên tour:', err);
}
};
if (tourId && currentUserId) {
fetchTourDetails();
}
}, [tourId, currentUserId]);
// Click outside to close mention dropdown
useEffect(() => {
const handleOutsideClick = (e: MouseEvent) => {
if (mentionRef.current && !mentionRef.current.contains(e.target as Node)) {
setShowMentionList(false);
}
};
document.addEventListener('mousedown', handleOutsideClick);
return () => document.removeEventListener('mousedown', handleOutsideClick);
}, []);
const filteredParticipants = participants.filter(p =>
p.name.toLowerCase().includes(mentionSearch.toLowerCase())
);
const handleInputChange = (e: React.ChangeEvent<HTMLInputElement>) => {
const value = e.target.value;
setNewMessage(value);
const selectionStart = e.target.selectionStart || 0;
const textBeforeCursor = value.slice(0, selectionStart);
const lastAtIndex = textBeforeCursor.lastIndexOf('@');
if (lastAtIndex !== -1) {
const textAfterAt = textBeforeCursor.slice(lastAtIndex + 1);
if (!textAfterAt.includes(' ')) {
setShowMentionList(true);
setMentionSearch(textAfterAt);
setMentionIndex(0);
return;
}
}
setShowMentionList(false);
};
const handleKeyDown = (e: React.KeyboardEvent<HTMLInputElement>) => {
if (!showMentionList) return;
const filtered = filteredParticipants;
if (filtered.length === 0) return;
if (e.key === 'ArrowDown') {
e.preventDefault();
setMentionIndex(prev => (prev + 1) % filtered.length);
} else if (e.key === 'ArrowUp') {
e.preventDefault();
setMentionIndex(prev => (prev - 1 + filtered.length) % filtered.length);
} else if (e.key === 'Enter') {
e.preventDefault();
insertMention(filtered[mentionIndex]);
} else if (e.key === 'Escape') {
e.preventDefault();
setShowMentionList(false);
}
};
const insertMention = (member: { id: string; name: string }) => {
const input = inputRef.current;
if (!input) return;
const selectionStart = input.selectionStart || 0;
const textBeforeCursor = newMessage.slice(0, selectionStart);
const textAfterCursor = newMessage.slice(selectionStart);
const lastAtIndex = textBeforeCursor.lastIndexOf('@');
if (lastAtIndex !== -1) {
const newTextBeforeCursor = textBeforeCursor.slice(0, lastAtIndex) + `@${member.name} `;
const updatedValue = newTextBeforeCursor + textAfterCursor;
setNewMessage(updatedValue);
setShowMentionList(false);
if (!taggedUserIds.includes(member.id)) {
setTaggedUserIds(prev => [...prev, member.id]);
}
setTimeout(() => {
input.focus();
const cursorPosition = newTextBeforeCursor.length;
input.setSelectionRange(cursorPosition, cursorPosition);
}, 0);
}
};
// Fetch past messages
useEffect(() => {
const fetchMessages = async () => {
try {
const res = await fetch(`/api/v1/tours/${tourId}/messages`, { headers: getHeaders() });
if (res.ok) {
const data = await res.json();
setMessages(data || []);
}
} catch (err) {
console.error('Lỗi khi tải tin nhắn:', err);
} finally {
setLoading(false);
}
};
fetchMessages();
}, [tourId]);
// Connect to socket and listen for tour messages
useEffect(() => {
const socket = io();
socketRef.current = socket;
socket.on('connect', () => {
socket.emit('joinTour', tourId);
});
socket.on('tourMessageReceived', (data: any) => {
if (data.tourId === tourId) {
setMessages(prev => [...prev, data.message]);
}
});
return () => {
socket.disconnect();
};
}, [tourId]);
// Autoscroll chat to bottom
useEffect(() => {
messagesEndRef.current?.scrollIntoView({ behavior: 'smooth' });
}, [messages]);
// Compress image to 2K (max 2048px longest side)
const compressImageTo2K = (file: File): Promise<Blob> => {
return new Promise((resolve, reject) => {
const reader = new FileReader();
reader.readAsDataURL(file);
reader.onload = (event) => {
const img = new Image();
img.src = event.target?.result as string;
img.onload = () => {
const MAX_DIM = 2048;
let width = img.width;
let height = img.height;
if (width > MAX_DIM || height > MAX_DIM) {
if (width > height) {
height = Math.round((height * MAX_DIM) / width);
width = MAX_DIM;
} else {
width = Math.round((width * MAX_DIM) / height);
height = MAX_DIM;
}
}
const canvas = document.createElement('canvas');
canvas.width = width;
canvas.height = height;
const ctx = canvas.getContext('2d');
if (!ctx) {
resolve(file);
return;
}
ctx.drawImage(img, 0, 0, width, height);
canvas.toBlob(
(blob) => {
if (blob) {
resolve(blob);
} else {
resolve(file);
}
},
'image/jpeg',
0.85
);
};
img.onerror = (err) => reject(err);
};
reader.onerror = (err) => reject(err);
});
};
// Handle Image Selection
const handleImageChange = (e: React.ChangeEvent<HTMLInputElement>) => {
const file = e.target.files?.[0];
if (file) {
setSelectedImage(file);
setImagePreview(URL.createObjectURL(file));
}
};
// Handle Location Sharing
const handleGetLocation = () => {
if (!navigator.geolocation) {
notify({
title: 'Không hỗ trợ',
message: 'Trình duyệt của bạn không hỗ trợ định vị GPS.',
type: 'error'
});
return;
}
setIsLocating(true);
navigator.geolocation.getCurrentPosition(
(position) => {
setAttachedLocation({
latitude: position.coords.latitude,
longitude: position.coords.longitude
});
notify({
title: 'Gắn vị trí thành công',
message: 'Vị trí hiện tại đã được đính kèm vào tin nhắn.',
type: 'success'
});
setIsLocating(false);
},
(error) => {
console.error('Lỗi định vị:', error);
notify({
title: 'Lỗi GPS',
message: 'Không thể lấy vị trí hiện tại của bạn. Hãy kiểm tra quyền truy cập.',
type: 'error'
});
setIsLocating(false);
},
{ enableHighAccuracy: true, timeout: 10000 }
);
};
// Upload image to backend
const uploadImage = async (file: File): Promise<string | null> => {
try {
setIsUploading(true);
// Compress first
const compressedBlob = await compressImageTo2K(file);
const formData = new FormData();
formData.append('image', compressedBlob, 'compressed.jpg');
const res = await fetch('/api/v1/upload', {
method: 'POST',
headers: {
'Authorization': `Bearer ${localStorage.getItem('token')}`
},
body: formData
});
if (res.ok) {
const data = await res.json();
return data.url;
}
return null;
} catch (err) {
console.error('Lỗi upload ảnh:', err);
return null;
} finally {
setIsUploading(false);
}
};
// Send Tour Message
const handleSendMessage = async (e?: React.FormEvent) => {
if (e) e.preventDefault();
if (!newMessage.trim() && !selectedImage && !attachedLocation) return;
let attachmentUrl = undefined;
if (selectedImage) {
attachmentUrl = await uploadImage(selectedImage);
if (!attachmentUrl) {
notify({
title: 'Lỗi',
message: 'Không thể tải ảnh đính kèm lên server.',
type: 'error'
});
return;
}
}
const actualTaggedUserIds = taggedUserIds.filter(userId => {
const member = participants.find(p => p.id === userId);
if (!member || !member.name) return false;
const cleanMessage = newMessage.toLowerCase();
const nameLower = member.name.toLowerCase();
// Try exact match first
if (cleanMessage.includes(`@${nameLower}`)) return true;
// Try match without parentheses (e.g. "Lộc Phạm (Chủ Tour)" -> "Lộc Phạm")
const nameWithoutParentheses = member.name.split('(')[0].trim().toLowerCase();
if (nameWithoutParentheses && cleanMessage.includes(`@${nameWithoutParentheses}`)) return true;
return false;
});
const payload = {
content: newMessage,
attachmentUrl,
latitude: attachedLocation?.latitude,
longitude: attachedLocation?.longitude,
taggedUserIds: actualTaggedUserIds
};
// Reset input fields immediately
setNewMessage('');
setSelectedImage(null);
setImagePreview(null);
setAttachedLocation(null);
setTaggedUserIds([]);
try {
const res = await fetch(`/api/v1/tours/${tourId}/messages`, {
method: 'POST',
headers: getHeaders(),
body: JSON.stringify(payload)
});
if (!res.ok) {
notify({
title: 'Lỗi',
message: 'Gửi tin nhắn thất bại.',
type: 'error'
});
}
} catch (err) {
console.error('Lỗi gửi tin nhắn:', err);
}
};
// Download image file helper
const handleDownloadImage = async (url: string, id: string) => {
try {
const response = await fetch(url);
const blob = await response.blob();
const blobUrl = window.URL.createObjectURL(blob);
const link = document.createElement('a');
link.href = blobUrl;
link.download = `tour-chat-photo-${id}.jpg`;
document.body.appendChild(link);
link.click();
document.body.removeChild(link);
window.URL.revokeObjectURL(blobUrl);
} catch (error) {
console.error('Lỗi tải ảnh:', error);
window.open(url, '_blank');
}
};
// currentUserId is defined at the top
return (
<div className="bg-white border border-gray-150 rounded-2xl shadow-lg overflow-hidden flex flex-col h-[500px]">
{/* Chat Header */}
<div className="p-4 border-b border-gray-150 flex items-center gap-2 bg-gray-50/50">
<MessageSquare className="w-5 h-5 text-blue-500" />
<div>
<h3 className="text-sm font-bold text-gray-800">Trò chuyện nhóm hành trình</h3>
<p className="text-[10px] text-gray-400 font-medium">Nơi trao đi thông tin, hình nh đnh vị giữa các thành viên</p>
</div>
</div>
{/* Messages list */}
<div className="flex-1 p-4 overflow-y-auto flex flex-col gap-3 min-h-0 bg-slate-50/20">
{loading ? (
<div className="flex-1 flex items-center justify-center text-gray-400 text-xs gap-1.5">
<Loader2 className="w-4 h-4 animate-spin text-blue-500" /> Đang tải tin nhắn...
</div>
) : messages.length === 0 ? (
<div className="flex-1 flex flex-col items-center justify-center text-gray-450 text-xs italic gap-1.5">
<MessageSquare className="w-8 h-8 text-gray-300" />
<span className="text-gray-400">Chưa tin nhắn nào trong phòng chat nhóm này.</span>
</div>
) : (
messages.map((msg) => {
const isMe = msg.senderId === currentUserId;
const initials = msg.sender?.name
? msg.sender.name.split(' ').map((n: string) => n[0]).slice(0, 2).join('').toUpperCase()
: 'U';
return (
<div
key={msg.id}
className={`flex gap-2 max-w-[80%] ${isMe ? 'self-end flex-row-reverse' : 'self-start'}`}
>
{!isMe && (
<div className="w-8 h-8 rounded-full bg-gradient-to-tr from-blue-500 to-indigo-500 flex items-center justify-center font-bold text-[10px] text-white shadow-sm shrink-0">
{msg.sender?.avatar ? (
<img src={msg.sender.avatar} alt={msg.sender.name} className="w-full h-full rounded-full object-cover" />
) : initials}
</div>
)}
<div className={`flex flex-col ${isMe ? 'items-end' : 'items-start'}`}>
{!isMe && (
<span className="text-[10px] font-bold text-gray-500 mb-0.5 ml-1">
{msg.sender?.name || 'Thành viên'}
</span>
)}
<div className={`p-3 rounded-2xl text-xs font-medium leading-relaxed flex flex-col gap-1.5 relative group ${
isMe
? 'bg-blue-600 text-white rounded-tr-none'
: 'bg-white text-gray-700 rounded-tl-none border border-gray-150 shadow-sm'
}`}>
{/* Attachment Image */}
{msg.attachmentUrl && (
<div className="relative rounded-lg overflow-hidden border border-black/5 max-w-xs group/img">
<img
src={msg.attachmentUrl}
alt="Đính kèm"
className="w-full max-h-48 object-cover hover:brightness-95 transition-all"
/>
<button
type="button"
onClick={() => handleDownloadImage(msg.attachmentUrl, msg.id)}
className="absolute bottom-2 right-2 p-1.5 bg-black/60 hover:bg-black/80 text-white rounded-md transition-all shadow-md flex items-center justify-center"
title="Tải ảnh này về máy"
>
<Download className="w-3.5 h-3.5" />
</button>
</div>
)}
{/* GPS Location badge */}
{msg.latitude !== undefined && msg.latitude !== null && (
<a
href={`https://www.google.com/maps/search/?api=1&query=${msg.latitude},${msg.longitude}`}
target="_blank"
rel="noopener noreferrer"
className={`flex items-center gap-1.5 px-3 py-2 rounded-xl text-[11px] font-bold transition-all border ${
isMe
? 'bg-blue-700 border-blue-600 text-blue-100 hover:bg-blue-800'
: 'bg-gray-100 border-gray-200 text-gray-750 hover:bg-gray-200'
}`}
>
<MapPin className="w-3.5 h-3.5 text-rose-500 shrink-0 animate-bounce" />
<div className="flex flex-col text-left">
<span>Vị trí hiện tại</span>
<span className="text-[9px] opacity-75">{msg.latitude.toFixed(6)}, {msg.longitude.toFixed(6)}</span>
</div>
</a>
)}
{/* Content text */}
{msg.content && <p className="whitespace-pre-wrap break-words">{msg.content}</p>}
</div>
<span className="text-[8px] text-gray-400 font-bold mt-1 px-1">
{new Date(msg.createdAt).toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' })}
</span>
</div>
</div>
);
})
)}
<div ref={messagesEndRef} />
</div>
{/* Previews (Image & GPS Location) */}
{(imagePreview || attachedLocation) && (
<div className="px-4 py-2 border-t border-gray-150 bg-gray-50/80 flex flex-wrap gap-2">
{imagePreview && (
<div className="relative w-16 h-16 rounded-lg overflow-hidden border border-gray-200 shadow-sm">
<img src={imagePreview} alt="Preview" className="w-full h-full object-cover" />
<button
type="button"
onClick={() => {
setSelectedImage(null);
setImagePreview(null);
}}
className="absolute top-0.5 right-0.5 p-0.5 bg-black/60 hover:bg-black text-white rounded-full transition-all"
>
<X className="w-3 h-3" />
</button>
</div>
)}
{attachedLocation && (
<div className="flex items-center gap-1.5 bg-rose-50 border border-rose-200 rounded-lg px-2.5 py-1 text-xs text-rose-700 font-bold">
<MapPin className="w-3.5 h-3.5 text-rose-500 animate-pulse" />
<span>Đã đính kèm GPS</span>
<button
type="button"
onClick={() => setAttachedLocation(null)}
className="hover:text-rose-950 transition-colors ml-1"
>
<X className="w-3.5 h-3.5" />
</button>
</div>
)}
</div>
)}
{/* Chat Input wrapper */}
<div className="relative">
{/* Mention list dropdown */}
{showMentionList && filteredParticipants.length > 0 && (
<div
ref={mentionRef}
className="absolute bottom-full left-3 right-3 mb-2 bg-white border border-gray-200 rounded-xl shadow-xl max-h-40 overflow-y-auto z-50 flex flex-col py-1"
>
{filteredParticipants.map((member, index) => (
<button
key={member.id}
type="button"
onClick={() => insertMention(member)}
className={`px-3 py-2 text-left text-xs font-semibold flex items-center gap-2 transition-colors ${
index === mentionIndex
? 'bg-blue-50 text-blue-700'
: 'text-gray-700 hover:bg-gray-50'
}`}
>
<div className="w-5 h-5 rounded-full bg-blue-100 flex items-center justify-center font-bold text-[9px] text-blue-600">
{member.name.split(' ').map((n: string) => n[0]).slice(0, 2).join('').toUpperCase()}
</div>
<span>{member.name}</span>
<span className="text-[10px] text-gray-400 font-medium font-mono">@{member.name}</span>
</button>
))}
</div>
)}
{/* Chat Input form */}
<form
onSubmit={handleSendMessage}
className="p-3 border-t border-gray-150 bg-gray-50 flex gap-2 items-center"
>
<input
type="file"
accept="image/*"
ref={fileInputRef}
onChange={handleImageChange}
className="hidden"
/>
{/* Attach photo button */}
<button
type="button"
onClick={() => fileInputRef.current?.click()}
className="p-2.5 bg-white border border-gray-200 hover:bg-gray-50 text-gray-500 rounded-xl transition-all shadow-sm active:scale-95 flex items-center justify-center shrink-0"
title="Đính kèm hình ảnh"
>
<ImageIcon className="w-4 h-4 text-blue-500" />
</button>
{/* Share current GPS button */}
<button
type="button"
onClick={handleGetLocation}
disabled={isLocating}
className={`p-2.5 bg-white border border-gray-200 hover:bg-gray-50 text-gray-500 rounded-xl transition-all shadow-sm active:scale-95 flex items-center justify-center shrink-0 ${
isLocating ? 'animate-pulse' : ''
}`}
title="Chia sẻ vị trí GPS hiện tại"
>
{isLocating ? (
<Loader2 className="w-4 h-4 animate-spin text-rose-500" />
) : (
<MapPin className="w-4 h-4 text-rose-500" />
)}
</button>
<input
type="text"
ref={inputRef}
value={newMessage}
onChange={handleInputChange}
onKeyDown={handleKeyDown}
placeholder={isUploading ? "Đang tải ảnh lên..." : "Nhập nội dung tin nhắn..."}
disabled={isUploading}
className="flex-1 bg-white border border-gray-200 rounded-xl py-2.5 px-3 text-xs text-gray-800 placeholder-gray-400 outline-none focus:border-blue-500 transition-all shadow-inner disabled:bg-gray-100 disabled:cursor-not-allowed"
/>
<button
type="submit"
disabled={isUploading || (!newMessage.trim() && !selectedImage && !attachedLocation)}
className="p-2.5 bg-blue-600 hover:bg-blue-700 text-white rounded-xl transition-all shadow-md active:scale-95 disabled:opacity-50 disabled:cursor-not-allowed shrink-0"
>
{isUploading ? (
<Loader2 className="w-4 h-4 animate-spin" />
) : (
<Send className="w-4 h-4" />
)}
</button>
</form>
</div>
</div>
);
};
+161 -30
View File
@@ -5,9 +5,10 @@ const MarkerClusterGroup = (_MarkerClusterGroup as any).default || _MarkerCluste
import L from 'leaflet';
import 'leaflet/dist/leaflet.css';
import { useTourStore } from '@/store/useTourStore';
import { X, Navigation, Image as ImageIcon, LogOut, Settings, Share2, Filter, MapPin, Loader2, UserPlus, Clock } from 'lucide-react';
import { X, Navigation, Image as ImageIcon, LogOut, Settings, Share2, Filter, MapPin, Loader2, UserPlus, Clock, ChevronLeft } from 'lucide-react';
import { UserManagementModal } from '@/components/UserManagementModal';
import { useNotification } from '@/hooks/useNotification';
import { useConfirm } from '@/hooks/useConfirm';
import { CreateTourModal } from '../components/CreateTourModal';
import { PublicPhotoModal } from '../components/PublicPhotoModal';
@@ -65,6 +66,80 @@ export const ExploreMap = ({ onBack, onLogout, user, onViewTour, onOpenMyPhotos,
const setMapCenter = useTourStore(state => state.setMapCenter);
const notify = useNotification();
const confirm = useConfirm();
// Refs for mobile long-press detection
const touchTimerRef = React.useRef<any>(null);
const touchMovedRef = React.useRef<boolean>(false);
// Phân loại trạng thái Tour dựa vào thời gian
const getTourStatus = (tour: any) => {
const now = new Date();
const startDate = tour.startDate ? new Date(tour.startDate) : null;
const endDate = tour.endDate ? new Date(tour.endDate) : null;
if (endDate && endDate < now) {
return { color: 'white', borderClass: 'border-white', label: 'Hành trình đã kết thúc' };
}
if (startDate && endDate && startDate <= now && endDate >= now) {
return { color: 'red', borderClass: 'border-rose-500', label: 'Hành trình đang diễn ra' };
}
return { color: 'green', borderClass: 'border-emerald-500', label: 'Hành trình mới tạo' };
};
const triggerJoinConfirmation = async (tour: any) => {
const currentUserId = user?.id;
const myParticipant = tour.participants?.find((p: any) => p.userId === currentUserId);
if (myParticipant) {
notify({
title: 'Thông báo',
message: 'Bạn đã là thành viên của hành trình này.',
type: 'info'
});
return;
}
const hasPendingRequest = tour.joinRequests && tour.joinRequests.length > 0;
if (hasPendingRequest) {
notify({
title: 'Thông báo',
message: 'Bạn đã gửi yêu cầu tham gia hành trình này và đang chờ duyệt.',
type: 'info'
});
return;
}
const isConfirmed = await confirm({
title: 'Yêu cầu tham gia Tour',
message: `Bạn có chắc chắn muốn gửi yêu cầu tham gia vào tour "${tour.title}" không?`
});
if (isConfirmed) {
try {
const res = await fetch(`/api/v1/tours/${tour.id}/join-requests`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'Authorization': `Bearer ${localStorage.getItem('token')}`
}
});
const data = await res.json();
if (!res.ok) {
throw new Error(data.message || 'Gửi yêu cầu tham gia thất bại.');
}
notify({
title: 'Thành công',
message: 'Đã gửi yêu cầu tham gia tour. Vui lòng chờ chủ tour duyệt.',
type: 'success'
});
fetchPublicTours();
} catch (err: any) {
notify({
title: 'Lỗi',
message: err.message || 'Không thể gửi yêu cầu tham gia.',
type: 'error'
});
}
}
};
// Khởi tạo vị trí từ localStorage nếu có, nếu không dùng mặc định (TP.HCM)
const [initialViewState] = useState(() => {
@@ -328,7 +403,7 @@ export const ExploreMap = ({ onBack, onLogout, user, onViewTour, onOpenMyPhotos,
className="w-11 h-11 flex items-center justify-center bg-white rounded-full shadow-xl hover:bg-gray-50 transition-all border border-gray-100 shrink-0"
title="Quay lại"
>
<X className="w-6 h-6 text-gray-800" />
<ChevronLeft className="w-6 h-6 text-gray-800" />
</button>
{/* Nút lọc Tag và Dropdown */}
@@ -487,43 +562,92 @@ export const ExploreMap = ({ onBack, onLogout, user, onViewTour, onOpenMyPhotos,
<MarkerClusterGroup key={`cluster-${filteredTours.length}`} chunkedLoading>
{filteredTours.map((tour) => {
const startLoc = tour.legs?.[0]?.locations?.[0];
let startLoc = null;
if (tour.legs && tour.legs.length > 0) {
for (const leg of tour.legs) {
if (leg.locations && leg.locations.length > 0) {
startLoc = leg.locations[0];
break;
}
}
}
const tourImage = tour.photos?.[0]?.imageUrl || `https://picsum.photos/seed/${tour.id}/200/200`;
const markerPos = startLoc
? [startLoc.latitude, startLoc.longitude] as [number, number]
: userPos;
const status = getTourStatus(tour);
return (
<Marker
key={tour.id}
position={markerPos}
eventHandlers={{
click: () => onViewTour(tour.id),
contextmenu: (e) => {
const currentUserId = user?.id;
const myParticipant = tour.participants?.find((p: any) => p.userId === currentUserId);
const isParticipant = !!myParticipant;
const myRole = myParticipant?.role;
const canShare = isParticipant && ['OWNER', 'MANAGER', 'MEMBER'].includes(myRole);
const hasPendingRequest = tour.joinRequests && tour.joinRequests.length > 0;
click: () => {
if (status.color === 'green') {
notify({
title: 'Thông báo',
message: 'Đây là hành trình mới tạo. Vui lòng nhấn chuột phải (hoặc nhấn giữ trên màn hình điện thoại) để gửi yêu cầu tham gia.',
type: 'info'
});
} else {
onViewTour(tour.id);
}
},
contextmenu: (e: any) => {
if (status.color === 'green') {
triggerJoinConfirmation(tour);
} else {
const currentUserId = user?.id;
const myParticipant = tour.participants?.find((p: any) => p.userId === currentUserId);
const isParticipant = !!myParticipant;
const myRole = myParticipant?.role;
const canShare = isParticipant && ['OWNER', 'MANAGER', 'MEMBER'].includes(myRole);
const hasPendingRequest = tour.joinRequests && tour.joinRequests.length > 0;
// Hiển thị menu tại vị trí chuột
setShareMenu({
x: e.containerPoint.x,
y: e.containerPoint.y,
id: tour.id,
title: tour.title,
canShare,
isParticipant,
hasPendingRequest
});
// Hiển thị menu tại vị trí chuột
setShareMenu({
x: e.containerPoint.x,
y: e.containerPoint.y,
id: tour.id,
title: tour.title,
canShare,
isParticipant,
hasPendingRequest
});
}
},
touchstart: () => {
if (status.color === 'green') {
if (touchTimerRef.current) {
clearTimeout(touchTimerRef.current);
}
touchMovedRef.current = false;
touchTimerRef.current = setTimeout(() => {
if (!touchMovedRef.current) {
triggerJoinConfirmation(tour);
}
}, 700);
}
},
touchend: () => {
if (touchTimerRef.current) {
clearTimeout(touchTimerRef.current);
touchTimerRef.current = null;
}
},
touchmove: () => {
touchMovedRef.current = true;
if (touchTimerRef.current) {
clearTimeout(touchTimerRef.current);
touchTimerRef.current = null;
}
}
}}
} as any}
icon={L.divIcon({
className: 'custom-bubble',
html: `
<div class="relative group">
<div class="w-12 h-12 rounded-full border-4 border-white shadow-lg overflow-hidden transition-transform group-hover:scale-110">
<div class="relative group w-14 h-14">
<div class="w-14 h-14 rounded-full border-4 ${status.borderClass} shadow-lg overflow-hidden transition-transform group-hover:scale-110 flex items-center justify-center bg-gray-100">
<img src="${tourImage}" class="w-full h-full object-cover" onerror="this.src='https://images.unsplash.com/photo-1476514525535-07fb3b4ae5f1?w=100'"/>
</div>
<div class="absolute -bottom-1 -right-1 bg-blue-600 rounded-full w-5 h-5 border-2 border-white flex items-center justify-center text-[10px] font-black text-white">
@@ -531,13 +655,13 @@ export const ExploreMap = ({ onBack, onLogout, user, onViewTour, onOpenMyPhotos,
</div>
</div>
`,
iconSize: [48, 48],
iconAnchor: [24, 24]
iconSize: [56, 56],
iconAnchor: [28, 28]
})}
>
<Tooltip direction="top" offset={[0, -20]} opacity={1}>
<div className="p-1 max-w-[180px]">
<div className="font-black text-blue-600 text-[11px] mb-0.5 uppercase tracking-tight truncate">{tour.title}</div>
<Tooltip direction="top" offset={[0, -28]} opacity={1}>
<div className="p-1.5 max-w-[180px]">
<div className="font-black text-blue-600 text-[11px] mb-1 uppercase tracking-tight truncate">{tour.title}</div>
{tour.tags && tour.tags.length > 0 && (
<div className="flex flex-wrap gap-1 mb-1">
{tour.tags.map((tag: string) => (
@@ -546,10 +670,17 @@ export const ExploreMap = ({ onBack, onLogout, user, onViewTour, onOpenMyPhotos,
</div>
)}
{tour.description && (
<div className="text-[10px] text-gray-500 line-clamp-2 leading-tight italic">
<div className="text-[10px] text-gray-500 line-clamp-2 leading-tight italic mb-1.5">
{tour.description}
</div>
)}
<div className="flex items-center gap-1.5 pt-1 border-t border-gray-100">
<span className={`w-2 h-2 rounded-full ${
status.color === 'green' ? 'bg-emerald-500' :
status.color === 'red' ? 'bg-rose-500' : 'bg-gray-400'
}`} />
<span className="text-[9px] font-bold text-gray-600">{status.label}</span>
</div>
</div>
</Tooltip>
</Marker>
File diff suppressed because it is too large Load Diff
+87 -109
View File
@@ -16,10 +16,10 @@ const getMostLikedPhoto = (photos: any[]) => {
export const MyPhotosPage = ({ onBack }: { onBack: () => void }) => {
const [photos, setPhotos] = useState<any[]>([]);
const [isLoading, setIsLoading] = useState(true);
const [selectedTourIdForPhoto, setSelectedTourIdForPhoto] = useState<string | 'all'>('all'); // Changed from filterTourId
const [selectedTourIdForPhoto, setSelectedTourIdForPhoto] = useState<string | 'all'>('all');
const [filterDate, setFilterDate] = useState<string>('');
const [selectedPhotoForDisplay, setSelectedPhotoForDisplay] = useState<any | null>(null); // New state for large photo display
const [sortOrder, setSortOrder] = useState<'newest' | 'oldest'>('newest'); // 'newest' by default
const [selectedPhotoForDisplay, setSelectedPhotoForDisplay] = useState<any | null>(null);
const [sortOrder, setSortOrder] = useState<'newest' | 'oldest'>('newest');
const notify = useNotification();
const confirm = useConfirm();
@@ -100,9 +100,7 @@ export const MyPhotosPage = ({ onBack }: { onBack: () => void }) => {
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) {
@@ -171,7 +169,6 @@ export const MyPhotosPage = ({ onBack }: { onBack: () => void }) => {
fetchPhotos();
}, []);
// Lấy danh sách các Tour duy nhất để hiển thị trong bộ lọc
const toursWithPhotos = useMemo(() => {
const tourMap = new Map();
photos.forEach(p => {
@@ -182,36 +179,27 @@ export const MyPhotosPage = ({ onBack }: { onBack: () => void }) => {
return Array.from(tourMap.values());
}, [photos]);
// Logic lọc ảnh tại Frontend
const filteredPhotos = useMemo(() => {
let photosToFilter = photos.filter(p => {
const matchTour = selectedTourIdForPhoto === 'all' || p.tourId === selectedTourIdForPhoto;
// So sánh ngày định dạng YYYY-MM-DD
const photoDate = p.capturedAt ? p.capturedAt.split('T')[0] : '';
const matchDate = !filterDate || photoDate === filterDate;
return matchTour && matchDate;
});
let sortedPhotos = photosToFilter;
// Sắp xếp ảnh
if (sortOrder === 'newest') {
sortedPhotos.sort((a, b) => new Date(b.capturedAt).getTime() - new Date(a.capturedAt).getTime());
} else { // 'oldest'
} else {
sortedPhotos.sort((a, b) => new Date(a.capturedAt).getTime() - new Date(b.capturedAt).getTime());
}
return sortedPhotos;
}, [photos, selectedTourIdForPhoto, filterDate, sortOrder]);
// Effect để thiết lập ảnh được chọn hiển thị hoặc reset nếu ảnh hiện tại không còn trong danh sách lọc
useEffect(() => {
if (filteredPhotos.length > 0 && (!selectedPhotoForDisplay || !filteredPhotos.some(p => p.id === selectedPhotoForDisplay.id))) {
setSelectedPhotoForDisplay(getMostLikedPhoto(filteredPhotos));
} else if (filteredPhotos.length === 0) {
setSelectedPhotoForDisplay(null);
} else if (selectedPhotoForDisplay) {
// Nếu ảnh đang chọn vẫn còn trong danh sách lọc, không làm gì cả
} else {
// Nếu không có ảnh nào để hiển thị
setSelectedPhotoForDisplay(null); // No photos to display
}
}, [filteredPhotos, selectedPhotoForDisplay]);
@@ -234,73 +222,66 @@ export const MyPhotosPage = ({ onBack }: { onBack: () => void }) => {
if (!response.ok) throw new Error('Failed to delete photo');
notify({ title: 'Thành công', message: 'Ảnh đã được xóa.', type: 'success' });
// Cập nhật lại danh sách ảnh sau khi xóa
setPhotos(prev => prev.filter(p => p.id !== photoId));
setSelectedPhotoForDisplay(null); // Reset ảnh đang hiển thị
setSelectedPhotoForDisplay(null);
} catch (error) {
notify({ title: 'Lỗi', message: 'Không thể xóa ảnh. Vui lòng thử lại.', type: 'error' });
}
};
return (
<div className="min-h-screen bg-gray-50 flex flex-col">
<div className="w-full flex flex-col text-slate-100 bg-transparent font-sans">
{/* Header */}
<div className="sticky top-0 z-30 bg-white/80 backdrop-blur-md border-b border-gray-100 px-4 py-4 flex items-center gap-4">
<button onClick={onBack} className="p-2 hover:bg-gray-100 rounded-full transition-colors">
<ChevronLeft className="w-6 h-6 text-gray-600" />
<div className="bg-slate-900/60 backdrop-blur-md border-b border-slate-800/80 px-6 py-4 flex items-center gap-4 rounded-t-3xl">
<button onClick={onBack} className="p-2 hover:bg-slate-800 rounded-full transition-colors">
<ChevronLeft className="w-6 h-6 text-slate-400 hover:text-white" />
</button>
<div>
<h1 className="text-xl font-black text-gray-900">nh của tôi</h1>
<p className="text-[10px] font-bold text-gray-400 uppercase tracking-widest">Kho lưu trữ nh gốc nhân</p>
<h1 className="text-xl font-black text-white">nh của tôi</h1>
<p className="text-[10px] font-bold text-slate-400 uppercase tracking-widest">Kho lưu trữ nh gốc nhân</p>
</div>
</div>
{/* Filter Bar - Thanh công cụ lọc */}
<div className="bg-white border-b border-gray-100 px-6 py-4 flex flex-wrap items-center gap-4 sticky top-[73px] z-20 shadow-sm">
<div className="flex items-center gap-2 text-gray-500">
{/* Filter Bar */}
<div className="bg-slate-900/40 border-b border-slate-800/60 px-6 py-4 flex flex-wrap items-center gap-4 shadow-sm">
<div className="flex items-center gap-2 text-slate-400">
<Filter className="w-4 h-4" />
<span className="text-xs font-bold uppercase tracking-wider text-gray-400">Bộ lọc:</span>
<span className="text-xs font-bold uppercase tracking-wider">Bộ lọc:</span>
</div>
{/* Chỉ báo Tour hiện tại */}
<div className="relative min-w-[150px]">
<div className="px-3 py-2 bg-blue-50 text-blue-600 rounded-xl text-xs font-bold border border-blue-100 truncate max-w-[200px]">
<div className="px-3 py-2 bg-slate-800 text-indigo-300 rounded-xl text-xs font-bold border border-slate-700/60 truncate max-w-[200px]">
{selectedTourIdForPhoto === 'all' ? 'Tất cả hành trình' : toursWithPhotos.find(t => t.id === selectedTourIdForPhoto)?.title || 'Tour đã chọn'}
</div>
</div>
{/* Lọc theo Thời gian */}
<div className="relative">
<input
type="date"
value={filterDate}
onChange={(e) => setFilterDate(e.target.value)}
className="pl-3 pr-3 py-2 bg-gray-50 border border-gray-100 rounded-xl text-xs font-bold outline-none focus:ring-2 focus:ring-blue-500 transition-all text-gray-700 cursor-pointer"
className="pl-3 pr-3 py-2 bg-slate-950 border border-slate-800 rounded-xl text-xs font-bold outline-none focus:ring-2 focus:ring-indigo-500/80 transition-all text-slate-200 cursor-pointer"
/>
</div>
{/* Lọc theo Sắp xếp */}
<div className="relative min-w-[120px]">
<select
value={sortOrder}
onChange={(e) => setSortOrder(e.target.value as 'newest' | 'oldest')}
className="w-full pl-3 pr-8 py-2 bg-gray-50 border border-gray-100 rounded-xl text-xs font-bold outline-none focus:ring-2 focus:ring-blue-500 transition-all appearance-none cursor-pointer text-gray-700"
className="w-full pl-3 pr-8 py-2 bg-slate-950 border border-slate-800 rounded-xl text-xs font-bold outline-none focus:ring-2 focus:ring-indigo-500/80 transition-all appearance-none cursor-pointer text-slate-200"
>
<option value="newest">Mới nhất</option>
<option value="oldest"> nhất</option>
</select>
<div className="absolute right-3 top-1/2 -translate-y-1/2 pointer-events-none text-gray-400">
<div className="absolute right-3 top-1/2 -translate-y-1/2 pointer-events-none text-slate-500">
<ChevronLeft className="w-3 h-3 -rotate-90" />
</div>
</div>
{/* Reset Filters - Nút xóa nhanh lọc */}
{(selectedTourIdForPhoto !== 'all' || filterDate) && (
<button
onClick={() => { setSelectedTourIdForPhoto('all'); setFilterDate(''); }}
className="flex items-center gap-1.5 px-3 py-2 text-xs font-bold text-red-500 hover:bg-red-50 rounded-xl transition-all"
className="flex items-center gap-1.5 px-3 py-2 text-xs font-bold text-rose-400 hover:bg-rose-950/20 rounded-xl transition-all"
>
<X className="w-3.5 h-3.5" />
Xóa lọc
@@ -308,29 +289,35 @@ export const MyPhotosPage = ({ onBack }: { onBack: () => void }) => {
)}
<div className="ml-auto">
<p className="text-[10px] font-black text-gray-400 uppercase tracking-tighter">
Kết quả: <span className="text-blue-600">{filteredPhotos.length}</span> / {photos.length} nh
<p className="text-[10px] font-black text-slate-400 uppercase tracking-tighter">
Kết quả: <span className="text-indigo-400">{filteredPhotos.length}</span> / {photos.length} nh
</p>
</div>
</div>
<div className="flex-1 p-6 max-w-5xl mx-auto w-full">
<div className="flex-1 p-6 w-full max-w-7xl mx-auto">
{isLoading ? (
<div className="flex flex-col items-center justify-center py-20 text-gray-400">
<Loader2 className="w-10 h-10 animate-spin mb-4" />
<p className="font-bold">Đang tải kho nh...</p>
<div className="flex flex-col items-center justify-center py-20 text-slate-400">
<Loader2 className="w-10 h-10 animate-spin mb-4 text-indigo-500" />
<p className="font-bold text-sm">Đang tải kho nh...</p>
</div>
) : photos.length === 0 ? (
<div className="py-24 text-center bg-slate-900/30 rounded-[40px] border-2 border-dashed border-slate-800/80">
<ImageIcon className="w-16 h-16 text-slate-700 mx-auto mb-4" />
<h3 className="text-xl font-bold text-slate-400">Chưa nh nào</h3>
<p className="text-sm text-slate-500">Hãy tham gia các chuyến đi lưu lại khoảnh khắc nhé!</p>
</div>
) : (
<div className="animate-in fade-in">
<div className="flex flex-col md:flex-row gap-4">
<div className="flex flex-col md:flex-row gap-6">
{/* Left Column: Tour List */}
<div className="md:w-1/4 bg-white rounded-2xl shadow-lg border border-gray-100 p-4 flex-shrink-0">
<h3 className="text-sm font-bold text-gray-800 mb-3">Tour của bạn</h3>
<div className="md:w-1/4 bg-slate-900/50 rounded-2xl border border-slate-800/60 p-4 flex-shrink-0">
<h3 className="text-xs font-black uppercase text-slate-400 tracking-wider mb-3">Hành trình của bạn</h3>
<div className="space-y-2">
<button
onClick={() => { setSelectedTourIdForPhoto('all'); setSelectedPhotoForDisplay(null); }}
className={`w-full text-left px-3 py-2 rounded-xl text-xs font-bold transition-all ${
selectedTourIdForPhoto === 'all' ? 'bg-blue-600 text-white' : 'bg-gray-100 text-gray-700 hover:bg-gray-200'
className={`w-full text-left px-3.5 py-2.5 rounded-xl text-xs font-bold transition-all ${
selectedTourIdForPhoto === 'all' ? 'bg-indigo-650 text-white shadow-md' : 'bg-slate-800/60 text-slate-300 hover:bg-slate-800'
}`}
>
Tất cả nh
@@ -339,9 +326,10 @@ export const MyPhotosPage = ({ onBack }: { onBack: () => void }) => {
<button
key={tour.id}
onClick={() => { setSelectedTourIdForPhoto(tour.id); setSelectedPhotoForDisplay(null); }}
className={`w-full text-left px-3 py-2 rounded-xl text-xs font-bold transition-all ${
selectedTourIdForPhoto === tour.id ? 'bg-blue-600 text-white' : 'bg-gray-100 text-gray-700 hover:bg-gray-200'
className={`w-full text-left px-3.5 py-2.5 rounded-xl text-xs font-bold transition-all truncate ${
selectedTourIdForPhoto === tour.id ? 'bg-indigo-650 text-white shadow-md' : 'bg-slate-800/60 text-slate-300 hover:bg-slate-800'
}`}
title={tour.title}
>
{tour.title}
</button>
@@ -350,87 +338,84 @@ export const MyPhotosPage = ({ onBack }: { onBack: () => void }) => {
</div>
{/* Right Column: Large Photo Display */}
<div className="md:flex-1 bg-white rounded-2xl shadow-lg border border-gray-100 p-4 flex flex-col items-center justify-center min-h-[300px]">
<div className="md:flex-1 bg-slate-900/50 rounded-2xl border border-slate-800/60 p-4 flex flex-col items-center justify-center min-h-[350px]">
{selectedPhotoForDisplay ? (
<div className="relative w-full h-full flex flex-col items-center justify-center">
<div className="relative overflow-hidden rounded-xl shadow-md max-w-full max-h-[calc(100vh-350px)] group">
<div className="relative overflow-hidden rounded-xl shadow-lg max-w-full max-h-[calc(100vh-380px)] group">
<img
src={selectedPhotoForDisplay.imageUrl || selectedPhotoForDisplay.originalUrl}
alt="Selected Photo"
className="max-w-full max-h-[calc(100vh-350px)] object-contain cursor-zoom-in"
alt="Selected"
className="max-w-full max-h-[calc(100vh-380px)] object-contain cursor-zoom-in"
onClick={() => setIsFullscreen(true)}
/>
{/* Overlays (Only show when not editing) */}
{!isEditing && (
<>
{/* Like (Heart) button overlay */}
{/* Like Button */}
<button
onClick={handleToggleLike}
className="absolute top-4 left-4 z-10 flex items-center gap-1.5 bg-black/60 hover:bg-black/75 border border-white/10 text-white font-bold py-1.5 px-3 rounded-full transition-all active:scale-95 text-[11px] backdrop-blur-md"
className="absolute top-4 left-4 z-10 flex items-center gap-1.5 bg-slate-950/80 hover:bg-slate-950 border border-slate-800/60 text-white font-bold py-1.5 px-3 rounded-full transition-all active:scale-95 text-[11px] backdrop-blur-md"
title={isLiked ? "Bỏ thích" : "Thích"}
>
<Heart className={`w-4 h-4 transition-colors ${
isLiked ? 'text-rose-500 fill-rose-500 animate-in zoom-in-75' : 'text-gray-300 hover:text-rose-450'
isLiked ? 'text-rose-500 fill-rose-500' : 'text-gray-300'
}`} />
<span>{likeCount}</span>
</button>
{/* Delete button overlay */}
{/* Delete Button */}
<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"
className="absolute top-4 right-4 z-10 p-2 bg-slate-950/80 hover:bg-rose-650 border border-slate-800/60 text-white rounded-full transition-all active:scale-95 backdrop-blur-md"
title="Xóa ảnh này"
>
<Trash2 className="w-4 h-4" />
</button>
{/* Bottom metadata details gradient panel overlay */}
<div className="absolute bottom-0 inset-x-0 bg-gradient-to-t from-black/60 via-black/20 to-transparent p-6 text-white flex flex-col gap-2 text-left">
{/* Metadata Overlay */}
<div className="absolute bottom-0 inset-x-0 bg-gradient-to-t from-slate-950 via-slate-950/45 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">
<h3 className="text-sm 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 tiêu đ</span>
<span className="text-xs text-slate-400 italic block mb-1">Chưa 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">
<p className="text-xs text-slate-300 leading-relaxed mt-1 break-words 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 tả</span>
<span className="text-[11px] text-slate-400 italic block mt-1">Chưa 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"
className="p-2 bg-slate-800 hover:bg-slate-800 border border-slate-700/50 rounded-xl text-white transition-all shrink-0"
title="Chỉnh sửa thông tin"
>
<Edit className="w-4 h-4" />
<Edit className="w-4 h-4 text-indigo-400" />
</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="flex flex-wrap items-center justify-between gap-4 border-t border-slate-800/80 pt-3 text-xs text-slate-300">
<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 className="flex items-center gap-2 text-slate-450 text-[10px] font-bold uppercase tracking-wider">
<Calendar className="w-3.5 h-3.5 text-indigo-450" />
Ngày chụp: {new Date(selectedPhotoForDisplay.capturedAt).toLocaleDateString('vi-VN')}
</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)}` : ''}>
<div className="flex items-center gap-2 font-bold text-slate-205">
<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">
<div className="text-[10px] text-slate-400">
Hành trình: {selectedPhotoForDisplay.tour.title}
</div>
)}
@@ -440,9 +425,9 @@ export const MyPhotosPage = ({ onBack }: { onBack: () => void }) => {
<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"
className="flex items-center gap-2 px-3.5 py-2 bg-indigo-600 hover:bg-indigo-700 text-white rounded-xl font-bold uppercase tracking-wider 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
<Download className="w-3.5 h-3.5" /> Tải nh gốc
</a>
)}
</div>
@@ -453,53 +438,53 @@ export const MyPhotosPage = ({ onBack }: { onBack: () => void }) => {
{isEditing && (
<div className="mt-6 w-full flex flex-col gap-4 px-2">
<div className="space-y-3 bg-gray-50 border border-gray-150 p-4 rounded-2xl w-full">
<h4 className="text-xs font-black uppercase tracking-wider text-blue-605">Chỉnh sửa thông tin nh</h4>
<div className="space-y-3 bg-slate-950/40 border border-slate-800/80 p-4 rounded-2xl w-full">
<h4 className="text-xs font-black uppercase tracking-wider text-indigo-400">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>
<label className="block text-[10px] uppercase font-bold tracking-wider text-slate-450 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"
className="w-full bg-slate-950 border border-slate-800 text-white rounded-xl px-3 py-2 text-xs focus:outline-none focus:ring-1 focus:ring-indigo-500"
/>
</div>
<div>
<label className="block text-[10px] uppercase font-bold tracking-wider text-gray-500 mb-1"> tả</label>
<label className="block text-[10px] uppercase font-bold tracking-wider text-slate-455 mb-1"> 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"
className="w-full bg-slate-950 border border-slate-800 text-white rounded-xl px-3 py-2 text-xs focus:outline-none focus:ring-1 focus:ring-indigo-500 resize-none"
/>
</div>
<div className="grid grid-cols-2 gap-2">
<div>
<label className="block text-[10px] uppercase font-bold tracking-wider text-gray-500 mb-1"> đ</label>
<label className="block text-[10px] uppercase font-bold tracking-wider text-slate-455 mb-1"> đ</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"
className="w-full bg-slate-950 border border-slate-800 text-white rounded-xl px-3 py-2 text-xs focus:outline-none"
/>
</div>
<div>
<label className="block text-[10px] uppercase font-bold tracking-wider text-gray-500 mb-1">Kinh đ</label>
<label className="block text-[10px] uppercase font-bold tracking-wider text-slate-455 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"
className="w-full bg-slate-950 border border-slate-800 text-white rounded-xl px-3 py-2 text-xs focus:outline-none"
/>
</div>
</div>
@@ -509,7 +494,7 @@ export const MyPhotosPage = ({ onBack }: { onBack: () => void }) => {
<button
type="button"
onClick={() => setIsMapOpen(true)}
className="flex items-center gap-1.5 px-3 py-1.5 bg-gray-100 hover:bg-gray-200 border border-gray-200 text-gray-700 hover:text-gray-900 rounded-xl text-[10px] font-bold transition-all"
className="flex items-center gap-1.5 px-3 py-1.5 bg-slate-800 hover:bg-slate-700 border border-slate-700 text-slate-200 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 đ
@@ -520,14 +505,14 @@ export const MyPhotosPage = ({ onBack }: { onBack: () => void }) => {
<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"
className="px-3 py-1.5 bg-slate-800 hover:bg-slate-750 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-blue-600 hover:bg-blue-700 disabled:opacity-50 text-white rounded-lg text-xs font-bold transition-all"
className="flex items-center gap-1 px-4 py-1.5 bg-indigo-650 hover:bg-indigo-600 disabled:opacity-50 text-white rounded-lg text-xs font-bold transition-all"
>
{isSavingEdit ? (
<>
@@ -544,26 +529,26 @@ export const MyPhotosPage = ({ onBack }: { onBack: () => void }) => {
)}
</div>
) : (
<div className="text-center text-gray-400 py-20">
<div className="text-center text-slate-500 py-20">
<ImageIcon className="w-16 h-16 mx-auto mb-4 opacity-20" />
<p className="text-lg font-bold">Chọn một tấm nh đ xem</p>
<p className="text-base font-bold">Chọn một tấm nh đ xem</p>
</div>
)}
</div>
</div>
{/* Bottom Row: Thumbnails */}
<div className="mt-4 bg-white rounded-2xl shadow-lg border border-gray-100 p-4">
<div className="mt-6 bg-slate-900/50 rounded-2xl border border-slate-800/60 p-4">
<div className="flex items-center justify-between mb-4 px-1">
<h3 className="text-xs font-black text-gray-400 uppercase tracking-widest">Kho nh ({filteredPhotos.length})</h3>
<h3 className="text-xs font-black text-slate-400 uppercase tracking-widest">Kho nh ({filteredPhotos.length})</h3>
</div>
<div className="grid grid-cols-4 sm:grid-cols-6 md:grid-cols-8 lg:grid-cols-10 gap-3 max-h-[300px] overflow-y-auto pr-2 custom-scrollbar">
<div className="grid grid-cols-4 sm:grid-cols-6 md:grid-cols-8 lg:grid-cols-10 gap-3 max-h-[250px] overflow-y-auto pr-2 custom-scrollbar">
{filteredPhotos.map((photo: any) => (
<div
key={photo.id}
onClick={() => setSelectedPhotoForDisplay(photo)}
className={`aspect-square bg-gray-100 rounded-xl overflow-hidden relative group border-2 transition-all cursor-pointer ${
selectedPhotoForDisplay?.id === photo.id ? 'border-blue-500 scale-[0.98]' : 'border-transparent hover:border-blue-200'
className={`aspect-square bg-slate-950 rounded-xl overflow-hidden relative group border-2 transition-all cursor-pointer ${
selectedPhotoForDisplay?.id === photo.id ? 'border-indigo-505 border-indigo-500 scale-[0.98]' : 'border-transparent hover:border-slate-700'
}`}
>
<img
@@ -576,14 +561,7 @@ export const MyPhotosPage = ({ onBack }: { onBack: () => void }) => {
</div>
</div>
</div>
) }
<div className="h-4"></div>
<div className="py-32 text-center bg-white rounded-[40px] border-2 border-dashed border-gray-100">
<ImageIcon className="w-16 h-16 text-gray-200 mx-auto mb-4" />
<h3 className="text-xl font-bold text-gray-400">Chưa nh nào</h3>
<p className="text-sm text-gray-300">Hãy tham gia các chuyến đi lưu lại khoảnh khắc nhé!</p>
</div>
{}
)}
</div>
<CoordinateSelectModal
isOpen={isMapOpen}
@@ -609,7 +587,7 @@ export const MyPhotosPage = ({ onBack }: { onBack: () => void }) => {
</button>
<img
src={selectedPhotoForDisplay.imageUrl || selectedPhotoForDisplay.originalUrl}
alt="Fullscreen photo"
alt="Fullscreen"
className="max-w-full max-h-full object-contain select-none animate-in zoom-in-95 duration-200"
/>
</div>
+31 -4
View File
@@ -10,6 +10,7 @@ import { useConfirm } from '@/hooks/useConfirm';
import { useNotification } from '@/hooks/useNotification';
import { CommentModal } from '@/components/CommentModal';
import { AddPhotoModal } from '@/components/AddPhotoModal';
import { TourChat } from '../components/TourChat';
import { MapContainer, TileLayer, Marker, Popup, useMapEvents, Polyline, useMap, Tooltip } from 'react-leaflet';
import _MarkerClusterGroup from 'react-leaflet-cluster';
const MarkerClusterGroup = (_MarkerClusterGroup as any).default || _MarkerClusterGroup;
@@ -361,8 +362,14 @@ export const TourDetailPage = ({
const [userSpeed, setUserSpeed] = useState<number | null>(null); // Tốc độ di chuyển từ GPS
// Di chuyển khai báo state lên trên useEffect để tránh lỗi "before initialization"
const [activeTab, setActiveTab] = useState<'plan' | 'expense' | 'photo' | 'settings' | 'members'>('plan');
const [activeTab, setActiveTab] = useState<'plan' | 'expense' | 'photo' | 'settings' | 'members' | 'chat'>(() => {
const defaultTab = localStorage.getItem('tour_detail_default_tab');
localStorage.removeItem('tour_detail_default_tab');
if (defaultTab === 'chat') return 'chat';
return 'plan';
});
const [mergingId, setMergingId] = useState<string | null>(null);
const [unreadChatCount, setUnreadChatCount] = useState(0);
const [viewMode, setViewMode] = useState<'map' | 'timeline'>('timeline');
const [isHeadingMode, setIsHeadingMode] = useState(false);
const [mapRotation, setMapRotation] = useState(0);
@@ -1137,8 +1144,14 @@ export const TourDetailPage = ({
handleCommentIncrement(data.locationId);
});
socket.on('tourMessageReceived', (data: any) => {
if (data.tourId === currentTour.id && activeTab !== 'chat') {
setUnreadChatCount(prev => prev + 1);
}
});
return () => { socket.disconnect(); };
}, [currentTour?.id]);
}, [currentTour?.id, activeTab]);
// This useEffect is for initial demo loading, might not be needed if tourId is always passed
// useEffect(() => {
@@ -1400,6 +1413,7 @@ export const TourDetailPage = ({
{ id: 'plan', label: 'Lộ trình', icon: MapIcon, visible: true },
{ id: 'expense', label: 'Chi phí', icon: Wallet, visible: ['OWNER', 'MANAGER', 'MEMBER'].includes(userRole || '') },
{ id: 'photo', label: 'Ảnh', icon: ImageIcon, visible: true },
{ id: 'chat', label: 'Trò chuyện', icon: MessageSquare, visible: !!userRole, hasBadge: unreadChatCount > 0, badgeCount: unreadChatCount },
{ id: 'members', label: 'Thành viên', icon: Users, visible: canManage },
{ id: 'settings', label: 'Cài đặt', icon: Settings, visible: userRole === 'OWNER' },
].filter(t => t.visible);
@@ -1744,8 +1758,11 @@ export const TourDetailPage = ({
{tabs.map((tab) => (
<button
key={tab.id}
onClick={() => setActiveTab(tab.id as any)}
className={`flex-1 flex items-center justify-center py-3 rounded-xl text-sm font-semibold transition-all ${tab.id === 'settings' && isPublicView ? 'hidden' : ''} ${ // Hide settings tab in public view
onClick={() => {
setActiveTab(tab.id as any);
if (tab.id === 'chat') setUnreadChatCount(0);
}}
className={`flex-1 flex items-center justify-center py-3 rounded-xl text-sm font-semibold transition-all relative ${tab.id === 'settings' && isPublicView ? 'hidden' : ''} ${ // Hide settings tab in public view
activeTab === tab.id
? 'bg-blue-50 text-blue-600 shadow-sm'
: 'text-gray-400 hover:text-gray-500 hover:bg-gray-50'
@@ -1753,6 +1770,11 @@ export const TourDetailPage = ({
>
<tab.icon className={`w-4 h-4 mr-2 ${activeTab === tab.id ? 'scale-110' : ''}`} />
{tab.label}
{tab.hasBadge && tab.badgeCount !== undefined && tab.badgeCount > 0 && (
<span className="absolute -top-1 -right-1.5 bg-red-500 text-white text-[9px] font-black rounded-full px-1.5 py-0.5 animate-bounce shadow-md">
{tab.badgeCount}
</span>
)}
</button>
))}
</div>
@@ -2667,6 +2689,11 @@ export const TourDetailPage = ({
onOpenAddMember={() => setIsAddMemberOpen(true)}
/>
)}
{activeTab === 'chat' && currentTour && (
<div className="animate-in fade-in slide-in-from-bottom-2">
<TourChat tourId={currentTour.id} />
</div>
)}
</div>
</div>