openfree commited on
Commit
f7ae2b0
Β·
verified Β·
1 Parent(s): 29d77cd

Create app-backup2.py

Browse files
Files changed (1) hide show
  1. app-backup2.py +2077 -0
app-backup2.py ADDED
@@ -0,0 +1,2077 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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)