fix: tạo tags cho ảnh public của guest

This commit is contained in:
2026-06-23 16:01:46 +07:00
parent 9f41400bd8
commit 28ea9abccd
28 changed files with 601 additions and 60 deletions
+13 -3
View File
@@ -3,6 +3,8 @@ import { X, Send, MessageSquare, User, Loader2, Calendar, MapPin, Download, Edit
import { io } from 'socket.io-client';
import { CoordinateSelectModal } from './CoordinateSelectModal';
import { useTranslation } from '../hooks/useTranslation';
import { useConfirm } from '../hooks/useConfirm';
import { useNotification } from '../hooks/useNotification';
interface Comment {
id: string;
@@ -48,6 +50,8 @@ export const PublicPhotoModal: React.FC<PublicPhotoModalProps> = ({
onUpdatePhoto
}) => {
const { t } = useTranslation();
const confirm = useConfirm();
const notify = useNotification();
const [comments, setComments] = useState<Comment[]>([]);
const [newComment, setNewComment] = useState('');
const [isLoading, setIsLoading] = useState(false);
@@ -358,7 +362,12 @@ export const PublicPhotoModal: React.FC<PublicPhotoModalProps> = ({
};
const handleDeleteComment = async (commentId: string) => {
if (!confirm(t('confirmDeleteComment') || 'Bạn có chắc chắn muốn xóa bình luận này không?')) return;
const shouldDelete = await confirm({
title: t('deleteComment') || 'Xóa bình luận',
message: t('confirmDeleteComment') || 'Bạn có chắc chắn muốn xóa bình luận này không?'
});
if (!shouldDelete) return;
try {
const token = localStorage.getItem('token') || localStorage.getItem('guest_token');
const res = await fetch(`/api/v1/public-photos/comments/${commentId}`, {
@@ -369,13 +378,14 @@ export const PublicPhotoModal: React.FC<PublicPhotoModalProps> = ({
});
if (res.ok) {
setComments(prev => prev.filter(c => c.id !== commentId));
notify({ title: 'Thành công', message: 'Bình luận đã được xóa.', type: 'success' });
} else {
const err = await res.json();
alert(err.message || 'Lỗi khi xóa bình luận.');
notify({ title: 'Lỗi', message: err.message || 'Lỗi khi xóa bình luận.', type: 'error' });
}
} catch (error) {
console.error('Lỗi khi xóa bình luận:', error);
alert('Không thể kết nối đến máy chủ.');
notify({ title: 'Lỗi', message: 'Không thể kết nối đến máy chủ.', type: 'error' });
}
};
+221
View File
@@ -0,0 +1,221 @@
import React, { useState } from 'react';
import { X, Check, Plus, Trash2 } from 'lucide-react';
import { useTranslation } from '../hooks/useTranslation';
interface TagSelectModalProps {
isOpen: boolean;
onClose: () => void;
onConfirm: (tags: string[]) => void;
photoUrl?: string;
}
const AVAILABLE_TAGS = [
{ id: 'phong-canh', label: '🏞️ Phong cảnh' },
{ id: 'con-nguoi', label: '👥 Con người' },
{ id: 'doi-thuong', label: '🎒 Đời thường' },
{ id: 'bien', label: '🌊 Biển' },
{ id: 'nui', label: '⛰️ Núi' },
{ id: 'do-thi', label: '🏙️ Đô thị' },
{ id: 'thuc-an', label: '🍜 Thức ăn' },
{ id: 'cho', label: '🛍️ Chợ' },
{ id: 'hien-dai', label: '🏗️ Hiện đại' },
{ id: 'dong-vat', label: '🦁 Động vật' },
{ id: 'thu-cung', label: '🐕 Thú cưng' }
];
export const TagSelectModal: React.FC<TagSelectModalProps> = ({ isOpen, onClose, onConfirm, photoUrl }) => {
const { t } = useTranslation();
const [selectedTags, setSelectedTags] = useState<string[]>([]);
const [customTagInput, setCustomTagInput] = useState('');
const [customTags, setCustomTags] = useState<string[]>([]);
const toggleTag = (tagId: string) => {
setSelectedTags(prev =>
prev.includes(tagId)
? prev.filter(t => t !== tagId)
: [...prev, tagId]
);
};
const addCustomTag = () => {
const trimmedTag = customTagInput.trim();
if (trimmedTag && !customTags.includes(trimmedTag)) {
setCustomTags(prev => [...prev, trimmedTag]);
setCustomTagInput('');
}
};
const removeCustomTag = (tag: string) => {
setCustomTags(prev => prev.filter(t => t !== tag));
};
const handleConfirm = () => {
const allTags = [...selectedTags, ...customTags];
onConfirm(allTags);
setSelectedTags([]);
setCustomTags([]);
setCustomTagInput('');
};
const handleClose = () => {
setSelectedTags([]);
setCustomTags([]);
setCustomTagInput('');
onClose();
};
if (!isOpen) return null;
return (
<div className="fixed inset-0 z-[6000] flex items-center justify-center p-4">
<div
className="absolute inset-0 bg-slate-950/80 backdrop-blur-md"
onClick={handleClose}
/>
<div className="relative bg-white dark:bg-slate-900 rounded-3xl shadow-2xl max-w-md w-full max-h-[90vh] overflow-y-auto animate-in zoom-in-95 duration-300">
{/* Header */}
<div className="sticky top-0 bg-white dark:bg-slate-900 border-b border-gray-200 dark:border-slate-700 px-6 py-5 flex justify-between items-center">
<h2 className="text-xl font-bold text-gray-900 dark:text-white">
🏷 Lựa chọn thẻ
</h2>
<button
onClick={handleClose}
className="p-2 hover:bg-gray-100 dark:hover:bg-slate-800 rounded-xl transition-colors"
>
<X className="w-5 h-5 text-gray-600 dark:text-gray-400" />
</button>
</div>
{/* Content */}
<div className="p-6 space-y-6">
{/* Image Preview */}
{photoUrl && (
<div className="flex justify-center">
<img
src={photoUrl}
alt="Preview"
className="max-w-full h-auto max-h-48 rounded-2xl shadow-lg object-cover border-2 border-gray-200 dark:border-slate-700"
/>
</div>
)}
{/* Predefined Tags */}
<div>
<p className="text-xs font-black text-gray-600 dark:text-gray-400 mb-3 uppercase tracking-widest">Thẻ sẵn</p>
<div className="grid grid-cols-2 gap-2">
{AVAILABLE_TAGS.map(tag => (
<button
key={tag.id}
onClick={() => toggleTag(tag.id)}
className={`flex items-center gap-2 px-3 py-2 rounded-xl transition-all text-xs font-bold border-2 ${
selectedTags.includes(tag.id)
? 'bg-blue-600 border-blue-600 text-white'
: 'bg-gray-100 dark:bg-slate-800 border-gray-200 dark:border-slate-700 text-gray-900 dark:text-gray-200 hover:bg-gray-200 dark:hover:bg-slate-700'
}`}
>
<span className="text-base">{tag.label.split(' ')[0]}</span>
<span className="text-[10px]">{tag.label.substring(2)}</span>
{selectedTags.includes(tag.id) && (
<Check className="w-3 h-3 ml-auto" />
)}
</button>
))}
</div>
</div>
{/* Custom Tag Input */}
<div className="space-y-3 border-t border-gray-200 dark:border-slate-700 pt-4">
<p className="text-xs font-black text-gray-600 dark:text-gray-400 uppercase tracking-widest">Thêm thẻ khác</p>
<div className="flex gap-2">
<input
type="text"
value={customTagInput}
onChange={(e) => setCustomTagInput(e.target.value)}
onKeyPress={(e) => {
if (e.key === 'Enter') {
addCustomTag();
}
}}
placeholder="Nhập thẻ mới..."
className="flex-1 px-3 py-2 border-2 border-gray-200 dark:border-slate-700 bg-white dark:bg-slate-800 text-gray-900 dark:text-white rounded-xl text-sm font-bold placeholder-gray-400 dark:placeholder-gray-500 focus:outline-none focus:border-blue-600"
/>
<button
onClick={addCustomTag}
className="px-3 py-2 bg-emerald-600 hover:bg-emerald-700 text-white rounded-xl font-bold transition-colors flex items-center gap-1 active:scale-95"
>
<Plus className="w-4 h-4" />
</button>
</div>
{/* Custom Tags Display */}
{customTags.length > 0 && (
<div className="flex flex-wrap gap-2">
{customTags.map((tag, idx) => (
<span
key={idx}
className="bg-emerald-100 dark:bg-emerald-950 text-emerald-700 dark:text-emerald-300 text-xs px-3 py-1.5 rounded-full font-semibold flex items-center gap-2 group"
>
{tag}
<button
onClick={() => removeCustomTag(tag)}
className="opacity-0 group-hover:opacity-100 transition-opacity"
>
<Trash2 className="w-3 h-3 hover:text-red-600" />
</button>
</span>
))}
</div>
)}
</div>
{/* All Selected Tags Summary */}
{(selectedTags.length > 0 || customTags.length > 0) && (
<div className="pt-3 border-t border-gray-200 dark:border-slate-700">
<p className="text-xs text-gray-600 dark:text-gray-400 mb-2 font-bold">
{selectedTags.length + customTags.length} thẻ đã chọn
</p>
<div className="flex flex-wrap gap-2">
{selectedTags.map(tagId => {
const tag = AVAILABLE_TAGS.find(t => t.id === tagId);
return (
<span
key={tagId}
className="bg-blue-100 dark:bg-blue-950 text-blue-700 dark:text-blue-300 text-xs px-3 py-1.5 rounded-full font-semibold"
>
{tag?.label}
</span>
);
})}
{customTags.map((tag, idx) => (
<span
key={`custom-${idx}`}
className="bg-emerald-100 dark:bg-emerald-950 text-emerald-700 dark:text-emerald-300 text-xs px-3 py-1.5 rounded-full font-semibold"
>
{tag}
</span>
))}
</div>
</div>
)}
</div>
{/* Footer */}
<div className="sticky bottom-0 bg-white dark:bg-slate-900 border-t border-gray-200 dark:border-slate-700 px-6 py-4 flex gap-3">
<button
onClick={handleClose}
className="flex-1 px-4 py-3 rounded-xl border-2 border-gray-200 dark:border-slate-700 text-gray-900 dark:text-white font-bold hover:bg-gray-100 dark:hover:bg-slate-800 transition-colors"
>
{t('cancel') || 'Hủy'}
</button>
<button
onClick={handleConfirm}
className="flex-1 px-4 py-3 rounded-xl bg-blue-600 hover:bg-blue-700 text-white font-bold transition-colors shadow-md active:scale-95"
>
{t('confirm') || 'Xác nhận'}
</button>
</div>
</div>
</div>
);
};
+99 -20
View File
@@ -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>
+96 -36
View File
@@ -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>
);
};