fix: ảnh phải giữ nguyên hướng chụp
This commit is contained in:
@@ -70,11 +70,32 @@ export const AddPhotoModal: React.FC<AddPhotoModalProps> = ({ isOpen, onClose, t
|
||||
|
||||
setIsUploading(true);
|
||||
try {
|
||||
// Lấy tọa độ hiện tại của người dùng làm dự phòng nếu ảnh EXIF không có GPS
|
||||
const location = await Promise.race([
|
||||
new Promise<GeolocationPosition | null>((resolve) => {
|
||||
if (!navigator.geolocation) {
|
||||
resolve(null);
|
||||
} else {
|
||||
navigator.geolocation.getCurrentPosition(
|
||||
(pos) => resolve(pos),
|
||||
() => resolve(null),
|
||||
{ timeout: 4000, enableHighAccuracy: true }
|
||||
);
|
||||
}
|
||||
}),
|
||||
new Promise<null>((resolve) => setTimeout(() => resolve(null), 4500))
|
||||
]);
|
||||
|
||||
const formData = new FormData();
|
||||
selectedFiles.forEach(file => {
|
||||
formData.append('images', file);
|
||||
});
|
||||
|
||||
if (location) {
|
||||
formData.append('latitude', location.coords.latitude.toString());
|
||||
formData.append('longitude', location.coords.longitude.toString());
|
||||
}
|
||||
|
||||
const response = await fetch(`/api/v1/tours/${tourId}/photos`, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
|
||||
@@ -7,11 +7,14 @@ interface UserManagementModalProps {
|
||||
}
|
||||
|
||||
export const UserManagementModal: React.FC<UserManagementModalProps> = ({ isOpen, onClose }) => {
|
||||
const [activeTab, setActiveTab] = useState<'users' | 'photos'>('users');
|
||||
const [activeTab, setActiveTab] = useState<'users' | 'photos' | 'trash'>('users');
|
||||
const [users, setUsers] = useState<any[]>([]);
|
||||
const [photos, setPhotos] = useState<any[]>([]);
|
||||
const [trashPhotos, setTrashPhotos] = useState<any[]>([]);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [photosLoading, setPhotosLoading] = useState(false);
|
||||
const [trashLoading, setTrashLoading] = useState(false);
|
||||
const [cleaning, setCleaning] = useState(false);
|
||||
const [error, setError] = useState('');
|
||||
|
||||
const fetchUsers = async () => {
|
||||
@@ -44,12 +47,72 @@ export const UserManagementModal: React.FC<UserManagementModalProps> = ({ isOpen
|
||||
}
|
||||
};
|
||||
|
||||
const fetchTrashPhotos = async () => {
|
||||
setTrashLoading(true);
|
||||
try {
|
||||
const response = await fetch(`/api/v1/admin/trash-photos`, {
|
||||
headers: { 'Authorization': `Bearer ${localStorage.getItem('token')}` }
|
||||
});
|
||||
if (!response.ok) throw new Error('Không thể tải danh sách ảnh rác');
|
||||
const data = await response.json();
|
||||
setTrashPhotos(data);
|
||||
} catch (err: any) {
|
||||
setError(err.message);
|
||||
} finally {
|
||||
setTrashLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleCleanTrash = async () => {
|
||||
if (!confirm('Bạn có chắc muốn dọn sạch tất cả ảnh rác? Thao tác này sẽ xóa vĩnh viễn các tệp vật lý và dọn dẹp các bản ghi mồ côi.')) return;
|
||||
setCleaning(true);
|
||||
try {
|
||||
const response = await fetch(`/api/v1/admin/trash-photos/clean`, {
|
||||
method: 'DELETE',
|
||||
headers: { 'Authorization': `Bearer ${localStorage.getItem('token')}` }
|
||||
});
|
||||
const data = await response.json();
|
||||
if (!response.ok) throw new Error(data.message || 'Dọn dẹp thất bại');
|
||||
alert(data.message);
|
||||
fetchTrashPhotos();
|
||||
} catch (err: any) {
|
||||
alert(err.message);
|
||||
} finally {
|
||||
setCleaning(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleDeleteSingleTrash = async (item: any) => {
|
||||
if (!confirm('Xóa mục này vĩnh viễn?')) return;
|
||||
try {
|
||||
const response = await fetch(`/api/v1/admin/trash-photos/delete-single`, {
|
||||
method: 'DELETE',
|
||||
headers: {
|
||||
'Authorization': `Bearer ${localStorage.getItem('token')}`,
|
||||
'Content-Type': 'application/json'
|
||||
},
|
||||
body: JSON.stringify({
|
||||
type: item.type,
|
||||
filePath: item.filePath,
|
||||
id: item.id
|
||||
})
|
||||
});
|
||||
const data = await response.json();
|
||||
if (!response.ok) throw new Error(data.message || 'Xóa mục thất bại');
|
||||
fetchTrashPhotos();
|
||||
} catch (err: any) {
|
||||
alert(err.message);
|
||||
}
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
if (isOpen) {
|
||||
if (activeTab === 'users') {
|
||||
fetchUsers();
|
||||
} else {
|
||||
} else if (activeTab === 'photos') {
|
||||
fetchPhotos();
|
||||
} else if (activeTab === 'trash') {
|
||||
fetchTrashPhotos();
|
||||
}
|
||||
}
|
||||
}, [isOpen, activeTab]);
|
||||
@@ -140,6 +203,15 @@ export const UserManagementModal: React.FC<UserManagementModalProps> = ({ isOpen
|
||||
<ImageIcon className="w-4 h-4" />
|
||||
Ảnh công cộng
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setActiveTab('trash')}
|
||||
className={`py-4 px-4 font-bold text-sm transition-all border-b-2 -mb-[2px] flex items-center gap-2 ${
|
||||
activeTab === 'trash' ? 'border-blue-600 text-blue-600' : 'border-transparent text-gray-400 hover:text-gray-600'
|
||||
}`}
|
||||
>
|
||||
<Trash2 className="w-4 h-4" />
|
||||
Ảnh rác
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Content Body */}
|
||||
@@ -212,7 +284,7 @@ export const UserManagementModal: React.FC<UserManagementModalProps> = ({ isOpen
|
||||
</tbody>
|
||||
</table>
|
||||
)
|
||||
) : (
|
||||
) : activeTab === 'photos' ? (
|
||||
photosLoading ? (
|
||||
<div className="flex justify-center py-20"><Loader2 className="w-10 h-10 animate-spin text-blue-600" /></div>
|
||||
) : photos.length === 0 ? (
|
||||
@@ -248,6 +320,94 @@ export const UserManagementModal: React.FC<UserManagementModalProps> = ({ isOpen
|
||||
))}
|
||||
</div>
|
||||
)
|
||||
) : (
|
||||
trashLoading ? (
|
||||
<div className="flex justify-center py-20"><Loader2 className="w-10 h-10 animate-spin text-blue-600" /></div>
|
||||
) : (
|
||||
<div className="space-y-4">
|
||||
<div className="flex justify-between items-center bg-gray-50 p-4 rounded-2xl border border-gray-100">
|
||||
<div>
|
||||
<span className="text-sm font-bold text-gray-800">
|
||||
Tổng số lượng: <span className="text-red-500 font-extrabold text-base">{trashPhotos.length}</span> mục ảnh rác
|
||||
</span>
|
||||
<span className="mx-2 text-gray-300">|</span>
|
||||
<span className="text-sm font-bold text-gray-800">
|
||||
Dung lượng ước tính: <span className="text-blue-600 font-extrabold text-base">
|
||||
{(trashPhotos.reduce((acc, curr) => acc + (curr.size || 0), 0) / (1024 * 1024)).toFixed(2)} MB
|
||||
</span>
|
||||
</span>
|
||||
</div>
|
||||
{trashPhotos.length > 0 && (
|
||||
<button
|
||||
onClick={handleCleanTrash}
|
||||
disabled={cleaning}
|
||||
className="px-4 py-2.5 bg-red-600 hover:bg-red-700 text-white rounded-xl font-bold text-xs uppercase tracking-wider flex items-center gap-2 shadow-md transition-all active:scale-95 disabled:opacity-50"
|
||||
>
|
||||
{cleaning ? <Loader2 className="w-4 h-4 animate-spin" /> : <Trash2 className="w-4 h-4" />}
|
||||
Dọn sạch ảnh rác
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{trashPhotos.length === 0 ? (
|
||||
<div className="text-center py-20 text-gray-400 italic">Không có ảnh rác nào. Hệ thống của bạn sạch sẽ!</div>
|
||||
) : (
|
||||
<div className="border border-gray-100 rounded-2xl overflow-hidden shadow-sm">
|
||||
<table className="w-full text-left border-collapse bg-white">
|
||||
<thead>
|
||||
<tr className="text-gray-400 text-xs uppercase tracking-wider border-b border-gray-100 bg-gray-50/50">
|
||||
<th className="py-3 px-4 font-bold">Hình ảnh</th>
|
||||
<th className="py-3 px-4 font-bold">Thông tin</th>
|
||||
<th className="py-3 px-4 font-bold">Lý do</th>
|
||||
<th className="py-3 px-4 font-bold">Kích thước</th>
|
||||
<th className="py-3 px-4 font-bold text-right">Thao tác</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="divide-y divide-gray-50">
|
||||
{trashPhotos.map((item, index) => (
|
||||
<tr key={index} className="group hover:bg-gray-50/50 transition-colors">
|
||||
<td className="py-3 px-4">
|
||||
{item.url ? (
|
||||
<div className="w-12 h-12 rounded-lg bg-gray-900 overflow-hidden shadow-sm border border-gray-100 flex items-center justify-center">
|
||||
<img src={item.url} alt="Trash preview" className="w-full h-full object-cover" onError={(e) => { (e.target as HTMLElement).style.display = 'none'; }} />
|
||||
</div>
|
||||
) : (
|
||||
<div className="w-12 h-12 rounded-lg bg-gray-100 border border-gray-100 flex items-center justify-center text-gray-400 text-[10px] font-bold">
|
||||
N/A
|
||||
</div>
|
||||
)}
|
||||
</td>
|
||||
<td className="py-3 px-4 max-w-[200px] truncate">
|
||||
<span className="text-xs font-mono text-gray-500 block truncate" title={item.url || item.filePath}>
|
||||
{item.url || item.filePath}
|
||||
</span>
|
||||
<span className="text-[10px] font-black uppercase text-gray-400 tracking-wider">
|
||||
{item.type === 'file_only' ? 'Tệp tin mồ côi' : 'Bản ghi mồ côi'}
|
||||
</span>
|
||||
</td>
|
||||
<td className="py-3 px-4">
|
||||
<span className="text-xs text-red-500 font-semibold">{item.reason}</span>
|
||||
</td>
|
||||
<td className="py-3 px-4 text-xs font-semibold text-gray-700">
|
||||
{((item.size || 0) / 1024).toFixed(1)} KB
|
||||
</td>
|
||||
<td className="py-3 px-4 text-right">
|
||||
<button
|
||||
onClick={() => handleDeleteSingleTrash(item)}
|
||||
className="p-2 bg-red-50 text-red-600 rounded-lg hover:bg-red-100 transition-all shadow-sm active:scale-95"
|
||||
title="Xóa vĩnh viễn"
|
||||
>
|
||||
<Trash2 className="w-4 h-4" />
|
||||
</button>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
Reference in New Issue
Block a user