openfree commited on
Commit
49da04e
·
verified ·
1 Parent(s): eca569c

Create app.py

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