openfree commited on
Commit
18a8ab6
·
verified ·
1 Parent(s): c4ca423

Create app.py

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