initial commit

This commit is contained in:
2026-02-06 15:16:49 +09:00
commit 1c72c185ab
12 changed files with 837 additions and 0 deletions

24
.gitignore vendored Normal file
View File

@@ -0,0 +1,24 @@
# Logs
logs
*.log
npm-debug.log*
yarn-debug.log*
yarn-error.log*
pnpm-debug.log*
lerna-debug.log*
node_modules
dist
dist-ssr
*.local
# Editor directories and files
.vscode/*
!.vscode/extensions.json
.idea
.DS_Store
*.suo
*.ntvs*
*.njsproj
*.sln
*.sw?

473
App.tsx Normal file
View File

@@ -0,0 +1,473 @@
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;

20
README.md Normal file
View File

@@ -0,0 +1,20 @@
<div align="center">
<img width="1200" height="475" alt="GHBanner" src="https://github.com/user-attachments/assets/0aa67016-6eaf-458a-adb2-6e31a0763ed6" />
</div>
# Run and deploy your AI Studio app
This contains everything you need to run your app locally.
View your app in AI Studio: https://ai.studio/apps/drive/1H0CCrJQObEQfnwsB8_C0QHoDCnQzTsNA
## Run Locally
**Prerequisites:** Node.js
1. Install dependencies:
`npm install`
2. Set the `GEMINI_API_KEY` in [.env.local](.env.local) to your Gemini API key
3. Run the app:
`npm run dev`

65
index.html Normal file
View File

@@ -0,0 +1,65 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>WiFi Share Pro</title>
<script src="https://cdn.tailwindcss.com"></script>
<link rel="stylesheet" href="https://unpkg.com/leaflet@1.9.4/dist/leaflet.css" integrity="sha256-p4NxAoJBhIIN+hmNHrzRCf9tD/miZyoHS5obTRR9BMY=" crossorigin=""/>
<style>
@import url('https://fonts.googleapis.com/css2?family=Inter:wght@300;400;500;600;700&display=swap');
body {
font-family: 'Inter', sans-serif;
margin: 0;
padding: 0;
overflow: hidden;
background-color: #f8fafc;
}
.leaflet-container {
width: 100%;
height: 100%;
z-index: 1;
background: #ebe7e3;
}
.custom-marker {
background: none;
border: none;
}
.custom-scrollbar::-webkit-scrollbar {
width: 6px;
}
.custom-scrollbar::-webkit-scrollbar-track {
background: transparent;
}
.custom-scrollbar::-webkit-scrollbar-thumb {
background: #e2e8f0;
border-radius: 10px;
}
.custom-scrollbar::-webkit-scrollbar-thumb:hover {
background: #cbd5e1;
}
</style>
<script type="importmap">
{
"imports": {
"react": "https://esm.sh/react@19.0.0",
"react-dom": "https://esm.sh/react-dom@19.0.0",
"react-dom/client": "https://esm.sh/react-dom@19.0.0/client",
"lucide-react": "https://esm.sh/lucide-react@0.460.0?external=react",
"leaflet": "https://esm.sh/leaflet@1.9.4",
"react-leaflet": "https://esm.sh/react-leaflet@5.0.0?external=react,react-dom,leaflet",
"qrcode.react": "https://esm.sh/qrcode.react@3.1.0?external=react",
"@google/genai": "https://esm.sh/@google/genai@1.40.0",
"react-dom/": "https://esm.sh/react-dom@^19.2.4/",
"react/": "https://esm.sh/react@^19.2.4/"
}
}
</script>
<link rel="stylesheet" href="/index.css">
</head>
<body class="bg-slate-50 text-slate-900">
<div id="root"></div>
<script type="module" src="/index.tsx"></script>
</body>
</html>

16
index.tsx Normal file
View File

@@ -0,0 +1,16 @@
import React from 'react';
import ReactDOM from 'react-dom/client';
import App from './App';
const rootElement = document.getElementById('root');
if (!rootElement) {
throw new Error("Could not find root element to mount to");
}
const root = ReactDOM.createRoot(rootElement);
root.render(
<React.StrictMode>
<App />
</React.StrictMode>
);

7
metadata.json Normal file
View File

@@ -0,0 +1,7 @@
{
"name": "WiFi Share Pro",
"description": "A sophisticated community-driven WiFi password sharing application with interactive map integration and smart location search.",
"requestFramePermissions": [
"geolocation"
]
}

26
package.json Normal file
View File

@@ -0,0 +1,26 @@
{
"name": "wifi-share-pro",
"private": true,
"version": "0.0.0",
"type": "module",
"scripts": {
"dev": "vite",
"build": "vite build",
"preview": "vite preview"
},
"dependencies": {
"react": "^19.2.4",
"react-dom": "^19.2.4",
"lucide-react": "0.460.0",
"leaflet": "1.9.4",
"react-leaflet": "5.0.0",
"qrcode.react": "3.1.0",
"@google/genai": "1.40.0"
},
"devDependencies": {
"@types/node": "^22.14.0",
"@vitejs/plugin-react": "^5.0.0",
"typescript": "~5.8.2",
"vite": "^6.2.0"
}
}

86
services/geminiService.ts Normal file
View File

@@ -0,0 +1,86 @@
import { GoogleGenAI, Type } from "@google/genai";
const ai = new GoogleGenAI({ apiKey: process.env.API_KEY });
export interface DetailedSearchResult {
title: string;
uri: string;
lat?: number;
lng?: number;
}
export const geminiService = {
async searchNearbyWiFi(query: string, location: { lat: number, lng: number }) {
try {
console.log("Stage 1: 정보 검색 시작...");
// Stage 1: Google Search/Maps Grounding을 통해 원시 데이터 확보
const searchResponse = await ai.models.generateContent({
model: "gemini-2.5-flash",
contents: `내 위치(위도: ${location.lat}, 경도: ${location.lng}) 주변에서 "${query}" 장소들을 찾아주세요.
각 장소의 이름, 주소, 그리고 가능한 경우 위도와 경도 정보를 상세히 포함해서 답변해 주세요.`,
config: {
tools: [{ googleSearch: {} }, { googleMaps: {} }],
toolConfig: {
retrievalConfig: {
latLng: {
latitude: location.lat,
longitude: location.lng
}
}
}
},
});
const rawText = searchResponse.text || "";
console.log("Stage 1 원문 결과:", rawText);
if (!rawText) {
return { text: "검색 결과를 가져오지 못했습니다.", results: [] };
}
console.log("Stage 2: 데이터 구조화 추출 시작...");
// Stage 2: 획득한 텍스트에서 JSON 형태로 좌표 및 정보 정밀 추출
const parseResponse = await ai.models.generateContent({
model: "gemini-3-flash-preview",
contents: `다음은 주변 장소 검색 결과 텍스트입니다. 이 텍스트에서 언급된 모든 장소를 추출하여 JSON 배열로 응답해 주세요.
각 객체는 title, uri(구글맵 링크), lat(숫자), lng(숫자) 속성을 가져야 합니다.
좌표가 텍스트에 직접 없더라도 문맥상 추론 가능하거나 본문에 포함되어 있다면 반드시 숫자로 변환하세요.
텍스트:
${rawText}`,
config: {
responseMimeType: "application/json",
responseSchema: {
type: Type.ARRAY,
items: {
type: Type.OBJECT,
properties: {
title: { type: Type.STRING, description: "장소 이름" },
uri: { type: Type.STRING, description: "구글 맵 링크" },
lat: { type: Type.NUMBER, description: "위도 (Latitude)" },
lng: { type: Type.NUMBER, description: "경도 (Longitude)" },
},
required: ["title", "lat", "lng"]
}
}
}
});
let results: DetailedSearchResult[] = [];
try {
results = JSON.parse(parseResponse.text || "[]");
console.log("Stage 2 추출 성공:", results);
} catch (e) {
console.error("JSON 파싱 실패:", e);
}
return { text: rawText, results };
} catch (error: any) {
console.error("Gemini 검색 에러:", error);
return { text: `에러: ${error.message}`, results: [] };
}
}
};

48
services/storage.ts Normal file
View File

@@ -0,0 +1,48 @@
import { WiFiHotspot } from '../types';
const STORAGE_KEY = 'wifi_hotspots_v2_db';
export const storageService = {
getHotspots: (): WiFiHotspot[] => {
try {
const data = localStorage.getItem(STORAGE_KEY);
if (!data) return [];
const parsed = JSON.parse(data);
return Array.isArray(parsed) ? parsed : [];
} catch (e) {
console.error("Failed to load hotspots from storage", e);
return [];
}
},
saveHotspot: (hotspot: WiFiHotspot): void => {
try {
const hotspots = storageService.getHotspots();
const index = hotspots.findIndex(h => h.id === hotspot.id);
if (index > -1) {
hotspots[index] = hotspot;
} else {
hotspots.push(hotspot);
}
localStorage.setItem(STORAGE_KEY, JSON.stringify(hotspots));
// Trigger a storage event for cross-tab sync if needed
window.dispatchEvent(new Event('storage_updated'));
} catch (e) {
console.error("Failed to save hotspot", e);
}
},
deleteHotspot: (id: string): void => {
try {
const hotspots = storageService.getHotspots();
const filtered = hotspots.filter(h => h.id !== id);
localStorage.setItem(STORAGE_KEY, JSON.stringify(filtered));
window.dispatchEvent(new Event('storage_updated'));
} catch (e) {
console.error("Failed to delete hotspot", e);
}
}
};

29
tsconfig.json Normal file
View File

@@ -0,0 +1,29 @@
{
"compilerOptions": {
"target": "ES2022",
"experimentalDecorators": true,
"useDefineForClassFields": false,
"module": "ESNext",
"lib": [
"ES2022",
"DOM",
"DOM.Iterable"
],
"skipLibCheck": true,
"types": [
"node"
],
"moduleResolution": "bundler",
"isolatedModules": true,
"moduleDetection": "force",
"allowJs": true,
"jsx": "react-jsx",
"paths": {
"@/*": [
"./*"
]
},
"allowImportingTsExtensions": true,
"noEmit": true
}
}

20
types.ts Normal file
View File

@@ -0,0 +1,20 @@
export interface WiFiHotspot {
id: string;
name: string;
password?: string;
ssid: string;
lat: number;
lng: number;
securityType: 'WPA2' | 'WPA3' | 'Open' | 'Other';
iconType: 'cafe' | 'restaurant' | 'general';
description?: string;
addedBy: string;
createdAt: number;
isPublic: boolean;
}
export interface SearchResult {
title: string;
uri: string;
}

23
vite.config.ts Normal file
View File

@@ -0,0 +1,23 @@
import path from 'path';
import { defineConfig, loadEnv } from 'vite';
import react from '@vitejs/plugin-react';
export default defineConfig(({ mode }) => {
const env = loadEnv(mode, '.', '');
return {
server: {
port: 3000,
host: '0.0.0.0',
},
plugins: [react()],
define: {
'process.env.API_KEY': JSON.stringify(env.GEMINI_API_KEY),
'process.env.GEMINI_API_KEY': JSON.stringify(env.GEMINI_API_KEY)
},
resolve: {
alias: {
'@': path.resolve(__dirname, '.'),
}
}
};
});