openfree commited on
Commit
e878e76
·
verified ·
1 Parent(s): f5b807d

Update app.py

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