Spaces:
Sleeping
Sleeping
| import streamlit as st | |
| import streamlit.components.v1 as components | |
| import json | |
| import base64 | |
| import os | |
| import pandas as pd | |
| import requests | |
| import io | |
| # ========================================== | |
| # π οΈ 1. μ€μ λ° μν¬λ¦Ώ (μ μ§) | |
| # ========================================== | |
| st.set_page_config(page_title="Drunken Plane", page_icon="βοΈ", layout="wide") | |
| GROQ_API_KEY = os.environ.get("GROQ_API_KEY", "").strip() | |
| if not GROQ_API_KEY and "GROQ_API_KEY" in st.secrets: | |
| GROQ_API_KEY = st.secrets["GROQ_API_KEY"].strip() | |
| SHEET_URL = os.environ.get("PRIVATE_SHEET_URL", "").strip() | |
| if not SHEET_URL and "PRIVATE_SHEET_URL" in st.secrets: | |
| SHEET_URL = st.secrets["PRIVATE_SHEET_URL"].strip() | |
| with st.sidebar: | |
| st.write("### π§ System Status") | |
| if GROQ_API_KEY and len(GROQ_API_KEY) > 10: | |
| st.success(f"π’ API Key Ready") | |
| else: | |
| st.error("π΄ API Key Missing") | |
| if SHEET_URL: | |
| st.success("π’ DB Connected") | |
| else: | |
| st.warning("π Using Dummy Data") | |
| 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. λ°μ΄ν° λ‘λ (μ μ§) | |
| # ========================================== | |
| PLACE_DB = {} | |
| DUMMY_DATA = { | |
| "Korea": [{"name": "Zest", "lat": 37.525, "lon": 127.040, "category": "Bar", "label": "πΈ World Best", "desc_ko": "μμΈ μ΅κ³ μ μΉ΅ν μΌ λ°", "address": "μμΈ κ°λ¨κ΅¬ λμ°λλ‘55κΈΈ 26"}], | |
| "Malaysia": [{"name": "Bar Trigona", "lat": 3.159, "lon": 101.714, "category": "Bar", "label": "π Sustainable", "desc_ko": "μΏ μλΌλ£ΈνΈλ₯΄μ λμ 리 λ°", "address": "Four Seasons Hotel, Kuala Lumpur"}], | |
| "Japan": [{"name": "Bar Benfiddich", "lat": 35.693, "lon": 139.699, "category": "Bar", "label": "πΏ Herbal", "desc_ko": "λμΏ μ μ£ΌμΏ μ μ½μ΄ μΉ΅ν μΌ λ°", "address": "Tokyo, Shinjuku"}] | |
| } | |
| try: | |
| if SHEET_URL: | |
| response = requests.get(SHEET_URL, timeout=5) | |
| response.encoding = 'utf-8' | |
| response.raise_for_status() | |
| df = pd.read_csv(io.StringIO(response.text), on_bad_lines='skip') | |
| df = df.fillna("") | |
| df.columns = df.columns.str.strip() | |
| for col in df.select_dtypes(include=['object']).columns: | |
| df[col] = df[col].astype(str).str.replace(r'[\r\n\t]+', ' ', regex=True) | |
| if 'Country' in df.columns: | |
| df['Country'] = df['Country'].astype(str).str.strip() | |
| for country, group in df.groupby('Country'): | |
| if country and country.lower() != 'nan' and len(country) > 1: | |
| PLACE_DB[country] = group.fillna("").to_dict(orient='records') | |
| else: | |
| PLACE_DB = DUMMY_DATA | |
| except: | |
| PLACE_DB = DUMMY_DATA | |
| emily_base64 = "" | |
| if os.path.exists('emily.jpg'): | |
| try: | |
| with open("emily.jpg", "rb") as f: | |
| emily_base64 = base64.b64encode(f.read()).decode() | |
| except: pass | |
| db_json_str = json.dumps(PLACE_DB, ensure_ascii=False) | |
| db_base64 = base64.b64encode(db_json_str.encode('utf-8')).decode('utf-8') | |
| # ========================================== | |
| # π₯οΈ 3. ν΅ν© νλ‘ νΈμλ (λ²νΌ ν΄λ¦ μλ¬ νν¬μΈνΈ μμ ) | |
| # ========================================== | |
| html_template = """ | |
| <!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" /> | |
| <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; --text: #e0e0e0; } | |
| body { margin: 0; padding: 0; background: var(--dark); color: var(--text); font-family: 'Segoe UI', sans-serif; overflow: hidden; } | |
| .page { position: absolute; top: 0; left: 0; width: 100%; height: 100vh; transition: transform 0.5s ease; display: flex; flex-direction: column; } | |
| .top-nav { height: 60px; display: flex; justify-content: space-between; align-items: center; padding: 0 20px; z-index: 500; background: rgba(0,0,0,0.8); } | |
| .passport-btn { font-size: 26px; cursor: pointer; background: rgba(255,215,0,0.1); padding: 8px; border-radius: 15px; border: 1px solid rgba(212,175,55,0.3); } | |
| .lang-switcher { display: flex; gap: 8px; } | |
| .lang-btn { background: rgba(0,0,0,0.6); border: 1px solid #444; color: #888; padding: 5px 10px; border-radius: 20px; cursor: pointer; font-size: 16px; transition: 0.3s; } | |
| .lang-btn.active { background: var(--gold); color: black; border-color: var(--gold); transform: scale(1.1); font-weight: bold; } | |
| .type-filter-container { width: 100%; padding: 10px 0; overflow-x: auto; display: flex; gap: 8px; justify-content: center; scrollbar-width: none; } | |
| .type-btn { padding: 8px 16px; border-radius: 20px; font-size: 11px; font-weight: bold; background: #1a1a1a; color: #777; border: 1px solid #333; white-space: nowrap; cursor: pointer; } | |
| .type-btn.selected { background: var(--gold); color: black; border-color: var(--gold); } | |
| .place-list-container { flex: 1; overflow-y: auto; padding: 0 20px 120px 20px; } | |
| .place-card { background: #161616; border: 1px solid #2a2a2a; border-radius: 18px; padding: 20px; margin-bottom: 15px; position: relative; transition: all 0.3s; } | |
| .place-card.visited { border: 2px solid var(--gold); box-shadow: 0 0 15px rgba(212, 175, 55, 0.2); } | |
| .visited-badge { position: absolute; top: -10px; right: -10px; background: var(--gold); color: black; font-weight: bold; padding: 5px 10px; border-radius: 15px; font-size: 10px; box-shadow: 0 2px 5px rgba(0,0,0,0.5); z-index: 10; animation: popIn 0.3s cubic-bezier(0.175, 0.885, 0.32, 1.275); } | |
| @keyframes popIn { from { transform: scale(0); } to { transform: scale(1); } } | |
| .pc-header { display: flex; justify-content: space-between; align-items: center; gap: 10px; } | |
| .pc-name { font-size: 18px; font-weight: bold; color: var(--gold); flex: 1; } | |
| .pc-dist { font-size: 12px; color: #888; font-weight: normal; margin-left: 5px; } | |
| .pc-label { font-size: 11px; padding: 3px 8px; border-radius: 8px; background: rgba(212,175,55,0.15); color: var(--gold); border: 1px solid var(--gold); white-space: nowrap; } | |
| .pc-desc { font-size: 13px; color: #aaa; margin: 10px 0; line-height: 1.5; } | |
| .btn-action { flex: 1; padding: 12px; border-radius: 10px; border: none; font-weight: bold; cursor: pointer; font-size: 12px; } | |
| .bartender-btn { position: fixed; bottom: 30%; right: 20px; width: 85px; height: 85px; border-radius: 50%; background-image: url('IMAGE_DATA'); background-size: cover; border: 3px solid var(--gold); z-index: 999; box-shadow: 0 0 20px rgba(212, 175, 55, 0.5); cursor: pointer; } | |
| #world-map-page, #passport-page, #chat-page { background: #000; z-index: 2000; transform: translateY(100%); } | |
| #passport-page { transform: translateX(-100%); background: #0f0f1b; } | |
| #detail-page { background: #0f0f12; z-index: 4000; transform: translateX(100%); display:flex; flex-direction:column; } | |
| #detail-map { width: 100%; height: 40vh; border-bottom: 2px solid var(--gold); } | |
| .review-content-box { flex: 1; overflow-y: auto; padding: 20px; } | |
| .review-header { text-align: center; margin-bottom: 20px; border-bottom: 1px solid #333; padding-bottom: 15px; } | |
| .review-title { font-size: 24px; font-weight: bold; color: var(--gold); margin-bottom: 5px; } | |
| .review-body { font-size: 16px; line-height: 1.8; color: #e0e0e0; font-family: 'Georgia', serif; font-style: italic; text-align: justify; } | |
| .review-signature { text-align: right; margin-top: 20px; color: var(--gold); font-weight: bold; } | |
| .detail-actions { display: flex; gap: 10px; margin-top: 20px; } | |
| .detail-btn { flex: 1; padding: 12px; border-radius: 10px; border: none; font-weight: bold; cursor: pointer; font-size: 14px; text-transform: uppercase; } | |
| .btn-map { background: #333; color: white; border: 1px solid #555; } | |
| .btn-stamp { background: var(--gold); color: black; } | |
| .nav-bar { height: 60px; display: flex; align-items: center; padding: 0 20px; border-bottom: 1px solid #222; background: #111; } | |
| .msg { max-width: 85%; padding: 12px 18px; border-radius: 15px; font-size: 14px; line-height: 1.6; margin-bottom: 12px; } | |
| .msg.bot { align-self: flex-start; background: #222; color: #eee; border: 1px solid #333; } | |
| .msg.user { align-self: flex-end; background: var(--gold); color: black; font-weight: bold; } | |
| .reco-btn { display: block; margin-top: 8px; padding: 10px; background: var(--gold); color: black !important; border-radius: 10px; font-weight: bold; text-decoration: none; font-size: 12px; cursor: pointer; border:none; width: 100%; text-align: center; } | |
| .popup-btn { background: var(--gold); color: black; border: none; padding: 6px 12px; border-radius: 5px; font-weight: bold; font-size: 11px; cursor: pointer; width: 100%; margin-top: 5px; } | |
| .leaflet-popup-content-wrapper { background: #1a1a1a; color: white; border: 1px solid var(--gold); } | |
| .leaflet-popup-content { margin: 10px; } | |
| .stamp-grid { display: grid; grid-template-columns: repeat(3, 1fr); gap: 15px; padding: 20px; } | |
| .stamp { height: 90px; border-radius: 15px; border: 2px dashed #333; display: flex; flex-direction: column; align-items: center; justify-content: center; font-size: 10px; color: #444; background: #161625; } | |
| .stamp.active { border: 2px solid var(--gold); color: var(--gold); background: rgba(212,175,55,0.05); } | |
| .map-search-container { position: absolute; top: 80px; left: 50%; transform: translateX(-50%); width: 90%; max-width: 500px; z-index: 3000; display: flex; gap: 10px; } | |
| .map-search-input { flex: 1; padding: 12px 15px; border-radius: 25px; border: 2px solid var(--gold); background: rgba(0,0,0,0.85); color: white; outline: none; font-size: 14px; } | |
| .map-loc-btn { width: 45px; height: 45px; border-radius: 50%; background: var(--gold); border: none; font-size: 20px; cursor: pointer; display: flex; align-items: center; justify-content: center; box-shadow: 0 0 10px rgba(0,0,0,0.5); } | |
| </style> | |
| </head> | |
| <body> | |
| <div id="main-page" class="page"> | |
| <div class="top-nav"> | |
| <div class="passport-btn" onclick="openPassport()">π«</div> | |
| <div class="lang-switcher"> | |
| <div class="lang-btn active" id="btn-KO" onclick="setLanguage('KO', this)">π°π·</div> | |
| <div class="lang-btn" id="btn-EN" onclick="setLanguage('EN', this)">πΊπΈ</div> | |
| <div class="lang-btn" id="btn-JP" onclick="setLanguage('JP', this)">π―π΅</div> | |
| <div class="lang-btn" id="btn-CN" onclick="setLanguage('CN', this)">π¨π³</div> | |
| </div> | |
| </div> | |
| <div style="text-align:center; padding: 5px 0 15px 0;"> | |
| <div style="color:var(--gold); font-size: 26px; font-weight: 900; letter-spacing: 4px;">DRUNKEN PLANE</div> | |
| <button onclick="openMap()" style="margin-top:10px; padding:10px 25px; background:var(--gold); border:none; border-radius:30px; font-weight:bold; cursor:pointer;">πΊοΈ EXPLORE WORLD</button> | |
| </div> | |
| <center><select id="country-select" style="width:85%; max-width:400px; padding:12px; background:#111; border:1px solid var(--gold); color:white; border-radius:10px;" onchange="selectCountry(this.value)"></select></center> | |
| <div class="type-filter-container" id="filter-box"></div> | |
| <div class="place-list-container" id="place-list"></div> | |
| <div class="bartender-btn" onclick="openChat()"></div> | |
| </div> | |
| <div id="passport-page" class="page"><div class="nav-bar"><button onclick="closePassport()">β</button> MY PASSPORT</div><div id="stamp-grid" class="stamp-grid" style="overflow-y:auto;"></div></div> | |
| <div id="world-map-page" class="page"> | |
| <div class="nav-bar"><button onclick="closeMap()">π HOME</button></div> | |
| <div class="map-search-container"> | |
| <input id="map-input" class="map-search-input" type="text" placeholder="Search City, Address or Place..." onkeydown="if(event.key==='Enter') searchMap()"> | |
| <button class="map-loc-btn" onclick="panToUser()">β</button> | |
| </div> | |
| <div id="full-map" style="width:100%; height:100%;"></div> | |
| </div> | |
| <div id="chat-page" class="page"> | |
| <div class="nav-bar"><button onclick="closeChat()">β</button> Emily</div> | |
| <div id="chat-messages" style="flex:1; overflow-y:auto; padding:20px; display:flex; flex-direction:column;"></div> | |
| <div style="padding:15px; display:flex; gap:10px; background:#111;"> | |
| <input id="chat-input" type="text" style="flex:1; padding:14px; border-radius:10px; border:1px solid #333; background:#222; color:#fff;" placeholder="Ask Emily..." onkeydown="if(event.key==='Enter') sendChat()"> | |
| <button onclick="sendChat()" style="padding:10px 25px; background:var(--gold); border:none; border-radius:10px; font-weight:bold;">SEND</button> | |
| </div> | |
| </div> | |
| <div id="detail-page" class="page"> | |
| <div class="nav-bar"><button onclick="closeDetail()">β</button> Bartender's Note</div> | |
| <div id="detail-map"></div> | |
| <div class="review-content-box"> | |
| <div class="review-header"> | |
| <div class="review-title" id="review-title">Loading...</div> | |
| <div style="color:#888;" id="review-subtitle"></div> | |
| </div> | |
| <div id="repo-content" class="review-body"> | |
| Please wait while Emily recalls this place... πΈ | |
| </div> | |
| <div class="detail-actions"> | |
| <button id="btn-detail-map" class="detail-btn btn-map">πΊοΈ OPEN MAP</button> | |
| <button id="btn-detail-stamp" class="detail-btn btn-stamp">βοΈ STAMP</button> | |
| </div> | |
| </div> | |
| </div> | |
| <script> | |
| function b64DecodeUnicode(str) { try { return decodeURIComponent(atob(str).split('').map(function(c) { return '%' + ('00' + c.charCodeAt(0).toString(16)).slice(-2); }).join('')); } catch(e) { return "{}"; } } | |
| const PLACE_DB = JSON.parse(b64DecodeUnicode("DB_DATA_BASE64")); | |
| let currentLang = "KO", selectedCountry = "", selectedType = "All", chatHistory = []; | |
| let visitedPlaces = JSON.parse(localStorage.getItem('visitedPlaces') || '[]'); | |
| let userLat = null, userLon = null; | |
| let worldMap = null; | |
| let detailMap = null; | |
| const TRANSLATIONS = { | |
| "KO": { "ai": "Korean", "greet": "μλ ? μ€λμ μ΄λμ νμν κΉ?", "aiBtn": "AI 리뷰", "mapBtn": "μ§λ", "filters": ["π μ 체", "πΊ μ ν΅μ£Ό", "π₯ μμ€ν€", "πΊ λ§₯μ£Ό", "π· μμΈ", "πΉ λ°"], "types": ["All", "Traditional", "Whiskey", "Beer", "Wine", "Bar"] }, | |
| "EN": { "ai": "English", "greet": "Hey! Where to drink tonight?", "aiBtn": "AI REVIEW", "mapBtn": "MAP", "filters": ["π All", "πΊ Trad.", "π₯ Whiskey", "πΊ Beer", "π· Wine", "πΉ Bar"], "types": ["All", "Traditional", "Whiskey", "Beer", "Wine", "Bar"] }, | |
| "JP": { "ai": "Japanese", "greet": "γγγγγγΌ. δ½γ«γγοΌ", "aiBtn": "AI γ¬γγ₯γΌ", "mapBtn": "ε°ε³", "filters": ["π ε ¨ι¨", "πΊ δΌη΅±ι ", "π₯ γ¦γ€γΉν€", "πΊ γγΌγ«", "π· γ―γ€γ³", "πΉ γγΌ"], "types": ["All", "Traditional", "Whiskey", "Beer", "Wine", "Bar"] }, | |
| "CN": { "ai": "Chinese", "greet": "δΊ²η±η, ζ³εηΉδ»δΉ?", "aiBtn": "AI ηΉθ―", "mapBtn": "ε°εΎ", "filters": ["π ε ¨ι¨", "πΊ δΌ η»ι ", "π₯ ε¨ε£«εΏ", "πΊ ε€ι ", "π· θ‘θι ", "πΉ ι ε§"], "types": ["All", "Traditional", "Whiskey", "Beer", "Wine", "Bar"] } | |
| }; | |
| function getDistanceFromLatLonInKm(lat1, lon1, lat2, lon2) { | |
| try { | |
| var R = 6371; var dLat = deg2rad(lat2-lat1); var dLon = deg2rad(lon2-lon1); | |
| var a = Math.sin(dLat/2) * Math.sin(dLat/2) + Math.cos(deg2rad(lat1)) * Math.cos(deg2rad(lat2)) * Math.sin(dLon/2) * Math.sin(dLon/2); | |
| var c = 2 * Math.atan2(Math.sqrt(a), Math.sqrt(1-a)); | |
| return (R * c).toFixed(1); | |
| } catch(e) { return "?"; } | |
| } | |
| function deg2rad(deg) { return deg * (Math.PI/180); } | |
| function getMapUrl(p, country) { | |
| if (country.includes("Korea") || country.includes("νκ΅")) return "https://map.naver.com/p/search/" + encodeURIComponent(p.address || p.name); | |
| return "https://www.google.com/maps/search/" + encodeURIComponent(p.name) + "/@" + p.lat + "," + p.lon + ",17z"; | |
| } | |
| window.onload = function() { | |
| try { | |
| const keys = Object.keys(PLACE_DB).sort(); | |
| const select = document.getElementById('country-select'); | |
| keys.forEach(k => { const opt = document.createElement('option'); opt.value = k; opt.text = k; select.appendChild(opt); }); | |
| if(keys.length > 0) selectCountry(keys[0]); | |
| renderFilters(); | |
| if(navigator.geolocation) { | |
| navigator.geolocation.getCurrentPosition(function(p) { | |
| userLat = p.coords.latitude; userLon = p.coords.longitude; renderList(); | |
| }); | |
| } | |
| } catch(e) { console.error(e); } | |
| }; | |
| function setLanguage(l, el) { | |
| currentLang = l; | |
| document.querySelectorAll('.lang-btn').forEach(b => b.classList.remove('active')); | |
| el.classList.add('active'); | |
| renderFilters(); | |
| renderList(); | |
| } | |
| function renderFilters() { | |
| const box = document.getElementById('filter-box'); box.innerHTML = ""; | |
| TRANSLATIONS[currentLang].filters.forEach((f, i) => { | |
| const btn = document.createElement('div'); btn.className = 'type-btn' + (selectedType === TRANSLATIONS[currentLang].types[i] ? ' selected' : ''); | |
| btn.innerText = f; btn.onclick = () => { selectedType = TRANSLATIONS[currentLang].types[i]; renderFilters(); renderList(); }; | |
| box.appendChild(btn); | |
| }); | |
| } | |
| function selectCountry(c) { selectedCountry = c; renderList(); } | |
| function renderList() { | |
| const list = document.getElementById('place-list'); list.innerHTML = ""; | |
| const countryPlaces = PLACE_DB[selectedCountry] || []; | |
| const filtered = countryPlaces.filter(p => selectedType === "All" || (p.category && p.category.includes(selectedType))); | |
| const t = TRANSLATIONS[currentLang]; | |
| filtered.forEach(p => { | |
| const isVisited = visitedPlaces.includes(p.name); | |
| const visitedClass = isVisited ? ' visited' : ''; | |
| const visitedBadge = isVisited ? '<div class="visited-badge">π VISITED</div>' : ''; | |
| const card = document.createElement('div'); | |
| card.className = 'place-card' + visitedClass; | |
| const displayLabel = p.label || p.category || ""; | |
| const desc = p['desc_'+currentLang.toLowerCase()] || p.desc_ko || ""; | |
| let distHtml = ""; | |
| if(userLat && userLon && p.lat && p.lon) { | |
| const dist = getDistanceFromLatLonInKm(userLat, userLon, p.lat, p.lon); | |
| distHtml = `<span class="pc-dist">(π ${dist}km)</span>`; | |
| } | |
| card.innerHTML = ` | |
| ${visitedBadge} | |
| <div class="pc-header"><div class="pc-name">${p.name} ${distHtml}</div><div class="pc-label">${displayLabel}</div></div> | |
| <div class="pc-desc">${desc}</div> | |
| <div style="display:flex; gap:10px; margin-top:15px;"> | |
| <button class="btn-action" style="background:var(--gold); color:black;" onclick="openReview('${p.name.replace(/'/g, "\\\\'")}', '${selectedCountry}')">${t.aiBtn}</button> | |
| <button class="btn-action" style="background:#333; color:white;" onclick="window.open('${getMapUrl(p, selectedCountry)}', '_blank')">${t.mapBtn}</button> | |
| </div>`; | |
| list.appendChild(card); | |
| }); | |
| } | |
| const ZOMBIE_MODELS = ["llama-3.1-8b-instant", "llama-3.3-70b-versatile", "mixtral-8x7b-32768"]; | |
| async function callGroq(msgs) { | |
| let apiKey = "GROQ_KEY_INJECTED".trim(); | |
| if(!apiKey || apiKey.includes("INJECTED") || apiKey.length < 10) return "Error: API Key Missing"; | |
| for (let model of ZOMBIE_MODELS) { | |
| try { | |
| const r = await fetch("https://api.groq.com/openai/v1/chat/completions", { | |
| method: "POST", | |
| headers: { "Content-Type": "application/json", "Authorization": "Bearer " + apiKey }, | |
| body: JSON.stringify({ model: model, messages: msgs, temperature: 0.7 }) | |
| }); | |
| if (r.ok) { | |
| const d = await r.json(); return d.choices[0].message.content; | |
| } | |
| if (r.status === 429) { await new Promise(r => setTimeout(r, 1000)); continue; } | |
| if (r.status === 401) return "Error 401: Invalid API Key."; | |
| } catch(e) { continue; } | |
| } | |
| return "Emily is sleeping (Network Issue). Please try again."; | |
| } | |
| async function sendChat() { | |
| const inp = document.getElementById('chat-input'); const msg = inp.value.trim(); if(!msg) return; | |
| addChatMessage(msg, 'user'); inp.value = ""; | |
| chatHistory.push({ role: "user", content: msg }); | |
| const recentHistory = chatHistory.slice(-4); | |
| let fullDbContext = "CITIES:\\n"; | |
| for(let c in PLACE_DB) { | |
| let pNames = PLACE_DB[c].map(p => p.name).join(","); | |
| fullDbContext += c + ": " + pNames + "\\n"; | |
| } | |
| if(fullDbContext.length > 15000) fullDbContext = fullDbContext.substring(0, 15000) + "...(truncated)"; | |
| let gpsContext = "No GPS."; | |
| if(userLat && userLon) { | |
| let nearbyPlaces = []; | |
| for(let c in PLACE_DB) { | |
| PLACE_DB[c].forEach(p => { | |
| if(p.lat && p.lon) { | |
| const d = getDistanceFromLatLonInKm(userLat, userLon, p.lat, p.lon); | |
| if(parseFloat(d) <= 20) nearbyPlaces.push(p.name); | |
| } | |
| }); | |
| } | |
| if(nearbyPlaces.length > 0) gpsContext = "GPS Nearby(20km): " + nearbyPlaces.join(", "); | |
| } | |
| const sysPrompt = `CRITICAL RULE: OUTPUT PLACE NAMES IN [BRACKETS]. Example: [Zest]. | |
| You are Emily, a bartender. Speak ONLY in ${TRANSLATIONS[currentLang].ai}. | |
| [DB] ${fullDbContext} | |
| [USER GPS] ${gpsContext} | |
| [INSTRUCTIONS] | |
| 1. Specific city mentioned? Use [DB]. | |
| 2. 'Nearby' mentioned? Use [USER GPS]. | |
| REMEMBER: WRAP PLACE NAMES IN [ ] OR I WILL BE FIRED.`; | |
| const botMsg = await callGroq([{ role: "system", content: sysPrompt }, ...recentHistory]); | |
| let finalHtml = botMsg; | |
| const regex = /\[(.*?)\]/g; | |
| let match; | |
| while ((match = regex.exec(botMsg)) !== null) { | |
| const pName = match[1]; | |
| let found = null; let fCountry = ""; | |
| for(let c in PLACE_DB) { | |
| found = PLACE_DB[c].find(p => p.name.includes(pName)); | |
| if(found) { fCountry = c; break; } | |
| } | |
| if(found) { | |
| const mapUrl = getMapUrl(found, fCountry); | |
| // [μμ ] μ±λ΄μ μΆμ² λ²νΌμμλ markVisited κΈ°λ₯ μ κ±° (μΌκ΄μ± μ μ§) | |
| finalHtml += `<br><button class="reco-btn" onclick="openReview('${found.name.replace(/'/g, "\\\\'")}', '${fCountry}')">π ${TRANSLATIONS[currentLang].mapBtn}: ${pName}</button>`; | |
| } | |
| } | |
| addChatMessage(finalHtml, 'bot', true); | |
| chatHistory.push({ role: "assistant", content: botMsg }); | |
| } | |
| function addChatMessage(t, s, isHtml=false) { | |
| const box = document.getElementById('chat-messages'); const div = document.createElement('div'); | |
| div.className = "msg " + s; | |
| if(isHtml) div.innerHTML = t; else div.innerText = t; | |
| box.appendChild(div); box.scrollTop = box.scrollHeight; | |
| } | |
| function markVisited(name) { | |
| let vp = JSON.parse(localStorage.getItem('visitedPlaces') || '[]'); | |
| if(!vp.includes(name)) { | |
| vp.push(name); | |
| localStorage.setItem('visitedPlaces', JSON.stringify(vp)); | |
| visitedPlaces = vp; | |
| alert("βοΈ Stamped on Passport!"); | |
| renderList(); | |
| } else { alert("You already visited here!"); } | |
| } | |
| function openPassport() { | |
| document.getElementById('passport-page').style.transform = 'translateX(0%)'; | |
| visitedPlaces = JSON.parse(localStorage.getItem('visitedPlaces') || '[]'); | |
| const grid = document.getElementById('stamp-grid'); | |
| grid.innerHTML = ""; | |
| const reversedVp = [...visitedPlaces].reverse(); | |
| reversedVp.forEach(name => { | |
| const div = document.createElement('div'); | |
| div.className = 'stamp active'; | |
| div.innerHTML = `π·<br><b>${name}</b>`; | |
| grid.appendChild(div); | |
| }); | |
| for(let c in PLACE_DB) { | |
| PLACE_DB[c].forEach(p => { | |
| if(!visitedPlaces.includes(p.name)) { | |
| const div = document.createElement('div'); | |
| div.className = 'stamp'; | |
| div.innerHTML = `βοΈ`; | |
| grid.appendChild(div); | |
| } | |
| }); | |
| } | |
| } | |
| function closePassport() { document.getElementById('passport-page').style.transform = 'translateX(-100%)'; } | |
| function openMap() { | |
| 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').addTo(worldMap); | |
| for(let c in PLACE_DB) PLACE_DB[c].forEach(p => { | |
| if(p.lat && p.lon) { | |
| const lbl = p.label || p.category || ""; | |
| const desc = p['desc_'+currentLang.toLowerCase()] || p.desc_ko || ""; | |
| const shortDesc = desc.length > 50 ? desc.substring(0, 50) + "..." : desc; | |
| const popupContent = ` | |
| <div style="text-align:left;"> | |
| <b style="color:#d4af37; font-size:14px;">${p.name}</b><br> | |
| <span style="font-size:10px; color:#aaa;">${lbl}</span><br> | |
| <div style="font-size:11px; margin:5px 0; color:#eee;">${shortDesc}</div> | |
| <div style="display:flex; gap:5px;"> | |
| <button class="popup-btn" style="background:#d4af37; color:black;" onclick="openReview('${p.name.replace(/'/g, "\\\\'")}', '${c}')">AI REVIEW</button> | |
| <button class="popup-btn" style="background:#333; color:white; border:1px solid #555;" onclick="window.open('${getMapUrl(p,c)}','_blank')">MAP</button> | |
| </div> | |
| </div> | |
| `; | |
| L.marker([p.lat,p.lon]).addTo(worldMap).bindPopup(popupContent); | |
| } | |
| }); | |
| } | |
| } | |
| function closeMap() { document.getElementById('world-map-page').style.transform = 'translateY(100%)'; } | |
| function searchMap() { | |
| const q = document.getElementById('map-input').value.toLowerCase(); | |
| if(!q) return; | |
| let found = false; | |
| for(let c in PLACE_DB) { | |
| if(c.toLowerCase().includes(q)) { if(PLACE_DB[c].length>0 && PLACE_DB[c][0].lat) { worldMap.setView([PLACE_DB[c][0].lat, PLACE_DB[c][0].lon], 12); found=true; break; } } | |
| for(let p of PLACE_DB[c]) { | |
| const content = (p.name + " " + (p.address||"") + " " + (p.desc_ko||"")).toLowerCase(); | |
| if(content.includes(q)) { if(p.lat && p.lon) { worldMap.setView([p.lat, p.lon], 16); L.popup().setLatLng([p.lat, p.lon]).setContent(`<b>${p.name}</b>`).openOn(worldMap); found=true; break; } } | |
| } | |
| if(found) break; | |
| } | |
| if(!found) alert("Not Found"); | |
| } | |
| function panToUser() { if(userLat && userLon) worldMap.setView([userLat, userLon], 14); else alert("GPS missing."); } | |
| function openChat() { document.getElementById('chat-page').style.transform = 'translateY(0%)'; if(chatHistory.length===0) addChatMessage(TRANSLATIONS[currentLang].greet, 'bot'); } | |
| function closeChat() { document.getElementById('chat-page').style.transform = 'translateY(100%)'; } | |
| async function openReview(n, c) { | |
| document.getElementById('detail-page').style.transform = 'translateX(0%)'; | |
| document.getElementById('review-title').innerText = n; | |
| document.getElementById('review-subtitle').innerText = c; | |
| document.getElementById('repo-content').innerText = "Bartender Emily is preparing your review... π₯"; | |
| let placeData = null; | |
| if(PLACE_DB[c]) placeData = PLACE_DB[c].find(p => p.name === n); | |
| if(placeData && placeData.lat && placeData.lon) { | |
| if(detailMap) { detailMap.remove(); } | |
| detailMap = L.map('detail-map').setView([placeData.lat, placeData.lon], 15); | |
| L.tileLayer('https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png').addTo(detailMap); | |
| L.marker([placeData.lat, placeData.lon]).addTo(detailMap); | |
| } | |
| document.getElementById('btn-detail-map').onclick = function() { window.open(getMapUrl(placeData, c), '_blank'); }; | |
| document.getElementById('btn-detail-stamp').onclick = function() { markVisited(n); }; | |
| const contextDesc = placeData ? (placeData['desc_'+currentLang.toLowerCase()] || placeData.desc_ko) : ""; | |
| const addressInfo = placeData.address || "Address not available"; | |
| let searchStrategy = ""; | |
| if (c.includes("Korea") || c.includes("νκ΅")) { | |
| searchStrategy = "SOURCE: Strictly base your review on the provided [CONTEXT] and your general knowledge of Korean geography. Do not hallucinate."; | |
| } else { | |
| searchStrategy = "SOURCE: Use your internal knowledge base (simulating online reviews, blogs, news) to enrich the details. The [CONTEXT] is just a starting point."; | |
| } | |
| const reviewPrompt = ` | |
| ROLE: You are Emily, a professional and charming bartender. | |
| TASK: Write a detailed review for the bar "${n}" located in "${c}". | |
| ${searchStrategy} | |
| [CONTEXT]: ${contextDesc} | |
| [ADDRESS]: ${addressInfo} | |
| REQUIRED SECTIONS (Translate headers to target language): | |
| 1. π Address & Access (How to get there easily) | |
| 2. π Story & Atmosphere (Vibe check) | |
| 3. π₯ Who goes there? (Demographics, age, style) | |
| 4. π₯ Recommended Menu (Signature drinks) | |
| 5. π€« Secret Tip (Best seat, best time, etc.) | |
| 6. π Bartender's Note (Final Verdict) | |
| LANGUAGE: Respond STRICTLY in ${TRANSLATIONS[currentLang].ai}. | |
| TONE: Insightful, witty, and warm. Like giving advice to a close friend. | |
| `; | |
| const res = await callGroq([{role:"system",content: reviewPrompt}]); | |
| document.getElementById('repo-content').innerHTML = res + "<div class='review-signature'>- Yours, Emily π</div>"; | |
| } | |
| function closeDetail() { document.getElementById('detail-page').style.transform = 'translateX(100%)'; } | |
| </script> | |
| </body> | |
| </html> | |
| """ | |
| html_code = html_template.replace("IMAGE_DATA", f"data:image/jpeg;base64,{emily_base64}") | |
| html_code = html_code.replace("DB_DATA_BASE64", db_base64) | |
| html_code = html_code.replace("GROQ_KEY_INJECTED", GROQ_API_KEY) | |
| components.html(html_code, height=1000, scrolling=True) |