drunken_plane / app.py
benjamin5607's picture
Update app.py
1b01ba6 verified
Raw
History Blame Contribute Delete
33.5 kB
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)