From 1c72c185ab55f247be4da08ee29fa501d8fa210d Mon Sep 17 00:00:00 2001 From: LGram16 Date: Fri, 6 Feb 2026 15:16:49 +0900 Subject: [PATCH] initial commit --- .gitignore | 24 ++ App.tsx | 473 ++++++++++++++++++++++++++++++++++++++ README.md | 20 ++ index.html | 65 ++++++ index.tsx | 16 ++ metadata.json | 7 + package.json | 26 +++ services/geminiService.ts | 86 +++++++ services/storage.ts | 48 ++++ tsconfig.json | 29 +++ types.ts | 20 ++ vite.config.ts | 23 ++ 12 files changed, 837 insertions(+) create mode 100644 .gitignore create mode 100644 App.tsx create mode 100644 README.md create mode 100644 index.html create mode 100644 index.tsx create mode 100644 metadata.json create mode 100644 package.json create mode 100644 services/geminiService.ts create mode 100644 services/storage.ts create mode 100644 tsconfig.json create mode 100644 types.ts create mode 100644 vite.config.ts 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, '.'), + } + } + }; +});