Spaces:
Running
Running
Update app.py
Browse files
app.py
CHANGED
@@ -1,7 +1,7 @@
|
|
1 |
-
import os,
|
2 |
|
3 |
# ───────────────────── 1. 기본 설정 ─────────────────────
|
4 |
-
BEST_FILE, PER_PAGE = "best_games.json", 9
|
5 |
|
6 |
# ───────────────────── 2. BEST 데이터 ────────────────────
|
7 |
def _init_best():
|
@@ -9,85 +9,48 @@ def _init_best():
|
|
9 |
json.dump([], open(BEST_FILE, "w"), ensure_ascii=False)
|
10 |
|
11 |
def _load_best():
|
12 |
-
"""best_games.json → ["https://foo.vercel.app", ...] 형태로 로드"""
|
13 |
try:
|
14 |
raw = json.load(open(BEST_FILE))
|
|
|
15 |
if isinstance(raw, list):
|
16 |
-
|
17 |
-
for it in raw:
|
18 |
-
if isinstance(it, str):
|
19 |
-
urls.append(it)
|
20 |
-
elif isinstance(it, dict) and "url" in it:
|
21 |
-
urls.append(it["url"])
|
22 |
-
return urls
|
23 |
return []
|
24 |
except Exception as e:
|
25 |
-
print(
|
26 |
return []
|
27 |
|
28 |
-
def _save_best(
|
29 |
try:
|
30 |
-
json.dump(
|
31 |
return True
|
32 |
except Exception as e:
|
33 |
-
print(
|
34 |
return False
|
35 |
|
36 |
-
|
37 |
-
|
38 |
-
|
39 |
-
data = _load_best()
|
40 |
-
if url in data:
|
41 |
-
print("이미 존재:", url)
|
42 |
-
return False
|
43 |
-
data.insert(0, url)
|
44 |
-
return _save_best(data)
|
45 |
-
except Exception as e:
|
46 |
-
print("URL 추가 오류:", e)
|
47 |
return False
|
|
|
|
|
48 |
|
49 |
-
# ─────────────────────
|
50 |
def page(lst, pg):
|
51 |
-
s = (pg-1) *
|
52 |
-
|
53 |
-
total = (len(lst) + PER_PAGE - 1) // PER_PAGE
|
54 |
return lst[s:e], total
|
55 |
|
56 |
-
# ───────────────────── 5. URL → iframe 변환 ───────────────
|
57 |
def process_url_for_iframe(url):
|
58 |
-
|
59 |
-
embed_urls = []
|
60 |
if "huggingface.co/spaces" in url:
|
61 |
-
|
62 |
-
|
63 |
-
|
64 |
-
|
65 |
-
|
66 |
-
if rest:
|
67 |
-
name = rest[0]
|
68 |
-
clean_owner = owner.lower()
|
69 |
-
clean_name = name.replace('.', '-').replace('_', '-').lower()
|
70 |
-
embed_urls.append(f"https://huggingface.co/spaces/{owner}/{name}/embed")
|
71 |
-
embed_urls.append(f"https://{clean_owner}-{clean_name}.hf.space")
|
72 |
-
else:
|
73 |
-
embed_urls.append(f"https://huggingface.co/spaces/{owner}/embed")
|
74 |
-
except Exception:
|
75 |
-
if not base_url.endswith("/embed"):
|
76 |
-
embed_urls.append(f"{base_url}/embed")
|
77 |
-
else:
|
78 |
-
embed_urls.append(base_url)
|
79 |
-
elif ".hf.space" in url:
|
80 |
-
is_huggingface = True
|
81 |
-
embed_urls.append(url)
|
82 |
-
else:
|
83 |
-
return url, False, []
|
84 |
-
primary = embed_urls[0] if embed_urls else url
|
85 |
-
return primary, is_huggingface, embed_urls[1:]
|
86 |
-
|
87 |
-
# ───────────────────── 6. HTML 렌더 ───────────────────────
|
88 |
def html(urls, pg, total):
|
89 |
if not urls:
|
90 |
-
return
|
91 |
|
92 |
css = r"""
|
93 |
<style>
|
@@ -95,92 +58,74 @@ def html(urls, pg, total):
|
|
95 |
.container{padding:16px;}
|
96 |
.grid{
|
97 |
display:grid;
|
98 |
-
grid-template-columns:repeat(auto-fill,minmax(
|
99 |
gap:16px;
|
100 |
}
|
101 |
.card{
|
102 |
-
background:#fff;
|
103 |
-
|
104 |
-
|
105 |
-
box-shadow:0 2px 8px rgba(0,0,0,.05);
|
106 |
transition:transform .2s;
|
107 |
}
|
|
|
|
|
|
|
|
|
108 |
.card:hover{transform:translateY(-4px);}
|
109 |
-
.frame{position:relative;width:100%;
|
110 |
.frame iframe{position:absolute;inset:0;width:100%;height:100%;border:none;}
|
111 |
-
.frame.huggingface iframe{padding:0;margin:0;}
|
112 |
.page-info{text-align:center;color:#777;margin:12px 0;}
|
113 |
</style>"""
|
114 |
|
115 |
-
js = """
|
116 |
-
<script>
|
117 |
-
function handleIframeError(id, alternates, origin){
|
118 |
-
const f=document.getElementById(id); if(!f)return;
|
119 |
-
f.onerror=()=>tryNext(id, alternates, origin);
|
120 |
-
f.onload=()=>setTimeout(()=>{if(f.offsetWidth===0||f.offsetHeight===0)tryNext(id, alternates, origin);},4000);
|
121 |
-
}
|
122 |
-
function tryNext(id, alternates, origin){
|
123 |
-
const f=document.getElementById(id); if(!f)return;
|
124 |
-
if(alternates.length){
|
125 |
-
f.src=alternates.shift();
|
126 |
-
handleIframeError(id, alternates, origin);
|
127 |
-
}else{
|
128 |
-
f.parentNode.innerHTML='<div style="display:flex;align-items:center;justify-content:center;height:100%;font-size:14px;color:#999;">로드 실패 ↗</div>';
|
129 |
-
}
|
130 |
-
}
|
131 |
-
window.addEventListener('load',()=>{
|
132 |
-
document.querySelectorAll('.huggingface iframe').forEach(f=>{
|
133 |
-
const id=f.id;
|
134 |
-
const alt=(f.getAttribute('data-alt')||'').split(',').filter(Boolean);
|
135 |
-
if(id&&alt.length)handleIframeError(id,alt,f.src);
|
136 |
-
});
|
137 |
-
});
|
138 |
-
</script>"""
|
139 |
-
|
140 |
body = '<div class="container"><div class="grid">'
|
141 |
-
for i,
|
142 |
-
|
143 |
-
frame_cls = "frame huggingface" if is_hf else "frame"
|
144 |
-
iframe_id = f"f{i}"
|
145 |
-
alt_attr = f'data-alt="{",".join(alt)}"' if alt else ""
|
146 |
body += f"""
|
147 |
<div class="card">
|
148 |
-
<div class="{
|
149 |
-
<iframe
|
150 |
</div>
|
151 |
</div>"""
|
152 |
body += "</div></div>"
|
153 |
page_info = f'<div class="page-info">Page {pg} / {total}</div>'
|
154 |
-
return css +
|
155 |
|
156 |
-
# ─────────────────────
|
157 |
def build():
|
158 |
_init_best()
|
159 |
|
160 |
-
|
161 |
<style>
|
162 |
-
.app-header{
|
|
|
163 |
.badge-row{display:inline-flex;gap:8px;margin:8px 0;}
|
164 |
</style>
|
165 |
<div class="app-header">
|
166 |
<h1 style="margin:0;font-size:28px;">🎮 Vibe Game Gallery</h1>
|
167 |
<div class="badge-row">
|
168 |
-
|
169 |
-
<
|
170 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
171 |
</div>
|
172 |
-
</div>
|
173 |
-
|
174 |
-
|
175 |
-
|
176 |
-
|
177 |
-
|
178 |
-
|
179 |
-
|
|
|
180 |
"""
|
181 |
|
182 |
-
with gr.Blocks(title="Vibe Game Gallery", css=
|
183 |
-
gr.HTML(
|
184 |
out = gr.HTML(elem_id="content-area")
|
185 |
with gr.Row(elem_classes="button-row"):
|
186 |
b_prev = gr.Button("◀ 이전", size="lg")
|
@@ -188,24 +133,14 @@ def build():
|
|
188 |
|
189 |
bp = gr.State(1)
|
190 |
|
191 |
-
def
|
192 |
-
|
193 |
-
return html(
|
194 |
-
|
195 |
-
def prev(b):
|
196 |
-
b = max(1, b-1)
|
197 |
-
h, _ = show_best(b)
|
198 |
-
return h, b
|
199 |
|
200 |
-
|
201 |
-
|
202 |
-
b = min(maxp, b+1)
|
203 |
-
h, _ = show_best(b)
|
204 |
-
return h, b
|
205 |
|
206 |
-
|
207 |
-
b_next.click(nxt, inputs=[bp], outputs=[out, bp])
|
208 |
-
demo.load(show_best, outputs=[out, bp])
|
209 |
|
210 |
return demo
|
211 |
|
|
|
1 |
+
import os, json, time, datetime, requests, gradio as gr
|
2 |
|
3 |
# ───────────────────── 1. 기본 설정 ─────────────────────
|
4 |
+
BEST_FILE, PER_PAGE = "best_games.json", 9 # ❶ 페이지당 9개 유지
|
5 |
|
6 |
# ───────────────────── 2. BEST 데이터 ────────────────────
|
7 |
def _init_best():
|
|
|
9 |
json.dump([], open(BEST_FILE, "w"), ensure_ascii=False)
|
10 |
|
11 |
def _load_best():
|
|
|
12 |
try:
|
13 |
raw = json.load(open(BEST_FILE))
|
14 |
+
# URL 리스트만 반환
|
15 |
if isinstance(raw, list):
|
16 |
+
return [u if isinstance(u, str) else u.get("url") for u in raw]
|
|
|
|
|
|
|
|
|
|
|
|
|
17 |
return []
|
18 |
except Exception as e:
|
19 |
+
print("BEST 로드 오류:", e)
|
20 |
return []
|
21 |
|
22 |
+
def _save_best(lst): # URL 리스트 저장
|
23 |
try:
|
24 |
+
json.dump(lst, open(BEST_FILE, "w"), ensure_ascii=False, indent=2)
|
25 |
return True
|
26 |
except Exception as e:
|
27 |
+
print("BEST 저장 오류:", e)
|
28 |
return False
|
29 |
|
30 |
+
def add_url_to_best(url: str):
|
31 |
+
data = _load_best()
|
32 |
+
if url in data:
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
33 |
return False
|
34 |
+
data.insert(0, url)
|
35 |
+
return _save_best(data)
|
36 |
|
37 |
+
# ───────────────────── 3. 유틸 ──────────────────────────
|
38 |
def page(lst, pg):
|
39 |
+
s, e = (pg-1)*PER_PAGE, (pg-1)*PER_PAGE+PER_PAGE
|
40 |
+
total = (len(lst)+PER_PAGE-1)//PER_PAGE
|
|
|
41 |
return lst[s:e], total
|
42 |
|
|
|
43 |
def process_url_for_iframe(url):
|
44 |
+
# Hugging Face Spaces embed 우선
|
|
|
45 |
if "huggingface.co/spaces" in url:
|
46 |
+
owner, name = url.rstrip("/").split("/spaces/")[1].split("/")[:2]
|
47 |
+
return f"https://huggingface.co/spaces/{owner}/{name}/embed", True, []
|
48 |
+
return url, False, []
|
49 |
+
|
50 |
+
# ───────────────────── 4. 카드 그리드 HTML ───────────────
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
51 |
def html(urls, pg, total):
|
52 |
if not urls:
|
53 |
+
return '<div style="text-align:center;padding:70px;color:#555;">표시할 배포가 ��습니다.</div>'
|
54 |
|
55 |
css = r"""
|
56 |
<style>
|
|
|
58 |
.container{padding:16px;}
|
59 |
.grid{
|
60 |
display:grid;
|
61 |
+
grid-template-columns:repeat(auto-fill,minmax(340px,1fr));
|
62 |
gap:16px;
|
63 |
}
|
64 |
.card{
|
65 |
+
background:#fff;border-radius:10px;overflow:hidden;
|
66 |
+
box-shadow:0 2px 10px rgba(0,0,0,.08);
|
67 |
+
height:480px; /* ★ 기본 카드 높이 ↑ */
|
|
|
68 |
transition:transform .2s;
|
69 |
}
|
70 |
+
@media (min-width:1400px){.card{height:640px;} }/* ★ 아주 큰 화면 */
|
71 |
+
@media (max-width:992px) {.card{height:420px;} }/* 태블릿 */
|
72 |
+
@media (max-width:600px) {.card{height:360px;} }/* 모바일 */
|
73 |
+
|
74 |
.card:hover{transform:translateY(-4px);}
|
75 |
+
.frame{position:relative;width:100%;height:100%;}
|
76 |
.frame iframe{position:absolute;inset:0;width:100%;height:100%;border:none;}
|
|
|
77 |
.page-info{text-align:center;color:#777;margin:12px 0;}
|
78 |
</style>"""
|
79 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
80 |
body = '<div class="container"><div class="grid">'
|
81 |
+
for i, origin in enumerate(urls):
|
82 |
+
src, is_hf, _ = process_url_for_iframe(origin)
|
|
|
|
|
|
|
83 |
body += f"""
|
84 |
<div class="card">
|
85 |
+
<div class="frame{' huggingface' if is_hf else ''}">
|
86 |
+
<iframe src="{src}" loading="lazy"></iframe>
|
87 |
</div>
|
88 |
</div>"""
|
89 |
body += "</div></div>"
|
90 |
page_info = f'<div class="page-info">Page {pg} / {total}</div>'
|
91 |
+
return css + body + page_info
|
92 |
|
93 |
+
# ───────────────────── 5. Gradio UI ─────────────────────
|
94 |
def build():
|
95 |
_init_best()
|
96 |
|
97 |
+
header = """
|
98 |
<style>
|
99 |
+
.app-header{position:sticky;top:0;text-align:center;background:#fff;
|
100 |
+
padding:16px 0 8px;border-bottom:1px solid #eee;z-index:1100;}
|
101 |
.badge-row{display:inline-flex;gap:8px;margin:8px 0;}
|
102 |
</style>
|
103 |
<div class="app-header">
|
104 |
<h1 style="margin:0;font-size:28px;">🎮 Vibe Game Gallery</h1>
|
105 |
<div class="badge-row">
|
106 |
+
<a href="https://huggingface.co/spaces/openfree/Vibe-Game" target="_blank">
|
107 |
+
<img src="https://img.shields.io/static/v1?label=huggingface&message=Vibe%20Game%20Craft&color=800080&labelColor=ffa500&logo=huggingface&logoColor=ffff00&style=for-the-badge">
|
108 |
+
</a>
|
109 |
+
<a href="https://huggingface.co/spaces/openfree/Game-Gallery" target="_blank">
|
110 |
+
<img src="https://img.shields.io/static/v1?label=huggingface&message=Game%20Gallery&color=800080&labelColor=ffa500&logo=huggingface&logoColor=ffff00&style=for-the-badge">
|
111 |
+
</a>
|
112 |
+
<a href="https://discord.gg/openfreeai" target="_blank">
|
113 |
+
<img src="https://img.shields.io/static/v1?label=Discord&message=Openfree%20AI&color=0000ff&labelColor=800080&logo=discord&logoColor=white&style=for-the-badge">
|
114 |
+
</a>
|
115 |
</div>
|
116 |
+
</div>"""
|
117 |
+
|
118 |
+
global_css = """
|
119 |
+
footer{display:none !important;}
|
120 |
+
.button-row{position:fixed;bottom:0;left:0;right:0;height:60px;
|
121 |
+
background:#f0f0f0;padding:10px;text-align:center;
|
122 |
+
box-shadow:0 -2px 10px rgba(0,0,0,.05);z-index:1000;}
|
123 |
+
.button-row button{margin:0 10px;padding:10px 20px;font-size:16px;font-weight:bold;border-radius:50px;}
|
124 |
+
#content-area{overflow-y:auto;height:calc(100vh - 60px - 120px);}
|
125 |
"""
|
126 |
|
127 |
+
with gr.Blocks(title="Vibe Game Gallery", css=global_css) as demo:
|
128 |
+
gr.HTML(header)
|
129 |
out = gr.HTML(elem_id="content-area")
|
130 |
with gr.Row(elem_classes="button-row"):
|
131 |
b_prev = gr.Button("◀ 이전", size="lg")
|
|
|
133 |
|
134 |
bp = gr.State(1)
|
135 |
|
136 |
+
def render(p=1):
|
137 |
+
data, tot = page(_load_best(), p)
|
138 |
+
return html(data, p, tot), p
|
|
|
|
|
|
|
|
|
|
|
139 |
|
140 |
+
b_prev.click(lambda p: render(max(1, p-1)), inputs=bp, outputs=[out, bp])
|
141 |
+
b_next.click(lambda p: render(p+1), inputs=bp, outputs=[out, bp])
|
|
|
|
|
|
|
142 |
|
143 |
+
demo.load(render, outputs=[out, bp])
|
|
|
|
|
144 |
|
145 |
return demo
|
146 |
|