Spaces:
Running
Running
Update app.py
Browse files
app.py
CHANGED
@@ -1,24 +1,37 @@
|
|
1 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
2 |
|
3 |
# ────────────────────────────────────────────────────────────────────────────────
|
4 |
-
# 1. Vercel API 설정
|
5 |
-
VERCEL_API_TOKEN = os.getenv("SVR_TOKEN")
|
6 |
if not VERCEL_API_TOKEN:
|
7 |
raise EnvironmentError("환경 변수 'SVR_TOKEN'이 설정되어 있지 않습니다!")
|
8 |
-
VERCEL_API_URL
|
9 |
HEADERS = {
|
10 |
"Authorization": f"Bearer {VERCEL_API_TOKEN}",
|
11 |
"Content-Type": "application/json",
|
12 |
}
|
13 |
|
14 |
# ────────────────────────────────────────────────────────────────────────────────
|
15 |
-
# 2.
|
16 |
BEST_GAMES_FILE = "best_games.json"
|
17 |
GAMES_PER_PAGE = 48
|
18 |
|
19 |
# ────────────────────────────────────────────────────────────────────────────────
|
20 |
-
# 3. BEST
|
21 |
-
def initialize_best_games():
|
22 |
if not os.path.exists(BEST_GAMES_FILE):
|
23 |
sample = [
|
24 |
{
|
@@ -40,33 +53,32 @@ def initialize_best_games():
|
|
40 |
"timestamp": time.time(),
|
41 |
},
|
42 |
]
|
43 |
-
json.dump(sample, open(BEST_GAMES_FILE, "w"))
|
44 |
|
45 |
-
def load_best_games():
|
46 |
try:
|
47 |
return json.load(open(BEST_GAMES_FILE))
|
48 |
except Exception:
|
49 |
return []
|
50 |
|
51 |
# ────────────────────────────────────────────────────────────────────────────────
|
52 |
-
# 4. 최신
|
53 |
-
def get_latest_deployments():
|
54 |
try:
|
55 |
-
|
56 |
f"{VERCEL_API_URL}/deployments",
|
57 |
headers=HEADERS,
|
58 |
params={"limit": 100},
|
59 |
timeout=30,
|
60 |
)
|
61 |
-
|
62 |
games = []
|
63 |
-
for d in
|
64 |
if d.get("state") != "READY":
|
65 |
continue
|
66 |
-
|
67 |
-
|
68 |
-
|
69 |
-
else int(time.time())
|
70 |
)
|
71 |
games.append(
|
72 |
{
|
@@ -76,47 +88,44 @@ def get_latest_deployments():
|
|
76 |
"timestamp": ts,
|
77 |
}
|
78 |
)
|
79 |
-
|
|
|
80 |
except Exception as e:
|
81 |
print("Vercel API 오류:", e)
|
82 |
return []
|
83 |
|
84 |
# ────────────────────────────────────────────────────────────────────────────────
|
85 |
-
# 5. 페이지네이션
|
86 |
-
def paginate(lst, page):
|
87 |
start = (page - 1) * GAMES_PER_PAGE
|
88 |
end = start + GAMES_PER_PAGE
|
89 |
total = (len(lst) + GAMES_PER_PAGE - 1) // GAMES_PER_PAGE
|
90 |
return lst[start:end], total
|
91 |
|
92 |
# ────────────────────────────────────────────────────────────────────────────────
|
93 |
-
# 6. 갤러리 HTML (
|
94 |
-
def generate_gallery_html(games, page, total_pages, tab_name):
|
95 |
if not games:
|
96 |
-
return
|
97 |
-
"<div style='text-align:center;padding:60px;'>표시할 게임이 없습니다.</div>"
|
98 |
-
)
|
99 |
|
100 |
-
# CSS (필요 최소만 인라인 - reference 스타일 재구성)
|
101 |
css = """
|
102 |
<style>
|
103 |
.grid{display:grid;grid-template-columns:repeat(auto-fill,minmax(360px,1fr));gap:24px;margin-bottom:40px}
|
104 |
-
.item{background
|
105 |
.item:hover{transform:translateY(-6px);box-shadow:0 12px 30px rgba(0,0,0,.15)}
|
106 |
.hdr{padding:14px 18px;background:rgba(255,255,255,.8);backdrop-filter:blur(6px);border-bottom:1px solid #eee}
|
107 |
-
.ttl{font-size:1.1rem;font-weight:700;
|
108 |
-
.meta{font-size:.85rem;color:#666;margin-top:4px
|
109 |
.iframe-box{position:relative;width:100%;padding-top:60%;overflow:hidden}
|
110 |
.iframe-box iframe{position:absolute;top:0;left:0;width:142.857%;height:142.857%;
|
111 |
transform:scale(.7);transform-origin:top left;border:0}
|
112 |
.footer{padding:10px 16px;background:rgba(255,255,255,.85);backdrop-filter:blur(6px);text-align:right}
|
113 |
.footer a{font-size:.85rem;font-weight:600;color:#2c5282;text-decoration:none}
|
114 |
.pg{display:flex;justify-content:center;gap:10px;margin-top:10px}
|
115 |
-
.pg button{border:1px solid #ddd;background
|
116 |
-
.pg button.active,.pg button:hover{background:#9c89b8;color
|
117 |
</style>
|
118 |
"""
|
119 |
-
|
120 |
html = css + "<div class='grid'>"
|
121 |
for g in games:
|
122 |
date = datetime.datetime.fromtimestamp(g["timestamp"]).strftime("%Y-%m-%d")
|
@@ -124,44 +133,16 @@ def generate_gallery_html(games, page, total_pages, tab_name):
|
|
124 |
<div class='item'>
|
125 |
<div class='hdr'>
|
126 |
<p class='ttl'>{g['title']}</p>
|
127 |
-
<div class='meta'
|
128 |
-
</div>
|
129 |
-
<div class='iframe-box'>
|
130 |
-
<iframe src="{g['url']}" loading="lazy"
|
131 |
-
allow="accelerometer; camera; encrypted-media; gyroscope;"></iframe>
|
132 |
-
</div>
|
133 |
-
<div class='footer'>
|
134 |
-
<a href="{g['url']}" target="_blank">원본 사이트 열기 ↗</a>
|
135 |
</div>
|
|
|
|
|
|
|
136 |
</div>
|
137 |
"""
|
138 |
-
|
139 |
-
# 페이지네이션
|
140 |
html += "</div>"
|
141 |
-
|
142 |
-
|
143 |
-
if page > 1:
|
144 |
-
html += (
|
145 |
-
f"<button onclick=\"refreshGallery('{tab_name}',{page-1})\">이전</button>"
|
146 |
-
)
|
147 |
-
for p in range(max(1, page - 2), min(total_pages, page + 2) + 1):
|
148 |
-
active = "active" if p == page else ""
|
149 |
-
html += (
|
150 |
-
f"<button class='{active}' onclick=\"refreshGallery('{tab_name}',{p})\">{p}</button>"
|
151 |
-
)
|
152 |
-
if page < total_pages:
|
153 |
-
html += (
|
154 |
-
f"<button onclick=\"refreshGallery('{tab_name}',{page+1})\">다음</button>"
|
155 |
-
)
|
156 |
-
html += "</div>"
|
157 |
-
|
158 |
-
# refreshGallery JS (탭 버튼 재사용)
|
159 |
-
html += """
|
160 |
-
<script>
|
161 |
-
function refreshGallery(t,p){document.getElementById(t+'-btn').click();
|
162 |
-
window.setTimeout(()=>window.__setPage&&__setPage(t,p),50);}
|
163 |
-
</script>
|
164 |
-
"""
|
165 |
return html
|
166 |
|
167 |
# ────────────────────────────────────────────────────────────────────────────────
|
@@ -169,20 +150,23 @@ def generate_gallery_html(games, page, total_pages, tab_name):
|
|
169 |
def create_gallery_interface():
|
170 |
initialize_best_games()
|
171 |
|
172 |
-
with gr.Blocks() as demo:
|
173 |
gr.HTML("<h1 style='text-align:center;'>🎮 Vibe Game Craft</h1>")
|
174 |
|
175 |
with gr.Row():
|
176 |
new_btn = gr.Button("NEW", elem_id="new-btn")
|
177 |
best_btn = gr.Button("BEST", elem_id="best-btn")
|
178 |
refresh = gr.Button("🔄 새로고침")
|
|
|
|
|
179 |
|
180 |
-
|
181 |
-
|
182 |
-
|
183 |
-
|
|
|
184 |
|
185 |
-
#
|
186 |
def render_new(page=1):
|
187 |
games, total = paginate(get_latest_deployments(), page)
|
188 |
return generate_gallery_html(games, page, total, "new"), "new", page
|
@@ -191,35 +175,61 @@ def create_gallery_interface():
|
|
191 |
games, total = paginate(load_best_games(), page)
|
192 |
return generate_gallery_html(games, page, total, "best"), "best", page
|
193 |
|
194 |
-
|
195 |
-
|
|
|
196 |
|
197 |
-
|
198 |
-
|
199 |
-
if tab == "new":
|
200 |
-
return render_new(p1)[0]
|
201 |
-
return render_best(p2)[0]
|
202 |
|
203 |
-
|
204 |
-
inputs=[tab_state, page_state, page_state2],
|
205 |
-
outputs=[gallery_out])
|
206 |
-
|
207 |
-
# 페이지네이션을 위한 JS 콜백용 숨은 함수
|
208 |
-
def set_page(tab, p):
|
209 |
if tab == "new":
|
210 |
-
return render_new(
|
211 |
-
return render_best(
|
212 |
|
213 |
-
|
214 |
-
|
|
|
215 |
|
216 |
-
#
|
217 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
218 |
|
219 |
return demo
|
220 |
|
221 |
# ────────────────────────────────────────────────────────────────────────────────
|
222 |
-
# 8.
|
223 |
app = create_gallery_interface()
|
224 |
|
225 |
if __name__ == "__main__":
|
|
|
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 |
{
|
|
|
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 |
{
|
|
|
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")
|
|
|
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 |
# ────────────────────────────────────────────────────────────────────────────────
|
|
|
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
|
|
|
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__":
|