openfree commited on
Commit
8d52231
·
verified ·
1 Parent(s): 4ef7fc9

Delete app-backup2.py

Browse files
Files changed (1) hide show
  1. app-backup2.py +0 -2077
app-backup2.py DELETED
@@ -1,2077 +0,0 @@
1
- from flask import Flask, render_template, request, jsonify
2
- import requests
3
- import os
4
- import time
5
- import random
6
- from collections import Counter
7
-
8
- app = Flask(__name__)
9
-
10
- # Generate dummy spaces in case of error
11
- def generate_dummy_spaces(count):
12
- """
13
- API 호출 실패 시 예시용 더미 스페이스 생성
14
- """
15
- spaces = []
16
- for i in range(count):
17
- spaces.append({
18
- 'id': f'dummy/space-{i}',
19
- 'owner': 'dummy',
20
- 'title': f'Example Space {i+1}',
21
- 'description': 'Dummy space for fallback',
22
- 'likes': 100 - i,
23
- 'createdAt': '2023-01-01T00:00:00.000Z',
24
- 'hardware': 'cpu',
25
- 'user': {
26
- 'avatar_url': 'https://huggingface.co/front/thumbnails/huggingface/default-avatar.svg',
27
- 'name': 'dummyUser'
28
- }
29
- })
30
- return spaces
31
-
32
- # Function to fetch Zero-GPU (CPU-based) Spaces from Huggingface with pagination
33
- def fetch_trending_spaces(offset=0, limit=72):
34
- """
35
- Trending용 CPU 스페이스 목록 가져오기 (정렬은 Hugging Face 기본 정렬)
36
- """
37
- try:
38
- url = "https://huggingface.co/api/spaces"
39
- params = {
40
- "limit": 10000, # 더 많이 가져오기
41
- "hardware": "cpu" # <-- Zero GPU(=CPU) 필터 적용
42
- }
43
- response = requests.get(url, params=params, timeout=30)
44
-
45
- if response.status_code == 200:
46
- spaces = response.json()
47
-
48
- # owner나 id가 'None'인 경우 제외
49
- filtered_spaces = [
50
- space for space in spaces
51
- if space.get('owner') != 'None'
52
- and space.get('id', '').split('/', 1)[0] != 'None'
53
- ]
54
-
55
- # 전체 목록에 대해 "글로벌 랭크"를 매긴다 (1부터 시작)
56
- for i, sp in enumerate(filtered_spaces):
57
- sp['global_rank'] = i + 1
58
-
59
- # Slice according to requested offset and limit
60
- start = min(offset, len(filtered_spaces))
61
- end = min(offset + limit, len(filtered_spaces))
62
-
63
- print(f"[fetch_trending_spaces] CPU기반 스페이스 총 {len(filtered_spaces)}개, "
64
- f"요청 구간 {start}~{end-1} 반환")
65
-
66
- return {
67
- 'spaces': filtered_spaces[start:end],
68
- 'total': len(filtered_spaces),
69
- 'offset': offset,
70
- 'limit': limit,
71
- 'all_spaces': filtered_spaces # 통계 산출용
72
- }
73
- else:
74
- print(f"Error fetching spaces: {response.status_code}")
75
- # 실패 시 더미 데이터 생성
76
- return {
77
- 'spaces': generate_dummy_spaces(limit),
78
- 'total': 200,
79
- 'offset': offset,
80
- 'limit': limit,
81
- 'all_spaces': generate_dummy_spaces(500)
82
- }
83
-
84
- except Exception as e:
85
- print(f"Exception when fetching spaces: {e}")
86
- # 실패 시 더미 데이터 생성
87
- return {
88
- 'spaces': generate_dummy_spaces(limit),
89
- 'total': 200,
90
- 'offset': offset,
91
- 'limit': limit,
92
- 'all_spaces': generate_dummy_spaces(500)
93
- }
94
-
95
- def fetch_latest_spaces(offset=0, limit=72):
96
- """
97
- 'createdAt' 기준 내림차순으로 최근 스페이스 500개를 추린 뒤,
98
- offset ~ offset+limit 개만 반환
99
- """
100
- try:
101
- url = "https://huggingface.co/api/spaces"
102
- params = {
103
- "limit": 10000, # 충분히 많이 가져옴
104
- "hardware": "cpu"
105
- }
106
- response = requests.get(url, params=params, timeout=30)
107
-
108
- if response.status_code == 200:
109
- spaces = response.json()
110
-
111
- # owner나 id가 'None'인 경우 제외
112
- filtered_spaces = [
113
- space for space in spaces
114
- if space.get('owner') != 'None'
115
- and space.get('id', '').split('/', 1)[0] != 'None'
116
- ]
117
-
118
- # createdAt 내림차순 정렬
119
- # createdAt 예: "2023-01-01T00:00:00.000Z"
120
- # 문자열 비교도 가능하지만, 안정성을 위해 time 파싱 후 비교할 수도 있음
121
- def parse_time(sp):
122
- return sp.get('createdAt') or ''
123
-
124
- # 내림차순
125
- filtered_spaces.sort(key=parse_time, reverse=True)
126
-
127
- # 상위 500개만 추리기
128
- truncated = filtered_spaces[:500]
129
-
130
- # 필요한 구간 슬라이싱
131
- start = min(offset, len(truncated))
132
- end = min(offset + limit, len(truncated))
133
-
134
- print(f"[fetch_latest_spaces] CPU기반 스페이스 총 {len(spaces)}개 중 필터 후 {len(filtered_spaces)}개, 상위 500개 중 {start}~{end-1} 반환")
135
-
136
- return {
137
- 'spaces': truncated[start:end],
138
- 'total': len(truncated), # 500 이하
139
- 'offset': offset,
140
- 'limit': limit
141
- }
142
- else:
143
- print(f"Error fetching spaces: {response.status_code}")
144
- return {
145
- 'spaces': generate_dummy_spaces(limit),
146
- 'total': 500,
147
- 'offset': offset,
148
- 'limit': limit
149
- }
150
- except Exception as e:
151
- print(f"Exception when fetching spaces: {e}")
152
- return {
153
- 'spaces': generate_dummy_spaces(limit),
154
- 'total': 500,
155
- 'offset': offset,
156
- 'limit': limit
157
- }
158
-
159
- # Transform Huggingface URL to direct space URL
160
- def transform_url(owner, name):
161
- """
162
- Hugging Face Space -> 서브도메인 접근 URL
163
- 예) huggingface.co/spaces/owner/spaceName -> owner-spacename.hf.space
164
- """
165
- # 1. Replace '.' with '-'
166
- name = name.replace('.', '-')
167
- # 2. Replace '_' with '-'
168
- name = name.replace('_', '-')
169
- # 3. Convert to lowercase
170
- owner = owner.lower()
171
- name = name.lower()
172
-
173
- return f"https://{owner}-{name}.hf.space"
174
-
175
- # Get space details
176
- def get_space_details(space_data, index, offset):
177
- """
178
- 스페이스 상세 필드 추출
179
- - rank는 offset 기반 (현재 페이지)
180
- """
181
- try:
182
- if '/' in space_data.get('id', ''):
183
- owner, name = space_data.get('id', '').split('/', 1)
184
- else:
185
- owner = space_data.get('owner', '')
186
- name = space_data.get('id', '')
187
-
188
- # Ignore if contains None
189
- if owner == 'None' or name == 'None':
190
- return None
191
-
192
- # Construct URLs
193
- original_url = f"https://huggingface.co/spaces/{owner}/{name}"
194
- embed_url = transform_url(owner, name)
195
-
196
- # Likes count
197
- likes_count = space_data.get('likes', 0)
198
-
199
- # Title
200
- title = space_data.get('title') or name
201
-
202
- # Description
203
- short_desc = space_data.get('description', '')
204
-
205
- # User info
206
- user_info = space_data.get('user', {})
207
- avatar_url = user_info.get('avatar_url', '')
208
- author_name = user_info.get('name') or owner
209
-
210
- return {
211
- 'url': original_url,
212
- 'embedUrl': embed_url,
213
- 'title': title,
214
- 'owner': owner,
215
- 'name': name,
216
- 'likes_count': likes_count,
217
- 'description': short_desc,
218
- 'avatar_url': avatar_url,
219
- 'author_name': author_name,
220
- 'rank': offset + index + 1 # 현재 페이지 표시용 랭크
221
- }
222
- except Exception as e:
223
- print(f"Error processing space data: {e}")
224
- # 에러 시 기본 데이터로 대체
225
- return {
226
- 'url': 'https://huggingface.co/spaces',
227
- 'embedUrl': 'https://huggingface.co/spaces',
228
- 'title': 'Error Loading Space',
229
- 'owner': 'huggingface',
230
- 'name': 'error',
231
- 'likes_count': 0,
232
- 'description': '',
233
- 'avatar_url': '',
234
- 'author_name': 'huggingface',
235
- 'rank': offset + index + 1
236
- }
237
-
238
- # Get owner statistics from all spaces (for the "Trending" tab's top owners)
239
- def get_owner_stats(all_spaces):
240
- """
241
- 상위 500위(global_rank <= 500) 이내에 배치된 스페이스들의 owner를 추출해,
242
- 각 owner가 몇 번 등장했는지 센 뒤 상위 30명만 반환
243
- """
244
- # Top 500
245
- top_500 = [s for s in all_spaces if s.get('global_rank', 999999) <= 500]
246
-
247
- owners = []
248
- for space in top_500:
249
- if '/' in space.get('id', ''):
250
- owner, _ = space.get('id', '').split('/', 1)
251
- else:
252
- owner = space.get('owner', '')
253
- if owner and owner != 'None':
254
- owners.append(owner)
255
-
256
- # Count occurrences of each owner in top 500
257
- owner_counts = Counter(owners)
258
-
259
- # Get top 30 owners by count
260
- top_owners = owner_counts.most_common(30)
261
- return top_owners
262
-
263
- # Homepage route
264
- @app.route('/')
265
- def home():
266
- """
267
- index.html 템플릿 렌더링 (메인 페이지)
268
- """
269
- return render_template('index.html')
270
-
271
- # Zero-GPU spaces API (Trending)
272
- @app.route('/api/trending-spaces', methods=['GET'])
273
- def trending_spaces():
274
- """
275
- hardware=cpu 스페이스 목록을 불러와 검색, 페이징, 통계 등을 적용 (기존 'Trending')
276
- """
277
- search_query = request.args.get('search', '').lower()
278
- offset = int(request.args.get('offset', 0))
279
- limit = int(request.args.get('limit', 72))
280
-
281
- # Fetch zero-gpu (cpu) spaces
282
- spaces_data = fetch_trending_spaces(offset, limit)
283
-
284
- # Process and filter spaces
285
- results = []
286
- for index, space_data in enumerate(spaces_data['spaces']):
287
- space_info = get_space_details(space_data, index, offset)
288
- if not space_info:
289
- continue
290
-
291
- # 검색어 필터
292
- if search_query:
293
- if (search_query not in space_info['title'].lower()
294
- and search_query not in space_info['owner'].lower()
295
- and search_query not in space_info['url'].lower()
296
- and search_query not in space_info['description'].lower()):
297
- continue
298
-
299
- results.append(space_info)
300
-
301
- # 오너 통계 (Top 500 → Top 30)
302
- top_owners = get_owner_stats(spaces_data.get('all_spaces', []))
303
-
304
- return jsonify({
305
- 'spaces': results,
306
- 'total': spaces_data['total'],
307
- 'offset': offset,
308
- 'limit': limit,
309
- 'top_owners': top_owners
310
- })
311
-
312
- # Zero-GPU spaces API (Latest Releases)
313
- @app.route('/api/latest-spaces', methods=['GET'])
314
- def latest_spaces():
315
- """
316
- hardware=cpu 스페이스 중에서 createdAt 기준으로 최신순 500개를 페이징, 검색
317
- """
318
- search_query = request.args.get('search', '').lower()
319
- offset = int(request.args.get('offset', 0))
320
- limit = int(request.args.get('limit', 72))
321
-
322
- spaces_data = fetch_latest_spaces(offset, limit)
323
-
324
- results = []
325
- for index, space_data in enumerate(spaces_data['spaces']):
326
- space_info = get_space_details(space_data, index, offset)
327
- if not space_info:
328
- continue
329
-
330
- # 검색어 필터
331
- if search_query:
332
- if (search_query not in space_info['title'].lower()
333
- and search_query not in space_info['owner'].lower()
334
- and search_query not in space_info['url'].lower()
335
- and search_query not in space_info['description'].lower()):
336
- continue
337
-
338
- results.append(space_info)
339
-
340
- return jsonify({
341
- 'spaces': results,
342
- 'total': spaces_data['total'],
343
- 'offset': offset,
344
- 'limit': limit
345
- })
346
-
347
- if __name__ == '__main__':
348
- """
349
- 서버 구동 시, templates/index.html 파일을 생성 후 Flask 실행
350
- """
351
- # Create templates folder if not exists
352
- os.makedirs('templates', exist_ok=True)
353
-
354
- # index.html 전체를 새로 작성
355
- with open('templates/index.html', 'w', encoding='utf-8') as f:
356
- f.write('''<!DOCTYPE html>
357
- <html lang="en">
358
- <head>
359
- <meta charset="UTF-8">
360
- <title>Huggingface Zero-GPU Spaces</title>
361
- <script src="https://cdn.jsdelivr.net/npm/chart.js"></script>
362
- <style>
363
- /* Google Fonts & Base Styling */
364
- @import url('https://fonts.googleapis.com/css2?family=Nunito:wght@300;400;500;600;700&display=swap');
365
-
366
- :root {
367
- --pastel-pink: #FFD6E0;
368
- --pastel-blue: #C5E8FF;
369
- --pastel-purple: #E0C3FC;
370
- --pastel-yellow: #FFF2CC;
371
- --pastel-green: #C7F5D9;
372
- --pastel-orange: #FFE0C3;
373
-
374
- --mac-window-bg: rgba(250, 250, 250, 0.85);
375
- --mac-toolbar: #F5F5F7;
376
- --mac-border: #E2E2E2;
377
- --mac-button-red: #FF5F56;
378
- --mac-button-yellow: #FFBD2E;
379
- --mac-button-green: #27C93F;
380
-
381
- --text-primary: #333;
382
- --text-secondary: #666;
383
- --box-shadow: 0 10px 30px rgba(0, 0, 0, 0.1);
384
- }
385
-
386
- * {
387
- margin: 0;
388
- padding: 0;
389
- box-sizing: border-box;
390
- }
391
-
392
- body {
393
- font-family: 'Nunito', -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Oxygen, Ubuntu, Cantarell, 'Open Sans', 'Helvetica Neue', sans-serif;
394
- line-height: 1.6;
395
- color: var(--text-primary);
396
- background-color: #f8f9fa;
397
- background-image: linear-gradient(135deg, var(--pastel-blue) 0%, var(--pastel-purple) 100%);
398
- min-height: 100vh;
399
- padding: 2rem;
400
- }
401
-
402
- .container {
403
- max-width: 1600px;
404
- margin: 0 auto;
405
- }
406
-
407
- /* Mac OS Window Styling */
408
- .mac-window {
409
- background-color: var(--mac-window-bg);
410
- border-radius: 10px;
411
- box-shadow: var(--box-shadow);
412
- backdrop-filter: blur(10px);
413
- overflow: hidden;
414
- margin-bottom: 2rem;
415
- border: 1px solid var(--mac-border);
416
- }
417
-
418
- .mac-toolbar {
419
- display: flex;
420
- align-items: center;
421
- padding: 10px 15px;
422
- background-color: var(--mac-toolbar);
423
- border-bottom: 1px solid var(--mac-border);
424
- }
425
-
426
- .mac-buttons {
427
- display: flex;
428
- gap: 8px;
429
- margin-right: 15px;
430
- }
431
-
432
- .mac-button {
433
- width: 12px;
434
- height: 12px;
435
- border-radius: 50%;
436
- cursor: default;
437
- }
438
-
439
- .mac-close {
440
- background-color: var(--mac-button-red);
441
- }
442
-
443
- .mac-minimize {
444
- background-color: var(--mac-button-yellow);
445
- }
446
-
447
- .mac-maximize {
448
- background-color: var(--mac-button-green);
449
- }
450
-
451
- .mac-title {
452
- flex-grow: 1;
453
- text-align: center;
454
- font-size: 0.9rem;
455
- color: var(--text-secondary);
456
- }
457
-
458
- .mac-content {
459
- padding: 20px;
460
- }
461
-
462
- /* Header Styling */
463
- .header {
464
- text-align: center;
465
- margin-bottom: 1.5rem;
466
- position: relative;
467
- }
468
-
469
- .header h1 {
470
- font-size: 2.2rem;
471
- font-weight: 700;
472
- margin: 0;
473
- color: #2d3748;
474
- letter-spacing: -0.5px;
475
- }
476
-
477
- .header p {
478
- color: var(--text-secondary);
479
- margin-top: 0.5rem;
480
- font-size: 1.1rem;
481
- }
482
-
483
- /* Tabs Styling */
484
- .tab-nav {
485
- display: flex;
486
- justify-content: center;
487
- margin-bottom: 1.5rem;
488
- }
489
-
490
- .tab-button {
491
- border: none;
492
- background-color: #edf2f7;
493
- color: var(--text-primary);
494
- padding: 10px 20px;
495
- margin: 0 5px;
496
- cursor: pointer;
497
- border-radius: 5px;
498
- font-size: 1rem;
499
- font-weight: 600;
500
- }
501
-
502
- .tab-button.active {
503
- background-color: var(--pastel-purple);
504
- color: #fff;
505
- }
506
-
507
- .tab-content {
508
- display: none;
509
- }
510
-
511
- .tab-content.active {
512
- display: block;
513
- }
514
-
515
- /* Controls Styling */
516
- .search-bar {
517
- display: flex;
518
- align-items: center;
519
- margin-bottom: 1.5rem;
520
- background-color: white;
521
- border-radius: 30px;
522
- padding: 5px;
523
- box-shadow: 0 2px 10px rgba(0, 0, 0, 0.05);
524
- max-width: 600px;
525
- margin-left: auto;
526
- margin-right: auto;
527
- }
528
-
529
- .search-bar input {
530
- flex-grow: 1;
531
- border: none;
532
- padding: 12px 20px;
533
- font-size: 1rem;
534
- outline: none;
535
- background: transparent;
536
- border-radius: 30px;
537
- }
538
-
539
- .search-bar .refresh-btn {
540
- background-color: var(--pastel-green);
541
- color: #1a202c;
542
- border: none;
543
- border-radius: 30px;
544
- padding: 10px 20px;
545
- font-size: 1rem;
546
- font-weight: 600;
547
- cursor: pointer;
548
- transition: all 0.2s;
549
- display: flex;
550
- align-items: center;
551
- gap: 8px;
552
- }
553
-
554
- .search-bar .refresh-btn:hover {
555
- background-color: #9ee7c0;
556
- box-shadow: 0 2px 8px rgba(0, 0, 0, 0.1);
557
- }
558
-
559
- .refresh-icon {
560
- display: inline-block;
561
- width: 16px;
562
- height: 16px;
563
- border: 2px solid #1a202c;
564
- border-top-color: transparent;
565
- border-radius: 50%;
566
- animation: none;
567
- }
568
-
569
- .refreshing .refresh-icon {
570
- animation: spin 1s linear infinite;
571
- }
572
-
573
- @keyframes spin {
574
- 0% { transform: rotate(0deg); }
575
- 100% { transform: rotate(360deg); }
576
- }
577
-
578
- /* Grid Styling */
579
- .grid-container {
580
- display: grid;
581
- grid-template-columns: repeat(auto-fill, minmax(350px, 1fr));
582
- gap: 1.5rem;
583
- margin-bottom: 2rem;
584
- }
585
-
586
- .grid-item {
587
- height: 500px;
588
- position: relative;
589
- overflow: hidden;
590
- transition: all 0.3s ease;
591
- border-radius: 15px;
592
- }
593
-
594
- .grid-item:nth-child(6n+1) { background-color: var(--pastel-pink); }
595
- .grid-item:nth-child(6n+2) { background-color: var(--pastel-blue); }
596
- .grid-item:nth-child(6n+3) { background-color: var(--pastel-purple); }
597
- .grid-item:nth-child(6n+4) { background-color: var(--pastel-yellow); }
598
- .grid-item:nth-child(6n+5) { background-color: var(--pastel-green); }
599
- .grid-item:nth-child(6n+6) { background-color: var(--pastel-orange); }
600
-
601
- .grid-item:hover {
602
- transform: translateY(-5px);
603
- box-shadow: 0 15px 30px rgba(0, 0, 0, 0.15);
604
- }
605
-
606
- .grid-header {
607
- padding: 15px;
608
- display: flex;
609
- flex-direction: column;
610
- background-color: rgba(255, 255, 255, 0.7);
611
- backdrop-filter: blur(5px);
612
- border-bottom: 1px solid rgba(0, 0, 0, 0.05);
613
- }
614
-
615
- .grid-header-top {
616
- display: flex;
617
- justify-content: space-between;
618
- align-items: center;
619
- margin-bottom: 8px;
620
- }
621
-
622
- .rank-badge {
623
- background-color: #1a202c;
624
- color: white;
625
- font-size: 0.8rem;
626
- font-weight: 600;
627
- padding: 4px 8px;
628
- border-radius: 50px;
629
- display: inline-block;
630
- }
631
-
632
- .grid-header h3 {
633
- margin: 0;
634
- font-size: 1.2rem;
635
- font-weight: 700;
636
- white-space: nowrap;
637
- overflow: hidden;
638
- text-overflow: ellipsis;
639
- }
640
-
641
- .grid-meta {
642
- display: flex;
643
- justify-content: space-between;
644
- align-items: center;
645
- font-size: 0.9rem;
646
- }
647
-
648
- .owner-info {
649
- color: var(--text-secondary);
650
- font-weight: 500;
651
- }
652
-
653
- .likes-counter {
654
- display: flex;
655
- align-items: center;
656
- color: #e53e3e;
657
- font-weight: 600;
658
- }
659
-
660
- .likes-counter span {
661
- margin-left: 4px;
662
- }
663
-
664
- .grid-actions {
665
- padding: 10px 15px;
666
- text-align: right;
667
- background-color: rgba(255, 255, 255, 0.7);
668
- backdrop-filter: blur(5px);
669
- position: absolute;
670
- bottom: 0;
671
- left: 0;
672
- right: 0;
673
- z-index: 10;
674
- display: flex;
675
- justify-content: flex-end;
676
- }
677
-
678
- .open-link {
679
- text-decoration: none;
680
- color: #2c5282;
681
- font-weight: 600;
682
- padding: 5px 10px;
683
- border-radius: 5px;
684
- transition: all 0.2s;
685
- background-color: rgba(237, 242, 247, 0.8);
686
- }
687
-
688
- .open-link:hover {
689
- background-color: #e2e8f0;
690
- }
691
-
692
- .grid-content {
693
- position: absolute;
694
- top: 0;
695
- left: 0;
696
- width: 100%;
697
- height: 100%;
698
- padding-top: 85px; /* Header height */
699
- padding-bottom: 45px; /* Actions height */
700
- }
701
-
702
- .iframe-container {
703
- width: 100%;
704
- height: 100%;
705
- overflow: hidden;
706
- position: relative;
707
- }
708
-
709
- /* Apply 70% scaling to iframes */
710
- .grid-content iframe {
711
- transform: scale(0.7);
712
- transform-origin: top left;
713
- width: 142.857%;
714
- height: 142.857%;
715
- border: none;
716
- border-radius: 0;
717
- }
718
-
719
- .error-placeholder {
720
- position: absolute;
721
- top: 0;
722
- left: 0;
723
- width: 100%;
724
- height: 100%;
725
- display: flex;
726
- flex-direction: column;
727
- justify-content: center;
728
- align-items: center;
729
- padding: 20px;
730
- background-color: rgba(255, 255, 255, 0.9);
731
- text-align: center;
732
- }
733
-
734
- .error-emoji {
735
- font-size: 6rem;
736
- margin-bottom: 1.5rem;
737
- animation: bounce 1s infinite alternate;
738
- text-shadow: 0 10px 20px rgba(0,0,0,0.1);
739
- }
740
-
741
- @keyframes bounce {
742
- from {
743
- transform: translateY(0px) scale(1);
744
- }
745
- to {
746
- transform: translateY(-15px) scale(1.1);
747
- }
748
- }
749
-
750
- /* Pagination Styling */
751
- .pagination {
752
- display: flex;
753
- justify-content: center;
754
- align-items: center;
755
- gap: 10px;
756
- margin: 2rem 0;
757
- }
758
-
759
- .pagination-button {
760
- background-color: white;
761
- border: none;
762
- padding: 10px 20px;
763
- border-radius: 10px;
764
- font-size: 1rem;
765
- font-weight: 600;
766
- cursor: pointer;
767
- transition: all 0.2s;
768
- color: var(--text-primary);
769
- box-shadow: 0 2px 5px rgba(0, 0, 0, 0.05);
770
- }
771
-
772
- .pagination-button:hover {
773
- background-color: #f8f9fa;
774
- box-shadow: 0 5px 15px rgba(0, 0, 0, 0.1);
775
- }
776
-
777
- .pagination-button.active {
778
- background-color: var(--pastel-purple);
779
- color: #4a5568;
780
- }
781
-
782
- .pagination-button:disabled {
783
- background-color: #edf2f7;
784
- color: #a0aec0;
785
- cursor: default;
786
- box-shadow: none;
787
- }
788
-
789
- /* Loading Indicator */
790
- .loading {
791
- position: fixed;
792
- top: 0;
793
- left: 0;
794
- right: 0;
795
- bottom: 0;
796
- background-color: rgba(255, 255, 255, 0.8);
797
- backdrop-filter: blur(5px);
798
- display: flex;
799
- justify-content: center;
800
- align-items: center;
801
- z-index: 1000;
802
- }
803
-
804
- .loading-content {
805
- text-align: center;
806
- }
807
-
808
- .loading-spinner {
809
- width: 60px;
810
- height: 60px;
811
- border: 5px solid #e2e8f0;
812
- border-top-color: var(--pastel-purple);
813
- border-radius: 50%;
814
- animation: spin 1s linear infinite;
815
- margin: 0 auto 15px;
816
- }
817
-
818
- .loading-text {
819
- font-size: 1.2rem;
820
- font-weight: 600;
821
- color: #4a5568;
822
- }
823
-
824
- .loading-error {
825
- display: none;
826
- margin-top: 10px;
827
- color: #e53e3e;
828
- font-size: 0.9rem;
829
- }
830
-
831
- /* Stats window styling */
832
- .stats-window {
833
- margin-top: 2rem;
834
- margin-bottom: 2rem;
835
- }
836
-
837
- .stats-header {
838
- display: flex;
839
- justify-content: space-between;
840
- align-items: center;
841
- margin-bottom: 1rem;
842
- }
843
-
844
- .stats-title {
845
- font-size: 1.5rem;
846
- font-weight: 700;
847
- color: #2d3748;
848
- }
849
-
850
- .stats-toggle {
851
- background-color: var(--pastel-blue);
852
- border: none;
853
- padding: 8px 16px;
854
- border-radius: 20px;
855
- font-weight: 600;
856
- cursor: pointer;
857
- transition: all 0.2s;
858
- }
859
-
860
- .stats-toggle:hover {
861
- background-color: var(--pastel-purple);
862
- }
863
-
864
- .stats-content {
865
- background-color: white;
866
- border-radius: 10px;
867
- padding: 20px;
868
- box-shadow: var(--box-shadow);
869
- max-height: 0;
870
- overflow: hidden;
871
- transition: max-height 0.5s ease-out;
872
- }
873
-
874
- .stats-content.open {
875
- max-height: 600px;
876
- }
877
-
878
- .chart-container {
879
- width: 100%;
880
- height: 500px;
881
- }
882
-
883
- /* Responsive Design */
884
- @media (max-width: 768px) {
885
- body {
886
- padding: 1rem;
887
- }
888
-
889
- .grid-container {
890
- grid-template-columns: 1fr;
891
- }
892
-
893
- .search-bar {
894
- flex-direction: column;
895
- padding: 10px;
896
- }
897
-
898
- .search-bar input {
899
- width: 100%;
900
- margin-bottom: 10px;
901
- }
902
-
903
- .search-bar .refresh-btn {
904
- width: 100%;
905
- justify-content: center;
906
- }
907
-
908
- .pagination {
909
- flex-wrap: wrap;
910
- }
911
-
912
- .chart-container {
913
- height: 300px;
914
- }
915
- }
916
-
917
- .error-emoji-detector {
918
- position: fixed;
919
- top: -9999px;
920
- left: -9999px;
921
- z-index: -1;
922
- opacity: 0;
923
- }
924
-
925
- /* 추가 레이아웃 수정(아바타, ZERO GPU 뱃지 등)을 위해
926
- 아래 클래스들을 일부 추가/수정할 수 있으나 여기서는 생략 */
927
-
928
- /* 다음 부분은 Zero GPU Spaces용 카드 구조에서 활용 */
929
- .space-header {
930
- display: flex;
931
- align-items: center;
932
- gap: 10px;
933
- margin-bottom: 4px;
934
- }
935
- .avatar-img {
936
- width: 32px;
937
- height: 32px;
938
- border-radius: 50%;
939
- object-fit: cover;
940
- border: 1px solid #ccc;
941
- }
942
- .space-title {
943
- font-size: 1rem;
944
- font-weight: 600;
945
- margin: 0;
946
- overflow: hidden;
947
- text-overflow: ellipsis;
948
- white-space: nowrap;
949
- max-width: 200px;
950
- }
951
- .zero-gpu-badge {
952
- font-size: 0.7rem;
953
- background-color: #e6fffa;
954
- color: #319795;
955
- border: 1px solid #81e6d9;
956
- border-radius: 6px;
957
- padding: 2px 6px;
958
- font-weight: 600;
959
- margin-left: 8px;
960
- }
961
- .desc-text {
962
- font-size: 0.85rem;
963
- color: #444;
964
- margin: 4px 0;
965
- line-clamp: 2;
966
- display: -webkit-box;
967
- -webkit-box-orient: vertical;
968
- overflow: hidden;
969
- }
970
- .author-name {
971
- font-size: 0.8rem;
972
- color: #666;
973
- }
974
- .likes-wrapper {
975
- display: flex;
976
- align-items: center;
977
- gap: 4px;
978
- color: #e53e3e;
979
- font-weight: bold;
980
- font-size: 0.85rem;
981
- }
982
- .likes-heart {
983
- font-size: 1rem;
984
- line-height: 1rem;
985
- color: #f56565;
986
- }
987
- /* 이모지 전용 스타일 (선택사항) */
988
- .emoji-avatar {
989
- font-size: 1.2rem;
990
- width: 32px;
991
- height: 32px;
992
- border-radius: 50%;
993
- border: 1px solid #ccc;
994
- display: flex;
995
- align-items: center;
996
- justify-content: center;
997
- }
998
- </style>
999
- </head>
1000
- <body>
1001
- <div class="container">
1002
- <div class="mac-window">
1003
- <div class="mac-toolbar">
1004
- <div class="mac-buttons">
1005
- <div class="mac-button mac-close"></div>
1006
- <div class="mac-button mac-minimize"></div>
1007
- <div class="mac-button mac-maximize"></div>
1008
- </div>
1009
- <div class="mac-title">Huggingface Explorer</div>
1010
- </div>
1011
-
1012
- <div class="mac-content">
1013
- <div class="header">
1014
- <h1>ZeroGPU Spaces Leaderboard</h1>
1015
- <p>Discover Zero GPU(Shared A100) spaces from Hugging Face</p>
1016
- </div>
1017
-
1018
- <!-- Tab Navigation -->
1019
- <div class="tab-nav">
1020
- <button id="tabTrendingButton" class="tab-button active">Trending</button>
1021
- <button id="tabLatestButton" class="tab-button">Latest Releases</button>
1022
- <button id="tabFixedButton" class="tab-button">Picks</button>
1023
- </div>
1024
-
1025
- <!-- Trending(Zero GPU) Tab Content -->
1026
- <div id="trendingTab" class="tab-content active">
1027
- <div class="stats-window mac-window">
1028
- <div class="mac-toolbar">
1029
- <div class="mac-buttons">
1030
- <div class="mac-button mac-close"></div>
1031
- <div class="mac-button mac-minimize"></div>
1032
- <div class="mac-button mac-maximize"></div>
1033
- </div>
1034
- <div class="mac-title">Creator Statistics</div>
1035
- </div>
1036
- <div class="mac-content">
1037
- <div class="stats-header">
1038
- <div class="stats-title">Top 30 Creators by Number of Spaces Ranked within Top 500</div>
1039
- <button id="statsToggle" class="stats-toggle">Show Stats</button>
1040
- </div>
1041
- <div id="statsContent" class="stats-content">
1042
- <div class="chart-container">
1043
- <canvas id="creatorStatsChart"></canvas>
1044
- </div>
1045
- </div>
1046
- </div>
1047
- </div>
1048
-
1049
- <div class="search-bar">
1050
- <input type="text" id="searchInputTrending" placeholder="Search by name, owner, or description..." />
1051
- <button id="refreshButtonTrending" class="refresh-btn">
1052
- <span class="refresh-icon"></span>
1053
- Refresh
1054
- </button>
1055
- </div>
1056
-
1057
- <div id="gridContainerTrending" class="grid-container"></div>
1058
-
1059
- <div id="paginationTrending" class="pagination"></div>
1060
- </div>
1061
-
1062
- <!-- Latest Releases Tab Content -->
1063
- <div id="latestTab" class="tab-content">
1064
- <div class="search-bar">
1065
- <input type="text" id="searchInputLatest" placeholder="Search by name, owner, or description..." />
1066
- <button id="refreshButtonLatest" class="refresh-btn">
1067
- <span class="refresh-icon"></span>
1068
- Refresh
1069
- </button>
1070
- </div>
1071
-
1072
- <div id="gridContainerLatest" class="grid-container"></div>
1073
-
1074
- <div id="paginationLatest" class="pagination"></div>
1075
- </div>
1076
-
1077
- <!-- Fixed Tab Content (기존 예시 유지) -->
1078
- <div id="fixedTab" class="tab-content">
1079
- <div id="fixedGrid" class="grid-container"></div>
1080
- </div>
1081
- </div>
1082
- </div>
1083
- </div>
1084
-
1085
- <div id="loadingIndicator" class="loading">
1086
- <div class="loading-content">
1087
- <div class="loading-spinner"></div>
1088
- <div class="loading-text">Loading Zero-GPU spaces...</div>
1089
- <div id="loadingError" class="loading-error">
1090
- If this takes too long, try refreshing the page.
1091
- </div>
1092
- </div>
1093
- </div>
1094
-
1095
- <script>
1096
- // ------------------------------------
1097
- // GLOBAL STATE & COMMON FUNCTIONS
1098
- // ------------------------------------
1099
- const globalState = {
1100
- isLoading: false,
1101
- loadingTimeout: null,
1102
- };
1103
- function setLoading(isLoading) {
1104
- globalState.isLoading = isLoading;
1105
- document.getElementById('loadingIndicator').style.display = isLoading ? 'flex' : 'none';
1106
-
1107
- const refreshButtons = document.querySelectorAll('.refresh-btn');
1108
- refreshButtons.forEach(btn => {
1109
- if (isLoading) {
1110
- btn.classList.add('refreshing');
1111
- } else {
1112
- btn.classList.remove('refreshing');
1113
- }
1114
- });
1115
-
1116
- if (isLoading) {
1117
- clearTimeout(globalState.loadingTimeout);
1118
- globalState.loadingTimeout = setTimeout(() => {
1119
- document.getElementById('loadingError').style.display = 'block';
1120
- }, 10000);
1121
- } else {
1122
- clearTimeout(globalState.loadingTimeout);
1123
- document.getElementById('loadingError').style.display = 'none';
1124
- }
1125
- }
1126
- function handleIframeError(iframe, owner, name, title) {
1127
- const container = iframe.parentNode;
1128
- const errorPlaceholder = document.createElement('div');
1129
- errorPlaceholder.className = 'error-placeholder';
1130
-
1131
- const errorMessage = document.createElement('p');
1132
- errorMessage.textContent = `"${title}" space couldn't be loaded`;
1133
- errorPlaceholder.appendChild(errorMessage);
1134
-
1135
- const directLink = document.createElement('a');
1136
- directLink.href = `https://huggingface.co/spaces/${owner}/${name}`;
1137
- directLink.target = '_blank';
1138
- directLink.textContent = 'Visit HF Space';
1139
- directLink.style.color = '#3182ce';
1140
- directLink.style.marginTop = '10px';
1141
- directLink.style.display = 'inline-block';
1142
- directLink.style.padding = '8px 16px';
1143
- directLink.style.background = '#ebf8ff';
1144
- directLink.style.borderRadius = '5px';
1145
- directLink.style.fontWeight = '600';
1146
- errorPlaceholder.appendChild(directLink);
1147
-
1148
- iframe.style.display = 'none';
1149
- container.appendChild(errorPlaceholder);
1150
- }
1151
-
1152
- // ------------------------------------
1153
- // IFRAME LOADER (공통)
1154
- // ------------------------------------
1155
- const iframeLoader = {
1156
- checkQueue: {},
1157
- maxAttempts: 5,
1158
- checkInterval: 5000,
1159
-
1160
- startChecking: function(iframe, owner, name, title, spaceKey) {
1161
- this.checkQueue[spaceKey] = {
1162
- iframe: iframe,
1163
- owner: owner,
1164
- name: name,
1165
- title: title,
1166
- attempts: 0,
1167
- status: 'loading'
1168
- };
1169
- this.checkIframeStatus(spaceKey);
1170
- },
1171
-
1172
- checkIframeStatus: function(spaceKey) {
1173
- if (!this.checkQueue[spaceKey]) return;
1174
-
1175
- const item = this.checkQueue[spaceKey];
1176
- if (item.status !== 'loading') {
1177
- delete this.checkQueue[spaceKey];
1178
- return;
1179
- }
1180
- item.attempts++;
1181
-
1182
- try {
1183
- if (!item.iframe || !item.iframe.parentNode) {
1184
- delete this.checkQueue[spaceKey];
1185
- return;
1186
- }
1187
-
1188
- // Check if content loaded
1189
- try {
1190
- const hasContent = item.iframe.contentWindow &&
1191
- item.iframe.contentWindow.document &&
1192
- item.iframe.contentWindow.document.body;
1193
- if (hasContent && item.iframe.contentWindow.document.body.innerHTML.length > 100) {
1194
- const bodyText = item.iframe.contentWindow.document.body.textContent.toLowerCase();
1195
- if (bodyText.includes('forbidden') || bodyText.includes('404') ||
1196
- bodyText.includes('not found') || bodyText.includes('error')) {
1197
- item.status = 'error';
1198
- handleIframeError(item.iframe, item.owner, item.name, item.title);
1199
- } else {
1200
- item.status = 'success';
1201
- }
1202
- delete this.checkQueue[spaceKey];
1203
- return;
1204
- }
1205
- } catch(e) {
1206
- // Cross-origin issues can happen; not always an error
1207
- }
1208
-
1209
- // Check if iframe is visible
1210
- const rect = item.iframe.getBoundingClientRect();
1211
- if (rect.width > 50 && rect.height > 50 && item.attempts > 2) {
1212
- item.status = 'success';
1213
- delete this.checkQueue[spaceKey];
1214
- return;
1215
- }
1216
-
1217
- // If max attempts reached
1218
- if (item.attempts >= this.maxAttempts) {
1219
- if (item.iframe.offsetWidth > 0 && item.iframe.offsetHeight > 0) {
1220
- item.status = 'success';
1221
- } else {
1222
- item.status = 'error';
1223
- handleIframeError(item.iframe, item.owner, item.name, item.title);
1224
- }
1225
- delete this.checkQueue[spaceKey];
1226
- return;
1227
- }
1228
-
1229
- // Re-check after some delay
1230
- const nextDelay = this.checkInterval * Math.pow(1.5, item.attempts - 1);
1231
- setTimeout(() => this.checkIframeStatus(spaceKey), nextDelay);
1232
-
1233
- } catch (e) {
1234
- console.error('Error checking iframe status:', e);
1235
- if (item.attempts >= this.maxAttempts) {
1236
- item.status = 'error';
1237
- handleIframeError(item.iframe, item.owner, item.name, item.title);
1238
- delete this.checkQueue[spaceKey];
1239
- } else {
1240
- setTimeout(() => this.checkIframeStatus(spaceKey), this.checkInterval);
1241
- }
1242
- }
1243
- }
1244
- };
1245
-
1246
- // ------------------------------------
1247
- // TRENDING TAB
1248
- // ------------------------------------
1249
- const trendingState = {
1250
- spaces: [],
1251
- currentPage: 0,
1252
- itemsPerPage: 72,
1253
- totalItems: 0,
1254
- topOwners: [],
1255
- iframeStatuses: {}
1256
- };
1257
- const trendingElements = {
1258
- searchInput: document.getElementById('searchInputTrending'),
1259
- refreshButton: document.getElementById('refreshButtonTrending'),
1260
- gridContainer: document.getElementById('gridContainerTrending'),
1261
- pagination: document.getElementById('paginationTrending'),
1262
- statsToggle: document.getElementById('statsToggle'),
1263
- statsContent: document.getElementById('statsContent'),
1264
- creatorStatsChart: document.getElementById('creatorStatsChart')
1265
- };
1266
-
1267
- let chartInstance = null;
1268
-
1269
- trendingElements.statsToggle.addEventListener('click', () => {
1270
- const isOpen = trendingElements.statsContent.classList.toggle('open');
1271
- trendingElements.statsToggle.textContent = isOpen ? 'Hide Stats' : 'Show Stats';
1272
- if (isOpen && trendingState.topOwners.length > 0) {
1273
- renderCreatorStats(trendingState.topOwners);
1274
- }
1275
- });
1276
-
1277
- function renderCreatorStats(topOwners) {
1278
- if (chartInstance) {
1279
- chartInstance.destroy();
1280
- }
1281
- const ctx = trendingElements.creatorStatsChart.getContext('2d');
1282
- const labels = topOwners.map(item => item[0]);
1283
- const data = topOwners.map(item => item[1]);
1284
-
1285
- const colors = [];
1286
- for (let i = 0; i < labels.length; i++) {
1287
- const hue = (i * 360 / labels.length) % 360;
1288
- colors.push(`hsla(${hue}, 70%, 80%, 0.7)`);
1289
- }
1290
-
1291
- chartInstance = new Chart(ctx, {
1292
- type: 'bar',
1293
- data: {
1294
- labels: labels,
1295
- datasets: [{
1296
- label: 'Number of Spaces in Top 500',
1297
- data: data,
1298
- backgroundColor: colors,
1299
- borderColor: colors.map(color => color.replace('0.7', '1')),
1300
- borderWidth: 1
1301
- }]
1302
- },
1303
- options: {
1304
- indexAxis: 'y',
1305
- responsive: true,
1306
- maintainAspectRatio: false,
1307
- plugins: {
1308
- legend: { display: false },
1309
- tooltip: {
1310
- callbacks: {
1311
- title: function(tooltipItems) {
1312
- return tooltipItems[0].label;
1313
- },
1314
- label: function(context) {
1315
- return `Spaces: ${context.raw}`;
1316
- }
1317
- }
1318
- }
1319
- },
1320
- scales: {
1321
- x: {
1322
- beginAtZero: true,
1323
- title: {
1324
- display: true,
1325
- text: 'Number of Spaces'
1326
- }
1327
- },
1328
- y: {
1329
- title: {
1330
- display: true,
1331
- text: 'Creator ID'
1332
- },
1333
- ticks: {
1334
- autoSkip: false,
1335
- font: function(context) {
1336
- const defaultSize = 11;
1337
- return {
1338
- size: labels.length > 20 ? defaultSize - 1 : defaultSize
1339
- };
1340
- }
1341
- }
1342
- }
1343
- }
1344
- }
1345
- });
1346
- }
1347
-
1348
- async function loadTrending(page=0) {
1349
- setLoading(true);
1350
- try {
1351
- const searchText = trendingElements.searchInput.value;
1352
- const offset = page * trendingState.itemsPerPage;
1353
-
1354
- const timeoutPromise = new Promise((_, reject) =>
1355
- setTimeout(() => reject(new Error('Request timeout')), 30000)
1356
- );
1357
- const fetchPromise = fetch(
1358
- `/api/trending-spaces?search=${encodeURIComponent(searchText)}&offset=${offset}&limit=${trendingState.itemsPerPage}`
1359
- );
1360
- const response = await Promise.race([fetchPromise, timeoutPromise]);
1361
- const data = await response.json();
1362
-
1363
- trendingState.spaces = data.spaces || [];
1364
- trendingState.totalItems = data.total || 0;
1365
- trendingState.currentPage = page;
1366
- trendingState.topOwners = data.top_owners || [];
1367
-
1368
- renderTrendingGrid(trendingState.spaces);
1369
- renderTrendingPagination();
1370
-
1371
- // 통계창 열려있다면 새 데이터로 갱신
1372
- if (trendingElements.statsContent.classList.contains('open') && trendingState.topOwners.length > 0) {
1373
- renderCreatorStats(trendingState.topOwners);
1374
- }
1375
- } catch (error) {
1376
- console.error('Error loading trending spaces:', error);
1377
- trendingElements.gridContainer.innerHTML = `
1378
- <div style="grid-column: 1/-1; text-align: center; padding: 40px;">
1379
- <div style="font-size: 3rem; margin-bottom: 20px;">⚠️</div>
1380
- <h3 style="margin-bottom: 10px;">Unable to load spaces (Trending)</h3>
1381
- <p style="color: #666;">Please try refreshing the page. If the problem persists, try again later.</p>
1382
- <button id="retryTrendingButton" style="margin-top: 20px; padding: 10px 20px; background: var(--pastel-purple); border: none; border-radius: 5px; cursor: pointer;">
1383
- Try Again
1384
- </button>
1385
- </div>
1386
- `;
1387
- document.getElementById('retryTrendingButton')?.addEventListener('click', () => loadTrending(0));
1388
- renderTrendingPagination();
1389
- } finally {
1390
- setLoading(false);
1391
- }
1392
- }
1393
-
1394
- function renderTrendingGrid(spaces) {
1395
- trendingElements.gridContainer.innerHTML = '';
1396
-
1397
- if (!spaces || spaces.length === 0) {
1398
- const noResultsMsg = document.createElement('p');
1399
- noResultsMsg.textContent = 'No zero-gpu spaces found matching your search.';
1400
- noResultsMsg.style.padding = '2rem';
1401
- noResultsMsg.style.textAlign = 'center';
1402
- noResultsMsg.style.fontStyle = 'italic';
1403
- noResultsMsg.style.color = '#718096';
1404
- trendingElements.gridContainer.appendChild(noResultsMsg);
1405
- return;
1406
- }
1407
-
1408
- spaces.forEach((item) => {
1409
- try {
1410
- const {
1411
- url, title, likes_count, owner, name, rank,
1412
- description, avatar_url, author_name, embedUrl
1413
- } = item;
1414
-
1415
- const gridItem = document.createElement('div');
1416
- gridItem.className = 'grid-item';
1417
-
1418
- // 상단 헤더
1419
- const headerDiv = document.createElement('div');
1420
- headerDiv.className = 'grid-header';
1421
-
1422
- const spaceHeader = document.createElement('div');
1423
- spaceHeader.className = 'space-header';
1424
-
1425
- const rankBadge = document.createElement('div');
1426
- rankBadge.className = 'rank-badge';
1427
- rankBadge.textContent = `#${rank}`;
1428
- spaceHeader.appendChild(rankBadge);
1429
-
1430
- const titleWrapper = document.createElement('div');
1431
- titleWrapper.style.display = 'flex';
1432
- titleWrapper.style.alignItems = 'center';
1433
- titleWrapper.style.marginLeft = '8px';
1434
-
1435
- const titleEl = document.createElement('h3');
1436
- titleEl.className = 'space-title';
1437
- titleEl.textContent = title;
1438
- titleEl.title = title;
1439
- titleWrapper.appendChild(titleEl);
1440
-
1441
- const zeroGpuBadge = document.createElement('span');
1442
- zeroGpuBadge.className = 'zero-gpu-badge';
1443
- zeroGpuBadge.textContent = 'ZERO GPU';
1444
- titleWrapper.appendChild(zeroGpuBadge);
1445
-
1446
- spaceHeader.appendChild(titleWrapper);
1447
- headerDiv.appendChild(spaceHeader);
1448
-
1449
- const metaInfo = document.createElement('div');
1450
- metaInfo.className = 'grid-meta';
1451
- metaInfo.style.display = 'flex';
1452
- metaInfo.style.justifyContent = 'space-between';
1453
- metaInfo.style.alignItems = 'center';
1454
- metaInfo.style.marginTop = '6px';
1455
-
1456
- const leftMeta = document.createElement('div');
1457
- const authorSpan = document.createElement('span');
1458
- authorSpan.className = 'author-name';
1459
- authorSpan.style.marginLeft = '8px';
1460
- authorSpan.textContent = `by ${author_name}`;
1461
- leftMeta.appendChild(authorSpan);
1462
-
1463
- metaInfo.appendChild(leftMeta);
1464
-
1465
- const likesDiv = document.createElement('div');
1466
- likesDiv.className = 'likes-wrapper';
1467
- likesDiv.innerHTML = `<span class="likes-heart">♥</span><span>${likes_count}</span>`;
1468
- metaInfo.appendChild(likesDiv);
1469
-
1470
- headerDiv.appendChild(metaInfo);
1471
- gridItem.appendChild(headerDiv);
1472
-
1473
- if (description) {
1474
- const descP = document.createElement('p');
1475
- descP.className = 'desc-text';
1476
- descP.textContent = description;
1477
- gridItem.appendChild(descP);
1478
- }
1479
-
1480
- const content = document.createElement('div');
1481
- content.className = 'grid-content';
1482
-
1483
- const iframeContainer = document.createElement('div');
1484
- iframeContainer.className = 'iframe-container';
1485
-
1486
- const iframe = document.createElement('iframe');
1487
- iframe.src = embedUrl;
1488
- iframe.title = title;
1489
- iframe.allow = 'accelerometer; camera; encrypted-media; geolocation; gyroscope;';
1490
- iframe.setAttribute('allowfullscreen', '');
1491
- iframe.setAttribute('frameborder', '0');
1492
- iframe.loading = 'lazy';
1493
-
1494
- const spaceKey = `${owner}/${name}`;
1495
- trendingState.iframeStatuses[spaceKey] = 'loading';
1496
-
1497
- iframe.onload = function() {
1498
- iframeLoader.startChecking(iframe, owner, name, title, spaceKey);
1499
- };
1500
- iframe.onerror = function() {
1501
- handleIframeError(iframe, owner, name, title);
1502
- trendingState.iframeStatuses[spaceKey] = 'error';
1503
- };
1504
- setTimeout(() => {
1505
- if (trendingState.iframeStatuses[spaceKey] === 'loading') {
1506
- handleIframeError(iframe, owner, name, title);
1507
- trendingState.iframeStatuses[spaceKey] = 'error';
1508
- }
1509
- }, 30000);
1510
-
1511
- iframeContainer.appendChild(iframe);
1512
- content.appendChild(iframeContainer);
1513
-
1514
- const actions = document.createElement('div');
1515
- actions.className = 'grid-actions';
1516
-
1517
- const linkEl = document.createElement('a');
1518
- linkEl.href = url;
1519
- linkEl.target = '_blank';
1520
- linkEl.className = 'open-link';
1521
- linkEl.textContent = 'Open in new window';
1522
- actions.appendChild(linkEl);
1523
-
1524
- gridItem.appendChild(content);
1525
- gridItem.appendChild(actions);
1526
-
1527
- trendingElements.gridContainer.appendChild(gridItem);
1528
-
1529
- } catch (err) {
1530
- console.error('Item rendering error:', err);
1531
- }
1532
- });
1533
- }
1534
-
1535
- function renderTrendingPagination() {
1536
- trendingElements.pagination.innerHTML = '';
1537
- const totalPages = Math.ceil(trendingState.totalItems / trendingState.itemsPerPage);
1538
-
1539
- // Previous page
1540
- const prevButton = document.createElement('button');
1541
- prevButton.className = 'pagination-button';
1542
- prevButton.textContent = 'Previous';
1543
- prevButton.disabled = (trendingState.currentPage === 0);
1544
- prevButton.addEventListener('click', () => {
1545
- if (trendingState.currentPage > 0) {
1546
- loadTrending(trendingState.currentPage - 1);
1547
- }
1548
- });
1549
- trendingElements.pagination.appendChild(prevButton);
1550
-
1551
- const maxButtons = 7;
1552
- let startPage = Math.max(0, trendingState.currentPage - Math.floor(maxButtons / 2));
1553
- let endPage = Math.min(totalPages - 1, startPage + maxButtons - 1);
1554
-
1555
- if (endPage - startPage + 1 < maxButtons) {
1556
- startPage = Math.max(0, endPage - maxButtons + 1);
1557
- }
1558
-
1559
- for (let i = startPage; i <= endPage; i++) {
1560
- const pageButton = document.createElement('button');
1561
- pageButton.className = 'pagination-button' + (i === trendingState.currentPage ? ' active' : '');
1562
- pageButton.textContent = (i + 1);
1563
- pageButton.addEventListener('click', () => {
1564
- if (i !== trendingState.currentPage) {
1565
- loadTrending(i);
1566
- }
1567
- });
1568
- trendingElements.pagination.appendChild(pageButton);
1569
- }
1570
-
1571
- // Next page
1572
- const nextButton = document.createElement('button');
1573
- nextButton.className = 'pagination-button';
1574
- nextButton.textContent = 'Next';
1575
- nextButton.disabled = (trendingState.currentPage >= totalPages - 1);
1576
- nextButton.addEventListener('click', () => {
1577
- if (trendingState.currentPage < totalPages - 1) {
1578
- loadTrending(trendingState.currentPage + 1);
1579
- }
1580
- });
1581
- trendingElements.pagination.appendChild(nextButton);
1582
- }
1583
-
1584
- // ------------------------------------
1585
- // LATEST RELEASES TAB
1586
- // ------------------------------------
1587
- const latestState = {
1588
- spaces: [],
1589
- currentPage: 0,
1590
- itemsPerPage: 72,
1591
- totalItems: 0,
1592
- iframeStatuses: {}
1593
- };
1594
- const latestElements = {
1595
- searchInput: document.getElementById('searchInputLatest'),
1596
- refreshButton: document.getElementById('refreshButtonLatest'),
1597
- gridContainer: document.getElementById('gridContainerLatest'),
1598
- pagination: document.getElementById('paginationLatest')
1599
- };
1600
-
1601
- async function loadLatest(page=0) {
1602
- setLoading(true);
1603
- try {
1604
- const searchText = latestElements.searchInput.value;
1605
- const offset = page * latestState.itemsPerPage;
1606
-
1607
- const timeoutPromise = new Promise((_, reject) =>
1608
- setTimeout(() => reject(new Error('Request timeout')), 30000)
1609
- );
1610
- const fetchPromise = fetch(
1611
- `/api/latest-spaces?search=${encodeURIComponent(searchText)}&offset=${offset}&limit=${latestState.itemsPerPage}`
1612
- );
1613
- const response = await Promise.race([fetchPromise, timeoutPromise]);
1614
- const data = await response.json();
1615
-
1616
- latestState.spaces = data.spaces || [];
1617
- latestState.totalItems = data.total || 0;
1618
- latestState.currentPage = page;
1619
-
1620
- renderLatestGrid(latestState.spaces);
1621
- renderLatestPagination();
1622
- } catch (error) {
1623
- console.error('Error loading latest spaces:', error);
1624
- latestElements.gridContainer.innerHTML = `
1625
- <div style="grid-column: 1/-1; text-align: center; padding: 40px;">
1626
- <div style="font-size: 3rem; margin-bottom: 20px;">⚠️</div>
1627
- <h3 style="margin-bottom: 10px;">Unable to load spaces (Latest)</h3>
1628
- <p style="color: #666;">Please try refreshing the page. If the problem persists, try again later.</p>
1629
- <button id="retryLatestButton" style="margin-top: 20px; padding: 10px 20px; background: var(--pastel-purple); border: none; border-radius: 5px; cursor: pointer;">
1630
- Try Again
1631
- </button>
1632
- </div>
1633
- `;
1634
- document.getElementById('retryLatestButton')?.addEventListener('click', () => loadLatest(0));
1635
- renderLatestPagination();
1636
- } finally {
1637
- setLoading(false);
1638
- }
1639
- }
1640
-
1641
- function renderLatestGrid(spaces) {
1642
- latestElements.gridContainer.innerHTML = '';
1643
-
1644
- if (!spaces || spaces.length === 0) {
1645
- const noResultsMsg = document.createElement('p');
1646
- noResultsMsg.textContent = 'No zero-gpu spaces found matching your search.';
1647
- noResultsMsg.style.padding = '2rem';
1648
- noResultsMsg.style.textAlign = 'center';
1649
- noResultsMsg.style.fontStyle = 'italic';
1650
- noResultsMsg.style.color = '#718096';
1651
- latestElements.gridContainer.appendChild(noResultsMsg);
1652
- return;
1653
- }
1654
-
1655
- spaces.forEach((item, index) => {
1656
- try {
1657
- const {
1658
- url, title, likes_count, owner, name, rank,
1659
- description, avatar_url, author_name, embedUrl
1660
- } = item;
1661
-
1662
- // rank가 없으므로 Latest 탭에서는 offset+index+1 형태로 표시
1663
- const computedRank = latestState.currentPage * latestState.itemsPerPage + (index + 1);
1664
-
1665
- const gridItem = document.createElement('div');
1666
- gridItem.className = 'grid-item';
1667
-
1668
- // 상단 헤더
1669
- const headerDiv = document.createElement('div');
1670
- headerDiv.className = 'grid-header';
1671
-
1672
- const spaceHeader = document.createElement('div');
1673
- spaceHeader.className = 'space-header';
1674
-
1675
- const rankBadge = document.createElement('div');
1676
- rankBadge.className = 'rank-badge';
1677
- rankBadge.textContent = `#${computedRank}`;
1678
- spaceHeader.appendChild(rankBadge);
1679
-
1680
- const titleWrapper = document.createElement('div');
1681
- titleWrapper.style.display = 'flex';
1682
- titleWrapper.style.alignItems = 'center';
1683
- titleWrapper.style.marginLeft = '8px';
1684
-
1685
- const titleEl = document.createElement('h3');
1686
- titleEl.className = 'space-title';
1687
- titleEl.textContent = title;
1688
- titleEl.title = title;
1689
- titleWrapper.appendChild(titleEl);
1690
-
1691
- const zeroGpuBadge = document.createElement('span');
1692
- zeroGpuBadge.className = 'zero-gpu-badge';
1693
- zeroGpuBadge.textContent = 'ZERO GPU';
1694
- titleWrapper.appendChild(zeroGpuBadge);
1695
-
1696
- spaceHeader.appendChild(titleWrapper);
1697
- headerDiv.appendChild(spaceHeader);
1698
-
1699
- const metaInfo = document.createElement('div');
1700
- metaInfo.className = 'grid-meta';
1701
- metaInfo.style.display = 'flex';
1702
- metaInfo.style.justifyContent = 'space-between';
1703
- metaInfo.style.alignItems = 'center';
1704
- metaInfo.style.marginTop = '6px';
1705
-
1706
- const leftMeta = document.createElement('div');
1707
- const authorSpan = document.createElement('span');
1708
- authorSpan.className = 'author-name';
1709
- authorSpan.style.marginLeft = '8px';
1710
- authorSpan.textContent = `by ${author_name}`;
1711
- leftMeta.appendChild(authorSpan);
1712
-
1713
- metaInfo.appendChild(leftMeta);
1714
-
1715
- const likesDiv = document.createElement('div');
1716
- likesDiv.className = 'likes-wrapper';
1717
- likesDiv.innerHTML = `<span class="likes-heart">♥</span><span>${likes_count}</span>`;
1718
- metaInfo.appendChild(likesDiv);
1719
-
1720
- headerDiv.appendChild(metaInfo);
1721
- gridItem.appendChild(headerDiv);
1722
-
1723
- if (description) {
1724
- const descP = document.createElement('p');
1725
- descP.className = 'desc-text';
1726
- descP.textContent = description;
1727
- gridItem.appendChild(descP);
1728
- }
1729
-
1730
- const content = document.createElement('div');
1731
- content.className = 'grid-content';
1732
-
1733
- const iframeContainer = document.createElement('div');
1734
- iframeContainer.className = 'iframe-container';
1735
-
1736
- const iframe = document.createElement('iframe');
1737
- iframe.src = embedUrl;
1738
- iframe.title = title;
1739
- iframe.allow = 'accelerometer; camera; encrypted-media; geolocation; gyroscope;';
1740
- iframe.setAttribute('allowfullscreen', '');
1741
- iframe.setAttribute('frameborder', '0');
1742
- iframe.loading = 'lazy';
1743
-
1744
- const spaceKey = `${owner}/${name}`;
1745
- latestState.iframeStatuses[spaceKey] = 'loading';
1746
-
1747
- iframe.onload = function() {
1748
- iframeLoader.startChecking(iframe, owner, name, title, spaceKey);
1749
- };
1750
- iframe.onerror = function() {
1751
- handleIframeError(iframe, owner, name, title);
1752
- latestState.iframeStatuses[spaceKey] = 'error';
1753
- };
1754
- setTimeout(() => {
1755
- if (latestState.iframeStatuses[spaceKey] === 'loading') {
1756
- handleIframeError(iframe, owner, name, title);
1757
- latestState.iframeStatuses[spaceKey] = 'error';
1758
- }
1759
- }, 30000);
1760
-
1761
- iframeContainer.appendChild(iframe);
1762
- content.appendChild(iframeContainer);
1763
-
1764
- const actions = document.createElement('div');
1765
- actions.className = 'grid-actions';
1766
-
1767
- const linkEl = document.createElement('a');
1768
- linkEl.href = url;
1769
- linkEl.target = '_blank';
1770
- linkEl.className = 'open-link';
1771
- linkEl.textContent = 'Open in new window';
1772
- actions.appendChild(linkEl);
1773
-
1774
- gridItem.appendChild(content);
1775
- gridItem.appendChild(actions);
1776
-
1777
- latestElements.gridContainer.appendChild(gridItem);
1778
-
1779
- } catch (err) {
1780
- console.error('Item rendering error (Latest):', err);
1781
- }
1782
- });
1783
- }
1784
-
1785
- function renderLatestPagination() {
1786
- latestElements.pagination.innerHTML = '';
1787
- const totalPages = Math.ceil(latestState.totalItems / latestState.itemsPerPage);
1788
-
1789
- // Previous page
1790
- const prevButton = document.createElement('button');
1791
- prevButton.className = 'pagination-button';
1792
- prevButton.textContent = 'Previous';
1793
- prevButton.disabled = (latestState.currentPage === 0);
1794
- prevButton.addEventListener('click', () => {
1795
- if (latestState.currentPage > 0) {
1796
- loadLatest(latestState.currentPage - 1);
1797
- }
1798
- });
1799
- latestElements.pagination.appendChild(prevButton);
1800
-
1801
- const maxButtons = 7;
1802
- let startPage = Math.max(0, latestState.currentPage - Math.floor(maxButtons / 2));
1803
- let endPage = Math.min(totalPages - 1, startPage + maxButtons - 1);
1804
-
1805
- if (endPage - startPage + 1 < maxButtons) {
1806
- startPage = Math.max(0, endPage - maxButtons + 1);
1807
- }
1808
-
1809
- for (let i = startPage; i <= endPage; i++) {
1810
- const pageButton = document.createElement('button');
1811
- pageButton.className = 'pagination-button' + (i === latestState.currentPage ? ' active' : '');
1812
- pageButton.textContent = (i + 1);
1813
- pageButton.addEventListener('click', () => {
1814
- if (i !== latestState.currentPage) {
1815
- loadLatest(i);
1816
- }
1817
- });
1818
- latestElements.pagination.appendChild(pageButton);
1819
- }
1820
-
1821
- // Next page
1822
- const nextButton = document.createElement('button');
1823
- nextButton.className = 'pagination-button';
1824
- nextButton.textContent = 'Next';
1825
- nextButton.disabled = (latestState.currentPage >= totalPages - 1);
1826
- nextButton.addEventListener('click', () => {
1827
- if (latestState.currentPage < totalPages - 1) {
1828
- loadLatest(latestState.currentPage + 1);
1829
- }
1830
- });
1831
- latestElements.pagination.appendChild(nextButton);
1832
- }
1833
-
1834
- // ------------------------------------
1835
- // FIXED TAB
1836
- // ------------------------------------
1837
- const fixedGridContainer = document.getElementById('fixedGrid');
1838
- function renderFixedGrid() {
1839
- fixedGridContainer.innerHTML = '';
1840
-
1841
- const staticSpaces = [
1842
- {
1843
- url: "https://huggingface.co/spaces/ginipick/spaces-research-think",
1844
- title: "Spaces Research Analysis",
1845
- likes_count: 0,
1846
- owner: "ginipick",
1847
- name: "3D-LLAMA",
1848
- rank: 1
1849
- },
1850
- {
1851
- url: "https://huggingface.co/spaces/ginipick/spaces-research-think",
1852
- title: "Spaces Research ",
1853
- likes_count: 0,
1854
- owner: "ginipick",
1855
- name: "3D-LLAMA",
1856
- rank: 2
1857
- },
1858
- {
1859
- url: "https://huggingface.co/spaces/ginigen/3D-LLAMA",
1860
- title: "3D-LLAMA",
1861
- likes_count: 999,
1862
- owner: "ginigen",
1863
- name: "3D-LLAMA",
1864
- rank: 3
1865
- },
1866
- ];
1867
-
1868
- if (!staticSpaces || staticSpaces.length === 0) {
1869
- const noResultsMsg = document.createElement('p');
1870
- noResultsMsg.textContent = 'No spaces to display.';
1871
- noResultsMsg.style.padding = '2rem';
1872
- noResultsMsg.style.textAlign = 'center';
1873
- noResultsMsg.style.fontStyle = 'italic';
1874
- noResultsMsg.style.color = '#718096';
1875
- fixedGridContainer.appendChild(noResultsMsg);
1876
- return;
1877
- }
1878
-
1879
- staticSpaces.forEach((item) => {
1880
- try {
1881
- const { url, title, likes_count, owner, name, rank } = item;
1882
- const gridItem = document.createElement('div');
1883
- gridItem.className = 'grid-item';
1884
-
1885
- const header = document.createElement('div');
1886
- header.className = 'grid-header';
1887
-
1888
- const headerTop = document.createElement('div');
1889
- headerTop.className = 'grid-header-top';
1890
-
1891
- // 로봇 이모지 + 타이틀 함께 표시
1892
- const leftWrapper = document.createElement('div');
1893
- leftWrapper.style.display = 'flex';
1894
- leftWrapper.style.alignItems = 'center';
1895
-
1896
- const emojiAvatar = document.createElement('div');
1897
- emojiAvatar.className = 'emoji-avatar';
1898
- emojiAvatar.textContent = '🤖';
1899
- leftWrapper.appendChild(emojiAvatar);
1900
-
1901
- const titleEl = document.createElement('h3');
1902
- titleEl.textContent = title;
1903
- titleEl.title = title;
1904
- leftWrapper.appendChild(titleEl);
1905
-
1906
- headerTop.appendChild(leftWrapper);
1907
-
1908
- const rankBadge = document.createElement('div');
1909
- rankBadge.className = 'rank-badge';
1910
- rankBadge.textContent = `#${rank}`;
1911
- headerTop.appendChild(rankBadge);
1912
-
1913
- header.appendChild(headerTop);
1914
-
1915
- const metaInfo = document.createElement('div');
1916
- metaInfo.className = 'grid-meta';
1917
-
1918
- const ownerEl = document.createElement('div');
1919
- ownerEl.className = 'owner-info';
1920
- ownerEl.textContent = `by ${owner}`;
1921
- metaInfo.appendChild(ownerEl);
1922
-
1923
- const likesCounter = document.createElement('div');
1924
- likesCounter.className = 'likes-counter';
1925
- likesCounter.innerHTML = '♥ <span>' + likes_count + '</span>';
1926
- metaInfo.appendChild(likesCounter);
1927
-
1928
- header.appendChild(metaInfo);
1929
- gridItem.appendChild(header);
1930
-
1931
- const content = document.createElement('div');
1932
- content.className = 'grid-content';
1933
-
1934
- const iframeContainer = document.createElement('div');
1935
- iframeContainer.className = 'iframe-container';
1936
-
1937
- const iframe = document.createElement('iframe');
1938
- iframe.src = "https://" + owner.toLowerCase() + "-" + name.toLowerCase() + ".hf.space";
1939
- iframe.title = title;
1940
- iframe.allow = 'accelerometer; camera; encrypted-media; geolocation; gyroscope;';
1941
- iframe.setAttribute('allowfullscreen', '');
1942
- iframe.setAttribute('frameborder', '0');
1943
- iframe.loading = 'lazy';
1944
-
1945
- iframe.onload = function() {
1946
- iframeLoader.startChecking(iframe, owner, name, title, `${owner}/${name}`);
1947
- };
1948
- iframe.onerror = function() {
1949
- handleIframeError(iframe, owner, name, title);
1950
- };
1951
- setTimeout(() => {
1952
- if (iframe.offsetWidth === 0 || iframe.offsetHeight === 0) {
1953
- handleIframeError(iframe, owner, name, title);
1954
- }
1955
- }, 30000);
1956
-
1957
- iframeContainer.appendChild(iframe);
1958
- content.appendChild(iframeContainer);
1959
-
1960
- const actions = document.createElement('div');
1961
- actions.className = 'grid-actions';
1962
-
1963
- const linkEl = document.createElement('a');
1964
- linkEl.href = url;
1965
- linkEl.target = '_blank';
1966
- linkEl.className = 'open-link';
1967
- linkEl.textContent = 'Open in new window';
1968
- actions.appendChild(linkEl);
1969
-
1970
- gridItem.appendChild(content);
1971
- gridItem.appendChild(actions);
1972
-
1973
- fixedGridContainer.appendChild(gridItem);
1974
-
1975
- } catch (error) {
1976
- console.error('Fixed tab rendering error:', error);
1977
- }
1978
- });
1979
- }
1980
-
1981
- // ------------------------------------
1982
- // TAB HANDLERS
1983
- // ------------------------------------
1984
- const tabTrendingButton = document.getElementById('tabTrendingButton');
1985
- const tabLatestButton = document.getElementById('tabLatestButton');
1986
- const tabFixedButton = document.getElementById('tabFixedButton');
1987
-
1988
- const trendingTab = document.getElementById('trendingTab');
1989
- const latestTab = document.getElementById('latestTab');
1990
- const fixedTab = document.getElementById('fixedTab');
1991
-
1992
- tabTrendingButton.addEventListener('click', () => {
1993
- tabTrendingButton.classList.add('active');
1994
- tabLatestButton.classList.remove('active');
1995
- tabFixedButton.classList.remove('active');
1996
- trendingTab.classList.add('active');
1997
- latestTab.classList.remove('active');
1998
- fixedTab.classList.remove('active');
1999
- loadTrending(trendingState.currentPage);
2000
- });
2001
-
2002
- tabLatestButton.addEventListener('click', () => {
2003
- tabLatestButton.classList.add('active');
2004
- tabTrendingButton.classList.remove('active');
2005
- tabFixedButton.classList.remove('active');
2006
- latestTab.classList.add('active');
2007
- trendingTab.classList.remove('active');
2008
- fixedTab.classList.remove('active');
2009
- loadLatest(latestState.currentPage);
2010
- });
2011
-
2012
- tabFixedButton.addEventListener('click', () => {
2013
- tabFixedButton.classList.add('active');
2014
- tabTrendingButton.classList.remove('active');
2015
- tabLatestButton.classList.remove('active');
2016
- fixedTab.classList.add('active');
2017
- trendingTab.classList.remove('active');
2018
- latestTab.classList.remove('active');
2019
- renderFixedGrid();
2020
- });
2021
-
2022
- // ------------------------------------
2023
- // EVENT LISTENERS
2024
- // ------------------------------------
2025
- trendingElements.searchInput.addEventListener('input', () => {
2026
- clearTimeout(trendingState.searchTimeout);
2027
- trendingState.searchTimeout = setTimeout(() => loadTrending(0), 300);
2028
- });
2029
- trendingElements.searchInput.addEventListener('keyup', (event) => {
2030
- if (event.key === 'Enter') {
2031
- loadTrending(0);
2032
- }
2033
- });
2034
- trendingElements.refreshButton.addEventListener('click', () => loadTrending(0));
2035
-
2036
- latestElements.searchInput.addEventListener('input', () => {
2037
- clearTimeout(latestState.searchTimeout);
2038
- latestState.searchTimeout = setTimeout(() => loadLatest(0), 300);
2039
- });
2040
- latestElements.searchInput.addEventListener('keyup', (event) => {
2041
- if (event.key === 'Enter') {
2042
- loadLatest(0);
2043
- }
2044
- });
2045
- latestElements.refreshButton.addEventListener('click', () => loadLatest(0));
2046
-
2047
- window.addEventListener('load', function() {
2048
- // 첫 진입시 Trending 탭 먼저 로드
2049
- setTimeout(() => loadTrending(0), 500);
2050
- });
2051
-
2052
- setTimeout(() => {
2053
- if (globalState.isLoading) {
2054
- setLoading(false);
2055
- // 타임아웃 시 메시지 표시
2056
- trendingElements.gridContainer.innerHTML = `
2057
- <div style="grid-column: 1/-1; text-align: center; padding: 40px;">
2058
- <div style="font-size: 3rem; margin-bottom: 20px;">⏱️</div>
2059
- <h3 style="margin-bottom: 10px;">Loading is taking longer than expected</h3>
2060
- <p style="color: #666;">Please try refreshing the page.</p>
2061
- <button onClick="window.location.reload()" style="margin-top: 20px; padding: 10px 20px; background: var(--pastel-purple); border: none; border-radius: 5px; cursor: pointer;">
2062
- Reload Page
2063
- </button>
2064
- </div>
2065
- `;
2066
- }
2067
- }, 20000);
2068
- </script>
2069
- </body>
2070
- </html>
2071
- ''')
2072
-
2073
-
2074
-
2075
- if __name__ == '__main__':
2076
- port = int(os.environ.get("PORT", 7860))
2077
- app.run(host='0.0.0.0', port=port)