feat: cho phép người dùng chọn tọa độ trên bản đồ khi sửa ảnh
This commit is contained in:
@@ -0,0 +1,163 @@
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import { X, MapPin } from 'lucide-react';
|
||||
import { MapContainer, TileLayer, Marker, useMap, useMapEvents } from 'react-leaflet';
|
||||
import L from 'leaflet';
|
||||
import 'leaflet/dist/leaflet.css';
|
||||
|
||||
// Fix Leaflet default marker icon bug
|
||||
const DefaultIcon = L.icon({
|
||||
iconUrl: 'https://unpkg.com/leaflet@1.9.4/dist/images/marker-icon.png',
|
||||
shadowUrl: 'https://unpkg.com/leaflet@1.9.4/dist/images/marker-shadow.png',
|
||||
iconSize: [25, 41],
|
||||
iconAnchor: [12, 41],
|
||||
});
|
||||
|
||||
interface CoordinateSelectModalProps {
|
||||
isOpen: boolean;
|
||||
onClose: () => void;
|
||||
initialLat?: number;
|
||||
initialLng?: number;
|
||||
onSelect: (lat: number, lng: number) => void;
|
||||
}
|
||||
|
||||
function MapClickEvents({ onClick }: { onClick: (lat: number, lng: number) => void }) {
|
||||
useMapEvents({
|
||||
click: (e) => {
|
||||
onClick(e.latlng.lat, e.latlng.lng);
|
||||
}
|
||||
});
|
||||
return null;
|
||||
}
|
||||
|
||||
function MapInvalidator() {
|
||||
const map = useMap();
|
||||
useEffect(() => {
|
||||
const timer = setTimeout(() => {
|
||||
map.invalidateSize();
|
||||
}, 250);
|
||||
return () => clearTimeout(timer);
|
||||
}, [map]);
|
||||
return null;
|
||||
}
|
||||
|
||||
function RecenterMap({ position }: { position: [number, number] }) {
|
||||
const map = useMap();
|
||||
useEffect(() => {
|
||||
map.setView(position, map.getZoom());
|
||||
}, [position, map]);
|
||||
return null;
|
||||
}
|
||||
|
||||
export const CoordinateSelectModal: React.FC<CoordinateSelectModalProps> = ({
|
||||
isOpen,
|
||||
onClose,
|
||||
initialLat,
|
||||
initialLng,
|
||||
onSelect
|
||||
}) => {
|
||||
const defaultCenter: [number, number] = [10.7769, 106.7009]; // TP.HCM default
|
||||
const [position, setPosition] = useState<[number, number]>(defaultCenter);
|
||||
const [hasSelected, setHasSelected] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
if (isOpen) {
|
||||
if (typeof initialLat === 'number' && typeof initialLng === 'number' && !isNaN(initialLat) && !isNaN(initialLng)) {
|
||||
setPosition([initialLat, initialLng]);
|
||||
setHasSelected(true);
|
||||
} else {
|
||||
setPosition(defaultCenter);
|
||||
setHasSelected(false);
|
||||
}
|
||||
}
|
||||
}, [isOpen, initialLat, initialLng]);
|
||||
|
||||
if (!isOpen) return null;
|
||||
|
||||
const handleMapClick = (lat: number, lng: number) => {
|
||||
setPosition([lat, lng]);
|
||||
setHasSelected(true);
|
||||
};
|
||||
|
||||
const handleConfirm = () => {
|
||||
onSelect(position[0], position[1]);
|
||||
onClose();
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="fixed inset-0 z-[6000] flex items-center justify-center p-4 animate-in fade-in duration-200">
|
||||
{/* Backdrop */}
|
||||
<div
|
||||
className="absolute inset-0 bg-slate-900/60 backdrop-blur-sm"
|
||||
onClick={onClose}
|
||||
/>
|
||||
|
||||
{/* Content */}
|
||||
<div className="relative w-full max-w-2xl h-[550px] bg-white rounded-3xl shadow-2xl overflow-hidden flex flex-col border border-gray-100 animate-in zoom-in-95 duration-200">
|
||||
{/* Header */}
|
||||
<div className="p-4 border-b border-gray-100 flex items-center justify-between bg-white shrink-0">
|
||||
<div className="flex items-center gap-2">
|
||||
<MapPin className="w-5 h-5 text-blue-500" />
|
||||
<div className="text-left">
|
||||
<h3 className="font-extrabold text-sm text-gray-900">Chọn vị trí trên bản đồ</h3>
|
||||
<p className="text-[10px] text-gray-400 font-bold uppercase tracking-wider">Click lên bản đồ để chọn tọa độ</p>
|
||||
</div>
|
||||
</div>
|
||||
<button
|
||||
onClick={onClose}
|
||||
className="p-1.5 hover:bg-gray-100 rounded-full transition-all text-gray-400 hover:text-gray-600"
|
||||
>
|
||||
<X className="w-5 h-5" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Map Body */}
|
||||
<div className="flex-1 bg-gray-50 relative min-h-[300px]" style={{ zIndex: 10 }}>
|
||||
<MapContainer
|
||||
center={position}
|
||||
zoom={13}
|
||||
style={{ width: '100%', height: '100%', zIndex: 1 }}
|
||||
>
|
||||
<TileLayer
|
||||
attribution='© <a href="https://www.openstreetmap.org/copyright">OpenStreetMap</a> contributors'
|
||||
url="https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png"
|
||||
/>
|
||||
<MapClickEvents onClick={handleMapClick} />
|
||||
<MapInvalidator />
|
||||
<RecenterMap position={position} />
|
||||
{hasSelected && (
|
||||
<Marker position={position} icon={DefaultIcon} />
|
||||
)}
|
||||
</MapContainer>
|
||||
</div>
|
||||
|
||||
{/* Footer */}
|
||||
<div className="p-4 border-t border-gray-100 flex items-center justify-between bg-white shrink-0">
|
||||
<div className="text-xs text-gray-500">
|
||||
{hasSelected ? (
|
||||
<span className="font-semibold text-gray-700">
|
||||
Tọa độ: {position[0].toFixed(6)}, {position[1].toFixed(6)}
|
||||
</span>
|
||||
) : (
|
||||
<span className="italic text-gray-400">Chưa chọn vị trí</span>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex gap-2">
|
||||
<button
|
||||
onClick={onClose}
|
||||
className="px-4 py-2 border border-gray-200 hover:bg-gray-50 text-gray-700 rounded-xl text-xs font-bold transition-all"
|
||||
>
|
||||
Hủy
|
||||
</button>
|
||||
<button
|
||||
onClick={handleConfirm}
|
||||
disabled={!hasSelected}
|
||||
className="px-5 py-2 bg-blue-600 hover:bg-blue-700 disabled:bg-gray-200 disabled:text-gray-400 disabled:cursor-not-allowed text-white rounded-xl text-xs font-bold transition-all shadow-md shadow-blue-500/10"
|
||||
>
|
||||
Xác nhận
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -1,6 +1,7 @@
|
||||
import React, { useState, useEffect, useRef } from 'react';
|
||||
import { X, Send, MessageSquare, User, Loader2, Calendar, MapPin, Download, Edit } from 'lucide-react';
|
||||
import { io } from 'socket.io-client';
|
||||
import { CoordinateSelectModal } from './CoordinateSelectModal';
|
||||
|
||||
interface Comment {
|
||||
id: string;
|
||||
@@ -58,6 +59,7 @@ export const PublicPhotoModal: React.FC<PublicPhotoModalProps> = ({
|
||||
const [editLat, setEditLat] = useState<number | ''>('');
|
||||
const [editLng, setEditLng] = useState<number | ''>('');
|
||||
const [isSavingEdit, setIsSavingEdit] = useState(false);
|
||||
const [isMapOpen, setIsMapOpen] = useState(false);
|
||||
|
||||
const checkCurrentUser = () => {
|
||||
const userStr = localStorage.getItem('user');
|
||||
@@ -382,6 +384,17 @@ export const PublicPhotoModal: React.FC<PublicPhotoModalProps> = ({
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex justify-start">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setIsMapOpen(true)}
|
||||
className="flex items-center gap-1.5 px-3 py-1.5 bg-slate-800 hover:bg-slate-750 border border-slate-700/50 text-slate-300 hover:text-white rounded-xl text-[10px] font-bold transition-all"
|
||||
>
|
||||
<MapPin className="w-3.5 h-3.5 text-rose-500" />
|
||||
Chọn trên bản đồ
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex justify-end gap-2 mt-2">
|
||||
@@ -571,6 +584,17 @@ export const PublicPhotoModal: React.FC<PublicPhotoModalProps> = ({
|
||||
</div>
|
||||
|
||||
</div>
|
||||
|
||||
<CoordinateSelectModal
|
||||
isOpen={isMapOpen}
|
||||
onClose={() => setIsMapOpen(false)}
|
||||
initialLat={typeof editLat === 'number' ? editLat : undefined}
|
||||
initialLng={typeof editLng === 'number' ? editLng : undefined}
|
||||
onSelect={(lat, lng) => {
|
||||
setEditLat(lat);
|
||||
setEditLng(lng);
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
Reference in New Issue
Block a user