benjamin5607 commited on
Commit
36b9b70
ยท
verified ยท
1 Parent(s): 58982d9

Update app.py

Browse files
Files changed (1) hide show
  1. app.py +273 -328
app.py CHANGED
@@ -1,13 +1,13 @@
1
  import streamlit as st
2
  import streamlit.components.v1 as components
 
3
 
4
  # 1. ์‹œํฌ๋ฆฟ ํ† ํฐ ํ™•์ธ
5
  try:
6
  token = st.secrets["HF_TOKEN"]
7
  except:
8
  token = ""
9
- # ํ† ํฐ ์—†์„ ๋•Œ ๊ฒฝ๊ณ ์ฐฝ
10
- st.error("๐Ÿšจ HF_TOKEN์ด ์„ค์ •๋˜์ง€ ์•Š์•˜์Šต๋‹ˆ๋‹ค! Space Settings์—์„œ ํ† ํฐ์„ ์ถ”๊ฐ€ํ•ด์ฃผ์„ธ์š”.")
11
 
12
  # 2. ํŽ˜์ด์ง€ ์„ค์ •
13
  st.set_page_config(page_title="Drunken Plane", page_icon="โœˆ๏ธ", layout="wide")
@@ -20,7 +20,75 @@ st.markdown("""
20
  </style>
21
  """, unsafe_allow_html=True)
22
 
23
- # 3. HTML/JS ์ „์ฒด ์ฝ”๋“œ
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
24
  html_code = f"""
25
  <!DOCTYPE html>
26
  <html>
@@ -36,101 +104,99 @@ html_code = f"""
36
  :root {{ --gold: #d4af37; --dark: #0a0a0a; --card-bg: #1c1c1c; --text: #e0e0e0; --accent: #c5a059; }}
37
  body {{ margin: 0; padding: 0; background: var(--dark); color: var(--text); font-family: 'Segoe UI', serif; overflow: hidden; }}
38
 
39
- /* ๐Ÿ  1. ๋ฉ”์ธ ํ™”๋ฉด */
40
  #main-page {{
41
  position: absolute; top: 0; left: 0; width: 100%; height: 100vh;
42
- background: linear-gradient(rgba(0,0,0,0.6), rgba(0,0,0,0.9)), url('https://images.unsplash.com/photo-1516981879613-9f5da904015f?q=80&w=2560&auto=format&fit=crop');
43
  background-size: cover; background-position: center;
44
- z-index: 5000; display: flex; flex-direction: column; align-items: center; justify-content: center;
 
45
  transition: transform 0.6s cubic-bezier(0.22, 1, 0.36, 1);
46
  }}
47
 
48
- .brand-logo {{ fill: var(--gold); width: 80px; height: 80px; margin-bottom: 10px; filter: drop-shadow(0 0 10px rgba(212,175,55,0.3)); }}
49
- .brand-title {{ font-size: 32px; font-weight: 900; color: var(--gold); letter-spacing: 4px; margin: 0; text-transform: uppercase; text-shadow: 0 4px 20px rgba(0,0,0,0.8); }}
50
- .brand-subtitle {{ font-size: 14px; color: #ccc; letter-spacing: 2px; margin-top: 5px; margin-bottom: 30px; font-weight: 300; }}
51
-
52
- /* ๊ตญ๊ฐ€ ๊ฒ€์ƒ‰์ฐฝ */
53
- .search-container {{ width: 80%; max-width: 500px; position: relative; margin-bottom: 20px; }}
54
- .country-input {{
55
- width: 100%; padding: 15px 20px; background: rgba(0,0,0,0.6);
56
- border: 1px solid var(--gold); border-radius: 30px; color: white;
57
- font-size: 16px; text-align: center; backdrop-filter: blur(5px);
58
- transition: all 0.3s;
59
- }}
60
- .country-input:focus {{ outline: none; background: rgba(0,0,0,0.8); box-shadow: 0 0 15px rgba(212, 175, 55, 0.3); }}
61
-
62
- /* ์œ„์น˜ ๊ฐ์ง€ ๋ฑƒ์ง€ */
63
- .location-detect {{
64
- background: rgba(255,255,255,0.1); backdrop-filter: blur(10px);
65
- padding: 8px 20px; border-radius: 30px; border: 1px solid rgba(255,255,255,0.2);
66
- color: #aaa; font-size: 13px; display: flex; align-items: center; gap: 8px; cursor: pointer;
67
- margin-bottom: 40px; transition: 0.3s;
68
- }}
69
- .location-detect:hover {{ background: rgba(255,255,255,0.2); color: white; }}
70
 
71
- /* ๐Ÿƒ ํ๋ ˆ์ด์…˜ ์นด๋“œ */
72
- .curation-container {{ width: 100%; padding: 0 20px; box-sizing: border-box; }}
73
- .curation-title {{ font-size: 18px; color: var(--gold); margin-bottom: 15px; font-weight: bold; border-left: 3px solid var(--gold); padding-left: 10px; }}
 
 
 
74
 
75
- .card-scroll {{
76
- display: flex; gap: 20px; overflow-x: auto; padding-bottom: 30px; scrollbar-width: none;
 
 
77
  }}
78
- .card-scroll::-webkit-scrollbar {{ display: none; }}
79
 
80
- .alcohol-card {{
81
- min-width: 280px; background: var(--card-bg); border: 1px solid #333;
82
- border-radius: 12px; padding: 25px; cursor: pointer; position: relative;
83
- transition: transform 0.3s, border-color 0.3s; box-shadow: 0 10px 30px rgba(0,0,0,0.5);
 
 
 
 
 
84
  }}
85
- .alcohol-card:hover {{ transform: translateY(-5px); border-color: var(--gold); }}
86
- .card-korean {{ font-size: 22px; font-weight: bold; color: white; margin-bottom: 5px; }}
87
- .card-english {{ font-size: 14px; color: var(--gold); font-style: italic; margin-bottom: 15px; display: block; }}
88
- .card-desc {{ font-size: 13px; color: #aaa; line-height: 1.6; display: -webkit-box; -webkit-line-clamp: 3; -webkit-box-orient: vertical; overflow: hidden; }}
89
- .card-btn {{ margin-top: 20px; font-size: 12px; color: var(--gold); font-weight: bold; text-transform: uppercase; letter-spacing: 1px; text-align: right; }}
90
-
91
- /* ๐Ÿ—บ๏ธ 2. ์ง€๋„ ๋ฐ ์ปจํ…์ธ  ํŽ˜์ด์ง€ */
92
- #map-view {{
 
 
 
 
93
  position: absolute; top: 0; left: 0; width: 100%; height: 100vh;
94
- transform: translateX(100%); transition: transform 0.6s cubic-bezier(0.22, 1, 0.36, 1);
95
- z-index: 1000; display: flex; flex-direction: column;
96
  }}
97
 
98
  .nav-bar {{
99
- height: 60px; background: #000; border-bottom: 1px solid #333;
100
- display: flex; align-items: center; padding: 0 20px; justify-content: space-between; z-index: 2000;
101
  }}
102
- .back-btn {{ background: none; border: none; color: white; font-size: 24px; cursor: pointer; }}
103
- .nav-title {{ color: var(--gold); font-weight: bold; font-size: 16px; text-transform: uppercase; }}
104
 
105
- #map {{ width: 100%; height: 40%; border-bottom: 1px solid var(--gold); }}
106
 
107
- .blog-container {{
108
- flex: 1; background: #111; overflow-y: auto; padding: 20px;
109
- box-sizing: border-box; position: relative;
110
  }}
111
 
112
- .place-article {{
113
- margin-bottom: 40px; border-bottom: 1px solid #333; padding-bottom: 30px;
114
- animation: fadeIn 0.8s ease-out;
115
- }}
116
- @keyframes fadeIn {{ from {{ opacity: 0; transform: translateY(20px); }} to {{ opacity: 1; transform: translateY(0); }} }}
117
-
118
- .place-title {{ font-size: 24px; color: var(--gold); font-weight: bold; margin-bottom: 5px; }}
119
- .place-type {{ font-size: 12px; background: #333; color: #ccc; padding: 3px 8px; border-radius: 4px; display: inline-block; margin-bottom: 15px; }}
120
- .place-content {{ font-size: 15px; color: #ddd; line-height: 1.8; white-space: pre-wrap; }}
121
- .gmap-link {{
122
- display: inline-block; margin-top: 15px; padding: 10px 20px;
123
- background: var(--gold); color: black; text-decoration: none; font-weight: bold;
124
- border-radius: 25px; font-size: 14px;
125
  }}
 
126
 
127
- /* ๋กœ๋”ฉ ์˜ค๋ฒ„๋ ˆ์ด */
128
  .loading-screen {{
129
- position: fixed; top: 0; left: 0; width: 100%; height: 100%; background: #000;
130
- z-index: 6000; display: none; flex-direction: column; justify-content: center; align-items: center;
 
131
  }}
132
- .typewriter {{ font-family: monospace; color: var(--gold); font-size: 16px; margin-top: 20px; }}
133
- .loader-ring {{ width: 50px; height: 50px; border: 2px solid #333; border-top: 2px solid var(--gold); border-radius: 50%; animation: spin 1s linear infinite; }}
134
  @keyframes spin {{ 0% {{ transform: rotate(0deg); }} 100% {{ transform: rotate(360deg); }} }}
135
 
136
  </style>
@@ -138,308 +204,187 @@ html_code = f"""
138
  <body>
139
 
140
  <div id="main-page">
141
- <svg class="brand-logo" viewBox="0 0 24 24"><path d="M11,9H13V7H11M12,20C7.59,20 4,16.41 4,12C4,7.59 7.59,4 12,4C16.41,4 20,7.59 20,12C20,16.41 16.41,20 12,20M12,2A10,10 0 0,0 2,12A10,10 0 0,0 12,22A10,10 0 0,0 22,12A10,10 0 0,0 12,2Z" /></svg>
142
- <h1 class="brand-title">DRUNKEN PLANE</h1>
143
- <div class="brand-subtitle">THE ALCOHOL HERITAGE TOUR</div>
144
-
145
- <div class="search-container">
146
- <input type="text" id="country-search" class="country-input" placeholder="์–ด๋А ๋‚˜๋ผ๋กœ ๋– ๋‚ ๊นŒ์š”? (์˜ˆ: Malaysia)" onkeypress="if(event.key==='Enter') searchCountry()">
147
  </div>
148
 
149
- <div id="location-box" class="location-detect" onclick="detectLocation()">
150
- <span id="loc-spinner" class="loader-ring" style="width:15px; height:15px; border-width:1px; display:none;"></span>
151
- <span id="loc-text">๐Ÿ“ ๋‚ด ์œ„์น˜(GPS) ๊ธฐ์ค€์œผ๋กœ ์ฐพ๊ธฐ</span>
152
  </div>
153
 
154
- <div class="curation-container">
155
- <div id="curation-header" class="curation-title">LOCAL HERITAGE</div>
156
- <div id="card-list" class="card-scroll">
157
- <div style="color:#666; padding:20px; font-size:0.9em;">
158
- ๊ตญ๊ฐ€๋ฅผ ๊ฒ€์ƒ‰ํ•˜๊ฑฐ๋‚˜ ๋‚ด ์œ„์น˜๋ฅผ ๋ˆŒ๋Ÿฌ์ฃผ์„ธ์š”.<br>
159
- Qwen AI๊ฐ€ ํ•ด๋‹น ๊ตญ๊ฐ€์˜ ๋Œ€ํ‘œ ๋ช…์ฃผ์™€ ํˆฌ์–ด ์ฝ”์Šค๋ฅผ ์„ค๊ณ„ํ•ด๋“œ๋ฆฝ๋‹ˆ๋‹ค.
160
- </div>
161
- </div>
162
- </div>
163
  </div>
164
 
165
- <div id="map-view">
166
  <div class="nav-bar">
167
- <button class="back-btn" onclick="goBack()">โ†</button>
168
- <div id="tour-title" class="nav-title">TOUR GUIDE</div>
169
- <div style="width:24px;"></div>
170
  </div>
 
171
  <div id="map"></div>
172
- <div id="blog-feed" class="blog-container"></div>
 
 
 
 
 
 
 
173
  </div>
174
 
175
- <div id="global-loader" class="loading-screen">
176
  <div class="loader-ring"></div>
177
- <div id="loader-msg" class="typewriter">๋ฐ์ดํ„ฐ ๋ถ„์„ ์ค‘...</div>
178
  </div>
179
 
180
  <script>
181
  const HF_TOKEN = "{token}";
182
- // ๐Ÿ”ฅ [์ค‘์š” ์ˆ˜์ •] 72B -> 32B ๋ชจ๋ธ๋กœ ๋ณ€๊ฒฝ (์•ˆ์ •์„ฑ ํ™•๋ณด)
183
  const MODEL_ID = "Qwen/Qwen2.5-32B-Instruct";
184
 
 
 
 
185
  let map = null;
186
- let currentLat = 0;
187
- let currentLng = 0;
188
- let targetCountry = "";
189
- let targetCity = "";
190
-
191
- // 1. ๋‚ด ์œ„์น˜(GPS) ์ฐพ๊ธฐ
192
- function detectLocation() {{
193
- document.getElementById('loc-spinner').style.display = 'inline-block';
194
- document.getElementById('loc-text').innerText = "์œ„์น˜ ํ™•์ธ ์ค‘...";
195
-
196
- if (navigator.geolocation) {{
197
- navigator.geolocation.getCurrentPosition(successGPS, failGPS);
198
- }} else {{
199
- failGPS();
200
- }}
201
- }}
202
-
203
- function successGPS(pos) {{
204
- currentLat = pos.coords.latitude;
205
- currentLng = pos.coords.longitude;
206
-
207
- fetch(`https://nominatim.openstreetmap.org/reverse?format=json&lat=${{currentLat}}&lon=${{currentLng}}`)
208
- .then(res => res.json())
209
- .then(data => {{
210
- targetCity = data.address.city || data.address.town || "";
211
- targetCountry = data.address.country || "Unknown";
212
-
213
- document.getElementById('loc-spinner').style.display = 'none';
214
- document.getElementById('loc-text').innerText = `๐Ÿ“ ${{targetCity}}, ${{targetCountry}}`;
215
- document.getElementById('curation-header').innerText = `HERITAGE OF ${{targetCountry.toUpperCase()}}`;
216
-
217
- getRecommendationCards(targetCity, targetCountry);
218
- }});
219
- }}
220
-
221
- function failGPS() {{
222
- document.getElementById('loc-spinner').style.display = 'none';
223
- document.getElementById('loc-text').innerText = "์œ„์น˜ ํ™•์ธ ๋ถˆ๊ฐ€";
224
- alert("์œ„์น˜ ๊ถŒํ•œ์„ ํ™•์ธํ•ด์ฃผ์„ธ์š”.");
225
- }}
226
-
227
- // 2. ๊ตญ๊ฐ€ ๊ฒ€์ƒ‰
228
- async function searchCountry() {{
229
- const query = document.getElementById('country-search').value;
230
- if(!query) return;
231
-
232
- document.getElementById('loc-text').innerText = "๐Ÿ“ ๋‚ด ์œ„์น˜(GPS) ๊ธฐ์ค€์œผ๋กœ ์ฐพ๊ธฐ";
233
-
234
- const res = await fetch(`https://nominatim.openstreetmap.org/search?format=json&q=${{query}}&limit=1`);
235
- const data = await res.json();
236
-
237
- if(data.length > 0) {{
238
- targetCountry = query;
239
- targetCity = "";
240
- document.getElementById('curation-header').innerText = `HERITAGE OF ${{targetCountry.toUpperCase()}}`;
241
- getRecommendationCards("", targetCountry);
242
- }} else {{
243
- alert("๊ตญ๊ฐ€๋ฅผ ์ฐพ์„ ์ˆ˜ ์—†์Šต๋‹ˆ๋‹ค.");
244
- }}
245
  }}
246
 
247
- // 3. Qwen: ์ฃผ๋ฅ˜ ์นด๋“œ ์ƒ์„ฑ (JSON ํŒŒ์‹ฑ ๊ฐ•ํ™” & ์—๋Ÿฌ ์ƒ์„ธ ์ถœ๋ ฅ)
248
- async function getRecommendationCards(city, country) {{
249
- const container = document.getElementById('card-list');
250
- container.innerHTML = "<div style='color:#888; padding:20px;'>AI๊ฐ€ ๊ตญ๊ฐ€ ๋ฐ์ดํ„ฐ๋ฅผ ๋ถ„์„ ์ค‘์ž…๋‹ˆ๋‹ค...</div>";
251
 
252
- const context = city ? `City: ${{city}}, Country: ${{country}}` : `Country: ${{country}} (Nationwide Scope)`;
253
-
254
- const prompt = `
255
- You are a luxury travel curator.
256
- Context: ${{context}}.
257
- Recommend 5 famous alcoholic beverages representing this region/country.
258
- Focus on Tradition, Craft, and Heritage.
259
 
260
- Output strictly valid JSON only. No markdown.
261
- [
262
- {{
263
- "name_kr": "Name in Korean",
264
- "name_en": "Name in English",
265
- "desc": "Short description (2 sentences)",
266
- "search_query": "Keyword to find production sites"
267
- }}
268
- ]
269
- `;
270
-
271
- try {{
272
- const data = await callQwen(prompt);
273
- renderCards(data);
274
- }} catch(e) {{
275
- console.error("AI Card Error:", e);
276
- // ์—๋Ÿฌ ๋ฉ”์‹œ์ง€๋ฅผ ํ™”๋ฉด์— ๋ณด์—ฌ์คŒ (๋””๋ฒ„๊น…์šฉ)
277
- container.innerHTML = `<div style='color:#ff5555; padding:20px;'>AI ์—ฐ๊ฒฐ ์˜ค๋ฅ˜: ${{e.message}}<br>์ž ์‹œ ํ›„ ๋‹ค์‹œ ์‹œ๋„ํ•ด์ฃผ์„ธ์š”.</div>`;
278
- }}
279
- }}
280
-
281
- function renderCards(data) {{
282
- const container = document.getElementById('card-list');
283
- container.innerHTML = "";
284
-
285
- data.forEach(item => {{
286
  const card = document.createElement('div');
287
- card.className = 'alcohol-card';
288
- card.onclick = () => startTour(item);
289
 
290
  card.innerHTML = `
291
- <div class="card-korean">${{item.name_kr}}</div>
292
- <span class="card-english">${{item.name_en}}</span>
293
- <div class="card-desc">${{item.desc}}</div>
294
- <div class="card-btn">EXPLORE TOUR โ†’</div>
 
 
295
  `;
296
- container.appendChild(card);
297
  }});
298
  }}
299
 
300
- // 4. ํˆฌ์–ด ์‹œ์ž‘ (์•ˆ์ •์„ฑ ๊ฐ•ํ™”)
301
- async function startTour(item) {{
302
- document.getElementById('global-loader').style.display = 'flex';
303
- document.getElementById('loader-msg').innerText = `'${{item.name_en}}' ๋ช…์†Œ ํƒ์ƒ‰ ์ค‘...`;
304
- document.getElementById('tour-title').innerText = item.name_en.toUpperCase();
 
 
 
 
 
 
305
 
306
- const scope = targetCity ? `in/near ${{targetCity}}` : `in the entire country of ${{targetCountry}}`;
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
307
 
308
- const listPrompt = `
309
- Act as a travel guide ${{scope}}.
310
- Find 3-5 SPECIFIC, REAL physical locations related to "${{item.search_query}}".
311
- Prioritize: Distilleries, Breweries, Wineries, Museums.
312
- Strictly NO generic bars.
313
 
314
- Output strictly valid JSON only. No markdown.
315
- [
316
- {{ "name": "Exact Name of Place", "type": "Distillery/Museum" }}
317
- ]
318
  `;
319
 
320
  try {{
321
- const places = await callQwen(listPrompt);
322
-
323
- const verifiedPlaces = [];
324
- for (const place of places) {{
325
- document.getElementById('loader-msg').innerText = `${{place.name}} ์œ„์น˜ ํ™•์ธ ์ค‘...`;
326
- const searchQuery = `${{place.name}}, ${{targetCountry}}`;
327
- const coords = await getCoords(searchQuery);
328
- if (coords) verifiedPlaces.push({{ ...place, ...coords }});
329
- }}
330
-
331
- if (verifiedPlaces.length === 0) {{
332
- alert("์ •ํ™•ํ•œ ์œ„์น˜๋ฅผ ์ฐพ์„ ์ˆ˜ ์—†์Šต๋‹ˆ๋‹ค.");
333
- document.getElementById('global-loader').style.display = 'none';
334
- return;
335
- }}
336
-
337
- const blogPrompt = `
338
- Write a travel blog post (Korean) for these places:
339
- ${{JSON.stringify(verifiedPlaces.map(p => p.name))}}
340
-
341
- For each, write: Meaning, Experience, Tips.
342
 
343
- Output strictly valid JSON only. No markdown.
344
- [
345
- {{ "name": "Place Name", "content": "Long text content..." }}
346
- ]
347
- `;
348
 
349
- document.getElementById('loader-msg').innerText = "๊ฐ€์ด๋“œ๋ถ ์ž‘์„ฑ ์ค‘...";
350
- const blogContent = await callQwen(blogPrompt, 1500);
351
-
352
- const finalData = verifiedPlaces.map(place => {{
353
- const blog = blogContent.find(b => b.name === place.name);
354
- return {{ ...place, content: blog ? blog.content : "์ •๋ณด ์—†์Œ" }};
355
- }});
356
-
357
- showMapView(finalData);
358
 
359
  }} catch(e) {{
360
- console.error("Tour Error:", e);
361
- alert("AI ์—ฐ๊ฒฐ ์ค‘ ์˜ค๋ฅ˜๊ฐ€ ๋ฐœ์ƒํ–ˆ์Šต๋‹ˆ๋‹ค: " + e.message);
362
- document.getElementById('global-loader').style.display = 'none';
 
 
 
 
 
 
363
  }}
364
  }}
365
 
366
- function showMapView(places) {{
367
- document.getElementById('global-loader').style.display = 'none';
368
- document.getElementById('main-page').style.transform = 'translateX(-100%)';
369
- document.getElementById('map-view').style.transform = 'translateX(0%)';
370
-
371
- if (!map) {{
372
- map = L.map('map');
373
- L.tileLayer('https://{{s}}.tile.openstreetmap.org/{{z}}/{{x}}/{{y}}.png', {{ attribution: 'ยฉ OpenStreetMap' }}).addTo(map);
374
- }}
375
 
376
- const bounds = [];
377
- map.eachLayer(layer => {{ if (layer instanceof L.Marker) map.removeLayer(layer); }});
378
-
379
- const blogContainer = document.getElementById('blog-feed');
380
- blogContainer.innerHTML = "";
381
-
382
- places.forEach((p, idx) => {{
383
- const latlng = [p.lat, p.lon];
384
- L.marker(latlng).addTo(map).bindPopup(`<b>${{p.name}}</b><br>${{p.type}}`).openPopup();
385
- bounds.push(latlng);
386
-
387
- const gmapUrl = `https://www.google.com/maps/search/?api=1&query=${{encodeURIComponent(p.name)}}`;
388
- const article = document.createElement('div');
389
- article.className = 'place-article';
390
- article.innerHTML = `
391
- <div class="place-title">${{idx+1}}. ${{p.name}}</div>
392
- <div class="place-type">${{p.type}}</div>
393
- <div class="place-content">${{p.content}}</div>
394
- <a href="${{gmapUrl}}" target="_blank" class="gmap-link">Google Maps์—์„œ ๊ธธ์ฐพ๊ธฐ</a>
395
- `;
396
- blogContainer.appendChild(article);
397
- }});
398
-
399
- if (bounds.length > 0) map.fitBounds(bounds, {{ padding: [50, 50] }});
400
  }}
401
 
402
  function goBack() {{
 
403
  document.getElementById('main-page').style.transform = 'translateX(0%)';
404
- document.getElementById('map-view').style.transform = 'translateX(100%)';
405
- }}
406
-
407
- // ๐Ÿ”ฅ [JSON ํŒŒ์‹ฑ ๋กœ์ง ๊ฐ•ํ™”]
408
- async function callQwen(prompt, maxTokens = 1000) {{
409
- const res = await fetch(`https://api-inference.huggingface.co/models/${{MODEL_ID}}`, {{
410
- method: "POST",
411
- headers: {{ "Authorization": `Bearer ${{HF_TOKEN}}`, "Content-Type": "application/json" }},
412
- body: JSON.stringify({{ inputs: prompt, parameters: {{ max_new_tokens: maxTokens, return_full_text: false }} }})
413
- }});
414
-
415
- // ์‘๋‹ต ์ฝ”๋“œ ์ฒดํฌ
416
- if (!res.ok) {{
417
- throw new Error(`HTTP Error ${{res.status}}: ${{res.statusText}}`);
418
- }}
419
-
420
- const json = await res.json();
421
-
422
- if (json.error) throw new Error(json.error);
423
- let text = json[0].generated_text;
424
-
425
- // ๋งˆํฌ๋‹ค์šด ์ œ๊ฑฐ
426
- text = text.replace(/```json/g, '').replace(/```/g, '').trim();
427
-
428
- const start = text.indexOf('[');
429
- const end = text.lastIndexOf(']') + 1;
430
-
431
- if (start === -1) throw new Error("No Valid JSON found in AI response");
432
-
433
- return JSON.parse(text.substring(start, end));
434
- }}
435
-
436
- async function getCoords(query) {{
437
- try {{
438
- const res = await fetch(`https://nominatim.openstreetmap.org/search?format=json&q=${{query}}&limit=1`);
439
- const data = await res.json();
440
- if (data.length > 0) return {{ lat: data[0].lat, lon: data[0].lon }};
441
- }} catch(e) {{ return null; }}
442
- return null;
443
  }}
444
 
445
  </script>
 
1
  import streamlit as st
2
  import streamlit.components.v1 as components
3
+ import json
4
 
5
  # 1. ์‹œํฌ๋ฆฟ ํ† ํฐ ํ™•์ธ
6
  try:
7
  token = st.secrets["HF_TOKEN"]
8
  except:
9
  token = ""
10
+ st.error("Settings์—์„œ 'HF_TOKEN'์„ ๋จผ์ € ์„ค์ •ํ•ด์ฃผ์„ธ์š”!")
 
11
 
12
  # 2. ํŽ˜์ด์ง€ ์„ค์ •
13
  st.set_page_config(page_title="Drunken Plane", page_icon="โœˆ๏ธ", layout="wide")
 
20
  </style>
21
  """, unsafe_allow_html=True)
22
 
23
+ # ---------------------------------------------------------
24
+ # ๐Ÿ’พ GLOBAL ALCOHOL HERITAGE DATABASE (์ „ ์„ธ๊ณ„ ๋ช…์†Œ)
25
+ # ---------------------------------------------------------
26
+ PLACE_DB = {
27
+ "Malaysia ๐Ÿ‡ฒ๐Ÿ‡พ": [
28
+ {"name": "The Old Tuak House", "type": "Traditional Rice Wine", "lat": 3.147, "lon": 101.708, "desc": "๋ณด๋ฅด๋„ค์˜ค ์›์ฃผ๋ฏผ์˜ ์˜ํ˜ผ์ด ๋‹ด๊ธด ์ „ํ†ต์ฃผ 'ํˆฌ์•…' ์ „๋ฌธ์ "},
29
+ {"name": "Heineken Malaysia Berhad", "type": "Brewery Tour", "lat": 3.085, "lon": 101.609, "desc": "ํƒ€์ด๊ฑฐ ๋งฅ์ฃผ์™€ ๊ธฐ๋„ค์Šค๊ฐ€ ์ƒ์‚ฐ๋˜๋Š” ๊ฑฐ๋Œ€ํ•œ ์–‘์กฐ ํ˜„์žฅ"},
30
+ {"name": "Bar Trigona", "type": "World's 50 Best Bars", "lat": 3.155, "lon": 101.714, "desc": "๋ง๋ ˆ์ด์‹œ์•„ ํ† ์ข… ๊ฟ€์„ ์‚ฌ์šฉํ•˜๋Š” ๋Ÿญ์…”๋ฆฌ ์นตํ…Œ์ผ ๋ฐ”"},
31
+ {"name": "JungleBird", "type": "Rum Bar", "lat": 3.153, "lon": 101.621, "desc": "์•„์‹œ์•„ ์ตœ๊ณ ์˜ ๋Ÿผ(Rum) ์„ฑ์ง€, ํŠธ๋กœํ”ผ์ปฌ ๋ถ„์œ„๊ธฐ"},
32
+ {"name": "Pahit", "type": "Gin Bar", "lat": 3.146, "lon": 101.708, "desc": "์˜ค๋ž˜๋œ ์ƒตํ•˜์šฐ์Šค๋ฅผ ๊ฐœ์กฐํ•œ ๊ฐ์„ฑ์ ์ธ ์ง„(Gin) ์ „๋ฌธ ๋ฐ”"}
33
+ ],
34
+ "South Korea ๐Ÿ‡ฐ๐Ÿ‡ท": [
35
+ {"name": "Hwayo Distillery (Yeoju)", "type": "Soju Distillery", "lat": 37.294, "lon": 127.632, "desc": "ํ•œ๊ตญ ํ”„๋ฆฌ๋ฏธ์—„ ์ฆ๋ฅ˜์‹ ์†Œ์ฃผ์˜ ํ‘œ์ค€, ํ™”์š” ์–‘์กฐ์žฅ"},
36
+ {"name": "Sansawon (Kooksoondang)", "type": "Liquor Museum", "lat": 37.899, "lon": 127.279, "desc": "์ „ํ†ต์ฃผ ๊ฐค๋Ÿฌ๋ฆฌ์™€ ์ •์›์ด ์–ด์šฐ๋Ÿฌ์ง„ ์‚ฐ์‚ฌ์›"},
37
+ {"name": "Jeju Beer Company", "type": "Craft Brewery", "lat": 33.364, "lon": 126.242, "desc": "์ œ์ฃผ์˜ ๋ง‘์€ ๋ฌผ๋กœ ๋งŒ๋“œ๋Š” ํ˜„๋Œ€์ ์ธ ์ˆ˜์ œ ๋งฅ์ฃผ ํˆฌ์–ด"},
38
+ {"name": "Boksoondoga", "type": "Makgeolli Brewery", "lat": 35.539, "lon": 129.106, "desc": "์ƒดํŽ˜์ธ ๊ฐ™์€ ํƒ„์‚ฐ ๋ง‰๊ฑธ๋ฆฌ, ๋ณต์ˆœ๋„๊ฐ€์˜ ๊ณ ํ–ฅ"}
39
+ ],
40
+ "Japan ๐Ÿ‡ฏ๐Ÿ‡ต": [
41
+ {"name": "Suntory Yamazaki Distillery", "type": "Whisky Distillery", "lat": 34.892, "lon": 135.674, "desc": "์ผ๋ณธ ์œ„์Šคํ‚ค์˜ ๋ฐœ์ƒ์ง€์ด์ž ์ „์„ค์ ์ธ ์•ผ๋งˆ์žํ‚ค ์ฆ๋ฅ˜์†Œ"},
42
+ {"name": "Sapporo Beer Museum", "type": "Beer Museum", "lat": 43.071, "lon": 141.369, "desc": "ํ›—์นด์ด๋„ ๋ถ‰์€ ๋ฒฝ๋Œ ๊ฑด๋ฌผ์—์„œ ์ฆ๊ธฐ๋Š” ์‚ฟํฌ๋กœ ๋งฅ์ฃผ"},
43
+ {"name": "Sake Brewery District (Fushimi)", "type": "Sake District", "lat": 34.929, "lon": 135.761, "desc": "๊ตํ† ์˜ ๋ง‘์€ ๋ฌผ๊ณผ ์Œ€๋กœ ๋นš๋Š” ์‚ฌ์ผ€ ์–‘์กฐ์žฅ ๊ฑฐ๋ฆฌ"},
44
+ {"name": "Nikka Yoichi Distillery", "type": "Whisky Distillery", "lat": 43.186, "lon": 140.798, "desc": "์Šค์ฝ”ํ‹€๋žœ๋“œ๋ฅผ ๊ฟˆ๊ฟจ๋˜ ํƒ€์ผ€์ธ ๋ฃจ์˜ ํ˜ผ์ด ๋‹ด๊ธด ์š”์ด์น˜"}
45
+ ],
46
+ "Scotland ๐Ÿด๓ ง๓ ข๓ ณ๓ ฃ๓ ด๓ ฟ": [
47
+ {"name": "The Macallan Distillery", "type": "Whisky Distillery", "lat": 57.484, "lon": -3.206, "desc": "์œ„์Šคํ‚ค์˜ ๋กค์Šค๋กœ์ด์Šค, ์˜ˆ์ˆ  ์ž‘ํ’ˆ ๊ฐ™์€ ์ฆ๋ฅ˜์†Œ"},
48
+ {"name": "Laphroaig Distillery", "type": "Islay Whisky", "lat": 55.629, "lon": -6.153, "desc": "์•„์ผ๋ผ ์„ฌ์˜ ๊ฑฐ์นœ ๋ฐ”๋‹ค์™€ ํ”ผํŠธ ํ–ฅ์„ ํ’ˆ์€ ๊ณณ"},
49
+ {"name": "Glenfiddich Distillery", "type": "Speyside Whisky", "lat": 57.454, "lon": -3.128, "desc": "์„ธ๊ณ„์—์„œ ๊ฐ€์žฅ ๋งŽ์ด ํŒ”๋ฆฌ๋Š” ์‹ฑ๊ธ€๋ชฐํŠธ์˜ ๊ณ ํ–ฅ"},
50
+ {"name": "The Scotch Whisky Experience", "type": "Museum", "lat": 55.948, "lon": -3.195, "desc": "์—๋“ ๋ฒ„๋Ÿฌ ์„ฑ ์˜†์—์„œ ์ฆ๊ธฐ๋Š” ์œ„์Šคํ‚ค ์ธํ„ฐ๋ž™ํ‹ฐ๋ธŒ ํˆฌ์–ด"}
51
+ ],
52
+ "Ireland ๐Ÿ‡ฎ๐Ÿ‡ช": [
53
+ {"name": "Guinness Storehouse", "type": "Stout Experience", "lat": 53.341, "lon": -6.286, "desc": "๋”๋ธ”๋ฆฐ ์—ฌํ–‰์˜ ํ•„์ˆ˜ ์ฝ”์Šค, ๊ธฐ๋„ค์Šค์˜ ๋ชจ๋“  ๊ฒƒ"},
54
+ {"name": "Jameson Distillery Bow St.", "type": "Irish Whiskey", "lat": 53.348, "lon": -6.277, "desc": "์•„์ด๋ฆฌ์‰ฌ ์œ„์Šคํ‚ค์˜ ์—ญ์‚ฌ์™€ ์‹œ์Œ์„ ํ•œ ๋ฒˆ์—"},
55
+ {"name": "The Temple Bar", "type": "Historic Pub", "lat": 53.345, "lon": -6.264, "desc": "๋”๋ธ”๋ฆฐ์—์„œ ๊ฐ€์žฅ ์ƒ์ง•์ ์ด๊ณ  ํ™œ๊ธฐ์ฐฌ ํŽ"}
56
+ ],
57
+ "France ๐Ÿ‡ซ๐Ÿ‡ท": [
58
+ {"name": "Moรซt & Chandon", "type": "Champagne House", "lat": 49.043, "lon": 3.957, "desc": "์ƒนํŒŒ๋‰ด ์ง€์—ญ ์ง€ํ•˜ ๋™๊ตด์—์„œ ์ฆ๊ธฐ๋Š” ๋” ํŽ˜๋ฆฌ๋‡ฝ"},
59
+ {"name": "Hennessy Maison", "type": "Cognac House", "lat": 45.694, "lon": -0.327, "desc": "์„ธ๊ณ„ ์ตœ๊ณ ์˜ ์ฝ”๋ƒ‘, ํ—ค๋„ค์‹œ์˜ ์‹ฌ์žฅ๋ถ€"},
60
+ {"name": "Citรฉ du Vin", "type": "Wine Museum", "lat": 44.862, "lon": -0.550, "desc": "๋ณด๋ฅด๋„์— ์œ„์น˜ํ•œ ์••๋„์  ๊ทœ๋ชจ์˜ ์™€์ธ ํ…Œ๋งˆํŒŒํฌ"}
61
+ ],
62
+ "Germany ๐Ÿ‡ฉ๐Ÿ‡ช": [
63
+ {"name": "Hofbrรคuhaus Mรผnchen", "type": "Historic Beer Hall", "lat": 48.137, "lon": 11.580, "desc": "๋ฎŒํ—จ ์˜ฅํ† ๋ฒ„ํŽ˜์ŠคํŠธ์˜ ์ •์‹ ์ด ์ผ๋…„ ๋‚ด๋‚ด ํ๋ฅด๋Š” ๊ณณ"},
64
+ {"name": "Weihenstephan Brewery", "type": "Oldest Brewery", "lat": 48.395, "lon": 11.729, "desc": "์„œ๊ธฐ 1040๋…„์— ์‹œ์ž‘๋œ ์„ธ๊ณ„์—์„œ ๊ฐ€์žฅ ์˜ค๋ž˜๋œ ์–‘์กฐ์žฅ"}
65
+ ],
66
+ "Czech Republic ๐Ÿ‡จ๐Ÿ‡ฟ": [
67
+ {"name": "Pilsner Urquell Brewery", "type": "Pilsner Origin", "lat": 49.747, "lon": 13.388, "desc": "์ „ ์„ธ๊ณ„ ๋ผ๊ฑฐ ๋งฅ์ฃผ์˜ ์›์กฐ, ํ•„์Šค๋„ˆ ์šฐ๋ฅด์ผˆ ์–‘์กฐ์žฅ"},
68
+ {"name": "U Flekลฏ", "type": "Dark Beer Brewery", "lat": 50.078, "lon": 14.417, "desc": "ํ”„๋ผํ•˜์—์„œ 500๋…„ ๋„˜๊ฒŒ ํ‘๋งฅ์ฃผ๋ฅผ ๋งŒ๋“ค์–ด์˜จ ํŽ"}
69
+ ],
70
+ "USA ๐Ÿ‡บ๐Ÿ‡ธ": [
71
+ {"name": "Jack Daniel's Distillery", "type": "Tennessee Whiskey", "lat": 35.284, "lon": -86.368, "desc": "๋ฏธ๊ตญ ์œ„์Šคํ‚ค์˜ ์ž์กด์‹ฌ, ํ…Œ๋„ค์‹œ ๋ฆฐ์น˜๋ฒ„๊ทธ ํˆฌ์–ด"},
72
+ {"name": "Buffalo Trace Distillery", "type": "Bourbon Distillery", "lat": 38.217, "lon": -84.870, "desc": "๋ฒ„๋ฒˆ ์œ„์Šคํ‚ค์˜ ์‚ด์•„์žˆ๋Š” ์—ญ์‚ฌ์™€ ์ „์„ค"},
73
+ {"name": "Robert Mondavi Winery", "type": "Napa Valley Wine", "lat": 38.427, "lon": -122.404, "desc": "์บ˜๋ฆฌํฌ๋‹ˆ์•„ ๋‚˜ํŒŒ๋ฐธ๋ฆฌ ์™€์ธ์˜ ์„ ๊ตฌ์ž"}
74
+ ],
75
+ "Mexico ๐Ÿ‡ฒ๐Ÿ‡ฝ": [
76
+ {"name": "Mundo Cuervo (Jose Cuervo)", "type": "Tequila Distillery", "lat": 20.878, "lon": -103.836, "desc": "๋ฐํ‚ฌ๋ผ ๋งˆ์„์—์„œ ๊ฒฝํ—˜ํ•˜๋Š” ํ˜ธ์„ธ ์ฟ ์—˜๋ณด์˜ ๋ชจ๋“  ๊ฒƒ"},
77
+ {"name": "La Capilla", "type": "Historic Bar", "lat": 20.876, "lon": -103.837, "desc": "์„ธ๊ณ„ 50๋Œ€ ๋ฐ”, ์นตํ…Œ์ผ '๋ฐ”ํƒ•๊ฐ€'์˜ ๋ฐœ์ƒ์ง€"}
78
+ ],
79
+ "Singapore ๐Ÿ‡ธ๐Ÿ‡ฌ": [
80
+ {"name": "Long Bar (Raffles Hotel)", "type": "Historic Bar", "lat": 1.294, "lon": 103.854, "desc": "์ „์„ค์ ์ธ ์นตํ…Œ์ผ '์‹ฑ๊ฐ€ํฌ๋ฅด ์Šฌ๋ง'์ด ํƒ„์ƒํ•œ ๊ณณ"},
81
+ {"name": "Atlas Bar", "type": "Gin Bar", "lat": 1.300, "lon": 103.858, "desc": "์„ธ๊ณ„ ์ตœ๋Œ€ ๊ทœ๋ชจ์˜ ์ง„ ์ปฌ๋ ‰์…˜์„ ๋ณด์œ ํ•œ ํ™”๋ คํ•œ ๋ฐ”"}
82
+ ],
83
+ "Taiwan ๐Ÿ‡น๐Ÿ‡ผ": [
84
+ {"name": "Kavalan Distillery", "type": "Whisky Distillery", "lat": 24.711, "lon": 121.691, "desc": "์„ธ๊ณ„ ์œ„์Šคํ‚ค ์‹œ์žฅ์„ ๋†€๋ผ๊ฒŒ ํ•œ ๋Œ€๋งŒ์˜ ์ž์กด์‹ฌ, ์นด๋ฐœ๋ž€"},
85
+ {"name": "Draft Land", "type": "Cocktail Tap Bar", "lat": 25.042, "lon": 121.548, "desc": "๋งฅ์ฃผ์ฒ˜๋Ÿผ ํƒญ์—์„œ ๋ฝ‘์•„ ๋งˆ์‹œ๋Š” ํ˜์‹ ์ ์ธ ์นตํ…Œ์ผ ๋ฐ”"}
86
+ ]
87
+ }
88
+
89
+ db_json = json.dumps(PLACE_DB)
90
+
91
+ # 4. HTML/JS ์ „์ฒด ์ฝ”๋“œ
92
  html_code = f"""
93
  <!DOCTYPE html>
94
  <html>
 
104
  :root {{ --gold: #d4af37; --dark: #0a0a0a; --card-bg: #1c1c1c; --text: #e0e0e0; --accent: #c5a059; }}
105
  body {{ margin: 0; padding: 0; background: var(--dark); color: var(--text); font-family: 'Segoe UI', serif; overflow: hidden; }}
106
 
107
+ /* ๐Ÿ  ๋ฉ”์ธ ํ™”๋ฉด */
108
  #main-page {{
109
  position: absolute; top: 0; left: 0; width: 100%; height: 100vh;
110
+ 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');
111
  background-size: cover; background-position: center;
112
+ z-index: 5000; display: flex; flex-direction: column; align-items: center; justify-content: flex-start;
113
+ padding-top: 50px; box-sizing: border-box;
114
  transition: transform 0.6s cubic-bezier(0.22, 1, 0.36, 1);
115
  }}
116
 
117
+ .brand-header {{ text-align: center; margin-bottom: 20px; animation: fadeInDown 1s; }}
118
+ .brand-title {{ font-size: 28px; font-weight: 900; color: var(--gold); letter-spacing: 3px; margin: 0; text-transform: uppercase; text-shadow: 0 4px 20px rgba(0,0,0,0.8); }}
119
+ .brand-subtitle {{ font-size: 12px; color: #ccc; letter-spacing: 2px; margin-top: 5px; font-weight: 300; }}
120
+
121
+ @keyframes fadeInDown {{ from {{ opacity: 0; transform: translateY(-20px); }} to {{ opacity: 1; transform: translateY(0); }} }}
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
122
 
123
+ /* ๊ตญ๊ฐ€ ์„ ํƒ๊ธฐ (๊ฐ€๋กœ ์Šคํฌ๋กค) */
124
+ .country-selector-wrapper {{ width: 100%; overflow: hidden; padding: 10px 0; background: rgba(0,0,0,0.3); backdrop-filter: blur(5px); }}
125
+ .country-selector {{
126
+ display: flex; gap: 10px; overflow-x: auto; padding: 0 20px; scrollbar-width: none;
127
+ }}
128
+ .country-selector::-webkit-scrollbar {{ display: none; }}
129
 
130
+ .country-btn {{
131
+ background: rgba(50,50,50,0.6); border: 1px solid #444; color: #aaa;
132
+ padding: 8px 16px; border-radius: 20px; cursor: pointer; white-space: nowrap; font-size: 14px;
133
+ transition: 0.3s;
134
  }}
135
+ .country-btn.active {{ background: var(--gold); color: black; border-color: var(--gold); font-weight: 800; transform: scale(1.05); box-shadow: 0 0 15px rgba(212, 175, 55, 0.4); }}
136
 
137
+ /* ์žฅ์†Œ ๋ฆฌ์ŠคํŠธ (์นด๋“œํ˜•) */
138
+ .place-list-container {{
139
+ width: 100%; max-width: 600px; flex: 1; overflow-y: auto; padding: 20px; box-sizing: border-box;
140
+ }}
141
+ .place-card {{
142
+ background: rgba(30,30,30,0.6); backdrop-filter: blur(10px);
143
+ border: 1px solid rgba(255,255,255,0.1); border-radius: 12px; padding: 20px;
144
+ margin-bottom: 15px; cursor: pointer; transition: all 0.3s;
145
+ display: flex; flex-direction: column; position: relative; overflow: hidden;
146
  }}
147
+ .place-card::before {{ content:''; position: absolute; left: 0; top: 0; height: 100%; width: 4px; background: var(--gold); opacity: 0; transition: 0.3s; }}
148
+ .place-card:hover {{ transform: translateX(5px); background: rgba(40,40,40,0.8); border-color: var(--gold); }}
149
+ .place-card:hover::before {{ opacity: 1; }}
150
+
151
+ .pc-header {{ display: flex; justify-content: space-between; align-items: center; margin-bottom: 5px; }}
152
+ .pc-name {{ font-size: 18px; font-weight: bold; color: white; }}
153
+ .pc-type {{ font-size: 11px; background: var(--gold); color: black; padding: 2px 6px; border-radius: 4px; font-weight: bold; text-transform: uppercase; }}
154
+ .pc-desc {{ font-size: 13px; color: #ccc; line-height: 1.4; }}
155
+ .pc-cta {{ margin-top: 15px; font-size: 12px; color: var(--gold); text-align: right; font-weight: bold; letter-spacing: 1px; }}
156
+
157
+ /* ๐Ÿ—บ๏ธ ์ƒ์„ธ ํ™”๋ฉด */
158
+ #detail-page {{
159
  position: absolute; top: 0; left: 0; width: 100%; height: 100vh;
160
+ transform: translateX(100%); transition: transform 0.5s cubic-bezier(0.22, 1, 0.36, 1);
161
+ z-index: 6000; display: flex; flex-direction: column; background: #111;
162
  }}
163
 
164
  .nav-bar {{
165
+ height: 50px; background: rgba(0,0,0,0.9); border-bottom: 1px solid #333;
166
+ display: flex; align-items: center; padding: 0 15px; justify-content: space-between;
167
  }}
168
+ .back-btn {{ background: none; border: none; color: var(--gold); font-size: 24px; cursor: pointer; padding: 0; }}
169
+ .nav-title {{ color: white; font-weight: bold; font-size: 16px; }}
170
 
171
+ #map {{ width: 100%; height: 35%; border-bottom: 2px solid var(--gold); }}
172
 
173
+ .report-container {{
174
+ flex: 1; overflow-y: auto; padding: 25px 20px 80px 20px; box-sizing: border-box;
 
175
  }}
176
 
177
+ .report-title {{ font-size: 26px; color: var(--gold); font-weight: bold; margin-bottom: 5px; font-family: serif; }}
178
+ .report-meta {{ font-size: 13px; color: #666; margin-bottom: 20px; border-bottom: 1px solid #333; padding-bottom: 15px; }}
179
+
180
+ .ai-content {{ font-size: 15px; color: #ddd; line-height: 1.8; white-space: pre-wrap; text-align: justify; }}
181
+
182
+ .gmap-btn {{
183
+ display: block; width: 90%; margin: 0 auto; padding: 15px 0;
184
+ background: var(--gold); color: black; text-align: center;
185
+ font-weight: bold; text-decoration: none; border-radius: 30px;
186
+ position: fixed; bottom: 20px; left: 5%; z-index: 7000;
187
+ box-shadow: 0 5px 20px rgba(212, 175, 55, 0.4);
188
+ transition: transform 0.2s;
 
189
  }}
190
+ .gmap-btn:active {{ transform: scale(0.95); }}
191
 
192
+ /* ๋กœ๋”ฉ */
193
  .loading-screen {{
194
+ position: absolute; top: 0; left: 0; width: 100%; height: 100%;
195
+ background: rgba(0,0,0,0.9); backdrop-filter: blur(5px);
196
+ z-index: 8000; display: none; flex-direction: column; justify-content: center; align-items: center;
197
  }}
198
+ .loader-ring {{ width: 50px; height: 50px; border: 3px solid #333; border-top: 3px solid var(--gold); border-radius: 50%; animation: spin 1s linear infinite; margin-bottom: 20px; }}
199
+ .loader-text {{ color: var(--gold); font-family: monospace; font-size: 14px; text-align: center; line-height: 1.5; }}
200
  @keyframes spin {{ 0% {{ transform: rotate(0deg); }} 100% {{ transform: rotate(360deg); }} }}
201
 
202
  </style>
 
204
  <body>
205
 
206
  <div id="main-page">
207
+ <div class="brand-header">
208
+ <h1 class="brand-title">DRUNKEN PLANE</h1>
209
+ <div class="brand-subtitle">GLOBAL ALCOHOL HERITAGE TOUR</div>
 
 
 
210
  </div>
211
 
212
+ <div class="country-selector-wrapper">
213
+ <div class="country-selector" id="country-tabs"></div>
 
214
  </div>
215
 
216
+ <div class="place-list-container" id="place-list"></div>
 
 
 
 
 
 
 
 
217
  </div>
218
 
219
+ <div id="detail-page">
220
  <div class="nav-bar">
221
+ <button class="back-btn" onclick="goBack()">โ† BACK</button>
222
+ <div class="nav-title">TOUR GUIDE</div>
223
+ <div style="width:40px;"></div>
224
  </div>
225
+
226
  <div id="map"></div>
227
+
228
+ <div class="report-container">
229
+ <div id="repo-title" class="report-title"></div>
230
+ <div id="repo-meta" class="report-meta"></div>
231
+ <div id="repo-content" class="ai-content"></div>
232
+ </div>
233
+
234
+ <a id="gmap-link" href="#" target="_blank" class="gmap-btn">๐Ÿ—บ๏ธ Google Maps์—์„œ ์œ„์น˜ ํ™•์ธ</a>
235
  </div>
236
 
237
+ <div id="loader" class="loading-screen">
238
  <div class="loader-ring"></div>
239
+ <div id="loader-msg" class="loader-text">AI ์—ฌํ–‰์ž‘๊ฐ€๊ฐ€<br>ํ˜„์ง€ ๋ฆฌํฌํŠธ๋ฅผ ์ž‘์„ฑ ์ค‘์ž…๋‹ˆ๋‹ค...</div>
240
  </div>
241
 
242
  <script>
243
  const HF_TOKEN = "{token}";
 
244
  const MODEL_ID = "Qwen/Qwen2.5-32B-Instruct";
245
 
246
+ // ํŒŒ์ด์ฌ์—์„œ ์ƒ์„ฑํ•œ ๊ฑฐ๋Œ€ DB ์ฃผ์ž…
247
+ const PLACE_DB = {db_json};
248
+
249
  let map = null;
250
+ let currentMarker = null;
251
+
252
+ window.onload = function() {{
253
+ renderCountryTabs();
254
+ // GPS๋กœ ๊ฐ€๊นŒ์šด ๊ตญ๊ฐ€ ์ฐพ๊ธฐ ๊ธฐ๋Šฅ ๋Œ€์‹ , ์ผ๋‹จ ์ฒซ๋ฒˆ์งธ ๊ตญ๊ฐ€ ์ž๋™ ์„ ํƒ (๋ง๋ ˆ์ด์‹œ์•„)
255
+ const firstCountry = Object.keys(PLACE_DB)[0];
256
+ selectCountry(firstCountry);
257
+ }};
258
+
259
+ function renderCountryTabs() {{
260
+ const tabs = document.getElementById('country-tabs');
261
+ tabs.innerHTML = "";
262
+ Object.keys(PLACE_DB).forEach(country => {{
263
+ const btn = document.createElement('div');
264
+ btn.className = 'country-btn';
265
+ btn.innerText = country;
266
+ btn.id = `tab-${{country.replace(/ /g, "_")}}`; // ID ๊ณต๋ฐฑ ์ œ๊ฑฐ
267
+ btn.onclick = () => selectCountry(country);
268
+ tabs.appendChild(btn);
269
+ }});
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
270
  }}
271
 
272
+ function selectCountry(country) {{
273
+ // ํƒญ ์Šคํƒ€์ผ
274
+ document.querySelectorAll('.country-btn').forEach(b => b.classList.remove('active'));
275
+ document.getElementById(`tab-${{country.replace(/ /g, "_")}}`).classList.add('active');
276
 
277
+ // ๋ฆฌ์ŠคํŠธ ๋ Œ๋”๋ง
278
+ const list = document.getElementById('place-list');
279
+ list.innerHTML = "";
 
 
 
 
280
 
281
+ PLACE_DB[country].forEach(place => {{
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
282
  const card = document.createElement('div');
283
+ card.className = 'place-card';
284
+ card.onclick = () => openDetail(place, country);
285
 
286
  card.innerHTML = `
287
+ <div class="pc-header">
288
+ <span class="pc-name">${{place.name}}</span>
289
+ <span class="pc-type">${{place.type}}</span>
290
+ </div>
291
+ <div class="pc-desc">${{place.desc}}</div>
292
+ <div class="pc-cta">READ AI REPORT โ†’</div>
293
  `;
294
+ list.appendChild(card);
295
  }});
296
  }}
297
 
298
+ async function openDetail(place, country) {{
299
+ // ํ™”๋ฉด ์ „ํ™˜
300
+ document.getElementById('main-page').style.transform = 'translateX(-30%)';
301
+ document.getElementById('detail-page').style.transform = 'translateX(0%)';
302
+ document.getElementById('loader').style.display = 'flex';
303
+
304
+ // ๊ธฐ๋ณธ ๋ฐ์ดํ„ฐ ๋ฐ”์ธ๋”ฉ
305
+ document.getElementById('repo-title').innerText = place.name;
306
+ document.getElementById('repo-meta').innerText = `${{place.type}} โ€ข ${{country}}`;
307
+ document.getElementById('gmap-link').href = `https://www.google.com/maps/search/?api=1&query=${{place.lat}},${{place.lon}}`;
308
+ document.getElementById('repo-content').innerHTML = "";
309
 
310
+ // ์ง€๋„ ์„ค์ • (100% ๋ณด์žฅ๋œ ์ขŒํ‘œ ์‚ฌ์šฉ)
311
+ if (!map) {{
312
+ map = L.map('map');
313
+ L.tileLayer('https://{{s}}.tile.openstreetmap.org/{{z}}/{{x}}/{{y}}.png', {{ attribution: 'ยฉ OpenStreetMap' }}).addTo(map);
314
+ }}
315
+ // ์ง€๋„ ๋ฆฌ์‚ฌ์ด์ฆˆ ํ•„์ˆ˜ (ํ™”๋ฉด ์ „ํ™˜ ํ›„ ๊นจ์ง ๋ฐฉ์ง€)
316
+ setTimeout(() => {{
317
+ map.invalidateSize();
318
+ map.setView([place.lat, place.lon], 15);
319
+ if (currentMarker) map.removeLayer(currentMarker);
320
+ currentMarker = L.marker([place.lat, place.lon]).addTo(map).bindPopup(place.name).openPopup();
321
+ }}, 300);
322
+
323
+ // AI ๋ฆฌํฌํŠธ ์ƒ์„ฑ
324
+ await generateAIReport(place, country);
325
+ }}
326
+
327
+ async function generateAIReport(place, country) {{
328
+ const prompt = `
329
+ Act as a professional travel writer for 'Lonely Planet'.
330
+ Write a fascinating, detailed article about "${{place.name}}" in ${{country}}.
331
 
332
+ Structure:
333
+ 1. **History & Story**: Why is this place legendary? (Origin story)
334
+ 2. **The Experience**: What exactly can a visitor drink, see, or smell here?
335
+ 3. **Sommelier's Tip**: Best menu/drink to order or buy.
 
336
 
337
+ Tone: Sophisticated, immersive, and informative.
338
+ Language: Korean (ํ•œ๊ตญ์–ด).
339
+ Format: Clean text with paragraphs. No markdown bolding needed.
 
340
  `;
341
 
342
  try {{
343
+ const res = await fetch(`https://api-inference.huggingface.co/models/${{MODEL_ID}}`, {{
344
+ method: "POST",
345
+ headers: {{ "Authorization": `Bearer ${{HF_TOKEN}}`, "Content-Type": "application/json" }},
346
+ body: JSON.stringify({{ inputs: prompt, parameters: {{ max_new_tokens: 1500, return_full_text: false }} }})
347
+ }});
348
+ const json = await res.json();
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
349
 
350
+ if(json.error) throw new Error(json.error);
351
+ const text = json[0].generated_text;
 
 
 
352
 
353
+ document.getElementById('loader').style.display = 'none';
354
+ typeWriter(text);
 
 
 
 
 
 
 
355
 
356
  }} catch(e) {{
357
+ console.error(e);
358
+ document.getElementById('loader').style.display = 'none';
359
+ document.getElementById('repo-content').innerHTML = `
360
+ <div style="color:#aaa; padding:20px;">
361
+ AI ๋ฆฌํฌํ„ฐ ์—ฐ๊ฒฐ์ด ์ง€์—ฐ๋˜๊ณ  ์žˆ์Šต๋‹ˆ๋‹ค.<br>
362
+ ํ•˜์ง€๋งŒ ์ด๊ณณ์€ <b>${{place.desc}}</b>๋กœ ์œ ๋ช…ํ•œ ๋ช…์†Œ์ž…๋‹ˆ๋‹ค.<br>
363
+ ์•„๋ž˜ ๊ตฌ๊ธ€๋งต ๋ฒ„ํŠผ์„ ๋ˆŒ๋Ÿฌ ์ง์ ‘ ํ™•์ธํ•ด๋ณด์„ธ์š”!
364
+ </div>
365
+ `;
366
  }}
367
  }}
368
 
369
+ function typeWriter(text) {{
370
+ const el = document.getElementById('repo-content');
371
+ let i = 0;
372
+ el.innerHTML = "";
373
+ const speed = 5;
 
 
 
 
374
 
375
+ function type() {{
376
+ if (i < text.length) {{
377
+ el.innerHTML += text.charAt(i);
378
+ i++;
379
+ setTimeout(type, speed);
380
+ }}
381
+ }}
382
+ type();
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
383
  }}
384
 
385
  function goBack() {{
386
+ document.getElementById('detail-page').style.transform = 'translateX(100%)';
387
  document.getElementById('main-page').style.transform = 'translateX(0%)';
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
388
  }}
389
 
390
  </script>