fix: ảnh phải giữ nguyên hướng chụp

This commit is contained in:
2026-06-20 07:28:28 +07:00
parent def0e0d0f9
commit 4373ed684a
26 changed files with 665 additions and 18 deletions
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
+2 -2
View File
@@ -5,8 +5,8 @@
<meta name="viewport" content="width=device-width, initial-scale=1.0, viewport-fit=cover" />
<link rel="icon" type="image/x-icon" href="/favicon.ico" />
<title>Travel Planner</title>
<script type="module" crossorigin src="/assets/index-Dn1e9vte.js"></script>
<link rel="stylesheet" crossorigin href="/assets/index-C4TR57aU.css">
<script type="module" crossorigin src="/assets/index-Kkg5SQS3.js"></script>
<link rel="stylesheet" crossorigin href="/assets/index-YlatkTXO.css">
</head>
<body>
<div id="root"></div>
+21
View File
@@ -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: {
+163 -3
View File
@@ -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 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"> 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>