474 lines
25 KiB
TypeScript
474 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 { storageService } from './services/storage';
|
|
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[]>(() => storageService.getHotspots());
|
|
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 saved = storageService.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));
|
|
}
|
|
|
|
if (navigator.geolocation) {
|
|
navigator.geolocation.getCurrentPosition((pos) => {
|
|
const newPos: [number, number] = [pos.coords.latitude, pos.coords.longitude];
|
|
setUserLocation(newPos);
|
|
if (saved.length === 0) {
|
|
setMapCenter(newPos);
|
|
setInputLat(newPos[0].toFixed(6));
|
|
setInputLng(newPos[1].toFixed(6));
|
|
}
|
|
}, null, { enableHighAccuracy: true });
|
|
}
|
|
}, []);
|
|
|
|
useEffect(() => {
|
|
const handleSync = () => setHotspots(storageService.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 = (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
|
|
};
|
|
|
|
storageService.saveHotspot(newHotspot);
|
|
setHotspots(storageService.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={() => { if(confirm("삭제하시겠습니까?")) { storageService.deleteHotspot(selectedHotspot.id); setHotspots(storageService.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;
|