import os import json import re import gradio as gr # ───────────────────── 1. 기본 설정 ───────────────────── BEST_FILE, PER_PAGE = "best_games.json", 9 # ❶ 한 페이지에 9개씩 # ───────────────────── 2. BEST 데이터 ──────────────────── def _init_best(): if not os.path.exists(BEST_FILE): json.dump([], open(BEST_FILE, "w"), ensure_ascii=False) def _load_best(): try: raw = json.load(open(BEST_FILE)) return [u if isinstance(u, str) else u.get("url") for u in raw] if isinstance(raw, list) else [] except Exception as e: print("BEST 로드 오류:", e) return [] def _save_best(lst): try: json.dump(lst, open(BEST_FILE, "w"), ensure_ascii=False, indent=2) return True except Exception as e: print("BEST 저장 오류:", e) return False # *.hf.space → Hub URL(새 탭용) 변환 def to_hub_space_url(url: str) -> str: m = re.match(r"https?://([^-]+)-([^.]+)\.hf\.space(/.*)?", url) if m: owner, space, _ = m.groups() return f"https://huggingface.co/spaces/{owner}/{space}" return url def add_url_to_best(url: str): data = _load_best() if url in data: return False data.insert(0, url) return _save_best(data) # ───────────────────── 3. 유틸 ────────────────────────── def page(lst, pg): s, e = (pg - 1) * PER_PAGE, (pg - 1) * PER_PAGE + PER_PAGE total = (len(lst) + PER_PAGE - 1) // PER_PAGE return lst[s:e], total def process_url_for_iframe(url): """iframe용 주소 변환""" if "huggingface.co/spaces" in url: owner, name = url.rstrip("/").split("/spaces/")[1].split("/")[:2] return f"https://huggingface.co/spaces/{owner}/{name}/embed", "huggingface", [] m = re.match(r"https?://([^/]+)\.hf\.space(/.*)?", url) if m: sub, rest = m.groups() static_url = f"https://{sub}.static.hf.space{rest or ''}" return static_url, "hfspace", [url] return url, "", [] # ───────────────────── 4. HTML 그리드 ─────────────────── def html(cards, pg, total): if not cards: return "<div style='text-align:center;padding:70px;color:#555;'>표시할 배포가 없습니다.</div>" css = r""" <style> /* 파스텔 배경 */ body{ margin:0;padding:0;font-family:Poppins,sans-serif; background:linear-gradient(135deg,#fdf4ff 0%,#f6fbff 50%,#fffaf4 100%); background-attachment:fixed; overflow-x:hidden;overflow-y:auto; } .container{width:100%;padding:10px 10px var(--bottom-gap,70px);box-sizing:border-box;} .grid{display:grid;grid-template-columns:repeat(3,1fr);gap:12px;width:100%;} .card{ background:#fff;border-radius:10px;overflow:hidden;box-shadow:0 4px 10px rgba(0,0,0,0.08); height:420px;display:flex;flex-direction:column;position:relative; } .frame{flex:1;position:relative;overflow:hidden;} .frame iframe{ position:absolute;top:0;left:0; width:166.667%;height:166.667%; transform:scale(0.6);transform-origin:top left;border:0; } .frame.huggingface iframe{width:100%!important;height:100%!important;transform:none!important;border:none!important;} .frame.hfspace iframe{ width:200%;height:200%; transform:scale(0.5);transform-origin:top left;border:0; } .foot{height:34px;display:flex;align-items:center;justify-content:center;background:#fafafa;border-top:1px solid #eee;} .foot a{font-size:0.85rem;font-weight:600;color:#4a6dd8;text-decoration:none;} .foot a:hover{text-decoration:underline;} @media(min-width:1200px){.card{height:560px;}} @media(max-width:767px){ .grid{grid-template-columns:1fr;} .card{height:480px;} } </style>""" # 버튼과 헤더 높이를 고려해 스크롤 영역 동적으로 계산 js = r""" <script> function adjustGap(){ const header = document.querySelector('.app-header'); const buttons = document.querySelector('.button-row'); const gap = (buttons?.offsetHeight || 60) + 10; // 10px 여유 document.documentElement.style.setProperty('--bottom-gap', gap + 'px'); const content = document.getElementById('content-area'); const h = (header?.offsetHeight || 0) + (buttons?.offsetHeight || 60); content.style.height = `calc(100vh - ${h}px)`; } window.addEventListener('load',adjustGap); window.addEventListener('resize',adjustGap); </script> """ h = css + js + '<div class="container"><div class="grid">' for idx, url in enumerate(cards): iframe_url, extra_cls, alt_urls = process_url_for_iframe(url) frame_class = f"frame {extra_cls}".strip() iframe_id = f"iframe-{idx}-{hash(url)%10000}" alt_attr = f'data-alternate-urls="{",".join(alt_urls)}"' if alt_urls else "" safe_url = to_hub_space_url(url) h += f""" <div class="card"> <div class="{frame_class}"> <iframe id="{iframe_id}" src="{iframe_url}" loading="lazy" sandbox="allow-forms allow-modals allow-popups allow-popups-to-escape-sandbox allow-same-origin allow-scripts allow-downloads" data-original-url="{url}" {alt_attr}></iframe> </div> <div class="foot"> <a href="{safe_url}" target="_blank" rel="noopener noreferrer">↗ Open in Full Screen (New Tab)</a> </div> </div>""" h += "</div></div>" h += f'<div class="page-info">Page {pg} / {total}</div>' return h # ───────────────────── 5. Gradio UI ───────────────────── def build(): _init_best() header = """ <style> .app-header{position:sticky;top:0;text-align:center;background:#fff;padding:16px 0 8px;border-bottom:1px solid #eee;z-index:1100;} .badge-row{display:inline-flex;gap:8px;margin:8px 0;} </style> <div class="app-header"> <h1 style="margin:0;font-size:28px;">🎮 Vibe Game Gallery</h1> <p style="margin:4px 0;font-size:11px;"> Only high-quality games automatically generated with <b>Vibe Game Craft</b> are showcased here.<br> Every game includes its full source code, and anyone can freely copy the <code>index.html</code> file from each URL and modify it as desired. All content is released under the <b>Apache 2.0</b> license. </p> <div class="badge-row"> <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=800080&labelColor=ffa500&logo=huggingface&logoColor=ffff00&style=for-the-badge"> </a> <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=800080&labelColor=ffa500&logo=huggingface&logoColor=ffff00&style=for-the-badge"> </a> <a href="https://discord.gg/openfreeai" target="_blank"> <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"> </a> </div> </div>""" global_css = """ footer{display:none !important;} .button-row{ position:fixed!important;bottom:0!important;left:0!important;right:0!important; height:60px;background:#f0f0f0;padding:10px;text-align:center; box-shadow:0 -2px 10px rgba(0,0,0,.05);z-index:10000; } .button-row button{margin:0 10px;padding:10px 20px;font-size:16px;font-weight:bold;border-radius:50px;} #content-area{overflow-y:auto;} """ with gr.Blocks(title="Vibe Game Gallery", css=global_css) as demo: gr.HTML(header) out = gr.HTML(elem_id="content-area") with gr.Row(elem_classes="button-row"): b_prev = gr.Button("◀ 이전", size="lg") b_next = gr.Button("다음 ▶", size="lg") bp = gr.State(1) def render(p=1): data, tot = page(_load_best(), p) return html(data, p, tot), p b_prev.click(lambda p: render(max(1, p-1)), inputs=bp, outputs=[out, bp]) b_next.click(lambda p: render(p+1), inputs=bp, outputs=[out, bp]) demo.load(render, outputs=[out, bp]) return demo app = build() if __name__ == "__main__": app.launch()