ginipick commited on
Commit
fa0ae19
ยท
verified ยท
1 Parent(s): 2312f14

Update app.py

Browse files
Files changed (1) hide show
  1. app.py +138 -199
app.py CHANGED
@@ -1,236 +1,175 @@
1
- """
2
- Vibe Game Craft โ€“ NEW / BEST ํƒญ์„ โ€˜์‚ฌ์ดํŠธ ๋ฏธ๋Ÿฌ๋งโ€™(iframe) ๋ฐฉ์‹์œผ๋กœ ๋ณด์—ฌ์ฃผ๋Š” Gradio Space
3
-
4
- โ— Gradio 4.x ๊ณต์šฉ API๋งŒ ์‚ฌ์šฉ โ€“ set_event_trigger ์ œ๊ฑฐ
5
- โ— Prev / Next ๋ฒ„ํŠผ์œผ๋กœ ํŽ˜์ด์ง€ ์ „ํ™˜
6
- โ— os.getenv("SVR_TOKEN") ํ•„์ˆ˜
7
- """
8
-
9
- import os
10
- import time
11
- import json
12
- import requests
13
- import datetime
14
- import gradio as gr
15
-
16
- # โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€
17
  # 1. Vercel API ์„ค์ •
18
- VERCEL_API_TOKEN = os.getenv("SVR_TOKEN") # โ† ํ™˜๊ฒฝ๋ณ€์ˆ˜์—์„œ ์ฝ์Œ
 
19
  if not VERCEL_API_TOKEN:
20
- raise EnvironmentError("ํ™˜๊ฒฝ ๋ณ€์ˆ˜ 'SVR_TOKEN'์ด ์„ค์ •๋˜์–ด ์žˆ์ง€ ์•Š์Šต๋‹ˆ๋‹ค!")
21
- VERCEL_API_URL = "https://api.vercel.com/v9"
22
- HEADERS = {
23
- "Authorization": f"Bearer {VERCEL_API_TOKEN}",
24
- "Content-Type": "application/json",
25
- }
26
-
27
- # โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€
28
- # 2. ๊ฐค๋Ÿฌ๋ฆฌ ์„ค์ •
29
- BEST_GAMES_FILE = "best_games.json"
30
- GAMES_PER_PAGE = 48
31
-
32
- # โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€
33
- # 3. BEST ํƒญ ์ดˆ๊ธฐํ™” / ๋กœ๋“œ
34
- def initialize_best_games() -> None:
35
- if not os.path.exists(BEST_GAMES_FILE):
36
- sample = [
37
- {
38
- "title": "ํ…ŒํŠธ๋ฆฌ์Šค",
39
- "description": "ํด๋ž˜์‹ ํ…ŒํŠธ๋ฆฌ์Šค ๊ฒŒ์ž„",
40
- "url": "https://tmkdop.vercel.app",
41
- "timestamp": time.time(),
42
- },
43
- {
44
- "title": "์Šค๋„ค์ดํฌ",
45
- "description": "์ „ํ†ต์ ์ธ ์Šค๋„ค์ดํฌ ๊ฒŒ์ž„",
46
- "url": "https://tmkdop.vercel.app",
47
- "timestamp": time.time(),
48
- },
49
- {
50
- "title": "ํŒฉ๋งจ",
51
- "description": "๊ณ ์ „ ์•„์ผ€์ด๋“œ ๊ฒŒ์ž„",
52
- "url": "https://tmkdop.vercel.app",
53
- "timestamp": time.time(),
54
- },
55
- ]
56
- json.dump(sample, open(BEST_GAMES_FILE, "w"), ensure_ascii=False, indent=2)
57
-
58
- def load_best_games() -> list:
59
  try:
60
- return json.load(open(BEST_GAMES_FILE))
61
  except Exception:
62
  return []
63
 
64
- # โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€
65
- # 4. Vercel ์ตœ์‹  ๋ฐฐํฌ ๊ฐ€์ ธ์˜ค๊ธฐ
66
- def get_latest_deployments() -> list:
67
  try:
68
- resp = requests.get(
69
- f"{VERCEL_API_URL}/deployments",
70
- headers=HEADERS,
71
- params={"limit": 100},
72
- timeout=30,
73
- )
74
- resp.raise_for_status()
75
  games = []
76
- for d in resp.json().get("deployments", []):
77
  if d.get("state") != "READY":
78
  continue
79
- created_at = d.get("createdAt", time.time() * 1000)
80
- ts = int(created_at / 1000) if isinstance(created_at, (int, float)) else int(
81
- time.time()
82
- )
83
- games.append(
84
- {
85
- "title": d.get("name", "๊ฒŒ์ž„"),
86
- "description": f"๋ฐฐํฌ๋œ ๊ฒŒ์ž„: {d.get('name')}",
87
- "url": f"https://{d.get('url')}",
88
- "timestamp": ts,
89
- }
90
- )
91
- games.sort(key=lambda x: x["timestamp"], reverse=True)
92
- return games
93
  except Exception as e:
94
  print("Vercel API ์˜ค๋ฅ˜:", e)
95
  return []
96
 
97
- # โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€
98
- # 5. ํŽ˜์ด์ง€๋„ค์ด์…˜
99
- def paginate(lst: list, page: int):
100
- start = (page - 1) * GAMES_PER_PAGE
101
- end = start + GAMES_PER_PAGE
102
- total = (len(lst) + GAMES_PER_PAGE - 1) // GAMES_PER_PAGE
103
- return lst[start:end], total
104
 
105
- # โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€
106
- # 6. ๊ฐค๋Ÿฌ๋ฆฌ HTML (iframe ๋ฏธ๋Ÿฌ๋ง)
107
- def generate_gallery_html(games: list, page: int, total_pages: int, tab_name: str) -> str:
108
- if not games:
109
- return "<div style='text-align:center;padding:60px;'>ํ‘œ์‹œํ•  ๊ฒŒ์ž„์ด ์—†์Šต๋‹ˆ๋‹ค.</div>"
110
 
111
  css = """
112
  <style>
113
- .grid{display:grid;grid-template-columns:repeat(auto-fill,minmax(360px,1fr));gap:24px;margin-bottom:40px}
114
- .item{background:#fff;border-radius:14px;overflow:hidden;box-shadow:0 4px 15px rgba(0,0,0,.1);transition:.3s}
115
- .item:hover{transform:translateY(-6px);box-shadow:0 12px 30px rgba(0,0,0,.15)}
116
- .hdr{padding:14px 18px;background:rgba(255,255,255,.8);backdrop-filter:blur(6px);border-bottom:1px solid #eee}
117
- .ttl{margin:0;font-size:1.1rem;font-weight:700;white-space:nowrap;overflow:hidden;text-overflow:ellipsis}
118
- .meta{font-size:.85rem;color:#666;margin-top:4px}
119
- .iframe-box{position:relative;width:100%;padding-top:60%;overflow:hidden}
120
- .iframe-box iframe{position:absolute;top:0;left:0;width:142.857%;height:142.857%;
121
- transform:scale(.7);transform-origin:top left;border:0}
122
- .footer{padding:10px 16px;background:rgba(255,255,255,.85);backdrop-filter:blur(6px);text-align:right}
123
- .footer a{font-size:.85rem;font-weight:600;color:#2c5282;text-decoration:none}
124
- .pg{display:flex;justify-content:center;gap:10px;margin-top:10px}
125
- .pg button{border:1px solid #ddd;background:#fff;padding:6px 14px;border-radius:8px;cursor:pointer}
126
- .pg button.active,.pg button:hover{background:#9c89b8;color:#fff;border-color:#9c89b8}
 
 
 
127
  </style>
128
  """
 
129
  html = css + "<div class='grid'>"
130
- for g in games:
131
- date = datetime.datetime.fromtimestamp(g["timestamp"]).strftime("%Y-%m-%d")
132
  html += f"""
133
- <div class='item'>
134
- <div class='hdr'>
135
- <p class='ttl'>{g['title']}</p>
136
- <div class='meta'>{date}</div>
 
 
 
 
 
 
 
137
  </div>
138
- <div class='iframe-box'><iframe src="{g['url']}" loading="lazy"
139
- allow="accelerometer; camera; encrypted-media; gyroscope;"></iframe></div>
140
- <div class='footer'><a href="{g['url']}" target="_blank">์›๋ณธ ์‚ฌ์ดํŠธ ์—ด๊ธฐ โ†—</a></div>
141
  </div>
142
  """
143
  html += "</div>"
144
- # ํŽ˜์ด์ง€ ๋ฒˆํ˜ธ(์ฝ๊ธฐ ์ „์šฉ) โ€“ Prev/Next ๋Š” Gradio์—์„œ ์ฒ˜๋ฆฌ
145
- html += f"<div style='text-align:center;margin-top:4px;font-size:.85rem;color:#666;'>Page {page} / {total_pages}</div>"
146
  return html
147
 
148
- # โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€
149
- # 7. Gradio UI
150
- def create_gallery_interface():
151
- initialize_best_games()
152
 
153
- with gr.Blocks(title="Vibe Game Craft") as demo:
154
- gr.HTML("<h1 style='text-align:center;'>๐ŸŽฎ Vibe Game Craft</h1>")
155
 
156
  with gr.Row():
157
- new_btn = gr.Button("NEW", elem_id="new-btn")
158
- best_btn = gr.Button("BEST", elem_id="best-btn")
159
- refresh = gr.Button("๐Ÿ”„ ์ƒˆ๋กœ๊ณ ์นจ")
160
- prev_btn = gr.Button("โฌ…๏ธ Prev")
161
- next_btn = gr.Button("Next โžก๏ธ")
162
-
163
- # ์ƒํƒœ
164
- current_tab = gr.State("new") # "new" or "best"
165
- new_page = gr.State(1)
166
- best_page = gr.State(1)
167
- gallery_html = gr.HTML()
168
-
169
- # NEW / BEST ๋ Œ๋” ํ•จ์ˆ˜
170
- def render_new(page=1):
171
- games, total = paginate(get_latest_deployments(), page)
172
- return generate_gallery_html(games, page, total, "new"), "new", page
173
-
174
- def render_best(page=1):
175
- games, total = paginate(load_best_games(), page)
176
- return generate_gallery_html(games, page, total, "best"), "best", page
177
-
178
- # ๋ฒ„ํŠผ ์ด๋ฒคํŠธ
179
- new_btn.click(fn=render_new,
180
- outputs=[gallery_html, current_tab, new_page])
181
-
182
- best_btn.click(fn=render_best,
183
- outputs=[gallery_html, current_tab, best_page])
184
-
185
- def refresh_gallery(tab, p_new, p_best):
186
- if tab == "new":
187
- return render_new(p_new)[0]
188
- return render_best(p_best)[0]
189
-
190
- refresh.click(fn=refresh_gallery,
191
- inputs=[current_tab, new_page, best_page],
192
- outputs=[gallery_html])
193
-
194
- # Prev / Next
195
- def prev_page(tab, p_new, p_best):
196
- if tab == "new":
197
- p_new = max(1, p_new - 1)
198
- html, _, _ = render_new(p_new)
199
- return html, p_new, p_best
200
- p_best = max(1, p_best - 1)
201
- html, _, _ = render_best(p_best)
202
- return html, p_new, p_best
203
-
204
- def next_page(tab, p_new, p_best):
205
- if tab == "new":
206
- games_total = len(get_latest_deployments())
207
- max_pages = (games_total + GAMES_PER_PAGE - 1) // GAMES_PER_PAGE
208
- p_new = min(max_pages, p_new + 1)
209
- html, _, _ = render_new(p_new)
210
- return html, p_new, p_best
211
- games_total = len(load_best_games())
212
- max_pages = (games_total + GAMES_PER_PAGE - 1) // GAMES_PER_PAGE
213
- p_best = min(max_pages, p_best + 1)
214
- html, _, _ = render_best(p_best)
215
- return html, p_new, p_best
216
-
217
- prev_btn.click(fn=prev_page,
218
- inputs=[current_tab, new_page, best_page],
219
- outputs=[gallery_html, new_page, best_page])
220
-
221
- next_btn.click(fn=next_page,
222
- inputs=[current_tab, new_page, best_page],
223
- outputs=[gallery_html, new_page, best_page])
224
-
225
- # ์ดˆ๊ธฐ NEW ํƒญ ํ‘œ์‹œ
226
- demo.load(fn=render_new,
227
- outputs=[gallery_html, current_tab, new_page])
228
 
229
  return demo
230
 
231
- # โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€
232
- # 8. ์ „์—ญ app (Spaces ์ž๋™ ๊ฐ์ง€)
233
- app = create_gallery_interface()
234
 
235
  if __name__ == "__main__":
236
  app.launch()
 
1
+ import os, time, json, datetime, requests, gradio as gr
2
+
3
+ # โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€
 
 
 
 
 
 
 
 
 
 
 
 
 
4
  # 1. Vercel API ์„ค์ •
5
+ VERCEL_API_TOKEN = os.getenv("SVR_TOKEN")
6
+ VERCEL_TEAM_ID = os.getenv("VERCEL_TEAM_ID") # ๊ฐœ์ธ ๊ณ„์ •์ด๋ฉด None
7
  if not VERCEL_API_TOKEN:
8
+ raise EnvironmentError("SVR_TOKEN ํ™˜๊ฒฝ๋ณ€์ˆ˜๋ฅผ ์„ค์ •ํ•˜์„ธ์š”.")
9
+
10
+ API_BASE = "https://api.vercel.com"
11
+ HEADERS = {"Authorization": f"Bearer {VERCEL_API_TOKEN}"}
12
+
13
+ # โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€
14
+ # 2. BEST ํƒญ ํŒŒ์ผ / ์ƒ์ˆ˜
15
+ BEST_FILE = "best_games.json"
16
+ CARDS_PER_PG = 48
17
+
18
+ def init_best():
19
+ if not os.path.exists(BEST_FILE):
20
+ json.dump([], open(BEST_FILE, "w"))
21
+
22
+ def load_best():
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
23
  try:
24
+ return json.load(open(BEST_FILE))
25
  except Exception:
26
  return []
27
 
28
+ # โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€
29
+ # 3. ๋ชจ๋“  ํ”„๋กœ์ ํŠธ ์ตœ์‹  ๋ฐฐํฌ ๊ฐ€์ ธ์˜ค๊ธฐ
30
+ def fetch_deployments(limit=100):
31
  try:
32
+ params = {"limit": limit}
33
+ if VERCEL_TEAM_ID:
34
+ params["teamId"] = VERCEL_TEAM_ID
35
+ r = requests.get(f"{API_BASE}/v13/deployments",
36
+ headers=HEADERS, params=params, timeout=30)
37
+ r.raise_for_status()
38
+
39
  games = []
40
+ for d in r.json().get("deployments", []):
41
  if d.get("state") != "READY":
42
  continue
43
+ ts = int(d.get("createdAt", time.time()*1000) / 1000)
44
+ games.append({
45
+ "title" : d.get("name", "(์ œ๋ชฉ ์—†์Œ)"),
46
+ "description": f"ํ”„๋กœ์ ํŠธ: {d.get('name')}",
47
+ "url" : f"https://{d.get('url')}",
48
+ "timestamp" : ts,
49
+ })
50
+ return sorted(games, key=lambda x: x["timestamp"], reverse=True)
 
 
 
 
 
 
51
  except Exception as e:
52
  print("Vercel API ์˜ค๋ฅ˜:", e)
53
  return []
54
 
55
+ # โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€
56
+ def paginate(lst, page):
57
+ s = (page-1) * CARDS_PER_PG
58
+ e = s + CARDS_PER_PG
59
+ total = (len(lst) + CARDS_PER_PG - 1)//CARDS_PER_PG
60
+ return lst[s:e], total
 
61
 
62
+ # โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€
63
+ # 4. HTML ์ƒ์„ฑ (ํŒŒ์Šคํ…” ์Šคํƒ€์ผ, ๊ฐ€๋กœ 100 %)
64
+ def make_html(cards, page, total):
65
+ if not cards:
66
+ return "<div style='text-align:center;padding:80px;font-size:1.1rem;color:#555;'>ํ‘œ์‹œํ•  ๋ฐฐํฌ๊ฐ€ ์—†์Šต๋‹ˆ๋‹ค.</div>"
67
 
68
  css = """
69
  <style>
70
+ body{margin:0;padding:0;font-family:'Poppins',sans-serif;background:linear-gradient(135deg,#C5E8FF 0%,#FFD6E0 100%);}
71
+ .grid{display:grid;grid-template-columns:1fr;gap:40px;padding-bottom:60px}
72
+ .card{background:#fff;border-radius:20px;overflow:hidden;box-shadow:0 15px 30px rgba(0,0,0,.08);transition:.3s}
73
+ .card:hover{transform:translateY(-8px);box-shadow:0 25px 45px rgba(0,0,0,.12)}
74
+ .header{padding:24px 28px;background:rgba(255,255,255,.75);backdrop-filter:blur(8px);border-bottom:1px solid #eee}
75
+ .title{margin:0;font-size:1.25rem;font-weight:700;color:#333}
76
+ .date{font-size:.9rem;color:#777;margin-top:4px}
77
+ .frame{position:relative;width:100%;padding-top:56.25%;overflow:hidden}
78
+ .frame iframe{position:absolute;top:0;left:0;width:100%;height:100%;border:0}
79
+ .footer{padding:16px 28px;background:rgba(255,255,255,.85);backdrop-filter:blur(8px);text-align:right}
80
+ .link{font-size:.9rem;font-weight:600;color:#4a6dd8;text-decoration:none}
81
+ .pager{display:flex;justify-content:center;gap:16px;margin:20px 0}
82
+ .btn{border:none;background:#fff;padding:10px 24px;border-radius:12px;font-weight:600;
83
+ box-shadow:0 4px 12px rgba(0,0,0,.05);cursor:pointer;transition:.25s}
84
+ .btn:hover{background:#f1e8ff}
85
+ .btn:disabled{opacity:.4;cursor:default}
86
+ .count{font-size:.85rem;color:#555;text-align:center;margin-top:-4px}
87
  </style>
88
  """
89
+
90
  html = css + "<div class='grid'>"
91
+ for c in cards:
92
+ date = datetime.datetime.fromtimestamp(c["timestamp"]).strftime("%Y-%m-%d")
93
  html += f"""
94
+ <div class='card'>
95
+ <div class='header'>
96
+ <p class='title'>{c['title']}</p>
97
+ <p class='date'>{date}</p>
98
+ </div>
99
+ <div class='frame'>
100
+ <iframe src="{c['url']}" loading="lazy"
101
+ allow="accelerometer; camera; encrypted-media; gyroscope;"></iframe>
102
+ </div>
103
+ <div class='footer'>
104
+ <a href="{c['url']}" target="_blank" class='link'>์›๋ณธ ์‚ฌ์ดํŠธ โ†—</a>
105
  </div>
 
 
 
106
  </div>
107
  """
108
  html += "</div>"
109
+ html += f"<p class='count'>Page {page} / {total}</p>"
 
110
  return html
111
 
112
+ # โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€
113
+ def build_app():
114
+ init_best()
 
115
 
116
+ with gr.Blocks(title="Vibe Game Craft", css="body{overflow-x:hidden;}") as demo:
117
+ gr.Markdown("<h1 style='text-align:center;padding:30px 0;color:#333;'>๐ŸŽฎ Vibe Game Craft</h1>")
118
 
119
  with gr.Row():
120
+ btn_new = gr.Button("NEW", size="sm")
121
+ btn_best = gr.Button("BEST", size="sm")
122
+ btn_prev = gr.Button("โฌ…๏ธ Prev", size="sm")
123
+ btn_next = gr.Button("Next โžก๏ธ", size="sm")
124
+ btn_ref = gr.Button("๐Ÿ”„ Reload", size="sm")
125
+
126
+ tab = gr.State("new")
127
+ np = gr.State(1)
128
+ bp = gr.State(1)
129
+ out = gr.HTML()
130
+
131
+ # ์ฝœ๋ฐฑ โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€
132
+ def show_new(p=1):
133
+ cards, total = paginate(fetch_deployments(), p)
134
+ return make_html(cards, p, total), "new", p
135
+
136
+ def show_best(p=1):
137
+ cards, total = paginate(load_best(), p)
138
+ return make_html(cards, p, total), "best", p
139
+
140
+ def prev(t, cp_n, cp_b):
141
+ if t=="new":
142
+ cp_n = max(1, cp_n-1); html, _,_ = show_new(cp_n)
143
+ return html, cp_n, cp_b
144
+ cp_b = max(1, cp_b-1); html, _,_ = show_best(cp_b)
145
+ return html, cp_n, cp_b
146
+
147
+ def nxt(t, cp_n, cp_b):
148
+ if t=="new":
149
+ max_pg = (len(fetch_deployments())+CARDS_PER_PG-1)//CARDS_PER_PG
150
+ cp_n = min(max_pg, cp_n+1); html, _,_ = show_new(cp_n)
151
+ return html, cp_n, cp_b
152
+ max_pg = (len(load_best())+CARDS_PER_PG-1)//CARDS_PER_PG
153
+ cp_b = min(max_pg, cp_b+1); html, _,_ = show_best(cp_b)
154
+ return html, cp_n, cp_b
155
+
156
+ def refresh(t, cp_n, cp_b):
157
+ return show_new(cp_n)[0] if t=="new" else show_best(cp_b)[0]
158
+
159
+ # โฆฟ ๋ฒ„ํŠผ ์—ฐ๊ฒฐ
160
+ btn_new.click(show_new, outputs=[out, tab, np])
161
+ btn_best.click(show_best, outputs=[out, tab, bp])
162
+ btn_prev.click(prev, inputs=[tab, np, bp], outputs=[out, np, bp])
163
+ btn_next.click(nxt, inputs=[tab, np, bp], outputs=[out, np, bp])
164
+ btn_ref.click(refresh, inputs=[tab, np, bp], outputs=[out])
165
+
166
+ # ์ดˆ๊ธฐ ๋กœ๋“œ
167
+ demo.load(show_new, outputs=[out, tab, np])
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
168
 
169
  return demo
170
 
171
+ # โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€
172
+ app = build_app()
 
173
 
174
  if __name__ == "__main__":
175
  app.launch()