Spaces:
Running
Running
Create app.py
Browse files
app.py
ADDED
@@ -0,0 +1,215 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
1 |
+
import os, re, time, json, datetime, requests, gradio as gr
|
2 |
+
|
3 |
+
# ───────────────────── 1. 기본 설정 ─────────────────────
|
4 |
+
BEST_FILE, PER_PAGE = "best_games.json", 9
|
5 |
+
|
6 |
+
# ───────────────────── 2. BEST 데이터 ────────────────────
|
7 |
+
def _init_best():
|
8 |
+
if not os.path.exists(BEST_FILE):
|
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 |
+
urls = []
|
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(f"BEST 데이터 로드 오류: {e}")
|
26 |
+
return []
|
27 |
+
|
28 |
+
def _save_best(url_list: list[str]) -> bool:
|
29 |
+
try:
|
30 |
+
json.dump(url_list, open(BEST_FILE, "w"), ensure_ascii=False, indent=2)
|
31 |
+
return True
|
32 |
+
except Exception as e:
|
33 |
+
print(f"BEST 데이터 저장 오류: {e}")
|
34 |
+
return False
|
35 |
+
|
36 |
+
# ───────────────────── 3. URL 추가 기능 ─────────────────────
|
37 |
+
def add_url_to_best(url: str) -> bool:
|
38 |
+
try:
|
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 |
+
# ───────────────────── 4. 페이지네이션 ───────────────────
|
50 |
+
def page(lst, pg):
|
51 |
+
s = (pg-1) * PER_PAGE
|
52 |
+
e = s + PER_PAGE
|
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 |
+
is_huggingface = False
|
59 |
+
embed_urls = []
|
60 |
+
if "huggingface.co/spaces" in url:
|
61 |
+
is_huggingface = True
|
62 |
+
base_url = url.rstrip("/")
|
63 |
+
try:
|
64 |
+
path = base_url.split("/spaces/")[1]
|
65 |
+
owner, *rest = path.split("/")
|
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 "<div style='text-align:center;padding:70px;color:#555;'>표시할 배포가 없습니다.</div>"
|
91 |
+
|
92 |
+
css = r"""
|
93 |
+
<style>
|
94 |
+
body{margin:0;font-family:Poppins,sans-serif;background:#f4f5f7;}
|
95 |
+
.container{padding:16px;}
|
96 |
+
.grid{
|
97 |
+
display:grid;
|
98 |
+
grid-template-columns:repeat(auto-fill,minmax(320px,1fr));
|
99 |
+
gap:16px;
|
100 |
+
}
|
101 |
+
.card{
|
102 |
+
background:#fff;
|
103 |
+
border-radius:10px;
|
104 |
+
overflow:hidden;
|
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%;padding-top:56.25%;}
|
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, url in enumerate(urls):
|
142 |
+
iframe_url, is_hf, alt = process_url_for_iframe(url)
|
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="{frame_cls}">
|
149 |
+
<iframe id="{iframe_id}" src="{iframe_url}" loading="lazy" {alt_attr}></iframe>
|
150 |
+
</div>
|
151 |
+
</div>"""
|
152 |
+
body += "</div></div>"
|
153 |
+
page_info = f'<div class="page-info">Page {pg} / {total}</div>'
|
154 |
+
return css + js + body + page_info
|
155 |
+
|
156 |
+
# ───────────────────── 7. UI ─────────────────────────────
|
157 |
+
def build():
|
158 |
+
_init_best()
|
159 |
+
|
160 |
+
header_html = """
|
161 |
+
<style>
|
162 |
+
.app-header{text-align:center;padding:16px 0 8px;margin:0;position:sticky;top:0;background:#fff;z-index:1100;border-bottom:1px solid #eee;}
|
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 |
+
<a href="https://huggingface.co/spaces/openfree/Vibe-Game" target="_blank"><img src="https://img.shields.io/static/v1?label=huggingface&message=Vibe%20Game%20Craft&color=%23800080&labelColor=%23ffa500&logo=huggingface&logoColor=%23ffff00&style=for-the-badge"></a>
|
169 |
+
<a href="https://huggingface.co/spaces/openfree/Game-Gallery" target="_blank"><img src="https://img.shields.io/static/v1?label=huggingface&message=Game%20Gallery&color=%23800080&labelColor=%23ffa500&logo=huggingface&logoColor=%23ffff00&style=for-the-badge"></a>
|
170 |
+
<a href="https://discord.gg/openfreeai" target="_blank"><img src="https://img.shields.io/static/v1?label=Discord&message=Openfree%20AI&color=%230000ff&labelColor=%23800080&logo=discord&logoColor=white&style=for-the-badge"></a>
|
171 |
+
</div>
|
172 |
+
</div>
|
173 |
+
"""
|
174 |
+
|
175 |
+
css_global = """
|
176 |
+
footer{display:none !important;}
|
177 |
+
.button-row{position:fixed;bottom:0;left:0;right:0;height:60px;background:#f0f0f0;padding:10px;text-align:center;box-shadow:0 -2px 10px rgba(0,0,0,.05);z-index:1000;}
|
178 |
+
.button-row button{margin:0 10px;padding:10px 20px;font-size:16px;font-weight:bold;border-radius:50px;}
|
179 |
+
#content-area{overflow-y:auto;height:calc(100vh - 60px - 120px);}
|
180 |
+
"""
|
181 |
+
|
182 |
+
with gr.Blocks(title="Vibe Game Gallery", css=css_global) as demo:
|
183 |
+
gr.HTML(header_html)
|
184 |
+
out = gr.HTML(elem_id="content-area")
|
185 |
+
with gr.Row(elem_classes="button-row"):
|
186 |
+
b_prev = gr.Button("◀ 이전", size="lg")
|
187 |
+
b_next = gr.Button("다음 ▶", size="lg")
|
188 |
+
|
189 |
+
bp = gr.State(1)
|
190 |
+
|
191 |
+
def show_best(p=1):
|
192 |
+
d, t = page(_load_best(), p)
|
193 |
+
return html(d, p, t), p
|
194 |
+
|
195 |
+
def prev(b):
|
196 |
+
b = max(1, b-1)
|
197 |
+
h, _ = show_best(b)
|
198 |
+
return h, b
|
199 |
+
|
200 |
+
def nxt(b):
|
201 |
+
maxp = (len(_load_best()) + PER_PAGE - 1) // PER_PAGE
|
202 |
+
b = min(maxp, b+1)
|
203 |
+
h, _ = show_best(b)
|
204 |
+
return h, b
|
205 |
+
|
206 |
+
b_prev.click(prev, inputs=[bp], outputs=[out, bp])
|
207 |
+
b_next.click(nxt, inputs=[bp], outputs=[out, bp])
|
208 |
+
demo.load(show_best, outputs=[out, bp])
|
209 |
+
|
210 |
+
return demo
|
211 |
+
|
212 |
+
app = build()
|
213 |
+
|
214 |
+
if __name__ == "__main__":
|
215 |
+
app.launch()
|