openfree commited on
Commit
61f29ba
·
verified ·
1 Parent(s): c61b01c

Update app-backup-last.py

Browse files
Files changed (1) hide show
  1. app-backup-last.py +99 -504
app-backup-last.py CHANGED
@@ -1,582 +1,177 @@
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"))
10
 
11
  def _load_best():
12
  try:
13
- data = json.load(open(BEST_FILE))
14
- for it in data:
15
- if "ts" not in it:
16
- it["ts"] = int(it.get("timestamp", time.time()))
17
- return data
18
  except Exception as e:
19
- print(f"BEST 데이터 로드 오류: {e}")
20
  return []
21
 
22
- def _save_best(data):
23
  try:
24
- json.dump(data, open(BEST_FILE, "w"))
25
  return True
26
  except Exception as e:
27
- print(f"BEST 데이터 저장 오류: {e}")
28
  return False
29
 
30
- # ───────────────────── 3. URL 추가 기능 ─────────────────────
31
- def add_url_to_best(title, url):
32
- """사용자가 제공한 URL을 BEST 목록에 추가합니다."""
33
- try:
34
- # 현재 BEST 데이터 로드
35
- data = _load_best()
36
-
37
- # URL이 이미 존재하는지 확인
38
- for item in data:
39
- if item.get("url") == url:
40
- print(f"URL이 이미 존재합니다: {url}")
41
- return False
42
-
43
- # 새 항목 추가
44
- new_item = {
45
- "title": title,
46
- "url": url,
47
- "ts": int(time.time()),
48
- "projectId": "", # 사용자가 직접 추가하므로 projectId 없음
49
- "deploymentId": "" # 사용자가 직접 추가하므로 deploymentId 없음
50
- }
51
-
52
- data.append(new_item)
53
-
54
- # 시간순으로 정렬
55
- data = sorted(data, key=lambda x: x["ts"], reverse=True)
56
-
57
- # 저장
58
- if _save_best(data):
59
- print(f"URL이 성공적으로 추가되었습니다: {url}")
60
- return True
61
- return False
62
- except Exception as e:
63
- print(f"URL 추가 오류: {str(e)}")
64
  return False
 
 
65
 
66
- # ───────────────────── 4. 페이지네이션 ───────────────────
67
  def page(lst, pg):
68
- s = (pg-1) * PER_PAGE
69
- e = s + PER_PAGE
70
- total = (len(lst) + PER_PAGE - 1) // PER_PAGE
71
  return lst[s:e], total
72
 
73
- # ───────────────────── 5. URL 처리 함수 ─────────────────────
74
  def process_url_for_iframe(url):
75
- """URL을 iframe에 표시하기 적합한 형태로 변환합니다."""
76
- # 허깅페이스 URL 패턴 감지
77
- is_huggingface = False
78
- embed_urls = []
79
-
80
- # 1. huggingface.co/spaces 패턴 처리
81
  if "huggingface.co/spaces" in url:
82
- is_huggingface = True
83
-
84
- # 기본 URL 정규화
85
- base_url = url.rstrip("/")
86
-
87
- try:
88
- # /spaces/ 이후의 경로 추출
89
- if "/spaces/" in base_url:
90
- path = base_url.split("/spaces/")[1]
91
- parts = path.split("/")
92
- owner = parts[0]
93
-
94
- # name 부분 추출
95
- if len(parts) > 1:
96
- name = parts[1]
97
-
98
- # 특수 문자 변환
99
- clean_name = name.replace('.', '-').replace('_', '-').lower()
100
- clean_owner = owner.lower()
101
-
102
- # 여러 포맷의 URL을 시도하기 위해 목록에 추가
103
- embed_urls.append(f"https://huggingface.co/spaces/{owner}/{name}/embed") # 공식 embed URL
104
- embed_urls.append(f"https://{clean_owner}-{clean_name}.hf.space") # 직접 도메인 접근
105
- else:
106
- # owner만 있는 경우 공식 URL 사용
107
- embed_urls.append(f"https://huggingface.co/spaces/{owner}/embed")
108
- except Exception as e:
109
- print(f"허깅페이스 URL 처리 중 오류: {e}")
110
- # 기본 embed URL 시도
111
- if not base_url.endswith("/embed"):
112
- embed_urls.append(f"{base_url}/embed")
113
- else:
114
- embed_urls.append(base_url)
115
-
116
- # 2. .hf.space 도메인 처리
117
- elif ".hf.space" in url:
118
- is_huggingface = True
119
- embed_urls.append(url) # 현재 URL 그대로 시도
120
-
121
- # 3. 일반 URL은 그대로 반환
122
- else:
123
- return url, is_huggingface, []
124
-
125
- # 최종 URL과 함께 시도할 대체 URL 목록 반환
126
- primary_url = embed_urls[0] if embed_urls else url
127
- return primary_url, is_huggingface, embed_urls[1:] if len(embed_urls) > 1 else []
128
 
129
  # ───────────────────── 6. HTML 그리드 ───────────────────
130
  def html(cards, pg, total):
131
  if not cards:
132
  return "<div style='text-align:center;padding:70px;color:#555;'>표시할 배포가 없습니다.</div>"
 
133
  css = r"""
134
  <style>
135
- body {
136
- margin: 0;
137
- padding: 0;
138
- font-family: Poppins, sans-serif;
139
- background: #f0f0f0;
140
- overflow-x: hidden;
141
- overflow-y: auto;
142
- }
143
- .container {
144
- position: relative;
145
- width: 100%;
146
- height: auto;
147
- box-sizing: border-box;
148
- padding: 10px;
149
- padding-bottom: 70px; /* Space for buttons */
150
- overflow-y: auto;
151
- }
152
- .grid {
153
- display: grid;
154
- grid-template-columns: repeat(3, 1fr);
155
- gap: 12px;
156
- width: 100%;
157
- margin-bottom: 60px; /* Space for buttons */
158
- }
159
- .card {
160
- background: #fff;
161
- border-radius: 10px;
162
- overflow: hidden;
163
- box-shadow: 0 4px 8px rgba(0,0,0,0.1);
164
- display: flex;
165
- flex-direction: column;
166
- /* Each card is 50% taller */
167
- height: 300px; /* Base height - will be 50% larger */
168
- }
169
- .hdr {
170
- padding: 8px 12px;
171
- background: rgba(255,255,255,.95);
172
- border-bottom: 1px solid #eee;
173
- flex-shrink: 0;
174
- z-index: 10;
175
- }
176
- .ttl {
177
- margin: 0;
178
- font-size: 0.95rem;
179
- font-weight: 600;
180
- color: #333;
181
- white-space: nowrap;
182
- overflow: hidden;
183
- text-overflow: ellipsis;
184
- }
185
- .date {
186
- margin-top: 2px;
187
- font-size: 0.75rem;
188
- color: #777;
189
- }
190
- .frame {
191
- flex: 1;
192
- position: relative;
193
- overflow: hidden;
194
- }
195
- .frame iframe {
196
- position: absolute;
197
- top: 0;
198
- left: 0;
199
- width: 142.857%;
200
- height: 142.857%;
201
- transform: scale(0.7);
202
- transform-origin: top left;
203
- border: 0;
204
- frameborder: 0;
205
- }
206
- /* 허깅페이스 iframe 특별 스타일 */
207
- .frame.huggingface iframe {
208
- width: 100% !important;
209
- height: 100% !important;
210
- transform: none !important;
211
- border: none !important;
212
- }
213
- .foot {
214
- padding: 6px 12px;
215
- background: rgba(255,255,255,.95);
216
- text-align: right;
217
- flex-shrink: 0;
218
- border-top: 1px solid #f0f0f0;
219
- z-index: 10;
220
- }
221
- .link {
222
- font-size: 0.8rem;
223
- font-weight: 600;
224
- color: #4a6dd8;
225
- text-decoration: none;
226
  }
227
 
228
- /* 버튼 컨테이너 */
229
- .button-area {
230
- position: fixed;
231
- bottom: 0;
232
- left: 0;
233
- right: 0;
234
- height: 60px;
235
- display: flex;
236
- justify-content: center;
237
- align-items: center;
238
- background: #f0f0f0;
239
- box-shadow: 0 -2px 10px rgba(0,0,0,0.05);
240
- z-index: 100;
241
- }
242
-
243
- /* 페이지 정보 */
244
- .page-info {
245
- position: fixed;
246
- bottom: 5px;
247
- left: 0;
248
- right: 0;
249
- text-align: center;
250
- font-size: 0.8rem;
251
- color: #777;
252
- z-index: 101;
253
  }
254
 
255
- /* 미디어 쿼리 */
256
- @media (min-width: 1200px) {
257
- .card {
258
- height: 450px; /* 50% taller than 300px */
259
- }
260
- }
261
- @media (min-width: 768px) and (max-width: 1199px) {
262
- .card {
263
- height: 400px; /* Adjusted for medium screens */
264
- }
265
- }
266
- @media (max-width: 767px) {
267
- .grid {
268
- grid-template-columns: 1fr;
269
- }
270
- .card {
271
- height: 350px; /* Adjusted for small screens */
272
- }
273
  }
 
274
 
275
- /* 오류 메시지 관련 CSS */
276
- .error-message {
277
- position: absolute;
278
- top: 0;
279
- left: 0;
280
- width: 100%;
281
- height: 100%;
282
- display: flex;
283
- flex-direction: column;
284
- justify-content: center;
285
- align-items: center;
286
- background-color: rgba(255, 255, 255, 0.9);
287
- z-index: 20;
288
- padding: 20px;
289
- text-align: center;
290
- }
291
- .error-icon {
292
- font-size: 48px;
293
- margin-bottom: 15px;
294
- }
295
- .error-text {
296
- font-weight: bold;
297
- margin-bottom: 15px;
298
- }
299
- .error-link {
300
- display: inline-block;
301
- padding: 8px 16px;
302
- background-color: #f0f0f7;
303
- border-radius: 4px;
304
- color: #4a6dd8;
305
- font-weight: bold;
306
- text-decoration: none;
307
- transition: background-color 0.2s;
308
- }
309
- .error-link:hover {
310
- background-color: #e0e0f0;
311
  }
312
  </style>"""
313
-
314
  js = """
315
  <script>
316
- // 허깅페이스 iframe 로딩 오류 처리
317
- function handleIframeError(iframeId, alternateUrls, originalUrl) {
318
- const iframe = document.getElementById(iframeId);
319
- if (!iframe) return;
320
-
321
- // iframe 로드 실패 처리
322
- iframe.onerror = function() {
323
- tryNextUrl(iframeId, alternateUrls, originalUrl);
324
- };
325
-
326
- // onload 이벤트에서 로드 실패 확인
327
- iframe.onload = function() {
328
- try {
329
- // iframe 내용에 접근 시도 (cross-origin 제한으로 실패할 수 있음)
330
- const iframeContent = iframe.contentWindow.document;
331
- console.log("iframe loaded successfully: " + iframeId);
332
- } catch (e) {
333
- // cross-origin 오류는 무시 (정상적인 상황일 수 있음)
334
- console.log("Cross-origin iframe loaded (expected): " + iframeId);
335
- }
336
-
337
- // iframe이 제대로 표시되는지 10초 후 확인
338
- setTimeout(function() {
339
- if (iframe.offsetWidth === 0 || iframe.offsetHeight === 0) {
340
- console.log("iframe not visible, trying alternate URL: " + iframeId);
341
- tryNextUrl(iframeId, alternateUrls, originalUrl);
342
- } else {
343
- console.log("iframe appears to be visible: " + iframeId);
344
- }
345
- }, 5000);
346
- };
347
- }
348
-
349
- // 대체 URL 시도
350
- function tryNextUrl(iframeId, alternateUrls, originalUrl) {
351
- const iframe = document.getElementById(iframeId);
352
- if (!iframe) return;
353
-
354
- // 대체 URL이 있으면 시도
355
- if (alternateUrls && alternateUrls.length > 0) {
356
- const nextUrl = alternateUrls.shift();
357
- console.log("Trying alternate URL: " + nextUrl);
358
- iframe.src = nextUrl;
359
-
360
- // 대체 URL로 다시 오류 처리 설정
361
- handleIframeError(iframeId, alternateUrls, originalUrl);
362
- } else {
363
- // 모든 URL 시도 실패 시 오류 메시지 표시
364
- console.log("All URLs failed, showing error for: " + iframeId);
365
- const container = iframe.parentNode;
366
-
367
- // 오류 메시지 표시
368
- const errorDiv = document.createElement('div');
369
- errorDiv.className = 'error-message';
370
-
371
- errorDiv.innerHTML = `
372
- <div class="error-icon">⚠️</div>
373
- <p class="error-text">콘텐츠를 로드할 수 없습니다</p>
374
- <a href="${originalUrl}" target="_blank" class="error-link">
375
- 원본 페이지에서 ���기
376
- </a>
377
- `;
378
-
379
- // iframe 숨기고 오류 메시지 표시
380
- iframe.style.display = 'none';
381
- container.appendChild(errorDiv);
382
- }
383
- }
384
-
385
- // 페이지 로드 시 모든 허깅페이스 iframe 처리
386
- window.addEventListener('load', function() {
387
- const iframes = document.querySelectorAll('.huggingface iframe');
388
- iframes.forEach(function(iframe) {
389
- const id = iframe.id;
390
- const alternateUrlsStr = iframe.getAttribute('data-alternate-urls');
391
- const alternateUrls = alternateUrlsStr ? alternateUrlsStr.split(',').filter(url => url) : [];
392
- const originalUrl = iframe.getAttribute('data-original-url') || iframe.src;
393
-
394
- if (id && alternateUrls.length > 0) {
395
- handleIframeError(id, alternateUrls, originalUrl);
396
- }
397
- });
398
- });
399
  </script>
400
  """
401
-
402
- h = css + js + """
403
- <div class="container">
404
- <div class="grid">
405
- """
406
-
407
- for idx, c in enumerate(cards):
408
- date = datetime.datetime.fromtimestamp(int(c["ts"])).strftime("%Y-%m-%d")
409
-
410
- # URL 처리: 허깅페이스 URL인 경우 특별 처리
411
- url = c['url']
412
  iframe_url, is_huggingface, alt_urls = process_url_for_iframe(url)
413
-
414
- # 허깅페이스 URL에 특별 클래스 추가
415
  frame_class = "frame huggingface" if is_huggingface else "frame"
416
-
417
- # 고유 ID 생성
418
- iframe_id = f"iframe-{idx}-{hash(url) % 10000}"
419
-
420
- # 대체 URL을 데이터 속성으로 추가
421
- alternate_urls_attr = ""
422
- if alt_urls:
423
- alternate_urls_attr = f'data-alternate-urls="{",".join(alt_urls)}"'
424
-
425
  h += f"""
426
  <div class="card">
427
- <div class="hdr"><p class="ttl">{c['title']}</p><p class="date">{date}</p></div>
428
  <div class="{frame_class}">
429
- <iframe
430
- id="{iframe_id}"
431
- src="{iframe_url}"
432
- loading="lazy"
433
- frameborder="0"
434
- width="100%"
435
- height="100%"
436
- allowfullscreen="true"
437
- allow="accelerometer; camera; encrypted-media; gyroscope; geolocation;"
438
  sandbox="allow-forms allow-modals allow-popups allow-popups-to-escape-sandbox allow-same-origin allow-scripts allow-downloads"
439
- data-original-url="{url}"
440
- {alternate_urls_attr}>
441
- </iframe>
442
  </div>
443
- <div class="foot"><a class="link" href="{url}" target="_blank">원본↗</a></div>
444
  </div>"""
445
-
446
- h += """
447
- </div>
448
- </div>
449
- """
450
-
451
- # 페이지 정보
452
  h += f'<div class="page-info">Page {pg} / {total}</div>'
453
-
454
  return h
455
 
456
- # ───────────────────── 7. Gradio Blocks UI ─────────────────────
457
- # ───────────────────── 7. Gradio Blocks UI ─────────────────────
458
  def build():
459
  _init_best()
460
 
461
- # (1) ── 헤더 HTML (변경 없음) ─────────────────────────────
462
- header_html_snippet = """
463
  <style>
464
- .app-header{ text-align:center; margin-bottom:24px; }
465
- .badge-row{
466
- display:inline-flex;
467
- gap:8px;
468
- margin:8px 0;
469
- }
470
  </style>
471
-
472
  <div class="app-header">
473
- <h1>🎮 Vibe Game Gallery</h1>
474
-
475
  <div class="badge-row">
476
- <a href="https://huggingface.co/spaces/openfree/Vibe-Game" target="_blank">
477
- <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" alt="HF-Vibe">
478
- </a>
479
- <a href="https://huggingface.co/spaces/openfree/Game-Gallery" target="_blank">
480
- <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" alt="HF-Gallery">
481
- </a>
482
- <a href="https://discord.gg/openfreeai" target="_blank">
483
- <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" alt="Discord">
484
- </a>
485
  </div>
486
-
487
- <p>프롬프트 입력만으로 최신 LLM들과 Agent가 협업하여 웹 기반 게임을 생성하고 배포합니다.</p>
488
- </div>
489
- """
490
-
491
- # (2) ── 전역 CSS (헤더 sticky + 스크롤 영역 재조정) ─────────
492
- css_global = """
493
- footer{display:none !important;}
494
-
495
- /* 상단 헤더를 항상 보이도록 고정 */
496
- .app-header{
497
- position:sticky;
498
- top:0;
499
- background:#fff;
500
- z-index:1100;
501
- padding:16px 0 8px;
502
- border-bottom:1px solid #eee;
503
- }
504
-
505
- /* 하단 고정 버튼 바 */
506
- .button-row{
507
- position:fixed !important;
508
- bottom:0 !important;
509
- left:0 !important;
510
- right:0 !important;
511
- height:60px !important;
512
- background:#f0f0f0 !important;
513
- padding:10px !important;
514
- text-align:center !important;
515
- box-shadow:0 -2px 10px rgba(0,0,0,0.05) !important;
516
- margin:0 !important;
517
- z-index:1000 !important;
518
- }
519
- .button-row button{
520
- margin:0 10px !important;
521
- padding:10px 20px !important;
522
- font-size:16px !important;
523
- font-weight:bold !important;
524
- border-radius:50px !important;
525
- }
526
-
527
- /* 카드 그리드 스크롤 영역 */
528
- #content-area{
529
- overflow-y:auto !important;
530
- height:calc(100vh - 60px - 160px) !important; /* 전체-높이 - 하단바 - 헤더 */
531
- box-sizing:border-box;
532
- padding-top:10px;
533
- }
534
  """
535
 
536
- # (3) ── Gradio Blocks ─────────────────────────────────────
537
- with gr.Blocks(title="Vibe Game Craft", css=css_global) as demo:
538
-
539
- # ① 고정 헤더
540
- gr.HTML(header_html_snippet)
541
-
542
- # ② 본문(카드 그리드) → 고유 ID 부여
543
  out = gr.HTML(elem_id="content-area")
544
-
545
- # ③ 하단 페이지 네비게이션 바
546
  with gr.Row(elem_classes="button-row"):
547
  b_prev = gr.Button("◀ 이전", size="lg")
548
  b_next = gr.Button("다음 ▶", size="lg")
549
 
550
- # ── 상태 및 헬퍼 ───────────────────────────
551
  bp = gr.State(1)
552
 
553
- def show_best(p=1):
554
- d, t = page(_load_best(), p)
555
- return html(d, p, t), p
556
-
557
- def prev(b):
558
- b = max(1, b-1)
559
- h, _ = show_best(b)
560
- return h, b
561
-
562
- def nxt(b):
563
- maxp = (len(_load_best()) + PER_PAGE - 1) // PER_PAGE
564
- b = min(maxp, b+1)
565
- h, _ = show_best(b)
566
- return h, b
567
 
568
- # ── 이벤트 바인딩 ──────────────────────────
569
- b_prev.click(prev, inputs=[bp], outputs=[out, bp])
570
- b_next.click(nxt, inputs=[bp], outputs=[out, bp])
571
 
572
- # 최초 로드
573
- demo.load(show_best, outputs=[out, bp])
574
 
575
  return demo
576
 
577
- # ───────────────────── 8. 앱 생성 & 실행 ─────────────────────
578
- app = build() # ← Blocks 인스턴스 생성
579
 
580
  if __name__ == "__main__":
581
- # Spaces나 로컬에서 실행될 때 진입점
582
- app.launch() # share=True 등 옵션이 필요하면 여기서 지정
 
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():
8
  if not os.path.exists(BEST_FILE):
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
  # ───────────────────── 6. HTML 그리드 ───────────────────
51
  def html(cards, pg, total):
52
  if not cards:
53
  return "<div style='text-align:center;padding:70px;color:#555;'>표시할 배포가 없습니다.</div>"
54
+
55
  css = r"""
56
  <style>
57
+ /* 파스텔 그라디에이션 배경 */
58
+ body{
59
+ margin:0;padding:0;font-family:Poppins,sans-serif;
60
+ background:linear-gradient(135deg,#fdf4ff 0%,#f6fbff 50%,#fffaf4 100%);
61
+ background-attachment:fixed;
62
+ overflow-x:hidden;overflow-y:auto;
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
63
  }
64
 
65
+ .container{width:100%;padding:10px 10px 70px;box-sizing:border-box;}
66
+ .grid{display:grid;grid-template-columns:repeat(3,1fr);gap:12px;width:100%;}
67
+ .card{
68
+ background:#fff;border-radius:10px;overflow:hidden;box-shadow:0 4px 10px rgba(0,0,0,0.08);
69
+ height:420px;display:flex;flex-direction:column;position:relative;
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
70
  }
71
 
72
+ /* 게임 화면 축소 */
73
+ .frame{flex:1;position:relative;overflow:hidden;}
74
+ .frame iframe{
75
+ position:absolute;top:0;left:0;
76
+ width:166.667%;height:166.667%;
77
+ transform:scale(0.6);transform-origin:top left;border:0;
 
 
 
 
 
 
 
 
 
 
 
 
78
  }
79
+ .frame.huggingface iframe{width:100%!important;height:100%!important;transform:none!important;border:none!important;}
80
 
81
+ /* 하단 바로가기 */
82
+ .foot{height:34px;display:flex;align-items:center;justify-content:center;background:#fafafa;border-top:1px solid #eee;}
83
+ .foot a{font-size:0.85rem;font-weight:600;color:#4a6dd8;text-decoration:none;}
84
+ .foot a:hover{text-decoration:underline;}
85
+
86
+ /* 반응형 높이 */
87
+ @media(min-width:1200px){.card{height:560px;}}
88
+ @media(max-width:767px){
89
+ .grid{grid-template-columns:1fr;}
90
+ .card{height:480px;}
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
91
  }
92
  </style>"""
93
+
94
  js = """
95
  <script>
96
+ /* 허깅페이스 iframe 로딩 오류 처리(생략 - 기존 스크립트 그대로) */
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
97
  </script>
98
  """
99
+
100
+ h = css + js + '<div class="container"><div class="grid">'
101
+ for idx, url in enumerate(cards):
 
 
 
 
 
 
 
 
102
  iframe_url, is_huggingface, alt_urls = process_url_for_iframe(url)
 
 
103
  frame_class = "frame huggingface" if is_huggingface else "frame"
104
+ iframe_id = f"iframe-{idx}-{hash(url)%10000}"
105
+ alt_attr = f'data-alternate-urls="{",".join(alt_urls)}"' if alt_urls else ""
 
 
 
 
 
 
 
106
  h += f"""
107
  <div class="card">
 
108
  <div class="{frame_class}">
109
+ <iframe id="{iframe_id}" src="{iframe_url}" loading="lazy"
 
 
 
 
 
 
 
 
110
  sandbox="allow-forms allow-modals allow-popups allow-popups-to-escape-sandbox allow-same-origin allow-scripts allow-downloads"
111
+ data-original-url="{url}" {alt_attr}></iframe>
 
 
112
  </div>
113
+ <div class="foot"><a href="{url}" target="_blank">↗ 큰 화면(새 탭)으로 열기</a></div>
114
  </div>"""
115
+ h += "</div></div>"
 
 
 
 
 
 
116
  h += f'<div class="page-info">Page {pg} / {total}</div>'
 
117
  return h
118
 
119
+
120
+ # ───────────────────── 5. Gradio UI ─────────────────────
121
  def build():
122
  _init_best()
123
 
124
+ header = """
 
125
  <style>
126
+ .app-header{position:sticky;top:0;text-align:center;background:#fff;
127
+ padding:16px 0 8px;border-bottom:1px solid #eee;z-index:1100;}
128
+ .badge-row{display:inline-flex;gap:8px;margin:8px 0;}
 
 
 
129
  </style>
 
130
  <div class="app-header">
131
+ <h1 style="margin:0;font-size:28px;">🎮 Vibe Game Gallery</h1>
 
132
  <div class="badge-row">
133
+ <a href="https://huggingface.co/spaces/openfree/Vibe-Game" target="_blank">
134
+ <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">
135
+ </a>
136
+ <a href="https://huggingface.co/spaces/openfree/Game-Gallery" target="_blank">
137
+ <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">
138
+ </a>
139
+ <a href="https://discord.gg/openfreeai" target="_blank">
140
+ <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">
141
+ </a>
142
  </div>
143
+ </div>"""
144
+
145
+ global_css = """
146
+ footer{display:none !important;}
147
+ .button-row{position:fixed;bottom:0;left:0;right:0;height:60px;
148
+ background:#f0f0f0;padding:10px;text-align:center;
149
+ box-shadow:0 -2px 10px rgba(0,0,0,.05);z-index:1000;}
150
+ .button-row button{margin:0 10px;padding:10px 20px;font-size:16px;font-weight:bold;border-radius:50px;}
151
+ #content-area{overflow-y:auto;height:calc(100vh - 60px - 120px);}
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
152
  """
153
 
154
+ with gr.Blocks(title="Vibe Game Gallery", css=global_css) as demo:
155
+ gr.HTML(header)
 
 
 
 
 
156
  out = gr.HTML(elem_id="content-area")
 
 
157
  with gr.Row(elem_classes="button-row"):
158
  b_prev = gr.Button("◀ 이전", size="lg")
159
  b_next = gr.Button("다음 ▶", size="lg")
160
 
 
161
  bp = gr.State(1)
162
 
163
+ def render(p=1):
164
+ data, tot = page(_load_best(), p)
165
+ return html(data, p, tot), p
 
 
 
 
 
 
 
 
 
 
 
166
 
167
+ b_prev.click(lambda p: render(max(1, p-1)), inputs=bp, outputs=[out, bp])
168
+ b_next.click(lambda p: render(p+1), inputs=bp, outputs=[out, bp])
 
169
 
170
+ demo.load(render, outputs=[out, bp])
 
171
 
172
  return demo
173
 
174
+ app = build()
 
175
 
176
  if __name__ == "__main__":
177
+ app.launch()