Spaces:
Sleeping
Sleeping
| import streamlit as st | |
| import streamlit.components.v1 as components | |
| import json | |
| import os | |
| # 1. 페이지 설정 | |
| st.set_page_config(page_title="Drunken Plane", page_icon="✈️", layout="wide") | |
| st.markdown(""" | |
| <style> | |
| .block-container { padding: 0 !important; margin: 0 !important; max-width: 100% !important; } | |
| header, footer { display: none !important; } | |
| iframe { height: 100vh !important; display: block; } | |
| </style> | |
| """, unsafe_allow_html=True) | |
| # 2. 데이터 로드 | |
| try: | |
| with open('places.json', 'r', encoding='utf-8') as f: | |
| PLACE_DB = json.load(f) | |
| except FileNotFoundError: | |
| PLACE_DB = {} | |
| st.error("🚨 'places.json' 파일을 찾을 수 없습니다. V49.0 데이터를 저장해주세요!") | |
| db_json = json.dumps(PLACE_DB) | |
| # 3. HTML/JS | |
| html_code = f""" | |
| <!DOCTYPE html> | |
| <html> | |
| <head> | |
| <meta charset="utf-8"> | |
| <meta name="viewport" content="width=device-width, initial-scale=1.0, maximum-scale=1.0, user-scalable=no" /> | |
| <title>Drunken Plane Tour</title> | |
| <script src="https://js.puter.com/v2/"></script> | |
| <link rel="stylesheet" href="https://unpkg.com/leaflet@1.9.4/dist/leaflet.css" /> | |
| <script src="https://unpkg.com/leaflet@1.9.4/dist/leaflet.js"></script> | |
| <style> | |
| :root {{ --gold: #d4af37; --dark: #0a0a0a; --card-bg: #1c1c1c; --text: #e0e0e0; --accent: #c5a059; }} | |
| body {{ margin: 0; padding: 0; background: var(--dark); color: var(--text); font-family: 'Segoe UI', sans-serif; overflow: hidden; }} | |
| /* --- Common --- */ | |
| .page {{ position: absolute; top: 0; left: 0; width: 100%; height: 100vh; transition: transform 0.5s cubic-bezier(0.22, 1, 0.36, 1); box-sizing: border-box; }} | |
| /* --- Main Page --- */ | |
| #main-page {{ | |
| background: linear-gradient(rgba(0,0,0,0.7), rgba(0,0,0,0.95)), url('https://images.unsplash.com/photo-1516981879613-9f5da904015f?q=80&w=2560&auto=format&fit=crop'); | |
| background-size: cover; background-position: center; | |
| z-index: 100; display: flex; flex-direction: column; align-items: center; justify-content: flex-start; | |
| padding-top: 20px; | |
| }} | |
| .lang-switcher {{ position: absolute; top: 20px; right: 20px; z-index: 200; display: flex; gap: 8px; }} | |
| .lang-btn {{ background: rgba(0,0,0,0.5); border: 1px solid #555; color: #aaa; padding: 5px 10px; border-radius: 15px; cursor: pointer; font-size: 12px; font-weight: bold; transition: all 0.2s; }} | |
| .lang-btn.active {{ background: var(--gold); color: black; border-color: var(--gold); }} | |
| .brand-title {{ font-size: 24px; font-weight: 900; color: var(--gold); letter-spacing: 4px; margin: 20px 0 0 0; text-transform: uppercase; }} | |
| .brand-subtitle {{ font-size: 11px; color: #ccc; letter-spacing: 2px; margin-bottom: 10px; }} | |
| /* World Map Entry Button */ | |
| .world-map-btn {{ | |
| background: rgba(212, 175, 55, 0.2); border: 1px solid var(--gold); color: var(--gold); | |
| padding: 10px 20px; border-radius: 30px; font-weight: bold; cursor: pointer; margin-bottom: 15px; | |
| display: flex; align-items: center; gap: 8px; backdrop-filter: blur(5px); | |
| }} | |
| .world-map-btn:hover {{ background: var(--gold); color: black; }} | |
| .country-select-wrapper {{ width: 80%; max-width: 400px; margin-bottom: 15px; }} | |
| .country-select {{ | |
| width: 100%; padding: 12px; background: rgba(30,30,30,0.9); border: 1px solid var(--gold); | |
| color: white; font-size: 16px; border-radius: 8px; outline: none; appearance: none; | |
| background-image: url("data:image/svg+xml;charset=US-ASCII,%3Csvg%20xmlns%3D%22http%3A%2F%2Fwww.w3.org%2F2000%2Fsvg%22%20width%3D%22292.4%22%20height%3D%22292.4%22%3E%3Cpath%20fill%3D%22%23d4af37%22%20d%3D%22M287%2069.4a17.6%2017.6%200%200%200-13-5.4H18.4c-5%200-9.3%201.8-12.9%205.4A17.6%2017.6%200%200%200%200%2082.2c0%205%201.8%209.3%205.4%2012.9l128%20127.9c3.6%203.6%207.8%205.4%2012.8%205.4s9.2-1.8%2012.8-5.4L287%2095c3.5-3.5%205.4-7.8%205.4-12.8%200-5-1.9-9.2-5.5-12.8z%22%2F%3E%3C%2Fsvg%3E"); | |
| background-repeat: no-repeat; background-position: right 15px top 50%; background-size: 12px auto; | |
| }} | |
| .type-filter-container {{ width: 100%; padding: 10px 20px; overflow-x: auto; display: flex; gap: 8px; scrollbar-width: none; }} | |
| .type-btn {{ padding: 6px 14px; border-radius: 20px; font-size: 12px; font-weight: bold; cursor: pointer; background: #222; color: #888; border: 1px solid #444; white-space: nowrap; transition: all 0.2s; display: flex; align-items: center; gap: 5px; }} | |
| .type-btn.selected {{ background: var(--gold); color: black; border-color: var(--gold); }} | |
| .place-list-container {{ width: 100%; max-width: 600px; flex: 1; overflow-y: auto; padding: 10px 20px 50px 20px; box-sizing: border-box; }} | |
| .place-card {{ background: rgba(30,30,30,0.6); backdrop-filter: blur(10px); border: 1px solid rgba(255,255,255,0.1); border-radius: 12px; padding: 15px; margin-bottom: 12px; transition: all 0.2s; display: flex; flex-direction: column; position: relative; }} | |
| .card-header {{ display: flex; justify-content: space-between; align-items: flex-start; margin-bottom: 5px; }} | |
| .card-label {{ font-size: 10px; background: rgba(212, 175, 55, 0.2); color: var(--gold); padding: 3px 8px; border-radius: 4px; font-weight: bold; border: 1px solid var(--gold); }} | |
| .pc-name {{ font-size: 16px; font-weight: bold; color: white; }} | |
| .pc-desc {{ font-size: 12px; color: #bbb; line-height: 1.4; margin-top: 5px; margin-bottom: 15px; }} | |
| .card-actions {{ display: flex; gap: 8px; margin-top: auto; }} | |
| .btn-action {{ flex: 1; padding: 10px 0; border-radius: 8px; font-size: 12px; font-weight: bold; cursor: pointer; text-align: center; transition: all 0.2s; border: none; }} | |
| .btn-ai {{ background: var(--gold); color: black; }} | |
| .btn-map {{ background: #333; color: white; border: 1px solid #555; }} | |
| /* --- Detail Page --- */ | |
| #detail-page {{ background: #111; z-index: 300; transform: translateX(100%); display: flex; flex-direction: column; }} | |
| .nav-bar {{ height: 50px; background: rgba(0,0,0,0.9); border-bottom: 1px solid #333; display: flex; align-items: center; padding: 0 15px; justify-content: space-between; }} | |
| .back-btn {{ background: none; border: none; color: var(--gold); font-size: 24px; cursor: pointer; }} | |
| #mini-map {{ width: 100%; height: 35%; border-bottom: 2px solid var(--gold); }} | |
| .report-container {{ flex: 1; overflow-y: auto; padding: 25px 20px 80px 20px; }} | |
| .report-title {{ font-size: 24px; color: var(--gold); font-weight: bold; margin-bottom: 5px; }} | |
| .report-meta {{ font-size: 12px; color: #666; margin-bottom: 20px; border-bottom: 1px solid #333; padding-bottom: 15px; }} | |
| .ai-content {{ font-size: 15px; color: #ddd; line-height: 1.8; white-space: pre-wrap; }} | |
| .gmap-btn {{ display: block; width: 90%; margin: 0 auto; padding: 15px 0; background: var(--gold); color: black; text-align: center; font-weight: bold; text-decoration: none; border-radius: 30px; position: fixed; bottom: 20px; left: 5%; z-index: 7000; }} | |
| .gmap-btn.naver {{ background: #03C75A !important; color: white !important; }} | |
| /* --- World Map Page --- */ | |
| #world-map-page {{ background: #111; z-index: 200; transform: translateY(100%); display: flex; flex-direction: column; }} | |
| .map-search-bar {{ | |
| position: absolute; top: 20px; left: 50%; transform: translateX(-50%); width: 90%; max-width: 500px; | |
| z-index: 1000; display: flex; gap: 10px; background: rgba(0,0,0,0.8); padding: 10px; border-radius: 12px; border: 1px solid #444; | |
| }} | |
| .map-input {{ flex: 1; background: transparent; border: none; color: white; font-size: 16px; outline: none; }} | |
| .map-icon-btn {{ background: none; border: none; font-size: 20px; cursor: pointer; color: var(--gold); }} | |
| .home-btn {{ background: #333; color: white; border: 1px solid #555; padding: 0 15px; border-radius: 8px; font-weight: bold; cursor: pointer; }} | |
| .floating-loc-btn {{ | |
| position: absolute; bottom: 30px; right: 20px; | |
| width: 50px; height: 50px; border-radius: 50%; | |
| background: white; color: black; border: none; | |
| box-shadow: 0 4px 10px rgba(0,0,0,0.3); | |
| font-size: 24px; cursor: pointer; z-index: 1000; | |
| display: flex; justify-content: center; align-items: center; | |
| transition: transform 0.2s; | |
| }} | |
| .floating-loc-btn:active {{ transform: scale(0.95); }} | |
| #full-map {{ width: 100%; height: 100%; }} | |
| /* --- Loader --- */ | |
| .loading-screen {{ position: absolute; top: 0; left: 0; width: 100%; height: 100%; background: rgba(0,0,0,0.9); z-index: 8000; display: none; flex-direction: column; justify-content: center; align-items: center; }} | |
| .loader-ring {{ width: 40px; height: 40px; border: 3px solid #333; border-top: 3px solid var(--gold); border-radius: 50%; animation: spin 1s linear infinite; margin-bottom: 15px; }} | |
| .loader-text {{ color: var(--gold); font-family: monospace; font-size: 13px; text-align: center; }} | |
| @keyframes spin {{ 0% {{ transform: rotate(0deg); }} 100% {{ transform: rotate(360deg); }} }} | |
| /* Popup Styling */ | |
| .leaflet-popup-content-wrapper {{ background: #222; color: #eee; border: 1px solid var(--gold); }} | |
| .leaflet-popup-tip {{ background: var(--gold); }} | |
| .popup-title {{ font-size: 16px; font-weight: bold; color: var(--gold); margin-bottom: 5px; }} | |
| .popup-badges {{ display: flex; gap: 5px; margin-bottom: 8px; }} | |
| .popup-badge {{ font-size: 10px; padding: 2px 6px; border-radius: 4px; font-weight: bold; }} | |
| .badge-cat {{ background: #000; color: white; border: 1px solid #444; }} | |
| .badge-lbl {{ background: #eee; color: #333; }} | |
| .popup-desc {{ font-size: 12px; color: #ccc; margin-bottom: 10px; line-height: 1.3; }} | |
| .popup-btn {{ width: 100%; padding: 8px; background: var(--gold); color: black; border: none; border-radius: 4px; font-weight: bold; cursor: pointer; }} | |
| </style> | |
| </head> | |
| <body> | |
| <div id="main-page" class="page"> | |
| <div class="lang-switcher"> | |
| <div class="lang-btn active" onclick="setLanguage('KO', this)">🇰🇷</div> | |
| <div class="lang-btn" onclick="setLanguage('EN', this)">🇺🇸</div> | |
| <div class="lang-btn" onclick="setLanguage('JP', this)">🇯🇵</div> | |
| <div class="lang-btn" onclick="setLanguage('CN', this)">🇨🇳</div> | |
| </div> | |
| <div style="text-align:center;"> | |
| <div class="brand-title">DRUNKEN PLANE</div> | |
| <div id="app-subtitle" class="brand-subtitle">GLOBAL ALCOHOL HERITAGE TOUR</div> | |
| <button class="world-map-btn" onclick="openWorldMapPage()">🗺️ WORLD MAP EXPLORER</button> | |
| </div> | |
| <div class="country-select-wrapper"> | |
| <select id="country-select" class="country-select" onchange="selectCountry(this.value)"></select> | |
| </div> | |
| <div class="type-filter-container"> | |
| <div class="type-btn selected" id="filter-all" onclick="filterType('All', this)">🌐 All</div> | |
| <div class="type-btn" id="filter-traditional" onclick="filterType('Traditional', this)">🏺 Traditional</div> | |
| <div class="type-btn" id="filter-whiskey" onclick="filterType('Whiskey', this)">🥃 Whiskey</div> | |
| <div class="type-btn" id="filter-spirits" onclick="filterType('Spirits', this)">🍸 Spirits</div> | |
| <div class="type-btn" id="filter-beer" onclick="filterType('Beer', this)">🍺 Beer</div> | |
| <div class="type-btn" id="filter-wine" onclick="filterType('Wine', this)">🍷 Wine</div> | |
| <div class="type-btn" id="filter-bar" onclick="filterType('Bar', this)">🍹 Bar</div> | |
| </div> | |
| <div class="place-list-container" id="place-list"></div> | |
| </div> | |
| <div id="detail-page" class="page"> | |
| <div class="nav-bar"> | |
| <button class="back-btn" onclick="closeDetail()">←</button> | |
| <div id="report-header" style="color:white; font-weight:bold;">TRAVEL REPORT</div> | |
| <div style="width:24px;"></div> | |
| </div> | |
| <div id="mini-map"></div> | |
| <div class="report-container"> | |
| <div id="repo-title" class="report-title"></div> | |
| <div id="repo-meta" class="report-meta"></div> | |
| <div id="repo-content" class="ai-content"></div> | |
| </div> | |
| <a id="gmap-link" href="#" target="_blank" class="gmap-btn">🗺️ Google Maps</a> | |
| </div> | |
| <div id="world-map-page" class="page"> | |
| <div class="map-search-bar"> | |
| <button class="home-btn" onclick="closeWorldMapPage()">🏠 HOME</button> | |
| <input type="text" id="map-search-input" class="map-input" placeholder="Search City (e.g. Gangnam, London)..." onkeydown="if(event.key === 'Enter') searchMapLocation()"> | |
| <button class="map-icon-btn" onclick="searchMapLocation()">🔍</button> | |
| </div> | |
| <div id="full-map"></div> | |
| <button class="floating-loc-btn" onclick="getMyLocation()">🎯</button> | |
| </div> | |
| <div id="loader" class="loading-screen"> | |
| <div class="loader-ring"></div> | |
| <div id="loader-msg" class="loader-text"></div> | |
| </div> | |
| <script> | |
| const PLACE_DB = {db_json}; | |
| let miniMap = null; | |
| let miniMarker = null; | |
| let worldMap = null; | |
| let worldMarkers = L.layerGroup(); | |
| let selectedCountry = ""; | |
| let selectedType = "All"; | |
| let currentLang = "KO"; | |
| let loadingInterval = null; | |
| const LOADING_MSGS = {{ | |
| "KO": ["AI가 비행기 타고 이동 중... ✈️", "가게 문을 열고 들어갑니다... 🚪", "분위기 스캔 중... 👀", "시그니처 시음 중... 🍷", "사장님 인터뷰 중... 🗣️", "리포트 작성 중... 📝"], | |
| "EN": ["Flying to location... ✈️", "Entering venue... 🚪", "Scanning vibes... 👀", "Tasting drinks... 🍷", "Interviewing staff... 🗣️", "Writing report... 📝"], | |
| "JP": ["現地へ移動中... ✈️", "入店しています... 🚪", "雰囲気スキャン中... 👀", "試飲中... 🍷", "レポート作成中... 📝"], | |
| "CN": ["正在前往... ✈️", "正在进店... 🚪", "扫描氛围... 👀", "品尝美酒... 🍷", "撰写报告... 📝"] | |
| }}; | |
| const TRANSLATIONS = {{ | |
| "KO": {{ "subtitle": "글로벌 주류 헤리티지 투어", "filters": {{ "All": "전체", "Whiskey": "위스키", "Spirits": "증류주", "Beer": "맥주", "Wine": "와인", "Traditional": "전통주/토속주", "Bar": "바/펍" }}, "report_header": "여행 리포트", "gmap": "구글맵", "nmap": "네이버 지도", "cta_ai": "🤖 AI 리뷰", "cta_map": "🗺️ 길찾기" }}, | |
| "EN": {{ "subtitle": "GLOBAL ALCOHOL HERITAGE TOUR", "filters": {{ "All": "All", "Whiskey": "Whiskey", "Spirits": "Spirits", "Beer": "Beer", "Wine": "Wine", "Traditional": "Traditional", "Bar": "Bar" }}, "report_header": "TRAVEL REPORT", "gmap": "Google Maps", "nmap": "Naver Map", "cta_ai": "🤖 AI Review", "cta_map": "🗺️ Map" }}, | |
| "JP": {{ "subtitle": "世界のお酒ヘリテージツアー", "filters": {{ "All": "すべて", "Whiskey": "ウイスキー", "Spirits": "蒸留酒", "Beer": "ビール", "Wine": "ワイン", "Traditional": "伝統酒", "Bar": "バー" }}, "report_header": "旅行レポート", "gmap": "Googleマップ", "nmap": "Naverマップ", "cta_ai": "🤖 AIレビュー", "cta_map": "🗺️ 地図" }}, | |
| "CN": {{ "subtitle": "全球酒类遗产之旅", "filters": {{ "All": "全部", "Whiskey": "威士忌", "Spirits": "烈酒", "Beer": "啤酒", "Wine": "葡萄酒", "Traditional": "传统酒", "Bar": "酒吧" }}, "report_header": "旅游报告", "gmap": "谷歌地图", "nmap": "Naver地图", "cta_ai": "🤖 AI 评论", "cta_map": "🗺️ 地图" }} | |
| }}; | |
| window.onload = function() {{ | |
| initCountryDropdown(); | |
| const keys = Object.keys(PLACE_DB).sort(); | |
| const defaultKey = keys.find(k => k.includes("Korea")) || keys[0]; | |
| document.getElementById('country-select').value = defaultKey; | |
| selectCountry(defaultKey); | |
| updateUIText(); | |
| }}; | |
| function setLanguage(lang, el) {{ | |
| currentLang = lang; | |
| document.querySelectorAll('.lang-btn').forEach(b => b.classList.remove('active')); | |
| el.classList.add('active'); | |
| updateUIText(); | |
| renderList(); | |
| }} | |
| function updateUIText() {{ | |
| const t = TRANSLATIONS[currentLang]; | |
| document.getElementById('app-subtitle').innerText = t.subtitle; | |
| document.getElementById('report-header').innerText = t.report_header; | |
| document.getElementById('filter-all').innerText = `🌐 ${{t.filters.All}}`; | |
| document.getElementById('filter-whiskey').innerText = `🥃 ${{t.filters.Whiskey}}`; | |
| document.getElementById('filter-spirits').innerText = `🍸 ${{t.filters.Spirits}}`; | |
| document.getElementById('filter-beer').innerText = `🍺 ${{t.filters.Beer}}`; | |
| document.getElementById('filter-wine').innerText = `🍷 ${{t.filters.Wine}}`; | |
| document.getElementById('filter-traditional').innerText = `🏺 ${{t.filters.Traditional}}`; | |
| document.getElementById('filter-bar').innerText = `🍹 ${{t.filters.Bar}}`; | |
| }} | |
| function initCountryDropdown() {{ | |
| const select = document.getElementById('country-select'); | |
| const keys = Object.keys(PLACE_DB).sort(); | |
| keys.forEach(country => {{ | |
| const option = document.createElement('option'); | |
| option.value = country; | |
| option.text = country; | |
| select.appendChild(option); | |
| }}); | |
| }} | |
| function selectCountry(country) {{ | |
| selectedCountry = country; | |
| renderList(); | |
| }} | |
| function filterType(type, el) {{ | |
| selectedType = type; | |
| document.querySelectorAll('.type-btn').forEach(b => b.classList.remove('selected')); | |
| el.classList.add('selected'); | |
| renderList(); | |
| }} | |
| function getMapUrl(place) {{ | |
| const isKorea = selectedCountry.includes("Korea") || (place.name && place.desc_ko && place.real_addr); | |
| let query = ""; | |
| if (isKorea && place.real_addr) {{ | |
| query = encodeURIComponent(place.real_addr); | |
| return `https://map.naver.com/v5/search/${{query}}`; | |
| }} else if (isKorea) {{ | |
| query = encodeURIComponent(place.name); | |
| return `https://map.naver.com/v5/search/${{query}}`; | |
| }} else {{ | |
| query = encodeURIComponent(place.name + " " + selectedCountry); | |
| return `https://www.google.com/maps/search/?api=1&query=$${{query}}`; | |
| }} | |
| }} | |
| function renderList() {{ | |
| const list = document.getElementById('place-list'); | |
| list.innerHTML = ""; | |
| const places = PLACE_DB[selectedCountry]; | |
| const filtered = selectedType === "All" ? places : places.filter(p => p.category === selectedType); | |
| if (filtered.length === 0) {{ | |
| list.innerHTML = "<div style='color:#888; text-align:center; padding:20px;'>No places found.</div>"; | |
| return; | |
| }} | |
| const t = TRANSLATIONS[currentLang]; | |
| filtered.forEach(place => {{ | |
| const card = document.createElement('div'); | |
| card.className = 'place-card'; | |
| let desc = currentLang === 'KO' ? place.desc_ko : place.desc_en; | |
| if(currentLang === 'JP' || currentLang === 'CN') desc = place.desc_en; | |
| card.innerHTML = ` | |
| <div class="card-header"> | |
| <div class="pc-name">${{place.name}}</div> | |
| <div class="card-label">${{place.label}}</div> | |
| </div> | |
| <div class="pc-desc">${{desc}}</div> | |
| <div class="card-actions"> | |
| <button class="btn-action btn-ai" onclick="openDetail('${{place.name}}', '${{selectedCountry}}')">${{t.cta_ai}}</button> | |
| <button class="btn-action btn-map" onclick="window.open('${{getMapUrl(place)}}', '_blank')">${{t.cta_map}}</button> | |
| </div> | |
| `; | |
| list.appendChild(card); | |
| }}); | |
| }} | |
| /* --- World Map Logic --- */ | |
| function openWorldMapPage() {{ | |
| document.getElementById('world-map-page').style.transform = 'translateY(0%)'; | |
| if (!worldMap) {{ | |
| worldMap = L.map('full-map').setView([20, 0], 2); | |
| L.tileLayer('https://{{s}}.tile.openstreetmap.org/{{z}}/{{x}}/{{y}}.png', {{ attribution: '© OpenStreetMap' }}).addTo(worldMap); | |
| loadAllMarkers(); | |
| }} | |
| }} | |
| function closeWorldMapPage() {{ | |
| document.getElementById('world-map-page').style.transform = 'translateY(100%)'; | |
| }} | |
| function loadAllMarkers() {{ | |
| worldMarkers.clearLayers(); | |
| Object.keys(PLACE_DB).forEach(country => {{ | |
| PLACE_DB[country].forEach(place => {{ | |
| let desc = currentLang === 'KO' ? place.desc_ko : place.desc_en; | |
| if(currentLang === 'JP' || currentLang === 'CN') desc = place.desc_en; | |
| // 🔥 Rich Popup Content (V53.0) | |
| const popupContent = ` | |
| <div style="min-width: 200px;"> | |
| <div class="popup-title">${{place.name}}</div> | |
| <div class="popup-badges"> | |
| <span class="popup-badge badge-cat">${{place.category}}</span> | |
| <span class="popup-badge badge-lbl">${{place.label}}</span> | |
| </div> | |
| <div class="popup-desc">${{desc}}</div> | |
| <button class="popup-btn" onclick="openDetailFromMap('${{place.name}}', '${{country}}')">View Details</button> | |
| </div> | |
| `; | |
| const marker = L.marker([place.lat, place.lon]).bindPopup(popupContent); | |
| worldMarkers.addLayer(marker); | |
| }}); | |
| }}); | |
| worldMarkers.addTo(worldMap); | |
| }} | |
| async function searchMapLocation() {{ | |
| const query = document.getElementById('map-search-input').value; | |
| if(!query) return; | |
| try {{ | |
| const res = await fetch(`https://nominatim.openstreetmap.org/search?format=json&q=${{query}}`); | |
| const data = await res.json(); | |
| if(data && data.length > 0) {{ | |
| const lat = data[0].lat; | |
| const lon = data[0].lon; | |
| worldMap.setView([lat, lon], 12); | |
| }} else {{ | |
| alert("Location not found!"); | |
| }} | |
| }} catch(e) {{ | |
| console.error(e); | |
| }} | |
| }} | |
| function getMyLocation() {{ | |
| if (navigator.geolocation) {{ | |
| navigator.geolocation.getCurrentPosition(position => {{ | |
| const lat = position.coords.latitude; | |
| const lon = position.coords.longitude; | |
| worldMap.setView([lat, lon], 12); | |
| L.marker([lat, lon], {{icon: L.divIcon({{html: '📍', className: 'my-loc-icon', iconSize: [20, 20]}})}}).addTo(worldMap).bindPopup("You are here").openPopup(); | |
| }}); | |
| }} else {{ | |
| alert("Geolocation is not supported by this browser."); | |
| }} | |
| }} | |
| async function openDetailFromMap(placeName, countryName) {{ | |
| selectedCountry = countryName; | |
| closeWorldMapPage(); | |
| openDetail(placeName, countryName); | |
| }} | |
| async function openDetail(placeName, countryName) {{ | |
| const cName = countryName || selectedCountry; | |
| const place = PLACE_DB[cName].find(p => p.name === placeName); | |
| if(!place) return; | |
| document.getElementById('main-page').style.transform = 'translateX(-30%)'; | |
| document.getElementById('detail-page').style.transform = 'translateX(0%)'; | |
| startLoadingAnimation(); | |
| document.getElementById('repo-title').innerText = place.name; | |
| document.getElementById('repo-meta').innerText = `${{place.label}} • ${{cName}}`; | |
| const btn = document.getElementById('gmap-link'); | |
| const isKorea = cName.includes("Korea"); | |
| btn.href = getMapUrl(place); | |
| btn.innerText = isKorea ? TRANSLATIONS[currentLang].nmap : TRANSLATIONS[currentLang].gmap; | |
| if(isKorea) btn.classList.add("naver"); else btn.classList.remove("naver"); | |
| document.getElementById('repo-content').innerHTML = ""; | |
| if (!miniMap) {{ | |
| miniMap = L.map('mini-map'); | |
| L.tileLayer('https://{{s}}.tile.openstreetmap.org/{{z}}/{{x}}/{{y}}.png', {{ attribution: '© OpenStreetMap' }}).addTo(miniMap); | |
| }} | |
| setTimeout(() => {{ | |
| miniMap.invalidateSize(); | |
| miniMap.setView([place.lat, place.lon], 15); | |
| if (miniMarker) miniMap.removeLayer(miniMarker); | |
| miniMarker = L.marker([place.lat, place.lon]).addTo(miniMap).bindPopup(place.name).openPopup(); | |
| }}, 300); | |
| let contextDesc = ""; | |
| if (place.real_addr) {{ | |
| contextDesc = `Address: ${{place.real_addr}}, Vibe: ${{place.real_vibe}}, Drink: ${{place.real_drink}}, Tip: ${{place.real_tip}}`; | |
| }} else {{ | |
| contextDesc = currentLang === 'KO' ? place.desc_ko : place.desc_en; | |
| }} | |
| await generatePuterReport(place, cName, contextDesc); | |
| }} | |
| function startLoadingAnimation() {{ | |
| const loader = document.getElementById('loader'); | |
| loader.style.display = 'flex'; | |
| const msgs = LOADING_MSGS[currentLang]; | |
| let step = 0; | |
| document.getElementById('loader-msg').innerText = msgs[0]; | |
| if (loadingInterval) clearInterval(loadingInterval); | |
| loadingInterval = setInterval(() => {{ | |
| step = (step + 1) % msgs.length; | |
| document.getElementById('loader-msg').innerText = msgs[step]; | |
| }}, 1500); | |
| }} | |
| function stopLoadingAnimation() {{ | |
| if (loadingInterval) clearInterval(loadingInterval); | |
| document.getElementById('loader').style.display = 'none'; | |
| }} | |
| async function generatePuterReport(place, countryName, contextDesc) {{ | |
| let langFull = "Korean"; | |
| if(currentLang === "EN") langFull = "English"; | |
| if(currentLang === "JP") langFull = "Japanese"; | |
| if(currentLang === "CN") langFull = "Chinese"; | |
| const prompt = ` | |
| Act as a travel expert. Write a detailed review about "${{place.name}}" in ${{countryName}}. | |
| [Context Info] | |
| ${{contextDesc}} | |
| Structure: | |
| 1. 📍 **Address/Location**: (Use context if available, else estimate) | |
| 2. ⭐ **Vibe/Atmosphere**: (Describe the feeling) | |
| 3. 🍷 **Signature Drink**: (What to drink) | |
| 4. ✨ **Pro Tip**: (Best time/booking info) | |
| Language: ${{langFull}}. | |
| Format: Clean Markdown. | |
| `; | |
| try {{ | |
| const response = await retryPuter(prompt, 3); | |
| let text = ""; | |
| if (typeof response === 'string') text = response; | |
| else if (response && response.message) text = response.message.content; | |
| stopLoadingAnimation(); | |
| document.getElementById('repo-content').innerHTML = text.replace(/\\n/g, '<br>'); | |
| }} catch(e) {{ | |
| console.warn(e); | |
| generateLocalReport(place, contextDesc); | |
| }} | |
| }} | |
| async function retryPuter(prompt, retries) {{ | |
| for (let i = 0; i < retries; i++) {{ | |
| try {{ | |
| const res = await puter.ai.chat(prompt); | |
| if(res) return res; | |
| }} catch (err) {{ | |
| if (i === retries - 1) throw err; | |
| }} | |
| }} | |
| }} | |
| function generateLocalReport(place, dbDesc) {{ | |
| stopLoadingAnimation(); | |
| const report = ` | |
| ✨ **[Offline Info]** | |
| **${{place.name}}** | |
| ${{dbDesc}} | |
| (Click the Map button below for details.) | |
| `; | |
| document.getElementById('repo-content').innerHTML = report.replace(/\\n/g, '<br>'); | |
| }} | |
| function closeDetail() {{ | |
| document.getElementById('detail-page').style.transform = 'translateX(100%)'; | |
| document.getElementById('main-page').style.transform = 'translateX(0%)'; | |
| }} | |
| </script> | |
| </body> | |
| </html> | |
| """ | |
| components.html(html_code, height=1000, scrolling=True) |