Files
WifiShare/App.tsx
2026-02-06 15:35:54 +09:00

479 lines
25 KiB
TypeScript

import React, { useState, useEffect, useCallback, useRef } from 'react';
import { MapContainer, TileLayer, Marker, Popup, useMapEvents, useMap, Circle } from 'react-leaflet';
import L from 'leaflet';
import { QRCodeCanvas } from 'qrcode.react';
import { WiFiHotspot } from './types';
import { apiService } from './services/api';
import { geminiService, DetailedSearchResult } from './services/geminiService';
import {
Wifi,
Plus,
Search,
Navigation,
MapPin,
X,
Eye,
EyeOff,
Trash2,
ZoomIn,
ZoomOut,
Target,
Coffee,
Utensils,
CheckCircle2,
Bug,
AlertCircle,
Terminal
} from 'lucide-react';
// Marker definitions
const WiFiIcon = (color: string, type: 'cafe' | 'restaurant' | 'general' = 'general') => {
let iconPath = '';
if (type === 'cafe') {
iconPath = '<path d="M17 8h1a4 4 0 1 1 0 8h-1"/><path d="M3 8h14v9a4 4 0 0 1-4 4H7a4 4 0 0 1-4-4Z"/><line x1="6" x2="6" y1="2" y2="4"/><line x1="10" x2="10" y1="2" y2="4"/><line x1="14" x2="14" y1="2" y2="4"/>';
} else if (type === 'restaurant') {
iconPath = '<path d="M3 2v7c0 1.1.9 2 2 2h4a2 2 0 0 0 2-2V2"/><path d="M7 2v20"/><path d="M21 15V2v0a5 5 0 0 0-5 5v6c0 1.1.9 2 2 2h3Zm0 0v7"/>';
} else {
iconPath = '<path d="M12 20h.01"/><path d="M2 8.82a15 15 0 0 1 20 0"/><path d="M5 12.85a10 10 0 0 1 14 0"/><path d="M8.5 16.42a5 5 0 0 1 7 0"/>';
}
return L.divIcon({
html: `<div style="background-color: ${color}; border-radius: 50%; width: 44px; height: 44px; display: flex; align-items: center; justify-content: center; border: 3px solid white; box-shadow: 0 4px 15px rgba(0,0,0,0.3); transition: transform 0.2s;">
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="white" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round">${iconPath}</svg>
</div>`,
className: 'custom-marker',
iconSize: [44, 44],
iconAnchor: [22, 22],
});
};
const UserLocationIcon = L.divIcon({
html: `<div class="relative flex items-center justify-center">
<div class="absolute w-10 h-10 bg-blue-500 rounded-full animate-ping opacity-20"></div>
<div class="w-6 h-6 bg-blue-600 rounded-full border-2 border-white shadow-xl"></div>
</div>`,
className: 'custom-marker',
iconSize: [40, 40],
iconAnchor: [20, 20],
});
const ChangeView = ({ center, zoom }: { center: [number, number], zoom?: number }) => {
const map = useMap();
useEffect(() => {
if (center[0] !== 0 && center[1] !== 0) {
map.flyTo(center, zoom || 17, { animate: true, duration: 1.5 });
}
}, [center, map, zoom]);
return null;
};
const MapInterface = ({ onMapClick, onLocate, onAdd, onStatsUpdate, onMapMove }: any) => {
const map = useMap();
const controlsRef = useRef<HTMLDivElement>(null);
useEffect(() => {
if (controlsRef.current) {
L.DomEvent.disableClickPropagation(controlsRef.current);
L.DomEvent.disableScrollPropagation(controlsRef.current);
}
}, []);
useMapEvents({
click(e) {
onMapClick(e.latlng.lat, e.latlng.lng);
},
zoomend() {
onStatsUpdate(map.getZoom());
},
move() {
const center = map.getCenter();
onMapMove(center.lat, center.lng);
}
});
return (
<div ref={controlsRef} className="absolute bottom-8 right-8 z-[1000] flex flex-col gap-3 pointer-events-auto">
<div className="flex flex-col gap-2 mb-2">
<button onClick={(e) => { e.stopPropagation(); map.zoomIn(); }} className="p-3 bg-white hover:bg-gray-50 text-gray-700 rounded-full shadow-xl border border-gray-100 transition-all active:scale-90"><ZoomIn size={20} /></button>
<button onClick={(e) => { e.stopPropagation(); map.zoomOut(); }} className="p-3 bg-white hover:bg-gray-50 text-gray-700 rounded-full shadow-xl border border-gray-100 transition-all active:scale-90"><ZoomOut size={20} /></button>
</div>
<button onClick={(e) => { e.stopPropagation(); onLocate(); }} className="p-4 bg-white hover:bg-gray-50 text-gray-700 rounded-full shadow-xl border border-gray-100 transition-all active:scale-90"><Navigation size={24} /></button>
<button onClick={(e) => { e.stopPropagation(); onAdd(); }} className="p-5 bg-blue-600 hover:bg-blue-700 text-white rounded-full shadow-2xl transition-all active:scale-90 flex items-center justify-center"><Plus size={32} /></button>
</div>
);
};
const App: React.FC = () => {
const [hotspots, setHotspots] = useState<WiFiHotspot[]>([]);
const [searchQuery, setSearchQuery] = useState('');
const [isAdding, setIsAdding] = useState(false);
const [selectedHotspot, setSelectedHotspot] = useState<WiFiHotspot | null>(null);
const [newHotspotPos, setNewHotspotPos] = useState<{lat: number, lng: number} | null>(null);
const [userLocation, setUserLocation] = useState<[number, number]>([37.5665, 126.9780]);
const [mapCenter, setMapCenter] = useState<[number, number]>([37.5665, 126.9780]);
const [showPass, setShowPass] = useState<string | null>(null);
const [isSearching, setIsSearching] = useState(false);
const [searchInfo, setSearchInfo] = useState<{text: string, results: DetailedSearchResult[]} | null>(null);
const [lastSearchRawText, setLastSearchRawText] = useState<string>('');
const [showDebug, setShowDebug] = useState(false);
const [selectedIconType, setSelectedIconType] = useState<'general' | 'cafe' | 'restaurant'>('general');
const [formPlaceName, setFormPlaceName] = useState('');
const [currentZoom, setCurrentZoom] = useState(17);
const [inputLat, setInputLat] = useState('');
const [inputLng, setInputLng] = useState('');
const markerRef = useRef<any>(null);
useEffect(() => {
const loadHotspots = async () => {
const saved = await apiService.getHotspots();
if (saved.length > 0) {
setHotspots(saved);
const lastSpot = saved[saved.length-1];
setMapCenter([lastSpot.lat, lastSpot.lng]);
setInputLat(lastSpot.lat.toFixed(6));
setInputLng(lastSpot.lng.toFixed(6));
} else if (navigator.geolocation) {
navigator.geolocation.getCurrentPosition((pos) => {
const newPos: [number, number] = [pos.coords.latitude, pos.coords.longitude];
setMapCenter(newPos);
setInputLat(newPos[0].toFixed(6));
setInputLng(newPos[1].toFixed(6));
});
}
};
loadHotspots();
if (navigator.geolocation) {
navigator.geolocation.getCurrentPosition((pos) => {
const newPos: [number, number] = [pos.coords.latitude, pos.coords.longitude];
setUserLocation(newPos);
}, null, { enableHighAccuracy: true });
}
}, []);
useEffect(() => {
const handleSync = async () => setHotspots(await apiService.getHotspots());
window.addEventListener('storage_updated', handleSync);
return () => window.removeEventListener('storage_updated', handleSync);
}, []);
const handleMapClick = useCallback((lat: number, lng: number) => {
setNewHotspotPos({ lat, lng });
setIsAdding(true);
setSelectedHotspot(null);
setFormPlaceName('');
}, []);
const handleMapMove = useCallback((lat: number, lng: number) => {
setInputLat(lat.toFixed(6));
setInputLng(lng.toFixed(6));
}, []);
const handleLocate = useCallback(() => {
if (navigator.geolocation) {
navigator.geolocation.getCurrentPosition((pos) => {
const newPos: [number, number] = [pos.coords.latitude, pos.coords.longitude];
setUserLocation(newPos);
setMapCenter(newPos);
setInputLat(newPos[0].toFixed(6));
setInputLng(newPos[1].toFixed(6));
});
}
}, []);
const handleAddInitiate = useCallback(() => {
const lat = parseFloat(inputLat);
const lng = parseFloat(inputLng);
if (!isNaN(lat) && !isNaN(lng)) {
setNewHotspotPos({ lat, lng });
setIsAdding(true);
setSelectedHotspot(null);
setFormPlaceName('');
}
}, [inputLat, inputLng]);
// 검색 결과 클릭 시 동작 수정
const handleSearchResultClick = (r: DetailedSearchResult) => {
if (r.lat && r.lng) {
// 1. 입력창 좌표 업데이트
setInputLat(r.lat.toFixed(6));
setInputLng(r.lng.toFixed(6));
// 2. 지도 중심 이동
setMapCenter([r.lat, r.lng]);
// 3. '새 WiFi 등록' 다이얼로그 닫기 및 관련 상태 초기화
setIsAdding(false);
setSelectedHotspot(null);
setNewHotspotPos(null);
setFormPlaceName(r.title);
} else {
setShowDebug(true);
if (r.uri) window.open(r.uri, '_blank');
}
};
const saveWiFi = async (e: React.FormEvent<HTMLFormElement>) => {
e.preventDefault();
const formData = new FormData(e.currentTarget);
const newHotspot: WiFiHotspot = {
id: Math.random().toString(36).substr(2, 9),
name: (formData.get('name') as string) || "WiFi 장소",
ssid: (formData.get('ssid') as string) || "알 수 없는 SSID",
password: formData.get('password') as string,
lat: newHotspotPos?.lat || parseFloat(inputLat),
lng: newHotspotPos?.lng || parseFloat(inputLng),
securityType: (formData.get('security') as any) || 'WPA2',
iconType: selectedIconType,
addedBy: 'User',
createdAt: Date.now(),
isPublic: true
};
await apiService.saveHotspot(newHotspot);
setHotspots(await apiService.getHotspots());
setIsAdding(false);
setNewHotspotPos(null);
setSelectedHotspot(newHotspot);
};
const performSearch = async () => {
if (!searchQuery.trim()) return;
setIsSearching(true);
setSearchInfo(null);
setLastSearchRawText("AI가 주변 정보를 분석하고 정밀하게 추출하는 중입니다...");
const result = await geminiService.searchNearbyWiFi(searchQuery, { lat: userLocation[0], lng: userLocation[1] });
setSearchInfo(result);
setLastSearchRawText(result.text || "결과를 찾을 수 없습니다.");
setIsSearching(false);
// 좌표가 인식된 결과들 중 내 위치와 가장 가까운 장소 찾기
const resultsWithCoords = result.results.filter(r => r.lat && r.lng);
if (resultsWithCoords.length > 0) {
let closestItem = resultsWithCoords[0];
let minDistance = Infinity;
resultsWithCoords.forEach(r => {
const distance = Math.sqrt(
Math.pow(r.lat! - userLocation[0], 2) +
Math.pow(r.lng! - userLocation[1], 2)
);
if (distance < minDistance) {
minDistance = distance;
closestItem = r;
}
});
if (closestItem.lat && closestItem.lng) {
setInputLat(closestItem.lat.toFixed(6));
setInputLng(closestItem.lng.toFixed(6));
setMapCenter([closestItem.lat, closestItem.lng]);
}
}
};
const getWifiQRValue = (hotspot: WiFiHotspot) => {
const security = hotspot.securityType === 'Open' ? 'nopass' : 'WPA';
return `WIFI:S:${hotspot.ssid};T:${security};P:${hotspot.password || ''};;`;
};
return (
<div className="relative w-full h-screen overflow-hidden flex flex-col md:flex-row bg-slate-50">
<div className="z-[1000] absolute top-4 left-4 right-4 md:right-auto md:w-[420px] flex flex-col gap-3 pointer-events-none">
<div className="bg-white rounded-3xl shadow-2xl p-2 flex items-center gap-2 pointer-events-auto border border-white/50 backdrop-blur-md">
<div className="p-3 text-blue-500"><Wifi size={24} strokeWidth={3} /></div>
<input
type="text"
placeholder="예: 첨단 파스쿠찌"
className="flex-1 bg-transparent border-none focus:ring-0 text-sm font-bold py-2 outline-none"
value={searchQuery}
onChange={(e) => setSearchQuery(e.target.value)}
onKeyDown={(e) => e.key === 'Enter' && performSearch()}
/>
<button onClick={performSearch} className="p-3 bg-blue-600 hover:bg-blue-700 text-white rounded-2xl shadow-lg active:scale-95 flex items-center justify-center min-w-[48px]">
{isSearching ? <div className="animate-spin rounded-full h-5 w-5 border-2 border-white border-t-transparent"></div> : <Search size={22} />}
</button>
</div>
{searchInfo && (
<div className="bg-white/98 backdrop-blur-xl rounded-[32px] shadow-2xl pointer-events-auto max-h-[70vh] overflow-hidden flex flex-col border border-white/40 animate-in slide-in-from-top-4">
<div className="p-5 border-b border-gray-100 bg-white/90 sticky top-0 z-10">
<div className="flex justify-between items-center mb-4">
<h3 className="font-black text-gray-800 text-sm flex items-center gap-2">
<MapPin size={16} className="text-blue-500" />
</h3>
<div className="flex gap-2">
<button onClick={() => setShowDebug(!showDebug)} className={`p-2 rounded-full transition-colors ${showDebug ? 'bg-amber-100 text-amber-600' : 'bg-slate-100 text-slate-400'}`} title="데이터 확인">
<Terminal size={18} />
</button>
<button onClick={() => setSearchInfo(null)} className="text-gray-400 p-1 hover:bg-gray-100 rounded-full"><X size={20} /></button>
</div>
</div>
{showDebug && (
<div className="mb-4 p-4 bg-slate-900 rounded-2xl border border-slate-700 animate-in fade-in zoom-in duration-200">
<p className="text-[10px] font-black text-slate-500 uppercase mb-2 flex items-center gap-1"><Bug size={10} /> AI </p>
<div className="max-h-[200px] overflow-y-auto text-[11px] font-mono text-blue-300 whitespace-pre-wrap custom-scrollbar">
{lastSearchRawText}
</div>
</div>
)}
</div>
<div className="flex-1 overflow-y-auto custom-scrollbar p-2 space-y-1">
{searchInfo.results.map((r, i) => (
<button
key={i}
onClick={() => handleSearchResultClick(r)}
className={`w-full text-left p-4 rounded-2xl flex items-center gap-4 transition-all group border ${r.lat && r.lng ? 'bg-blue-50/50 border-blue-100 hover:bg-blue-100' : 'hover:bg-gray-50 border-transparent'}`}
>
<div className={`p-3 rounded-xl transition-all ${r.lat && r.lng ? 'bg-blue-600 text-white' : 'bg-amber-50 text-amber-500'}`}>
{r.lat && r.lng ? <MapPin size={18} /> : <AlertCircle size={18} />}
</div>
<div className="flex-1 overflow-hidden">
<span className="block text-sm font-black text-gray-800 truncate">{r.title}</span>
<span className={`text-[10px] font-bold uppercase tracking-tight ${r.lat && r.lng ? 'text-blue-600' : 'text-amber-500'}`}>
{r.lat && r.lng ? `좌표 인식 성공 (${r.lat.toFixed(4)}, ${r.lng.toFixed(4)})` : '좌표 인식 실패'}
</span>
</div>
</button>
))}
</div>
</div>
)}
<div className="bg-white/90 backdrop-blur-xl rounded-3xl shadow-xl p-4 pointer-events-auto border border-white/50">
<div className="flex justify-between items-center text-[10px] font-black text-gray-400 uppercase tracking-widest mb-3">
<span> WiFi: <span className="text-blue-600">{hotspots.length}</span></span>
<span> : {currentZoom}</span>
</div>
<div className="flex gap-2">
<input placeholder="위도" value={inputLat} onChange={(e) => setInputLat(e.target.value)} className="w-full text-[11px] font-bold p-2 bg-gray-100 rounded-xl outline-none border border-transparent focus:border-blue-300" />
<input placeholder="경도" value={inputLng} onChange={(e) => setInputLng(e.target.value)} className="w-full text-[11px] font-bold p-2 bg-gray-100 rounded-xl outline-none border border-transparent focus:border-blue-300" />
<button onClick={() => {
const lat = parseFloat(inputLat);
const lng = parseFloat(inputLng);
if(!isNaN(lat) && !isNaN(lng)) setMapCenter([lat, lng]);
}} className="p-2 bg-gray-900 text-white rounded-xl active:scale-90 shadow-lg"><Target size={14} /></button>
</div>
</div>
</div>
<div className="flex-1 relative">
<MapContainer center={mapCenter} zoom={17} zoomControl={false}>
<ChangeView center={mapCenter} />
<TileLayer url="https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png" />
<MapInterface
onMapClick={handleMapClick}
onLocate={handleLocate}
onAdd={handleAddInitiate}
onStatsUpdate={(z: any) => setCurrentZoom(Math.round(z))}
onMapMove={handleMapMove}
/>
<Marker position={userLocation} icon={UserLocationIcon} />
<Circle center={userLocation} radius={100} pathOptions={{ color: '#3b82f6', fillOpacity: 0.05, weight: 1 }} />
{hotspots.map(spot => (
<Marker key={spot.id} position={[spot.lat, spot.lng]} icon={WiFiIcon(spot.securityType === 'Open' ? '#22c55e' : '#3b82f6', spot.iconType)} eventHandlers={{ click: (e) => { L.DomEvent.stopPropagation(e as any); setSelectedHotspot(spot); setIsAdding(false); setMapCenter([spot.lat, spot.lng]); } }} />
))}
{newHotspotPos && (
<Marker draggable={true} position={[newHotspotPos.lat, newHotspotPos.lng]} icon={WiFiIcon('#f97316', selectedIconType)} ref={markerRef} eventHandlers={{ dragend() { const latLng = markerRef.current.getLatLng(); setNewHotspotPos({ lat: latLng.lat, lng: latLng.lng }); } }}>
<Popup closeButton={false}><span className="text-[10px] font-black uppercase"> </span></Popup>
</Marker>
)}
</MapContainer>
<div className="absolute top-1/2 left-1/2 -translate-x-1/2 -translate-y-1/2 z-[500] pointer-events-none opacity-20">
<div className="w-6 h-6 border-2 border-blue-600 rounded-full flex items-center justify-center">
<div className="w-1 h-1 bg-blue-600 rounded-full"></div>
</div>
</div>
</div>
{(selectedHotspot || isAdding) && (
<div className="z-[2000] absolute bottom-0 left-0 right-0 md:top-0 md:left-auto md:w-[480px] bg-white shadow-2xl md:h-full rounded-t-[40px] md:rounded-none overflow-hidden border-l border-gray-100 flex flex-col animate-in slide-in-from-right-8 duration-500">
<div className="p-8 flex flex-col h-full overflow-y-auto custom-scrollbar">
<div className="flex justify-between items-center mb-10">
<h2 className="text-3xl font-black text-slate-900 tracking-tighter">{isAdding ? "새 WiFi 등록" : "장소 정보"}</h2>
<button onClick={() => { setIsAdding(false); setSelectedHotspot(null); setNewHotspotPos(null); }} className="p-2 hover:bg-slate-100 rounded-full text-slate-400"><X size={28} /></button>
</div>
{isAdding ? (
<form onSubmit={saveWiFi} className="space-y-8">
<div className="p-6 bg-orange-50 rounded-[32px] border border-orange-100 flex items-center gap-4 shadow-sm">
<MapPin className="text-orange-500" />
<div>
<p className="text-[10px] font-black text-orange-400 uppercase"> </p>
<p className="text-sm font-black text-orange-800">{newHotspotPos?.lat.toFixed(6)}, {newHotspotPos?.lng.toFixed(6)}</p>
</div>
</div>
<div className="grid grid-cols-3 gap-4">
{(['general', 'cafe', 'restaurant'] as const).map(type => (
<button key={type} type="button" onClick={() => setSelectedIconType(type)} className={`flex flex-col items-center py-6 px-2 rounded-[24px] border-4 transition-all gap-2 ${selectedIconType === type ? 'border-blue-600 bg-blue-50 text-blue-700 shadow-md' : 'border-slate-50 bg-slate-50 text-slate-300 hover:bg-slate-100'}`}>
{type === 'cafe' ? <Coffee /> : type === 'restaurant' ? <Utensils /> : <Wifi />}
<span className="text-[10px] font-black uppercase">{type === 'cafe' ? '카페' : type === 'restaurant' ? '음식점' : '일반'}</span>
</button>
))}
</div>
<div className="space-y-4">
<input required name="name" value={formPlaceName} onChange={(e) => setFormPlaceName(e.target.value)} placeholder="장소 명칭 (예: 파스쿠찌)" className="w-full p-5 rounded-2xl bg-slate-50 font-bold outline-none border-2 border-transparent focus:border-blue-500 transition-all shadow-inner" />
<input required name="ssid" placeholder="WiFi 이름(SSID)" className="w-full p-5 rounded-2xl bg-slate-50 font-bold outline-none border-2 border-transparent focus:border-blue-500 transition-all shadow-inner" />
<input name="password" placeholder="비밀번호" className="w-full p-5 rounded-2xl bg-slate-50 font-bold outline-none border-2 border-transparent focus:border-blue-500 transition-all shadow-inner" />
<select name="security" className="w-full p-5 rounded-2xl bg-slate-50 font-black outline-none border-2 border-transparent focus:border-blue-500 shadow-inner">
<option value="WPA2">WPA2 ()</option>
<option value="WPA3">WPA3 ()</option>
<option value="Open"> (Open)</option>
</select>
</div>
<button type="submit" className="w-full py-6 bg-blue-600 hover:bg-blue-700 text-white font-black rounded-3xl shadow-xl active:scale-[0.98] transition-all">WiFi </button>
</form>
) : selectedHotspot && (
<div className="space-y-10 animate-in fade-in duration-500">
<div className="p-8 bg-gradient-to-br from-blue-600 to-blue-400 rounded-[40px] flex flex-col items-center text-center space-y-4 shadow-xl relative overflow-hidden">
<div className="p-6 bg-white rounded-3xl shadow-xl z-10 transition-transform hover:scale-110">
{selectedHotspot.iconType === 'cafe' ? <Coffee size={48} className="text-blue-600" /> : selectedHotspot.iconType === 'restaurant' ? <Utensils size={48} className="text-blue-600" /> : <Wifi size={48} className="text-blue-600" />}
</div>
<div className="z-10">
<h3 className="text-4xl font-black text-white tracking-tighter mb-1 break-all">{selectedHotspot.name}</h3>
<p className="text-xs font-black text-white/80 uppercase tracking-widest">{selectedHotspot.ssid}</p>
</div>
</div>
<div className="p-7 bg-slate-900 rounded-[32px] flex flex-col gap-4 shadow-xl">
<p className="text-[11px] font-black text-slate-500 uppercase tracking-widest">WiFi </p>
<div className="flex justify-between items-center">
<p className="text-4xl font-mono font-black text-white tracking-widest overflow-hidden truncate mr-4">{showPass === selectedHotspot.id ? (selectedHotspot.password || 'OPEN') : '••••••••'}</p>
<button onClick={() => setShowPass(showPass === selectedHotspot.id ? null : selectedHotspot.id)} className="p-4 bg-white/10 hover:bg-white/20 text-white rounded-2xl transition-all">{showPass === selectedHotspot.id ? <EyeOff /> : <Eye />}</button>
</div>
</div>
<div className="p-8 bg-white rounded-[40px] border-4 border-blue-50 flex flex-col items-center gap-6 shadow-sm">
<p className="text-xs font-black text-slate-400 uppercase tracking-widest"> QR </p>
<div className="p-6 bg-white rounded-[40px] shadow-2xl border border-slate-100 transition-all hover:scale-105">
<QRCodeCanvas key={selectedHotspot.id} value={getWifiQRValue(selectedHotspot)} size={240} level="H" includeMargin={false} />
</div>
<div className="flex items-center gap-2 text-green-600 font-black uppercase text-[10px] tracking-tight"><CheckCircle2 size={16} /> </div>
</div>
<div className="flex flex-col gap-4 pt-6">
<button onClick={() => { if(selectedHotspot.password) { navigator.clipboard.writeText(selectedHotspot.password); alert("비밀번호가 복사되었습니다!"); } }} className="w-full py-6 bg-slate-900 hover:bg-black text-white font-black rounded-3xl shadow-xl transition-all"> </button>
<button onClick={async () => { if(confirm("삭제하시겠습니까?")) { await apiService.deleteHotspot(selectedHotspot.id); setHotspots(await apiService.getHotspots()); setSelectedHotspot(null); } }} className="w-full py-5 text-red-500 hover:bg-red-50 font-black rounded-3xl transition-colors flex items-center justify-center gap-2">
<Trash2 size={20} />
</button>
</div>
</div>
)}
</div>
</div>
)}
</div>
);
};
export default App;