feat: tải ảnh lên bằng tài khoản public và cho phép bình luận

This commit is contained in:
2026-06-19 22:38:16 +07:00
parent 4640526e4b
commit 6c1b77d7d2
29 changed files with 1752 additions and 165 deletions
+1
View File
@@ -112,6 +112,7 @@ function App() {
user={user}
onViewTour={handleViewTour}
onOpenMyPhotos={() => setCurrentPage('myPhotos')}
onLoginSuccess={handleLoginSuccess}
/>
);
}
@@ -0,0 +1,348 @@
import React, { useState, useEffect, useRef } from 'react';
import { X, Send, MessageSquare, User, Loader2, Calendar, MapPin, Download } from 'lucide-react';
import { io } from 'socket.io-client';
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;
};
uploader?: {
id: string;
name: string;
};
};
photoGroup?: any[];
onSelectPhoto?: (photo: any) => void;
onLoginSuccess?: (user: any) => void;
}
export const PublicPhotoModal: React.FC<PublicPhotoModalProps> = ({
isOpen,
onClose,
photo,
photoGroup = [],
onSelectPhoto,
onLoginSuccess
}) => {
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 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
}
];
});
}
});
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);
}
}
const 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);
}
};
if (!isOpen) return null;
return (
<div className="fixed inset-0 z-[5000] flex items-center justify-center p-4">
{/* Backdrop */}
<div
className="absolute inset-0 bg-slate-950/80 backdrop-blur-md animate-in fade-in duration-300"
onClick={onClose}
/>
{/* Container */}
<div className="relative w-full max-w-5xl h-[85vh] bg-slate-900 border border-slate-800 rounded-[32px] shadow-2xl overflow-hidden flex flex-col md:flex-row animate-in zoom-in-95 duration-300 text-slate-100">
{/* Close Button Mobile/Desktop */}
<button
onClick={onClose}
className="absolute top-4 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 h-2/5 md:h-full bg-slate-950 flex items-center justify-center overflow-hidden group">
<img
src={photo.imageUrl}
alt="Public Map Upload"
className="w-full h-full object-contain"
/>
{/* Info & Timeline overlay inside photo panel */}
<div className="absolute bottom-0 left-0 right-0 p-6 bg-gradient-to-t from-slate-950 via-slate-950/90 to-transparent 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">
<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">
{photoGroup.map((p) => {
const isActive = p.id === photo.id;
return (
<button
key={p.id}
onClick={() => 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" className="w-full h-full object-cover" />
<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>
)}
{/* Photo Metadata */}
<div className="flex flex-wrap items-center gap-4 text-xs text-slate-300">
<span className="flex items-center gap-1.5 font-semibold text-emerald-400">
<User className="w-4 h-4" />
{photo.uploader?.name || 'Ẩn danh'}
</span>
<span className="flex items-center gap-1.5 text-slate-400">
<Calendar className="w-4 h-4" />
{new Date(photo.capturedAt).toLocaleDateString('vi-VN', {
day: '2-digit',
month: '2-digit',
year: 'numeric',
hour: '2-digit',
minute: '2-digit'
})}
</span>
{photo.metadata?.lat && photo.metadata?.lng && (
<span className="flex items-center gap-1.5 text-slate-400">
<MapPin className="w-4 h-4 text-rose-500" />
{photo.metadata.lat.toFixed(4)}, {photo.metadata.lng.toFixed(4)}
</span>
)}
{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 h-3/5 md:h-full flex flex-col bg-slate-900 border-t md:border-t-0 md:border-l border-slate-800">
{/* Comments Header */}
<div className="p-6 border-b border-slate-800 flex items-center justify-between">
<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" />
Bình luận cộng đng
</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="flex-1 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">Đang tải bình luận...</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 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>
<span className="text-[9px] font-medium text-slate-500">
{new Date(c.createdAt).toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' })}
</span>
</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="p-4 bg-slate-950/40 border-t border-slate-800/80">
<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-sm focus:outline-none focus:ring-2 focus:ring-emerald-500 focus:border-transparent transition-all"
disabled={isSending}
/>
<button
onClick={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>
</div>
);
};
+173 -68
View File
@@ -1,5 +1,5 @@
import React, { useState, useEffect } from 'react';
import { X, User, Trash2, Shield, ShieldAlert, Lock, Unlock, Loader2, UserPlus } from 'lucide-react';
import { X, User, Trash2, Shield, ShieldAlert, Lock, Unlock, Loader2, Image as ImageIcon } from 'lucide-react';
interface UserManagementModalProps {
isOpen: boolean;
@@ -7,8 +7,11 @@ interface UserManagementModalProps {
}
export const UserManagementModal: React.FC<UserManagementModalProps> = ({ isOpen, onClose }) => {
const [activeTab, setActiveTab] = useState<'users' | 'photos'>('users');
const [users, setUsers] = useState<any[]>([]);
const [photos, setPhotos] = useState<any[]>([]);
const [loading, setLoading] = useState(false);
const [photosLoading, setPhotosLoading] = useState(false);
const [error, setError] = useState('');
const fetchUsers = async () => {
@@ -27,9 +30,29 @@ export const UserManagementModal: React.FC<UserManagementModalProps> = ({ isOpen
}
};
const fetchPhotos = async () => {
setPhotosLoading(true);
try {
const response = await fetch(`/api/v1/public-photos`);
if (!response.ok) throw new Error('Không thể tải danh sách ảnh công cộng');
const data = await response.json();
setPhotos(data);
} catch (err: any) {
setError(err.message);
} finally {
setPhotosLoading(false);
}
};
useEffect(() => {
if (isOpen) fetchUsers();
}, [isOpen]);
if (isOpen) {
if (activeTab === 'users') {
fetchUsers();
} else {
fetchPhotos();
}
}
}, [isOpen, activeTab]);
const handleToggleBlock = async (id: string) => {
try {
@@ -60,89 +83,171 @@ export const UserManagementModal: React.FC<UserManagementModalProps> = ({ isOpen
}
};
const handleDeletePhoto = async (id: string) => {
if (!confirm('Bạn có chắc chắn muốn xóa bức ảnh công khai này?')) return;
try {
const res = await fetch(`/api/v1/photos/${id}`, {
method: 'DELETE',
headers: { 'Authorization': `Bearer ${localStorage.getItem('token')}` }
});
if (!res.ok) {
const data = await res.json();
throw new Error(data.message);
}
fetchPhotos();
} catch (err: any) {
alert(err.message);
}
};
if (!isOpen) return null;
return (
<div className="fixed inset-0 z-[2000] flex items-center justify-center p-4">
<div className="absolute inset-0 bg-gray-900/60 backdrop-blur-sm" onClick={onClose} />
<div className="relative w-full max-w-4xl bg-white rounded-3xl shadow-2xl overflow-hidden flex flex-col max-h-[90vh]">
<div className="relative w-full max-w-4xl bg-white rounded-3xl shadow-2xl overflow-hidden flex flex-col h-[85vh]">
{/* Header */}
<div className="p-6 border-b border-gray-100 flex justify-between items-center bg-gray-50/50">
<div>
<h2 className="text-2xl font-bold text-gray-900 flex items-center gap-2">
<Shield className="w-6 h-6 text-blue-600" /> Quản người dùng
<Shield className="w-6 h-6 text-blue-600" /> Hệ thống quản trị
</h2>
<p className="text-sm text-gray-500">Quản trị viên quyền thêm, sửa, xóa hoặc khóa tài khoản.</p>
<p className="text-sm text-gray-500">Quản thành viên các tài nguyên công cộng của ng dụng.</p>
</div>
<button onClick={onClose} className="p-2 hover:bg-gray-200 rounded-full transition-colors">
<X className="w-6 h-6 text-gray-400" />
</button>
</div>
{/* Tab Selection */}
<div className="flex border-b border-gray-100 bg-gray-50/20 px-6">
<button
onClick={() => setActiveTab('users')}
className={`py-4 px-4 font-bold text-sm transition-all border-b-2 -mb-[2px] flex items-center gap-2 ${
activeTab === 'users' ? 'border-blue-600 text-blue-600' : 'border-transparent text-gray-400 hover:text-gray-600'
}`}
>
<User className="w-4 h-4" />
Thành viên
</button>
<button
onClick={() => setActiveTab('photos')}
className={`py-4 px-4 font-bold text-sm transition-all border-b-2 -mb-[2px] flex items-center gap-2 ${
activeTab === 'photos' ? 'border-blue-600 text-blue-600' : 'border-transparent text-gray-400 hover:text-gray-600'
}`}
>
<ImageIcon className="w-4 h-4" />
nh công cộng
</button>
</div>
{/* Content Body */}
<div className="flex-1 overflow-y-auto p-6">
{loading ? (
<div className="flex justify-center py-20"><Loader2 className="w-10 h-10 animate-spin text-blue-600" /></div>
) : error ? (
<div className="p-4 bg-red-50 text-red-600 rounded-xl font-bold">{error}</div>
) : (
<table className="w-full text-left border-collapse">
<thead>
<tr className="text-gray-400 text-xs uppercase tracking-wider border-b border-gray-100">
<th className="pb-4 font-bold px-2">Người dùng</th>
<th className="pb-4 font-bold">Vai trò</th>
<th className="pb-4 font-bold">Trạng thái</th>
<th className="pb-4 font-bold text-right">Thao tác</th>
</tr>
</thead>
<tbody className="divide-y divide-gray-50">
{users.map(u => (
<tr key={u.id} className="group hover:bg-gray-50/50 transition-colors">
<td className="py-4 px-2">
<div className="flex items-center gap-3">
<div className="w-10 h-10 rounded-full bg-blue-100 flex items-center justify-center text-blue-600 font-bold">
{u.name?.charAt(0) || <User className="w-5 h-5" />}
</div>
<div>
<div className="font-bold text-gray-900">{u.name || 'N/A'}</div>
<div className="text-xs text-gray-400">{u.email}</div>
</div>
</div>
</td>
<td className="py-4">
{u.isAdmin ? (
<span className="px-2 py-1 bg-purple-50 text-purple-600 text-[10px] font-black rounded-md border border-purple-100">ADMIN</span>
) : (
<span className="px-2 py-1 bg-gray-50 text-gray-500 text-[10px] font-black rounded-md border border-gray-100">USER</span>
)}
</td>
<td className="py-4">
{u.isBlocked ? (
<span className="flex items-center gap-1 text-red-500 text-xs font-bold"><ShieldAlert className="w-3 h-3" /> Đã khóa</span>
) : (
<span className="text-green-500 text-xs font-bold">Đang hoạt đng</span>
)}
</td>
<td className="py-4 text-right">
<div className="flex justify-end gap-2">
<button
onClick={() => handleToggleBlock(u.id)}
className={`p-2 rounded-xl transition-all ${u.isBlocked ? 'bg-green-50 text-green-600 hover:bg-green-100' : 'bg-amber-50 text-amber-600 hover:bg-amber-100'}`}
title={u.isBlocked ? 'Mở khóa' : 'Khóa tài khoản'}
>
{u.isBlocked ? <Unlock className="w-4 h-4" /> : <Lock className="w-4 h-4" />}
</button>
<button
onClick={() => handleDelete(u.id)}
className="p-2 bg-red-50 text-red-600 rounded-xl hover:bg-red-100 transition-all"
title="Xóa người dùng"
>
<Trash2 className="w-4 h-4" />
</button>
</div>
</td>
{error && (
<div className="p-4 bg-red-50 text-red-600 rounded-xl font-bold mb-4">{error}</div>
)}
{activeTab === 'users' ? (
loading ? (
<div className="flex justify-center py-20"><Loader2 className="w-10 h-10 animate-spin text-blue-600" /></div>
) : (
<table className="w-full text-left border-collapse">
<thead>
<tr className="text-gray-400 text-xs uppercase tracking-wider border-b border-gray-100">
<th className="pb-4 font-bold px-2">Người dùng</th>
<th className="pb-4 font-bold">Vai trò</th>
<th className="pb-4 font-bold">Trạng thái</th>
<th className="pb-4 font-bold text-right">Thao tác</th>
</tr>
</thead>
<tbody className="divide-y divide-gray-50">
{users.map(u => (
<tr key={u.id} className="group hover:bg-gray-50/50 transition-colors">
<td className="py-4 px-2">
<div className="flex items-center gap-3">
<div className="w-10 h-10 rounded-full bg-blue-100 flex items-center justify-center text-blue-600 font-bold">
{u.name?.charAt(0) || <User className="w-5 h-5" />}
</div>
<div>
<div className="font-bold text-gray-900">{u.name || 'N/A'}</div>
<div className="text-xs text-gray-400">{u.email}</div>
</div>
</div>
</td>
<td className="py-4">
{u.isAdmin ? (
<span className="px-2 py-1 bg-purple-50 text-purple-600 text-[10px] font-black rounded-md border border-purple-100">ADMIN</span>
) : (
<span className="px-2 py-1 bg-gray-50 text-gray-500 text-[10px] font-black rounded-md border border-gray-100">USER</span>
)}
</td>
<td className="py-4">
{u.isBlocked ? (
<span className="flex items-center gap-1 text-red-500 text-xs font-bold"><ShieldAlert className="w-3 h-3" /> Đã khóa</span>
) : (
<span className="text-green-500 text-xs font-bold">Đang hoạt đng</span>
)}
</td>
<td className="py-4 text-right">
<div className="flex justify-end gap-2">
<button
onClick={() => handleToggleBlock(u.id)}
className={`p-2 rounded-xl transition-all ${u.isBlocked ? 'bg-green-50 text-green-600 hover:bg-green-100' : 'bg-amber-50 text-amber-600 hover:bg-amber-100'}`}
title={u.isBlocked ? 'Mở khóa' : 'Khóa tài khoản'}
>
{u.isBlocked ? <Unlock className="w-4 h-4" /> : <Lock className="w-4 h-4" />}
</button>
<button
onClick={() => handleDelete(u.id)}
className="p-2 bg-red-50 text-red-600 rounded-xl hover:bg-red-100 transition-all"
title="Xóa người dùng"
>
<Trash2 className="w-4 h-4" />
</button>
</div>
</td>
</tr>
))}
</tbody>
</table>
)
) : (
photosLoading ? (
<div className="flex justify-center py-20"><Loader2 className="w-10 h-10 animate-spin text-blue-600" /></div>
) : photos.length === 0 ? (
<div className="text-center py-20 text-gray-400 italic">Chưa nh công cộng nào đưc tải lên.</div>
) : (
<div className="grid grid-cols-2 sm:grid-cols-3 md:grid-cols-4 gap-6">
{photos.map(p => (
<div key={p.id} className="relative group bg-gray-50 border border-gray-100 rounded-2xl overflow-hidden shadow-sm hover:shadow-md transition-all flex flex-col justify-between">
<div className="aspect-square bg-slate-900 overflow-hidden flex items-center justify-center relative">
<img src={p.imageUrl} alt="Public content" className="w-full h-full object-cover transition-transform group-hover:scale-105" />
{/* Delete button shown on hover/focus */}
<button
onClick={() => handleDeletePhoto(p.id)}
className="absolute top-2 right-2 p-2 bg-red-600 hover:bg-red-500 text-white rounded-xl shadow-lg transition-all active:scale-95 opacity-0 group-hover:opacity-100 focus:opacity-100 z-10"
title="Xóa ảnh công cộng"
>
<Trash2 className="w-4 h-4" />
</button>
</div>
<div className="p-3 bg-white">
<div className="font-bold text-xs text-gray-800 truncate" title={p.uploader?.name || 'Ẩn danh'}>
Đăng bởi: {p.uploader?.name || 'Ẩn danh'}
</div>
<div className="text-[10px] text-gray-400 mt-1">
{new Date(p.capturedAt).toLocaleDateString('vi-VN', {
day: '2-digit',
month: '2-digit',
year: 'numeric'
})}
</div>
</div>
</div>
))}
</tbody>
</table>
</div>
)
)}
</div>
</div>
+93 -4
View File
@@ -1,14 +1,15 @@
import React, { useEffect, useState } from 'react';
import { MapContainer, TileLayer, Marker, Popup, useMap, useMapEvents, Tooltip } from 'react-leaflet';
import { MapContainer, TileLayer, Marker, useMap, useMapEvents, Tooltip } from 'react-leaflet';
import _MarkerClusterGroup from 'react-leaflet-cluster';
const MarkerClusterGroup = (_MarkerClusterGroup as any).default || _MarkerClusterGroup;
import L from 'leaflet';
import 'leaflet/dist/leaflet.css';
import { useTourStore } from '@/store/useTourStore';
import { X, Navigation, Image as ImageIcon, LogOut, Settings, Edit2, Trash2, Share2, Filter, Tag as TagIcon, MapPin, Loader2 } from 'lucide-react';
import { X, Navigation, Image as ImageIcon, LogOut, Settings, Share2, Filter, MapPin, Loader2 } from 'lucide-react';
import { UserManagementModal } from '@/components/UserManagementModal';
import { useNotification } from '@/hooks/useNotification';
import { CreateTourModal } from '../components/CreateTourModal';
import { PublicPhotoModal } from '../components/PublicPhotoModal';
// Fix lỗi icon mặc định của Leaflet
const DefaultIcon = L.icon({
@@ -56,7 +57,7 @@ function MapTracker() {
return null;
}
export const ExploreMap = ({ onBack, onLogout, user, onViewTour, onOpenMyPhotos }: { onBack: () => void, onLogout?: () => void, user?: any, onViewTour: (id: string) => void, onOpenMyPhotos: () => void }) => {
export const ExploreMap = ({ onBack, onLogout, user, onViewTour, onOpenMyPhotos, onLoginSuccess }: { onBack: () => void, onLogout?: () => void, user?: any, onViewTour: (id: string) => void, onOpenMyPhotos: () => void, onLoginSuccess?: (user: any) => void }) => {
// Tối ưu hóa việc lấy dữ liệu từ store bằng selectors để tránh re-render thừa
const publicTours = useTourStore(state => state.publicTours);
const fetchPublicTours = useTourStore(state => state.fetchPublicTours);
@@ -76,6 +77,37 @@ export const ExploreMap = ({ onBack, onLogout, user, onViewTour, onOpenMyPhotos
const [userPos, setUserPos] = useState<[number, number]>(initialViewState?.center || [10.7769, 106.7009]);
const [mapZoom] = useState(initialViewState?.zoom || 13);
const [publicPhotos, setPublicPhotos] = useState<any[]>([]);
const [selectedPhoto, setSelectedPhoto] = useState<any | null>(null);
const [selectedPhotoGroup, setSelectedPhotoGroup] = useState<any[]>([]);
const groupedPhotos = React.useMemo(() => {
const groups: { [key: string]: any[] } = {};
publicPhotos.forEach((photo) => {
const lat = photo.metadata?.lat;
const lng = photo.metadata?.lng;
if (typeof lat === 'number' && typeof lng === 'number') {
const key = `${lat.toFixed(5)},${lng.toFixed(5)}`;
if (!groups[key]) {
groups[key] = [];
}
groups[key].push(photo);
}
});
return Object.values(groups);
}, [publicPhotos]);
const fetchPublicPhotos = async () => {
try {
const response = await fetch('/api/v1/public-photos');
if (response.ok) {
const data = await response.json();
setPublicPhotos(data);
}
} catch (error) {
console.error('Error fetching public photos:', error);
}
};
const [isAdminModalOpen, setIsAdminModalOpen] = useState(false);
const [isCreateModalOpen, setIsCreateModalOpen] = useState(false);
const [isFilterDropdownOpen, setIsFilterDropdownOpen] = useState(false); // State để điều khiển hiển thị dropdown lọc
@@ -194,7 +226,10 @@ export const ExploreMap = ({ onBack, onLogout, user, onViewTour, onOpenMyPhotos
}, [publicTours, selectedFilterTag]);
useEffect(() => {
// Chỉ fetch dữ liệu khi người dùng đã đăng nhập và có token
// Luôn tải danh sách ảnh công khai để hiển thị trên bản đồ cho tất cả mọi người
fetchPublicPhotos();
// Chỉ tải danh sách tour khi người dùng đã đăng nhập và có token
if (user || localStorage.getItem('token')) {
fetchPublicTours();
}
@@ -449,6 +484,46 @@ export const ExploreMap = ({ onBack, onLogout, user, onViewTour, onOpenMyPhotos
);
})}
</MarkerClusterGroup>
{groupedPhotos.map((photoGroup) => {
const latestPhoto = photoGroup[0];
const lat = latestPhoto.metadata?.lat;
const lng = latestPhoto.metadata?.lng;
if (typeof lat !== 'number' || typeof lng !== 'number') return null;
return (
<Marker
key={latestPhoto.id}
position={[lat, lng]}
eventHandlers={{
click: () => {
setSelectedPhoto(latestPhoto);
setSelectedPhotoGroup(photoGroup);
}
}}
icon={L.divIcon({
className: 'custom-photo-bubble',
html: `
<div class="relative group">
<div class="w-12 h-12 rounded-full border-4 border-emerald-500 shadow-lg overflow-hidden transition-transform group-hover:scale-110">
<img src="${latestPhoto.imageUrl}" class="w-full h-full object-cover" />
</div>
<div class="absolute -bottom-1 -right-1 bg-emerald-600 rounded-full w-5 h-5 border-2 border-white flex items-center justify-center text-[10px] font-black text-white">
📸
</div>
${photoGroup.length > 1 ? `
<div class="absolute -top-1 -left-1 bg-rose-600 rounded-full w-5 h-5 border-2 border-white flex items-center justify-center text-[9px] font-black text-white shadow-md animate-bounce">
${photoGroup.length}
</div>
` : ''}
</div>
`,
iconSize: [48, 48],
iconAnchor: [24, 24]
})}
/>
);
})}
</MapContainer>
{/* Context Menu Chia sẻ */}
@@ -498,6 +573,20 @@ export const ExploreMap = ({ onBack, onLogout, user, onViewTour, onOpenMyPhotos
onViewTour(tour.id);
}}
/>
{selectedPhoto && (
<PublicPhotoModal
isOpen={!!selectedPhoto}
onClose={() => {
setSelectedPhoto(null);
setSelectedPhotoGroup([]);
}}
photo={selectedPhoto}
photoGroup={selectedPhotoGroup}
onSelectPhoto={(photo) => setSelectedPhoto(photo)}
onLoginSuccess={onLoginSuccess}
/>
)}
</div>
);
};
+241 -51
View File
@@ -1,6 +1,7 @@
import React, { useState, useMemo } from 'react';
import React, { useState, useRef, useEffect } from 'react';
import { LogIn, Compass, Map as MapIcon, Camera } from 'lucide-react';
import { LoginModal } from '../components/LoginModal';
import { useNotification } from '@/hooks/useNotification';
interface LandingPageProps {
onContinue?: () => void;
@@ -11,63 +12,209 @@ interface LandingPageProps {
}
// Component Animation cho người lữ hành
const TravelerAnimation = () => (
<div className="relative w-64 h-64">
{/* 1. Animation người lữ hành (SVG Silhouette) */}
<svg
viewBox="0 0 100 100"
className="absolute inset-0 w-full h-full"
style={{ animation: 'travelerFadeIn 4s cubic-bezier(0.4, 0, 0.6, 1) infinite' }}
>
{/* Thân người */}
<path d="M 50,30 C 40,30 35,40 35,50 L 35,90 L 65,90 L 65,50 C 65,40 60,30 50,30 Z" fill="rgba(255,255,255,0.1)" />
{/* Máy ảnh */}
<rect x="42" y="45" width="16" height="10" rx="2" fill="rgba(255,255,255,0.5)" />
</svg>
{/* 2. Animation đèn flash (giữ nguyên) */}
<div
className="absolute top-1/2 left-1/2 w-48 h-48 md:w-64 md:h-64 bg-white rounded-full"
const TravelerAnimation = ({ onClick }: { onClick?: () => void }) => {
return <button
onClick={onClick}
className="relative w-48 h-48 flex items-center justify-center transition-transform active:scale-95 focus:outline-none"
style={{
transform: 'translate(-50%, -50%)',
animation: 'flash 4s cubic-bezier(0.4, 0, 0.6, 1) infinite',
animation: 'gentle-shake 5s ease-in-out infinite',
}}
/>
{/* 3. Icon máy ảnh tĩnh để luôn hiển thị */}
<div className="absolute inset-0 flex items-center justify-center opacity-50">
<Camera className="w-20 h-20 md:w-24 md:h-24 text-white/60 drop-shadow-xl" strokeWidth={1.5} />
</div>
{/* 4. Thêm keyframes cho cả hai animation */}
<style>{`
@keyframes travelerFadeIn {
0%, 40%, 100% { opacity: 0; transform: scale(0.8); }
50%, 90% { opacity: 1; transform: scale(1); }
}
@keyframes flash {
0%, 50%, 100% { opacity: 0; transform: translate(-50%, -50%) scale(0.5); }
55% { opacity: 0.6; transform: translate(-50%, -50%) scale(1.2); }
}
`}</style>
</div>
);
aria-label="Khám phá bản đồ"
>
{/* Giảm kích thước icon xuống 75% (w-24 -> w-18, md:w-28 -> md:w-21) */}
<Camera className="w-18 h-18 md:w-21 md:h-21 text-white/80 drop-shadow-2xl" strokeWidth={1.5} />
{/* Keyframes cho animation rung lắc */}
<style>{`
@keyframes gentle-shake {
0%, 100% {
transform: rotate(0deg) scale(1);
}
50% {
transform: rotate(2deg) scale(1.05);
}
}
`}</style>
</button>;
};
export const LandingPage: React.FC<LandingPageProps> = ({ onContinue, onGoToSignup, onGoToMap, onLoginSuccess, isInitialSetup }) => {
const [isLoginModalOpen, setIsLoginModalOpen] = useState(false);
const fileInputRef = useRef<HTMLInputElement>(null);
const notify = useNotification();
const [publicPhotos, setPublicPhotos] = useState<any[]>([]);
const [currentBgIndex, setCurrentBgIndex] = useState(0);
const [bg1, setBg1] = useState('/background.avif');
const [bg2, setBg2] = useState('');
const [fade1, setFade1] = useState(true);
const [fade2, setFade2] = useState(false);
const [activeSlot, setActiveSlot] = useState<1 | 2>(1);
useEffect(() => {
const nextUrl = publicPhotos.length > 0 ? publicPhotos[currentBgIndex]?.imageUrl : '/background.avif';
if (!nextUrl) return;
const currentUrl = activeSlot === 1 ? bg1 : bg2;
if (nextUrl === currentUrl) return;
if (activeSlot === 1) {
setBg2(nextUrl);
setFade2(true);
setFade1(false);
setActiveSlot(2);
} else {
setBg1(nextUrl);
setFade1(true);
setFade2(false);
setActiveSlot(1);
}
}, [currentBgIndex, publicPhotos]);
const fetchPublicPhotos = async () => {
try {
const res = await fetch('/api/v1/public-photos');
if (res.ok) {
const data = await res.json();
setPublicPhotos(data);
}
} catch (e) {
console.error('Lỗi khi tải ảnh công khai:', e);
}
};
useEffect(() => {
fetchPublicPhotos();
}, []);
useEffect(() => {
if (publicPhotos.length <= 1) return;
const interval = setInterval(() => {
setCurrentBgIndex((prev) => (prev + 1) % publicPhotos.length);
}, 12000);
return () => clearInterval(interval);
}, [publicPhotos]);
const handleFileChange = async (event: React.ChangeEvent<HTMLInputElement>) => {
const file = event.target.files?.[0];
if (!file) return;
notify({ title: 'Đang xử lý...', message: 'Vui lòng chờ trong giây lát.', type: 'info' });
try {
// Lấy tọa độ hiện tại của người dùng để làm tọa độ dự phòng
const location = await new Promise<GeolocationPosition | null>((resolve) => {
if (!navigator.geolocation) {
resolve(null);
} else {
navigator.geolocation.getCurrentPosition(
(pos) => resolve(pos),
() => resolve(null),
{ timeout: 5000, enableHighAccuracy: true }
);
}
});
// 1. Tạo tài khoản khách và lấy token
let guestToken = localStorage.getItem('guest_token');
let guestUser = JSON.parse(localStorage.getItem('guest_user') || 'null');
if (!guestToken) {
const guestRes = await fetch('/api/v1/auth/create-guest', { method: 'POST' });
if (!guestRes.ok) throw new Error('Không thể tạo phiên khách.');
const guestData = await guestRes.json();
guestToken = guestData.access_token;
guestUser = guestData.user;
localStorage.setItem('guest_token', guestToken!);
localStorage.setItem('guest_user', JSON.stringify(guestUser));
}
// 2. Tải ảnh lên
const formData = new FormData();
formData.append('images', file);
if (location) {
formData.append('latitude', location.coords.latitude.toString());
formData.append('longitude', location.coords.longitude.toString());
}
const uploadRes = await fetch('/api/v1/photos/upload-anonymous', {
method: 'POST',
headers: { 'Authorization': `Bearer ${guestToken!}` },
body: formData,
});
if (!uploadRes.ok) {
const errorData = await uploadRes.json();
throw new Error(errorData.message || 'Tải ảnh thất bại.');
}
notify({ title: 'Thành công!', message: 'Ảnh của bạn đã được tải lên.', type: 'success' });
// Cập nhật lại danh sách ảnh lập tức
await fetchPublicPhotos();
setCurrentBgIndex(0);
// Đăng nhập luôn bằng tài khoản khách này để người dùng xem được ảnh của mình trên bản đồ
localStorage.setItem('token', guestToken!);
localStorage.setItem('user', JSON.stringify(guestUser));
if (onLoginSuccess) {
onLoginSuccess(guestUser);
}
} catch (error: any) {
notify({ title: 'Lỗi', message: error.message, type: 'error' });
} finally {
// Reset input để có thể chọn lại cùng 1 file
if (event.target) event.target.value = '';
}
};
// Sử dụng trực tiếp ảnh nền AVIF từ thư mục `public`
const backgroundImage = '/background.avif'; // Giả sử bạn đặt tên file là background.avif
return (
<div className="h-screen w-full overflow-hidden font-sans bg-gray-900 relative">
{/* Background Image - Hiển thị trên mọi thiết bị */}
<img
src={backgroundImage}
className="absolute inset-0 w-full h-full object-cover opacity-60"
alt="Travel Background"
/>
<div className="absolute inset-0 bg-gradient-to-t from-black/50 via-transparent to-black/20" />
{/* Background Image - Hiển thị trên mọi thiết bị với hiệu ứng Crossfade & Panning */}
<div className="absolute inset-0 z-0 bg-gray-950 overflow-hidden">
{/* Slot 1 */}
{bg1 && (
<img
key={bg1}
src={bg1}
className="absolute inset-y-0 left-0 h-full w-[120%] max-w-none object-cover transition-opacity duration-[1200ms] ease-in-out animate-panning"
style={{
opacity: fade1 ? 0.8 : 0,
}}
alt="Travel Background 1"
/>
)}
{/* Slot 2 */}
{bg2 && (
<img
key={bg2}
src={bg2}
className="absolute inset-y-0 left-0 h-full w-[120%] max-w-none object-cover transition-opacity duration-[1200ms] ease-in-out animate-panning"
style={{
opacity: fade2 ? 0.8 : 0,
}}
alt="Travel Background 2"
/>
)}
</div>
{/* Keyframes cho hiệu ứng panning từ trái sang phải */}
<style>{`
@keyframes pan-left-to-right {
0% {
transform: translateX(0) translateZ(0);
}
100% {
transform: translateX(-16.667%) translateZ(0);
}
}
.animate-panning {
animation: pan-left-to-right 13500ms linear forwards;
will-change: transform;
}
`}</style>
{/* Top Bar - Thanh điều hướng trên cùng */}
<div className="absolute top-0 left-0 right-0 z-20 p-4 flex justify-between items-center">
@@ -85,10 +232,53 @@ export const LandingPage: React.FC<LandingPageProps> = ({ onContinue, onGoToSign
</div>
{/* Body: Animation người lữ hành */}
<div className="absolute inset-0 flex items-center justify-center z-10 pointer-events-none">
<TravelerAnimation />
<div className="absolute inset-0 flex items-center justify-center z-10" >
{/* Kích hoạt click vào file input để chọn hoặc chụp ảnh */}
<TravelerAnimation onClick={() => fileInputRef.current?.click()} />
</div>
{/* Input chọn file ẩn để chụp/chọn ảnh */}
<input
type="file"
ref={fileInputRef}
onChange={handleFileChange}
accept="image/*"
capture="environment"
className="hidden"
/>
{/* Community Gallery Previews */}
{publicPhotos.length > 0 && (
<div className="absolute bottom-28 left-0 right-0 z-20 px-4 flex flex-col items-center gap-2">
<span className="text-xs font-bold uppercase tracking-wider text-white/70 drop-shadow-md">
Khoảnh khắc từ cộng đng ({publicPhotos.length})
</span>
<div className="flex gap-3 overflow-x-auto max-w-full no-scrollbar pb-2 px-4 justify-center">
{publicPhotos.slice(0, 8).map((photo, index) => (
<button
key={photo.id}
onClick={() => setCurrentBgIndex(index)}
className={`relative w-14 h-14 rounded-xl overflow-hidden border-2 transition-all active:scale-95 shrink-0 ${
currentBgIndex === index ? 'border-emerald-500 scale-110 shadow-lg' : 'border-white/20 hover:border-white/50'
}`}
>
<img src={photo.imageUrl} alt="Community thumbnail" className="w-full h-full object-cover" />
</button>
))}
</div>
<style>{`
.no-scrollbar::-webkit-scrollbar {
display: none;
}
.no-scrollbar {
-ms-overflow-style: none;
scrollbar-width: none;
}
`}</style>
</div>
)}
{/* Bottom Bar - Nút hành động chính */}
<div className="absolute bottom-8 left-1/2 -translate-x-1/2 z-20 w-full px-4">
<button
+11 -2
View File
@@ -1,5 +1,5 @@
import React, { useState } from 'react';
import { User, Mail, Lock, ArrowRight, ChevronLeft, Compass, Phone, MapPin, ShieldCheck } from 'lucide-react';
import { User, Mail, Lock, ArrowRight, ChevronLeft, Phone, MapPin, ShieldCheck } from 'lucide-react';
interface SignupPageProps {
onBack: () => void;
@@ -54,16 +54,25 @@ const SignupPage: React.FC<SignupPageProps> = ({ onBack, onSuccess }) => {
setStep('otp');
} else {
// Xác thực mã OTP bước hoàn tất
const guestUserStr = localStorage.getItem('guest_user');
const guestUser = guestUserStr ? JSON.parse(guestUserStr) : null;
const guestId = guestUser ? guestUser.id : undefined;
const response = await fetch(`/api/v1/auth/signup/verify`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
email: formData.email,
otp: otp
otp: otp,
guestId: guestId
}),
});
const data = await response.json();
if (!response.ok) throw new Error(data.message || 'Mã OTP không chính xác');
// Dọn dẹp session lữ khách sau khi đăng ký thành công
localStorage.removeItem('guest_token');
localStorage.removeItem('guest_user');
onSuccess();
}