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:
Vendored
+1
-1
File diff suppressed because one or more lines are too long
+2
File diff suppressed because one or more lines are too long
-211
File diff suppressed because one or more lines are too long
+211
File diff suppressed because one or more lines are too long
-2
File diff suppressed because one or more lines are too long
+1
-1
File diff suppressed because one or more lines are too long
Vendored
+2
-2
@@ -5,8 +5,8 @@
|
|||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0, viewport-fit=cover" />
|
<meta name="viewport" content="width=device-width, initial-scale=1.0, viewport-fit=cover" />
|
||||||
<link rel="icon" type="image/x-icon" href="/favicon.ico" />
|
<link rel="icon" type="image/x-icon" href="/favicon.ico" />
|
||||||
<title>Travel Planner</title>
|
<title>Travel Planner</title>
|
||||||
<script type="module" crossorigin src="/assets/index-BCrVtqAn.js"></script>
|
<script type="module" crossorigin src="/assets/index-BT1r-9nH.js"></script>
|
||||||
<link rel="stylesheet" crossorigin href="/assets/index-D_q28YD-.css">
|
<link rel="stylesheet" crossorigin href="/assets/index-B4mnZ6Vc.css">
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
<div id="root"></div>
|
<div id="root"></div>
|
||||||
|
|||||||
@@ -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 React, { useState, useEffect, useRef } from 'react';
|
||||||
import { X, Send, MessageSquare, User, Loader2, Calendar, MapPin, Download, Edit } from 'lucide-react';
|
import { X, Send, MessageSquare, User, Loader2, Calendar, MapPin, Download, Edit } from 'lucide-react';
|
||||||
import { io } from 'socket.io-client';
|
import { io } from 'socket.io-client';
|
||||||
|
import { CoordinateSelectModal } from './CoordinateSelectModal';
|
||||||
|
|
||||||
interface Comment {
|
interface Comment {
|
||||||
id: string;
|
id: string;
|
||||||
@@ -58,6 +59,7 @@ export const PublicPhotoModal: React.FC<PublicPhotoModalProps> = ({
|
|||||||
const [editLat, setEditLat] = useState<number | ''>('');
|
const [editLat, setEditLat] = useState<number | ''>('');
|
||||||
const [editLng, setEditLng] = useState<number | ''>('');
|
const [editLng, setEditLng] = useState<number | ''>('');
|
||||||
const [isSavingEdit, setIsSavingEdit] = useState(false);
|
const [isSavingEdit, setIsSavingEdit] = useState(false);
|
||||||
|
const [isMapOpen, setIsMapOpen] = useState(false);
|
||||||
|
|
||||||
const checkCurrentUser = () => {
|
const checkCurrentUser = () => {
|
||||||
const userStr = localStorage.getItem('user');
|
const userStr = localStorage.getItem('user');
|
||||||
@@ -382,6 +384,17 @@ export const PublicPhotoModal: React.FC<PublicPhotoModalProps> = ({
|
|||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</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>
|
||||||
|
|
||||||
<div className="flex justify-end gap-2 mt-2">
|
<div className="flex justify-end gap-2 mt-2">
|
||||||
@@ -571,6 +584,17 @@ export const PublicPhotoModal: React.FC<PublicPhotoModalProps> = ({
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
</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>
|
</div>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ import React, { useEffect, useState, useMemo } from 'react';
|
|||||||
import { ChevronLeft, Image as ImageIcon, Download, Calendar, MapPin, Loader2, Filter, X, Trash2, Edit } from 'lucide-react';
|
import { ChevronLeft, Image as ImageIcon, Download, Calendar, MapPin, Loader2, Filter, X, Trash2, Edit } from 'lucide-react';
|
||||||
import { useNotification } from '@/hooks/useNotification';
|
import { useNotification } from '@/hooks/useNotification';
|
||||||
import { useConfirm } from '@/hooks/useConfirm';
|
import { useConfirm } from '@/hooks/useConfirm';
|
||||||
|
import { CoordinateSelectModal } from '../components/CoordinateSelectModal';
|
||||||
|
|
||||||
export const MyPhotosPage = ({ onBack }: { onBack: () => void }) => {
|
export const MyPhotosPage = ({ onBack }: { onBack: () => void }) => {
|
||||||
const [photos, setPhotos] = useState<any[]>([]);
|
const [photos, setPhotos] = useState<any[]>([]);
|
||||||
@@ -19,6 +20,7 @@ export const MyPhotosPage = ({ onBack }: { onBack: () => void }) => {
|
|||||||
const [editLat, setEditLat] = useState<number | ''>('');
|
const [editLat, setEditLat] = useState<number | ''>('');
|
||||||
const [editLng, setEditLng] = useState<number | ''>('');
|
const [editLng, setEditLng] = useState<number | ''>('');
|
||||||
const [isSavingEdit, setIsSavingEdit] = useState(false);
|
const [isSavingEdit, setIsSavingEdit] = useState(false);
|
||||||
|
const [isMapOpen, setIsMapOpen] = useState(false);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (selectedPhotoForDisplay) {
|
if (selectedPhotoForDisplay) {
|
||||||
@@ -345,6 +347,17 @@ export const MyPhotosPage = ({ onBack }: { onBack: () => void }) => {
|
|||||||
</div>
|
</div>
|
||||||
</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-gray-100 hover:bg-gray-200 border border-gray-200 text-gray-700 hover:text-gray-900 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 className="flex justify-end gap-2 mt-2">
|
<div className="flex justify-end gap-2 mt-2">
|
||||||
<button
|
<button
|
||||||
onClick={() => setIsEditing(false)}
|
onClick={() => setIsEditing(false)}
|
||||||
@@ -470,6 +483,16 @@ export const MyPhotosPage = ({ onBack }: { onBack: () => void }) => {
|
|||||||
</div>
|
</div>
|
||||||
{}
|
{}
|
||||||
</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>
|
</div>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
@@ -12,6 +12,7 @@ import { AddPhotoModal } from '@/components/AddPhotoModal';
|
|||||||
import { MapContainer, TileLayer, Marker, Popup, useMapEvents, Polyline, useMap, Tooltip } from 'react-leaflet';
|
import { MapContainer, TileLayer, Marker, Popup, useMapEvents, Polyline, useMap, Tooltip } from 'react-leaflet';
|
||||||
import _MarkerClusterGroup from 'react-leaflet-cluster';
|
import _MarkerClusterGroup from 'react-leaflet-cluster';
|
||||||
const MarkerClusterGroup = (_MarkerClusterGroup as any).default || _MarkerClusterGroup;
|
const MarkerClusterGroup = (_MarkerClusterGroup as any).default || _MarkerClusterGroup;
|
||||||
|
import { CoordinateSelectModal } from '../components/CoordinateSelectModal';
|
||||||
import {
|
import {
|
||||||
Map as MapIcon,
|
Map as MapIcon,
|
||||||
Wallet,
|
Wallet,
|
||||||
@@ -527,6 +528,7 @@ export const TourDetailPage = ({
|
|||||||
const [editPhotoLat, setEditPhotoLat] = useState<number | ''>('');
|
const [editPhotoLat, setEditPhotoLat] = useState<number | ''>('');
|
||||||
const [editPhotoLng, setEditPhotoLng] = useState<number | ''>('');
|
const [editPhotoLng, setEditPhotoLng] = useState<number | ''>('');
|
||||||
const [isSavingPhotoEdit, setIsSavingPhotoEdit] = useState(false);
|
const [isSavingPhotoEdit, setIsSavingPhotoEdit] = useState(false);
|
||||||
|
const [isMapOpen, setIsMapOpen] = useState(false);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (selectedPhotoForDisplay) {
|
if (selectedPhotoForDisplay) {
|
||||||
@@ -2062,6 +2064,17 @@ export const TourDetailPage = ({
|
|||||||
</div>
|
</div>
|
||||||
</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-gray-100 hover:bg-gray-200 border border-gray-200 text-gray-700 hover:text-gray-900 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 className="flex justify-end gap-2 mt-2">
|
<div className="flex justify-end gap-2 mt-2">
|
||||||
<button
|
<button
|
||||||
onClick={() => setIsEditingPhoto(false)}
|
onClick={() => setIsEditingPhoto(false)}
|
||||||
@@ -2637,6 +2650,16 @@ export const TourDetailPage = ({
|
|||||||
onCommentAdded={() => handleCommentIncrement(commentLocationId)}
|
onCommentAdded={() => handleCommentIncrement(commentLocationId)}
|
||||||
onCommentDeleted={() => handleCommentDecrement(commentLocationId)}
|
onCommentDeleted={() => handleCommentDecrement(commentLocationId)}
|
||||||
/>
|
/>
|
||||||
|
<CoordinateSelectModal
|
||||||
|
isOpen={isMapOpen}
|
||||||
|
onClose={() => setIsMapOpen(false)}
|
||||||
|
initialLat={typeof editPhotoLat === 'number' ? editPhotoLat : undefined}
|
||||||
|
initialLng={typeof editPhotoLng === 'number' ? editPhotoLng : undefined}
|
||||||
|
onSelect={(lat, lng) => {
|
||||||
|
setEditPhotoLat(lat);
|
||||||
|
setEditPhotoLng(lng);
|
||||||
|
}}
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
Reference in New Issue
Block a user