drunken_plane / app.py
benjamin5607's picture
Update app.py
d1c299d verified
Raw
History Blame
29.9 kB
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)