commit 1c72c185ab55f247be4da08ee29fa501d8fa210d Author: LGram16 Date: Fri Feb 6 15:16:49 2026 +0900 initial commit diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..a547bf3 --- /dev/null +++ b/.gitignore @@ -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? diff --git a/App.tsx b/App.tsx new file mode 100644 index 0000000..07a626b --- /dev/null +++ b/App.tsx @@ -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 = ''; + } else if (type === 'restaurant') { + iconPath = ''; + } else { + iconPath = ''; + } + + return L.divIcon({ + html: `
+ ${iconPath} +
`, + className: 'custom-marker', + iconSize: [44, 44], + iconAnchor: [22, 22], + }); +}; + +const UserLocationIcon = L.divIcon({ + html: `
+
+
+
`, + 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(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 ( +
+
+ + +
+ + +
+ ); +}; + +const App: React.FC = () => { + const [hotspots, setHotspots] = useState(() => storageService.getHotspots()); + const [searchQuery, setSearchQuery] = useState(''); + const [isAdding, setIsAdding] = useState(false); + const [selectedHotspot, setSelectedHotspot] = useState(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(null); + const [isSearching, setIsSearching] = useState(false); + const [searchInfo, setSearchInfo] = useState<{text: string, results: DetailedSearchResult[]} | null>(null); + const [lastSearchRawText, setLastSearchRawText] = useState(''); + 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(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) => { + 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 ( +
+
+
+
+ setSearchQuery(e.target.value)} + onKeyDown={(e) => e.key === 'Enter' && performSearch()} + /> + +
+ + {searchInfo && ( +
+
+
+

+ 주변 검색 결과 +

+
+ + +
+
+ + {showDebug && ( +
+

AI 정밀 분석 데이터

+
+ {lastSearchRawText} +
+
+ )} +
+ +
+ {searchInfo.results.map((r, i) => ( + + ))} +
+
+ )} + +
+
+ 나의 WiFi: {hotspots.length} + 줌 레벨: {currentZoom} +
+
+ 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" /> + 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" /> + +
+
+
+ +
+ + + + setCurrentZoom(Math.round(z))} + onMapMove={handleMapMove} + /> + + + {hotspots.map(spot => ( + { L.DomEvent.stopPropagation(e as any); setSelectedHotspot(spot); setIsAdding(false); setMapCenter([spot.lat, spot.lng]); } }} /> + ))} + {newHotspotPos && ( + + 정확한 위치로 드래그 + + )} + +
+
+
+
+
+
+ + {(selectedHotspot || isAdding) && ( +
+
+
+

{isAdding ? "새 WiFi 등록" : "장소 정보"}

+ +
+ + {isAdding ? ( +
+
+ +
+

등록될 위치

+

{newHotspotPos?.lat.toFixed(6)}, {newHotspotPos?.lng.toFixed(6)}

+
+
+ +
+ {(['general', 'cafe', 'restaurant'] as const).map(type => ( + + ))} +
+ +
+ 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" /> + + + +
+ +
+ ) : selectedHotspot && ( +
+
+
+ {selectedHotspot.iconType === 'cafe' ? : selectedHotspot.iconType === 'restaurant' ? : } +
+
+

{selectedHotspot.name}

+

{selectedHotspot.ssid}

+
+
+ +
+

WiFi 비밀번호

+
+

{showPass === selectedHotspot.id ? (selectedHotspot.password || 'OPEN') : '••••••••'}

+ +
+
+ +
+

자동 연결 QR 코드

+
+ +
+
카메라로 스캔하세요
+
+ +
+ + +
+
+ )} +
+
+ )} +
+ ); +}; + +export default App; diff --git a/README.md b/README.md new file mode 100644 index 0000000..253cffe --- /dev/null +++ b/README.md @@ -0,0 +1,20 @@ +
+GHBanner +
+ +# 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` diff --git a/index.html b/index.html new file mode 100644 index 0000000..840cb2a --- /dev/null +++ b/index.html @@ -0,0 +1,65 @@ + + + + + + + WiFi Share Pro + + + + + + + +
+ + + diff --git a/index.tsx b/index.tsx new file mode 100644 index 0000000..aaa0c6e --- /dev/null +++ b/index.tsx @@ -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( + + + +); diff --git a/metadata.json b/metadata.json new file mode 100644 index 0000000..400db5a --- /dev/null +++ b/metadata.json @@ -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" + ] +} \ No newline at end of file diff --git a/package.json b/package.json new file mode 100644 index 0000000..8518505 --- /dev/null +++ b/package.json @@ -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" + } +} diff --git a/services/geminiService.ts b/services/geminiService.ts new file mode 100644 index 0000000..edbbed3 --- /dev/null +++ b/services/geminiService.ts @@ -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: [] }; + } + } +}; diff --git a/services/storage.ts b/services/storage.ts new file mode 100644 index 0000000..c233957 --- /dev/null +++ b/services/storage.ts @@ -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); + } + } +}; diff --git a/tsconfig.json b/tsconfig.json new file mode 100644 index 0000000..2c6eed5 --- /dev/null +++ b/tsconfig.json @@ -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 + } +} \ No newline at end of file diff --git a/types.ts b/types.ts new file mode 100644 index 0000000..fe644bf --- /dev/null +++ b/types.ts @@ -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; +} diff --git a/vite.config.ts b/vite.config.ts new file mode 100644 index 0000000..ee5fb8d --- /dev/null +++ b/vite.config.ts @@ -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, '.'), + } + } + }; +});