261 lines
11 KiB
TypeScript
261 lines
11 KiB
TypeScript
import React, { useState, useRef } from 'react';
|
|
import { X, Upload, Image as ImageIcon, Loader2 } from 'lucide-react';
|
|
import { useNotification } from '@/hooks/useNotification';
|
|
import { useTourStore } from '@/store/useTourStore';
|
|
import { processImageModeration } from '@/hooks/useImageModeration';
|
|
import { processAndResizeImage } from '../utils/imageProcessor';
|
|
import { getDeviceLocation } from '../utils/geolocation';
|
|
|
|
interface AddPhotoModalProps {
|
|
isOpen: boolean;
|
|
onClose: () => void;
|
|
tourId: string;
|
|
onSuccess?: () => void;
|
|
isPublicView?: boolean;
|
|
}
|
|
|
|
interface PendingPhoto {
|
|
file: File;
|
|
latitude: number;
|
|
longitude: number;
|
|
}
|
|
|
|
export const AddPhotoModal: React.FC<AddPhotoModalProps> = ({ isOpen, onClose, tourId, onSuccess, isPublicView }) => {
|
|
const [selectedFiles, setSelectedFiles] = useState<PendingPhoto[]>([]);
|
|
const [previews, setPreviews] = useState<string[]>([]);
|
|
const [isUploading, setIsUploading] = useState(false);
|
|
const [isProcessing, setIsProcessing] = useState(false);
|
|
const fileInputRef = useRef<HTMLInputElement>(null);
|
|
const notify = useNotification();
|
|
const fetchTour = useTourStore(state => state.fetchTour);
|
|
|
|
if (!isOpen) return null;
|
|
|
|
const handleFileChange = async (e: React.ChangeEvent<HTMLInputElement>) => {
|
|
if (e.target.files) {
|
|
const files = Array.from(e.target.files);
|
|
setIsProcessing(true);
|
|
notify({ title: 'Đang kiểm duyệt...', message: 'Đang kiểm tra và lọc hình ảnh của bạn...', type: 'info' });
|
|
|
|
try {
|
|
// Fetch device location once to serve as Priority 2 fallback for files without EXIF
|
|
const deviceLocation = await getDeviceLocation();
|
|
|
|
const newValidPhotos: PendingPhoto[] = [];
|
|
const newValidPreviews: string[] = [];
|
|
|
|
for (const file of files) {
|
|
if (!file || file.size === 0) {
|
|
notify({ title: 'Lỗi tệp', message: `Tệp ${file.name} trống hoặc không khả dụng.`, type: 'error' });
|
|
continue;
|
|
}
|
|
|
|
// Process image: Reads EXIF data and forces 2K resizing on device memory
|
|
const { file: processedFile, latitude: exifLat, longitude: exifLng } = await processAndResizeImage(file);
|
|
|
|
// 2. Chạy kiểm duyệt hình ảnh
|
|
const moderationResult = await processImageModeration(processedFile);
|
|
if (moderationResult.blocked) {
|
|
notify({ title: 'Ảnh bị từ chối', message: `Ảnh ${file.name} chứa nội dung không phù hợp và bị chặn.`, type: 'error' });
|
|
continue;
|
|
}
|
|
|
|
const finalProcessedFile = moderationResult.file;
|
|
const previewUrl = URL.createObjectURL(finalProcessedFile);
|
|
|
|
// 3. Kiểm tra tính toàn vẹn
|
|
const isValidImage = await new Promise<boolean>((resolve) => {
|
|
const img = new Image();
|
|
img.onload = () => resolve(true);
|
|
img.onerror = () => resolve(false);
|
|
img.src = previewUrl;
|
|
});
|
|
|
|
if (isValidImage) {
|
|
// Resolve coordinates based on priority checklist:
|
|
let finalLat: number | null = exifLat;
|
|
let finalLng: number | null = exifLng;
|
|
|
|
// Priority 2: Device location
|
|
if ((finalLat === null || finalLng === null) && deviceLocation) {
|
|
finalLat = deviceLocation.latitude;
|
|
finalLng = deviceLocation.longitude;
|
|
}
|
|
|
|
// Priority 3: Map view state
|
|
if (finalLat === null || finalLng === null) {
|
|
const lastViewStateStr = localStorage.getItem('map_view_state');
|
|
if (lastViewStateStr) {
|
|
try {
|
|
const lastViewState = JSON.parse(lastViewStateStr);
|
|
if (lastViewState && Array.isArray(lastViewState.center) && lastViewState.center.length === 2) {
|
|
finalLat = Number(lastViewState.center[0]);
|
|
finalLng = Number(lastViewState.center[1]);
|
|
}
|
|
} catch (e) {
|
|
console.error('[AddPhotoModal] Error parsing map_view_state:', e);
|
|
}
|
|
}
|
|
}
|
|
|
|
// Priority 4: Fallback defaults
|
|
if (finalLat === null || finalLng === null) {
|
|
finalLat = 10.7769;
|
|
finalLng = 106.7009;
|
|
}
|
|
|
|
newValidPhotos.push({
|
|
file: finalProcessedFile,
|
|
latitude: finalLat,
|
|
longitude: finalLng
|
|
});
|
|
newValidPreviews.push(previewUrl);
|
|
} else {
|
|
URL.revokeObjectURL(previewUrl);
|
|
notify({ title: 'Lỗi ảnh', message: `Không thể đọc nội dung ảnh: ${file.name}. Vui lòng kiểm tra lại file.`, type: 'error' });
|
|
}
|
|
}
|
|
|
|
setSelectedFiles(prev => [...prev, ...newValidPhotos]);
|
|
setPreviews(prev => [...prev, ...newValidPreviews]);
|
|
} catch (err) {
|
|
console.error('File checking error:', err);
|
|
notify({ title: 'Lỗi', message: 'Lỗi trong quá trình kiểm duyệt ảnh.', type: 'error' });
|
|
} finally {
|
|
setIsProcessing(false);
|
|
}
|
|
}
|
|
};
|
|
|
|
const removeFile = (index: number) => {
|
|
// Thu hồi URL khi xóa khỏi danh sách chờ để giải phóng bộ nhớ
|
|
URL.revokeObjectURL(previews[index]);
|
|
setSelectedFiles(prev => prev.filter((_, i) => i !== index));
|
|
setPreviews(prev => prev.filter((_, i) => i !== index));
|
|
};
|
|
|
|
const handleSubmit = async (e: React.FormEvent) => {
|
|
e.preventDefault();
|
|
if (selectedFiles.length === 0) return;
|
|
|
|
setIsUploading(true);
|
|
try {
|
|
// Upload each photo sequentially so we can attach its specific coordinates
|
|
for (const item of selectedFiles) {
|
|
const formData = new FormData();
|
|
formData.append('latitude', item.latitude.toString());
|
|
formData.append('longitude', item.longitude.toString());
|
|
formData.append('images', item.file);
|
|
|
|
const response = await fetch(`/api/v1/tours/${tourId}/photos`, {
|
|
method: 'POST',
|
|
headers: {
|
|
'Authorization': `Bearer ${localStorage.getItem('token')}`
|
|
},
|
|
body: formData
|
|
});
|
|
|
|
if (!response.ok) throw new Error('Tải lên ảnh thất bại');
|
|
}
|
|
|
|
|
|
// Giải phóng bộ nhớ sau khi hoàn tất
|
|
previews.forEach(url => URL.revokeObjectURL(url));
|
|
setSelectedFiles([]);
|
|
setPreviews([]);
|
|
|
|
// Refresh tour data (applies to both authenticated and public users)
|
|
// This ensures newly uploaded photos appear immediately without requiring a page reload
|
|
fetchTour(tourId);
|
|
if (onSuccess) onSuccess();
|
|
|
|
// For public users: redirect to landing page after upload (after data refresh)
|
|
// For authenticated users: close modal and show updated tour
|
|
if (isPublicView) {
|
|
onClose();
|
|
// Give time for tour data to refresh before redirecting
|
|
setTimeout(() => {
|
|
localStorage.setItem('fromPublicUpload', 'true');
|
|
window.location.href = '/';
|
|
}, 1500);
|
|
} else {
|
|
onClose();
|
|
}
|
|
} catch (error) {
|
|
notify({
|
|
title: 'Lỗi',
|
|
message: 'Không thể tải ảnh lên. Vui lòng thử lại.',
|
|
type: 'error'
|
|
});
|
|
} finally {
|
|
setIsUploading(false);
|
|
}
|
|
};
|
|
|
|
|
|
return (
|
|
<div className="fixed inset-0 z-[2500] flex items-end sm:items-center justify-center p-0 sm:p-4">
|
|
<div className="absolute inset-0 bg-gray-900/60 backdrop-blur-sm" onClick={onClose} />
|
|
<div className="relative w-full h-full sm:max-w-lg bg-[var(--surface)] rounded-t-2xl sm:rounded-3xl shadow-2xl overflow-hidden flex flex-col sm:max-h-[85vh] animate-in zoom-in-95 duration-200">
|
|
<div className="flex justify-between items-center mb-6 p-5 sm:p-8 pb-0 sm:pb-0 shrink-0">
|
|
<h2 className="text-2xl font-bold text-[var(--text-primary)] flex items-center gap-2">
|
|
<ImageIcon className="w-6 h-6 text-blue-600" /> Tải ảnh lên
|
|
</h2>
|
|
<button onClick={onClose} className="p-2 hover:bg-[var(--background)] rounded-full transition-colors text-[var(--text-muted)]">
|
|
<X className="w-6 h-6" />
|
|
</button>
|
|
</div>
|
|
|
|
<form onSubmit={handleSubmit} className="flex-1 flex flex-col min-h-0 p-5 sm:p-8 pt-0 sm:pt-0">
|
|
<div
|
|
onClick={() => fileInputRef.current?.click()}
|
|
className="border-2 border-dashed border-[var(--border)] rounded-[32px] p-10 flex flex-col items-center justify-center cursor-pointer hover:bg-[var(--background)]/50 hover:border-blue-200 transition-all mb-6 group"
|
|
>
|
|
<input type="file" ref={fileInputRef} className="hidden" multiple accept="image/*" onChange={handleFileChange} />
|
|
<div className="w-16 h-16 rounded-2xl bg-blue-50 flex items-center justify-center text-blue-600 mb-4 group-hover:scale-110 transition-transform shadow-inner">
|
|
<Upload className="w-8 h-8" />
|
|
</div>
|
|
<p className="text-sm font-black text-[var(--text-secondary)]">Nhấn để chọn ảnh</p>
|
|
<p className="text-xs text-[var(--text-muted)] mt-1 font-medium">Hỗ trợ JPG, PNG, WEBP</p>
|
|
</div>
|
|
|
|
{previews.length > 0 && (
|
|
<div className="flex-1 overflow-y-auto mb-6 pr-2">
|
|
<p className="text-[10px] font-black text-[var(--text-muted)] uppercase tracking-widest mb-3">Đã chọn {previews.length} tệp</p>
|
|
<div className="grid grid-cols-3 gap-3">
|
|
{previews.map((src, idx) => (
|
|
<div key={idx} className="relative aspect-square rounded-2xl overflow-hidden border border-[var(--border)] shadow-sm group">
|
|
<img src={src} className="w-full h-full object-cover" alt="preview" />
|
|
<button
|
|
type="button"
|
|
onClick={() => removeFile(idx)}
|
|
className="absolute top-1.5 right-1.5 p-1.5 bg-red-500/80 backdrop-blur-sm text-white rounded-full opacity-0 group-hover:opacity-100 transition-opacity"
|
|
>
|
|
<X className="w-3 h-3" />
|
|
</button>
|
|
</div>
|
|
))}
|
|
</div>
|
|
</div>
|
|
)}
|
|
|
|
<button
|
|
disabled={isUploading || isProcessing || selectedFiles.length === 0}
|
|
className="w-full py-4 bg-blue-600 hover:bg-blue-700 disabled:bg-gray-300 text-white font-black uppercase tracking-widest rounded-2xl flex items-center justify-center gap-2 shadow-lg shadow-blue-100 transition-all active:scale-95"
|
|
>
|
|
{isUploading ? (
|
|
<Loader2 className="w-5 h-5 animate-spin" />
|
|
) : isProcessing ? (
|
|
<>
|
|
<Loader2 className="w-5 h-5 animate-spin" />
|
|
Đang xử lý ảnh...
|
|
</>
|
|
) : (
|
|
'Xác nhận tải lên'
|
|
)}
|
|
</button>
|
|
</form>
|
|
</div>
|
|
</div>
|
|
);
|
|
}; |