fix: tạo tags cho ảnh public của guest
This commit is contained in:
@@ -24,6 +24,21 @@ const DefaultIcon = L.icon({
|
||||
});
|
||||
L.Marker.prototype.options.icon = DefaultIcon;
|
||||
|
||||
// Tag ID to Label Mapping for Public Photos
|
||||
const PHOTO_TAG_LABELS: { [key: string]: string } = {
|
||||
'phong-canh': '🏞️ Phong cảnh',
|
||||
'con-nguoi': '👥 Con người',
|
||||
'doi-thuong': '🎒 Đời thường',
|
||||
'bien': '🌊 Biển',
|
||||
'nui': '⛰️ Núi',
|
||||
'do-thi': '🏙️ Đô thị',
|
||||
'thuc-an': '🍜 Thức ăn',
|
||||
'cho': '🛍️ Chợ',
|
||||
'hien-dai': '🏗️ Hiện đại',
|
||||
'dong-vat': '🦁 Động vật',
|
||||
'thu-cung': '🐕 Thú cưng'
|
||||
};
|
||||
|
||||
// Component Helper để cập nhật tâm bản đồ khi vị trí người dùng thay đổi
|
||||
function RecenterMap({ position }: { position: [number, number] }) {
|
||||
const map = useMap();
|
||||
@@ -192,10 +207,22 @@ export const ExploreMap = ({ onBack, onLogout, user, onViewTour, onOpenMyPhotos,
|
||||
const [publicPhotos, setPublicPhotos] = useState<any[]>([]);
|
||||
const [selectedPhoto, setSelectedPhoto] = useState<any | null>(null);
|
||||
const [selectedPhotoGroup, setSelectedPhotoGroup] = useState<any[]>([]);
|
||||
const [selectedPhotoFilterTags, setSelectedPhotoFilterTags] = useState<string[]>([]);
|
||||
|
||||
const groupedPhotos = React.useMemo(() => {
|
||||
// Filter photos by selected tags if any are selected
|
||||
let filteredPhotos = publicPhotos;
|
||||
if (selectedPhotoFilterTags.length > 0) {
|
||||
filteredPhotos = publicPhotos.filter((photo) => {
|
||||
const photoTags = photo.metadata?.tags as string[] | undefined;
|
||||
if (!Array.isArray(photoTags)) return false;
|
||||
// Check if photo has at least one of the selected tags
|
||||
return selectedPhotoFilterTags.some(tag => photoTags.includes(tag));
|
||||
});
|
||||
}
|
||||
|
||||
const groups: { [key: string]: any[] } = {};
|
||||
publicPhotos.forEach((photo) => {
|
||||
filteredPhotos.forEach((photo) => {
|
||||
const lat = photo.metadata?.lat;
|
||||
const lng = photo.metadata?.lng;
|
||||
if (typeof lat === 'number' && typeof lng === 'number') {
|
||||
@@ -215,7 +242,7 @@ export const ExploreMap = ({ onBack, onLogout, user, onViewTour, onOpenMyPhotos,
|
||||
});
|
||||
});
|
||||
return Object.values(groups);
|
||||
}, [publicPhotos]);
|
||||
}, [publicPhotos, selectedPhotoFilterTags]);
|
||||
|
||||
const fetchPublicPhotos = async () => {
|
||||
try {
|
||||
@@ -464,6 +491,18 @@ export const ExploreMap = ({ onBack, onLogout, user, onViewTour, onOpenMyPhotos,
|
||||
return Array.from(tagsSet);
|
||||
}, [publicTours]);
|
||||
|
||||
// Tổng hợp nhãn từ danh sách ảnh công khai để lọc ảnh
|
||||
const availablePhotoTags = React.useMemo(() => {
|
||||
const tagsSet = new Set<string>();
|
||||
publicPhotos.forEach(photo => {
|
||||
const tags = photo.metadata?.tags as string[] | undefined;
|
||||
if (Array.isArray(tags)) {
|
||||
tags.forEach(tag => tagsSet.add(tag));
|
||||
}
|
||||
});
|
||||
return Array.from(tagsSet).sort();
|
||||
}, [publicPhotos]);
|
||||
|
||||
// State cho menu chuột phải chia sẻ
|
||||
const [shareMenu, setShareMenu] = useState<{ x: number, y: number, id: string, title: string, canShare: boolean, isParticipant: boolean, hasPendingRequest: boolean } | null>(null);
|
||||
|
||||
@@ -634,28 +673,68 @@ export const ExploreMap = ({ onBack, onLogout, user, onViewTour, onOpenMyPhotos,
|
||||
|
||||
{/* Filter Dropdown Content */}
|
||||
{isFilterDropdownOpen && (
|
||||
<div className="absolute top-full left-0 mt-3 bg-white/90 backdrop-blur-md p-2 rounded-2xl shadow-xl border border-white/20 flex flex-col gap-2 max-w-[200px] z-[1003] animate-in slide-in-from-left-2 duration-200">
|
||||
<div className="flex items-center gap-2 px-2 py-1 border-b border-gray-100 mb-1">
|
||||
<Filter className="w-3.5 h-3.5 text-blue-600" />
|
||||
<span className="text-[11px] font-black uppercase text-gray-500 tracking-wider">Lọc theo loại</span>
|
||||
</div>
|
||||
<div className="flex flex-col gap-1.5"> {/* Hiển thị tag theo chiều dọc */}
|
||||
<button
|
||||
onClick={() => { setSelectedFilterTag(null); setIsFilterDropdownOpen(false); }}
|
||||
className={`px-2.5 py-1 rounded-lg text-[10px] font-bold transition-all ${!selectedFilterTag ? 'bg-blue-600 text-white' : 'bg-gray-100 text-gray-500 hover:bg-gray-200'}`}
|
||||
>
|
||||
Tất cả
|
||||
</button>
|
||||
{allFilterTags.map(tag => (
|
||||
<div className="absolute top-full left-0 mt-3 bg-white/90 backdrop-blur-md p-3 rounded-2xl shadow-xl border border-white/20 flex flex-col gap-3 max-w-[220px] z-[1003] animate-in slide-in-from-left-2 duration-200 max-h-[400px] overflow-y-auto">
|
||||
{/* Tour Filter Section */}
|
||||
<div>
|
||||
<div className="flex items-center gap-2 px-2 py-1 border-b border-gray-100 mb-1.5">
|
||||
<Filter className="w-3.5 h-3.5 text-blue-600" />
|
||||
<span className="text-[10px] font-black uppercase text-gray-500 tracking-wider">🧳 Chuyến đi</span>
|
||||
</div>
|
||||
<div className="flex flex-col gap-1.5">
|
||||
<button
|
||||
key={tag}
|
||||
onClick={() => { setSelectedFilterTag(tag === selectedFilterTag ? null : tag); setIsFilterDropdownOpen(false); }}
|
||||
className={`px-2.5 py-1 rounded-lg text-[10px] font-bold transition-all ${selectedFilterTag === tag ? 'bg-blue-600 text-white' : 'bg-gray-100 text-gray-500 hover:bg-gray-200'}`}
|
||||
onClick={() => { setSelectedFilterTag(null); setIsFilterDropdownOpen(false); }}
|
||||
className={`px-2.5 py-1 rounded-lg text-[10px] font-bold transition-all ${!selectedFilterTag ? 'bg-blue-600 text-white' : 'bg-gray-100 text-gray-500 hover:bg-gray-200'}`}
|
||||
>
|
||||
{tag}
|
||||
Tất cả
|
||||
</button>
|
||||
))}
|
||||
{allFilterTags.map(tag => (
|
||||
<button
|
||||
key={tag}
|
||||
onClick={() => { setSelectedFilterTag(tag === selectedFilterTag ? null : tag); setIsFilterDropdownOpen(false); }}
|
||||
className={`px-2.5 py-1 rounded-lg text-[10px] font-bold transition-all ${selectedFilterTag === tag ? 'bg-blue-600 text-white' : 'bg-gray-100 text-gray-500 hover:bg-gray-200'}`}
|
||||
>
|
||||
{tag}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Photo Filter Section */}
|
||||
{availablePhotoTags.length > 0 && (
|
||||
<div className="border-t border-gray-200 pt-3">
|
||||
<div className="flex items-center gap-2 px-2 py-1 border-b border-gray-100 mb-1.5">
|
||||
<ImageIcon className="w-3.5 h-3.5 text-emerald-600" />
|
||||
<span className="text-[10px] font-black uppercase text-gray-500 tracking-wider">📸 Ảnh công khai</span>
|
||||
</div>
|
||||
<div className="flex flex-wrap gap-1.5">
|
||||
<button
|
||||
onClick={() => { setSelectedPhotoFilterTags([]); setIsFilterDropdownOpen(false); }}
|
||||
className={`px-2 py-1 rounded-lg text-[9px] font-bold transition-all ${selectedPhotoFilterTags.length === 0 ? 'bg-emerald-600 text-white' : 'bg-gray-100 text-gray-500 hover:bg-gray-200'}`}
|
||||
>
|
||||
Tất cả
|
||||
</button>
|
||||
{availablePhotoTags.map(tagId => {
|
||||
const tagLabel = PHOTO_TAG_LABELS[tagId] || tagId;
|
||||
return (
|
||||
<button
|
||||
key={tagId}
|
||||
onClick={() => {
|
||||
setSelectedPhotoFilterTags(prev =>
|
||||
prev.includes(tagId)
|
||||
? prev.filter(t => t !== tagId)
|
||||
: [...prev, tagId]
|
||||
);
|
||||
}}
|
||||
className={`px-2 py-1 rounded-lg text-[9px] font-bold transition-all ${selectedPhotoFilterTags.includes(tagId) ? 'bg-emerald-600 text-white' : 'bg-gray-100 text-gray-500 hover:bg-gray-200'}`}
|
||||
title={tagLabel}
|
||||
>
|
||||
{tagLabel.length > 13 ? tagLabel.substring(0, 13) + '...' : tagLabel}
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
@@ -2,6 +2,7 @@ import React, { useState, useRef, useEffect } from 'react';
|
||||
import { LogIn, Compass, Map as MapIcon, Camera, ShieldAlert } from 'lucide-react';
|
||||
import { LoginModal } from '../components/LoginModal';
|
||||
import { ReportBusinessModal } from '../components/ReportBusinessModal';
|
||||
import { TagSelectModal } from '../components/TagSelectModal';
|
||||
import { useNotification } from '@/hooks/useNotification';
|
||||
import { processImageModeration } from '../hooks/useImageModeration';
|
||||
import { useTranslation } from '../hooks/useTranslation';
|
||||
@@ -18,7 +19,12 @@ interface LandingPageProps {
|
||||
|
||||
export const LandingPage: React.FC<LandingPageProps> = ({ onGoToSignup, onGoToMap, onLoginSuccess }) => {
|
||||
const [isLoginModalOpen, setIsLoginModalOpen] = useState(false);
|
||||
const [isReportModalOpen, setIsReportModalOpen] = useState(false);
|
||||
const [isTagsModalOpen, setIsTagsModalOpen] = useState(false);
|
||||
const fileInputRef = useRef<HTMLInputElement>(null);
|
||||
const [pendingPhotoFile, setPendingPhotoFile] = useState<File | null>(null);
|
||||
const [pendingPhotoLocation, setPendingPhotoLocation] = useState<GeolocationPosition | null>(null);
|
||||
const [photoPreviewUrl, setPhotoPreviewUrl] = useState<string>('');
|
||||
const notify = useNotification();
|
||||
const { t, lang, changeLanguage } = useTranslation();
|
||||
const { theme, changeTheme } = useTheme();
|
||||
@@ -26,7 +32,6 @@ export const LandingPage: React.FC<LandingPageProps> = ({ onGoToSignup, onGoToMa
|
||||
const [publicPhotos, setPublicPhotos] = useState<any[]>([]);
|
||||
const [trustedUsers, setTrustedUsers] = useState<any[]>([]);
|
||||
const [blacklist, setBlacklist] = useState<any[]>([]);
|
||||
const [isReportModalOpen, setIsReportModalOpen] = useState(false);
|
||||
const [currentBgIndex, setCurrentBgIndex] = useState(0);
|
||||
const [bg1, setBg1] = useState('/background.avif');
|
||||
const [bg2, setBg2] = useState('');
|
||||
@@ -144,30 +149,58 @@ export const LandingPage: React.FC<LandingPageProps> = ({ onGoToSignup, onGoToMa
|
||||
new Promise<null>((resolve) => setTimeout(() => resolve(null), 4500))
|
||||
]);
|
||||
|
||||
// 1. Xóa token cũ nếu có để đảm bảo guest không bị nhầm lẫn
|
||||
localStorage.removeItem('token');
|
||||
localStorage.removeItem('user');
|
||||
|
||||
// 2. 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));
|
||||
}
|
||||
// Lưu file và location vào state pending, hiển thị modal tags
|
||||
setPendingPhotoFile(processedFile);
|
||||
setPendingPhotoLocation(location);
|
||||
|
||||
// Tạo preview URL cho ảnh
|
||||
const previewUrl = URL.createObjectURL(processedFile);
|
||||
setPhotoPreviewUrl(previewUrl);
|
||||
|
||||
setIsTagsModalOpen(true);
|
||||
} 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 = '';
|
||||
}
|
||||
};
|
||||
|
||||
const handleConfirmTags = async (selectedTags: string[]) => {
|
||||
if (!pendingPhotoFile) return;
|
||||
|
||||
setIsTagsModalOpen(false);
|
||||
notify({ title: 'Đang tải lên...', message: 'Vui lòng chờ trong giây lát.', type: 'info' });
|
||||
|
||||
try {
|
||||
// 1. Xóa token cũ nếu có để đảm bảo guest không bị nhầm lẫn
|
||||
localStorage.removeItem('token');
|
||||
localStorage.removeItem('user');
|
||||
|
||||
// 2. 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');
|
||||
|
||||
// 2. Tải ảnh lên
|
||||
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));
|
||||
}
|
||||
|
||||
// 3. Tải ảnh lên
|
||||
const formData = new FormData();
|
||||
formData.append('images', processedFile);
|
||||
if (location) {
|
||||
formData.append('latitude', location.coords.latitude.toString());
|
||||
formData.append('longitude', location.coords.longitude.toString());
|
||||
formData.append('images', pendingPhotoFile);
|
||||
if (pendingPhotoLocation) {
|
||||
formData.append('latitude', pendingPhotoLocation.coords.latitude.toString());
|
||||
formData.append('longitude', pendingPhotoLocation.coords.longitude.toString());
|
||||
}
|
||||
// Thêm tags vào formData
|
||||
if (selectedTags.length > 0) {
|
||||
formData.append('tags', JSON.stringify(selectedTags));
|
||||
}
|
||||
|
||||
let uploadRes = await fetch('/api/v1/photos/upload-anonymous', {
|
||||
@@ -203,23 +236,34 @@ export const LandingPage: React.FC<LandingPageProps> = ({ onGoToSignup, onGoToMa
|
||||
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' });
|
||||
notify({ title: 'Thành công!', message: 'Ảnh của bạn đã được tải lên.', type: 'success' });
|
||||
|
||||
// Xóa pendingInviteToken và parameter token khỏi URL để tránh tự động chuyển sang JoinTourPage
|
||||
localStorage.removeItem('pendingInviteToken');
|
||||
const url = new URL(window.location.href);
|
||||
url.searchParams.delete('token');
|
||||
window.history.replaceState({}, '', url.toString());
|
||||
// Xóa pendingInviteToken và parameter token khỏi URL để tránh tự động chuyển sang JoinTourPage
|
||||
localStorage.removeItem('pendingInviteToken');
|
||||
const url = new URL(window.location.href);
|
||||
url.searchParams.delete('token');
|
||||
window.history.replaceState({}, '', url.toString());
|
||||
|
||||
// Cập nhật lại danh sách ảnh lập tức
|
||||
await fetchPublicPhotos();
|
||||
setCurrentBgIndex(0);
|
||||
|
||||
// Cập nhật lại danh sách ảnh lập tức
|
||||
await fetchPublicPhotos();
|
||||
setCurrentBgIndex(0);
|
||||
|
||||
// Xóa pending data
|
||||
setPendingPhotoFile(null);
|
||||
setPendingPhotoLocation(null);
|
||||
if (photoPreviewUrl) {
|
||||
URL.revokeObjectURL(photoPreviewUrl);
|
||||
setPhotoPreviewUrl('');
|
||||
}
|
||||
} 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 = '';
|
||||
// Cleanup on error too
|
||||
setPendingPhotoFile(null);
|
||||
setPendingPhotoLocation(null);
|
||||
if (photoPreviewUrl) {
|
||||
URL.revokeObjectURL(photoPreviewUrl);
|
||||
setPhotoPreviewUrl('');
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
@@ -549,6 +593,22 @@ return (
|
||||
isOpen={isReportModalOpen}
|
||||
onClose={() => setIsReportModalOpen(false)}
|
||||
/>
|
||||
|
||||
{/* Tag Select Modal */}
|
||||
<TagSelectModal
|
||||
isOpen={isTagsModalOpen}
|
||||
onClose={() => {
|
||||
setIsTagsModalOpen(false);
|
||||
setPendingPhotoFile(null);
|
||||
setPendingPhotoLocation(null);
|
||||
if (photoPreviewUrl) {
|
||||
URL.revokeObjectURL(photoPreviewUrl);
|
||||
setPhotoPreviewUrl('');
|
||||
}
|
||||
}}
|
||||
onConfirm={handleConfirmTags}
|
||||
photoUrl={photoPreviewUrl}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
Reference in New Issue
Block a user