ZeroGPU-Leader / app.py
openfree's picture
Update app.py
32c33a2 verified
raw
history blame
66.2 kB
from flask import Flask, render_template, request, jsonify
import requests
import os
import time
import random
from collections import Counter
app = Flask(__name__)
# Generate dummy spaces in case of error
def generate_dummy_spaces(count):
"""
API 호출 실패 시 예시용 더미 스페이스 생성
"""
spaces = []
for i in range(count):
spaces.append({
'id': f'dummy/space-{i}',
'owner': 'dummy',
'title': f'Example Space {i+1}',
'description': 'Dummy space for fallback',
'likes': 100 - i,
'createdAt': '2023-01-01T00:00:00.000Z',
'hardware': 'cpu',
'user': {
'avatar_url': 'https://huggingface.co/front/thumbnails/huggingface/default-avatar.svg',
'name': 'dummyUser'
}
})
return spaces
# Function to fetch Zero-GPU (CPU-based) Spaces from Huggingface with pagination
def fetch_trending_spaces(offset=0, limit=72):
"""
Trending용 CPU 스페이스 목록 가져오기 (정렬은 Hugging Face 기본 정렬)
"""
try:
url = "https://huggingface.co/api/spaces"
params = {
"limit": 10000, # 더 많이 가져오기
"hardware": "cpu" # <-- Zero GPU(=CPU) 필터 적용
}
response = requests.get(url, params=params, timeout=30)
if response.status_code == 200:
spaces = response.json()
# owner나 id가 'None'인 경우 제외
filtered_spaces = [
space for space in spaces
if space.get('owner') != 'None'
and space.get('id', '').split('/', 1)[0] != 'None'
]
# 전체 목록에 대해 "글로벌 랭크"를 매긴다 (1부터 시작)
for i, sp in enumerate(filtered_spaces):
sp['global_rank'] = i + 1
# Slice according to requested offset and limit
start = min(offset, len(filtered_spaces))
end = min(offset + limit, len(filtered_spaces))
print(f"[fetch_trending_spaces] CPU기반 스페이스 총 {len(filtered_spaces)}개, "
f"요청 구간 {start}~{end-1} 반환")
return {
'spaces': filtered_spaces[start:end],
'total': len(filtered_spaces),
'offset': offset,
'limit': limit,
'all_spaces': filtered_spaces # 통계 산출용
}
else:
print(f"Error fetching spaces: {response.status_code}")
# 실패 시 더미 데이터 생성
return {
'spaces': generate_dummy_spaces(limit),
'total': 200,
'offset': offset,
'limit': limit,
'all_spaces': generate_dummy_spaces(500)
}
except Exception as e:
print(f"Exception when fetching spaces: {e}")
# 실패 시 더미 데이터 생성
return {
'spaces': generate_dummy_spaces(limit),
'total': 200,
'offset': offset,
'limit': limit,
'all_spaces': generate_dummy_spaces(500)
}
def fetch_latest_spaces(offset=0, limit=72):
"""
'createdAt' 기준 내림차순으로 최근 스페이스 500개를 추린 뒤,
offset ~ offset+limit 개만 반환
"""
try:
url = "https://huggingface.co/api/spaces"
params = {
"limit": 10000, # 충분히 많이 가져옴
"hardware": "cpu"
}
response = requests.get(url, params=params, timeout=30)
if response.status_code == 200:
spaces = response.json()
# owner나 id가 'None'인 경우 제외
filtered_spaces = [
space for space in spaces
if space.get('owner') != 'None'
and space.get('id', '').split('/', 1)[0] != 'None'
]
# createdAt 내림차순 정렬
# createdAt 예: "2023-01-01T00:00:00.000Z"
# 문자열 비교도 가능하지만, 안정성을 위해 time 파싱 후 비교할 수도 있음
def parse_time(sp):
return sp.get('createdAt') or ''
# 내림차순
filtered_spaces.sort(key=parse_time, reverse=True)
# 상위 500개만 추리기
truncated = filtered_spaces[:500]
# 필요한 구간 슬라이싱
start = min(offset, len(truncated))
end = min(offset + limit, len(truncated))
print(f"[fetch_latest_spaces] CPU기반 스페이스 총 {len(spaces)}개 중 필터 후 {len(filtered_spaces)}개, 상위 500개 중 {start}~{end-1} 반환")
return {
'spaces': truncated[start:end],
'total': len(truncated), # 500 이하
'offset': offset,
'limit': limit
}
else:
print(f"Error fetching spaces: {response.status_code}")
return {
'spaces': generate_dummy_spaces(limit),
'total': 500,
'offset': offset,
'limit': limit
}
except Exception as e:
print(f"Exception when fetching spaces: {e}")
return {
'spaces': generate_dummy_spaces(limit),
'total': 500,
'offset': offset,
'limit': limit
}
# Transform Huggingface URL to direct space URL
def transform_url(owner, name):
"""
Hugging Face Space -> 서브도메인 접근 URL
예) huggingface.co/spaces/owner/spaceName -> owner-spacename.hf.space
"""
# 1. Replace '.' with '-'
name = name.replace('.', '-')
# 2. Replace '_' with '-'
name = name.replace('_', '-')
# 3. Convert to lowercase
owner = owner.lower()
name = name.lower()
return f"https://{owner}-{name}.hf.space"
# Get space details
def get_space_details(space_data, index, offset):
"""
스페이스 상세 필드 추출
- rank는 offset 기반 (현재 페이지)
"""
try:
if '/' in space_data.get('id', ''):
owner, name = space_data.get('id', '').split('/', 1)
else:
owner = space_data.get('owner', '')
name = space_data.get('id', '')
# Ignore if contains None
if owner == 'None' or name == 'None':
return None
# Construct URLs
original_url = f"https://huggingface.co/spaces/{owner}/{name}"
embed_url = transform_url(owner, name)
# Likes count
likes_count = space_data.get('likes', 0)
# Title
title = space_data.get('title') or name
# Description
short_desc = space_data.get('description', '')
# User info
user_info = space_data.get('user', {})
avatar_url = user_info.get('avatar_url', '')
author_name = user_info.get('name') or owner
return {
'url': original_url,
'embedUrl': embed_url,
'title': title,
'owner': owner,
'name': name,
'likes_count': likes_count,
'description': short_desc,
'avatar_url': avatar_url,
'author_name': author_name,
'rank': offset + index + 1 # 현재 페이지 표시용 랭크
}
except Exception as e:
print(f"Error processing space data: {e}")
# 에러 시 기본 데이터로 대체
return {
'url': 'https://huggingface.co/spaces',
'embedUrl': 'https://huggingface.co/spaces',
'title': 'Error Loading Space',
'owner': 'huggingface',
'name': 'error',
'likes_count': 0,
'description': '',
'avatar_url': '',
'author_name': 'huggingface',
'rank': offset + index + 1
}
# Get owner statistics from all spaces (for the "Trending" tab's top owners)
def get_owner_stats(all_spaces):
"""
상위 500위(global_rank <= 500) 이내에 배치된 스페이스들의 owner를 추출해,
각 owner가 몇 번 등장했는지 센 뒤 상위 30명만 반환
"""
# Top 500
top_500 = [s for s in all_spaces if s.get('global_rank', 999999) <= 500]
owners = []
for space in top_500:
if '/' in space.get('id', ''):
owner, _ = space.get('id', '').split('/', 1)
else:
owner = space.get('owner', '')
if owner and owner != 'None':
owners.append(owner)
# Count occurrences of each owner in top 500
owner_counts = Counter(owners)
# Get top 30 owners by count
top_owners = owner_counts.most_common(30)
return top_owners
# Homepage route
@app.route('/')
def home():
"""
index.html 템플릿 렌더링 (메인 페이지)
"""
return render_template('index.html')
# Zero-GPU spaces API (Trending)
@app.route('/api/trending-spaces', methods=['GET'])
def trending_spaces():
"""
hardware=cpu 스페이스 목록을 불러와 검색, 페이징, 통계 등을 적용 (기존 'Trending')
"""
search_query = request.args.get('search', '').lower()
offset = int(request.args.get('offset', 0))
limit = int(request.args.get('limit', 72))
# Fetch zero-gpu (cpu) spaces
spaces_data = fetch_trending_spaces(offset, limit)
# Process and filter spaces
results = []
for index, space_data in enumerate(spaces_data['spaces']):
space_info = get_space_details(space_data, index, offset)
if not space_info:
continue
# 검색어 필터
if search_query:
if (search_query not in space_info['title'].lower()
and search_query not in space_info['owner'].lower()
and search_query not in space_info['url'].lower()
and search_query not in space_info['description'].lower()):
continue
results.append(space_info)
# 오너 통계 (Top 500 → Top 30)
top_owners = get_owner_stats(spaces_data.get('all_spaces', []))
return jsonify({
'spaces': results,
'total': spaces_data['total'],
'offset': offset,
'limit': limit,
'top_owners': top_owners
})
# Zero-GPU spaces API (Latest Releases)
@app.route('/api/latest-spaces', methods=['GET'])
def latest_spaces():
"""
hardware=cpu 스페이스 중에서 createdAt 기준으로 최신순 500개를 페이징, 검색
"""
search_query = request.args.get('search', '').lower()
offset = int(request.args.get('offset', 0))
limit = int(request.args.get('limit', 72))
spaces_data = fetch_latest_spaces(offset, limit)
results = []
for index, space_data in enumerate(spaces_data['spaces']):
space_info = get_space_details(space_data, index, offset)
if not space_info:
continue
# 검색어 필터
if search_query:
if (search_query not in space_info['title'].lower()
and search_query not in space_info['owner'].lower()
and search_query not in space_info['url'].lower()
and search_query not in space_info['description'].lower()):
continue
results.append(space_info)
return jsonify({
'spaces': results,
'total': spaces_data['total'],
'offset': offset,
'limit': limit
})
if __name__ == '__main__':
"""
서버 구동 시, templates/index.html 파일을 생성 후 Flask 실행
"""
# Create templates folder if not exists
os.makedirs('templates', exist_ok=True)
# index.html 전체를 새로 작성
with open('templates/index.html', 'w', encoding='utf-8') as f:
f.write('''<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<title>Huggingface Zero-GPU Spaces</title>
<script src="https://cdn.jsdelivr.net/npm/chart.js"></script>
<style>
/* Google Fonts & Base Styling */
@import url('https://fonts.googleapis.com/css2?family=Nunito:wght@300;400;500;600;700&display=swap');
:root {
--pastel-pink: #FFD6E0;
--pastel-blue: #C5E8FF;
--pastel-purple: #E0C3FC;
--pastel-yellow: #FFF2CC;
--pastel-green: #C7F5D9;
--pastel-orange: #FFE0C3;
--mac-window-bg: rgba(250, 250, 250, 0.85);
--mac-toolbar: #F5F5F7;
--mac-border: #E2E2E2;
--mac-button-red: #FF5F56;
--mac-button-yellow: #FFBD2E;
--mac-button-green: #27C93F;
--text-primary: #333;
--text-secondary: #666;
--box-shadow: 0 10px 30px rgba(0, 0, 0, 0.1);
}
* {
margin: 0;
padding: 0;
box-sizing: border-box;
}
body {
font-family: 'Nunito', -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Oxygen, Ubuntu, Cantarell, 'Open Sans', 'Helvetica Neue', sans-serif;
line-height: 1.6;
color: var(--text-primary);
background-color: #f8f9fa;
background-image: linear-gradient(135deg, var(--pastel-blue) 0%, var(--pastel-purple) 100%);
min-height: 100vh;
padding: 2rem;
}
.container {
max-width: 1600px;
margin: 0 auto;
}
/* Mac OS Window Styling */
.mac-window {
background-color: var(--mac-window-bg);
border-radius: 10px;
box-shadow: var(--box-shadow);
backdrop-filter: blur(10px);
overflow: hidden;
margin-bottom: 2rem;
border: 1px solid var(--mac-border);
}
.mac-toolbar {
display: flex;
align-items: center;
padding: 10px 15px;
background-color: var(--mac-toolbar);
border-bottom: 1px solid var(--mac-border);
}
.mac-buttons {
display: flex;
gap: 8px;
margin-right: 15px;
}
.mac-button {
width: 12px;
height: 12px;
border-radius: 50%;
cursor: default;
}
.mac-close {
background-color: var(--mac-button-red);
}
.mac-minimize {
background-color: var(--mac-button-yellow);
}
.mac-maximize {
background-color: var(--mac-button-green);
}
.mac-title {
flex-grow: 1;
text-align: center;
font-size: 0.9rem;
color: var(--text-secondary);
}
.mac-content {
padding: 20px;
}
/* Header Styling */
.header {
text-align: center;
margin-bottom: 1.5rem;
position: relative;
}
.header h1 {
font-size: 2.2rem;
font-weight: 700;
margin: 0;
color: #2d3748;
letter-spacing: -0.5px;
}
.header p {
color: var(--text-secondary);
margin-top: 0.5rem;
font-size: 1.1rem;
}
/* Tabs Styling */
.tab-nav {
display: flex;
justify-content: center;
margin-bottom: 1.5rem;
}
.tab-button {
border: none;
background-color: #edf2f7;
color: var(--text-primary);
padding: 10px 20px;
margin: 0 5px;
cursor: pointer;
border-radius: 5px;
font-size: 1rem;
font-weight: 600;
}
.tab-button.active {
background-color: var(--pastel-purple);
color: #fff;
}
.tab-content {
display: none;
}
.tab-content.active {
display: block;
}
/* Controls Styling */
.search-bar {
display: flex;
align-items: center;
margin-bottom: 1.5rem;
background-color: white;
border-radius: 30px;
padding: 5px;
box-shadow: 0 2px 10px rgba(0, 0, 0, 0.05);
max-width: 600px;
margin-left: auto;
margin-right: auto;
}
.search-bar input {
flex-grow: 1;
border: none;
padding: 12px 20px;
font-size: 1rem;
outline: none;
background: transparent;
border-radius: 30px;
}
.search-bar .refresh-btn {
background-color: var(--pastel-green);
color: #1a202c;
border: none;
border-radius: 30px;
padding: 10px 20px;
font-size: 1rem;
font-weight: 600;
cursor: pointer;
transition: all 0.2s;
display: flex;
align-items: center;
gap: 8px;
}
.search-bar .refresh-btn:hover {
background-color: #9ee7c0;
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.1);
}
.refresh-icon {
display: inline-block;
width: 16px;
height: 16px;
border: 2px solid #1a202c;
border-top-color: transparent;
border-radius: 50%;
animation: none;
}
.refreshing .refresh-icon {
animation: spin 1s linear infinite;
}
@keyframes spin {
0% { transform: rotate(0deg); }
100% { transform: rotate(360deg); }
}
/* Grid Styling */
.grid-container {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(350px, 1fr));
gap: 1.5rem;
margin-bottom: 2rem;
}
.grid-item {
height: 500px;
position: relative;
overflow: hidden;
transition: all 0.3s ease;
border-radius: 15px;
}
.grid-item:nth-child(6n+1) { background-color: var(--pastel-pink); }
.grid-item:nth-child(6n+2) { background-color: var(--pastel-blue); }
.grid-item:nth-child(6n+3) { background-color: var(--pastel-purple); }
.grid-item:nth-child(6n+4) { background-color: var(--pastel-yellow); }
.grid-item:nth-child(6n+5) { background-color: var(--pastel-green); }
.grid-item:nth-child(6n+6) { background-color: var(--pastel-orange); }
.grid-item:hover {
transform: translateY(-5px);
box-shadow: 0 15px 30px rgba(0, 0, 0, 0.15);
}
.grid-header {
padding: 15px;
display: flex;
flex-direction: column;
background-color: rgba(255, 255, 255, 0.7);
backdrop-filter: blur(5px);
border-bottom: 1px solid rgba(0, 0, 0, 0.05);
}
.grid-header-top {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 8px;
}
.rank-badge {
background-color: #1a202c;
color: white;
font-size: 0.8rem;
font-weight: 600;
padding: 4px 8px;
border-radius: 50px;
display: inline-block;
}
.grid-header h3 {
margin: 0;
font-size: 1.2rem;
font-weight: 700;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.grid-meta {
display: flex;
justify-content: space-between;
align-items: center;
font-size: 0.9rem;
}
.owner-info {
color: var(--text-secondary);
font-weight: 500;
}
.likes-counter {
display: flex;
align-items: center;
color: #e53e3e;
font-weight: 600;
}
.likes-counter span {
margin-left: 4px;
}
.grid-actions {
padding: 10px 15px;
text-align: right;
background-color: rgba(255, 255, 255, 0.7);
backdrop-filter: blur(5px);
position: absolute;
bottom: 0;
left: 0;
right: 0;
z-index: 10;
display: flex;
justify-content: flex-end;
}
.open-link {
text-decoration: none;
color: #2c5282;
font-weight: 600;
padding: 5px 10px;
border-radius: 5px;
transition: all 0.2s;
background-color: rgba(237, 242, 247, 0.8);
}
.open-link:hover {
background-color: #e2e8f0;
}
.grid-content {
position: absolute;
top: 0;
left: 0;
width: 100%;
height: 100%;
padding-top: 85px; /* Header height */
padding-bottom: 45px; /* Actions height */
}
.iframe-container {
width: 100%;
height: 100%;
overflow: hidden;
position: relative;
}
/* Apply 70% scaling to iframes */
.grid-content iframe {
transform: scale(0.7);
transform-origin: top left;
width: 142.857%;
height: 142.857%;
border: none;
border-radius: 0;
}
.error-placeholder {
position: absolute;
top: 0;
left: 0;
width: 100%;
height: 100%;
display: flex;
flex-direction: column;
justify-content: center;
align-items: center;
padding: 20px;
background-color: rgba(255, 255, 255, 0.9);
text-align: center;
}
.error-emoji {
font-size: 6rem;
margin-bottom: 1.5rem;
animation: bounce 1s infinite alternate;
text-shadow: 0 10px 20px rgba(0,0,0,0.1);
}
@keyframes bounce {
from {
transform: translateY(0px) scale(1);
}
to {
transform: translateY(-15px) scale(1.1);
}
}
/* Pagination Styling */
.pagination {
display: flex;
justify-content: center;
align-items: center;
gap: 10px;
margin: 2rem 0;
}
.pagination-button {
background-color: white;
border: none;
padding: 10px 20px;
border-radius: 10px;
font-size: 1rem;
font-weight: 600;
cursor: pointer;
transition: all 0.2s;
color: var(--text-primary);
box-shadow: 0 2px 5px rgba(0, 0, 0, 0.05);
}
.pagination-button:hover {
background-color: #f8f9fa;
box-shadow: 0 5px 15px rgba(0, 0, 0, 0.1);
}
.pagination-button.active {
background-color: var(--pastel-purple);
color: #4a5568;
}
.pagination-button:disabled {
background-color: #edf2f7;
color: #a0aec0;
cursor: default;
box-shadow: none;
}
/* Loading Indicator */
.loading {
position: fixed;
top: 0;
left: 0;
right: 0;
bottom: 0;
background-color: rgba(255, 255, 255, 0.8);
backdrop-filter: blur(5px);
display: flex;
justify-content: center;
align-items: center;
z-index: 1000;
}
.loading-content {
text-align: center;
}
.loading-spinner {
width: 60px;
height: 60px;
border: 5px solid #e2e8f0;
border-top-color: var(--pastel-purple);
border-radius: 50%;
animation: spin 1s linear infinite;
margin: 0 auto 15px;
}
.loading-text {
font-size: 1.2rem;
font-weight: 600;
color: #4a5568;
}
.loading-error {
display: none;
margin-top: 10px;
color: #e53e3e;
font-size: 0.9rem;
}
/* Stats window styling */
.stats-window {
margin-top: 2rem;
margin-bottom: 2rem;
}
.stats-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 1rem;
}
.stats-title {
font-size: 1.5rem;
font-weight: 700;
color: #2d3748;
}
.stats-toggle {
background-color: var(--pastel-blue);
border: none;
padding: 8px 16px;
border-radius: 20px;
font-weight: 600;
cursor: pointer;
transition: all 0.2s;
}
.stats-toggle:hover {
background-color: var(--pastel-purple);
}
.stats-content {
background-color: white;
border-radius: 10px;
padding: 20px;
box-shadow: var(--box-shadow);
max-height: 0;
overflow: hidden;
transition: max-height 0.5s ease-out;
}
.stats-content.open {
max-height: 600px;
}
.chart-container {
width: 100%;
height: 500px;
}
/* Responsive Design */
@media (max-width: 768px) {
body {
padding: 1rem;
}
.grid-container {
grid-template-columns: 1fr;
}
.search-bar {
flex-direction: column;
padding: 10px;
}
.search-bar input {
width: 100%;
margin-bottom: 10px;
}
.search-bar .refresh-btn {
width: 100%;
justify-content: center;
}
.pagination {
flex-wrap: wrap;
}
.chart-container {
height: 300px;
}
}
.error-emoji-detector {
position: fixed;
top: -9999px;
left: -9999px;
z-index: -1;
opacity: 0;
}
/* 추가 레이아웃 수정(아바타, ZERO GPU 뱃지 등)을 위해
아래 클래스들을 일부 추가/수정할 수 있으나 여기서는 생략 */
/* 다음 부분은 Zero GPU Spaces용 카드 구조에서 활용 */
.space-header {
display: flex;
align-items: center;
gap: 10px;
margin-bottom: 4px;
}
.avatar-img {
width: 32px;
height: 32px;
border-radius: 50%;
object-fit: cover;
border: 1px solid #ccc;
}
.space-title {
font-size: 1rem;
font-weight: 600;
margin: 0;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
max-width: 200px;
}
.zero-gpu-badge {
font-size: 0.7rem;
background-color: #e6fffa;
color: #319795;
border: 1px solid #81e6d9;
border-radius: 6px;
padding: 2px 6px;
font-weight: 600;
margin-left: 8px;
}
.desc-text {
font-size: 0.85rem;
color: #444;
margin: 4px 0;
line-clamp: 2;
display: -webkit-box;
-webkit-box-orient: vertical;
overflow: hidden;
}
.author-name {
font-size: 0.8rem;
color: #666;
}
.likes-wrapper {
display: flex;
align-items: center;
gap: 4px;
color: #e53e3e;
font-weight: bold;
font-size: 0.85rem;
}
.likes-heart {
font-size: 1rem;
line-height: 1rem;
color: #f56565;
}
/* 이모지 전용 스타일 (선택사항) */
.emoji-avatar {
font-size: 1.2rem;
width: 32px;
height: 32px;
border-radius: 50%;
border: 1px solid #ccc;
display: flex;
align-items: center;
justify-content: center;
}
</style>
</head>
<body>
<div class="container">
<div class="mac-window">
<div class="mac-toolbar">
<div class="mac-buttons">
<div class="mac-button mac-close"></div>
<div class="mac-button mac-minimize"></div>
<div class="mac-button mac-maximize"></div>
</div>
<div class="mac-title">Huggingface Explorer</div>
</div>
<div class="mac-content">
<div class="header">
<h1>ZeroGPU Spaces Leaderboard</h1>
<p>Discover Zero GPU(Shared A100) spaces from Hugging Face</p>
</div>
<!-- Tab Navigation -->
<div class="tab-nav">
<button id="tabTrendingButton" class="tab-button active">Trending</button>
<button id="tabLatestButton" class="tab-button">Latest Releases</button>
<button id="tabFixedButton" class="tab-button">Picks</button>
</div>
<!-- Trending(Zero GPU) Tab Content -->
<div id="trendingTab" class="tab-content active">
<div class="stats-window mac-window">
<div class="mac-toolbar">
<div class="mac-buttons">
<div class="mac-button mac-close"></div>
<div class="mac-button mac-minimize"></div>
<div class="mac-button mac-maximize"></div>
</div>
<div class="mac-title">Creator Statistics</div>
</div>
<div class="mac-content">
<div class="stats-header">
<div class="stats-title">Top 30 Creators by Number of Spaces Ranked within Top 500</div>
<button id="statsToggle" class="stats-toggle">Show Stats</button>
</div>
<div id="statsContent" class="stats-content">
<div class="chart-container">
<canvas id="creatorStatsChart"></canvas>
</div>
</div>
</div>
</div>
<div class="search-bar">
<input type="text" id="searchInputTrending" placeholder="Search by name, owner, or description..." />
<button id="refreshButtonTrending" class="refresh-btn">
<span class="refresh-icon"></span>
Refresh
</button>
</div>
<div id="gridContainerTrending" class="grid-container"></div>
<div id="paginationTrending" class="pagination"></div>
</div>
<!-- Latest Releases Tab Content -->
<div id="latestTab" class="tab-content">
<div class="search-bar">
<input type="text" id="searchInputLatest" placeholder="Search by name, owner, or description..." />
<button id="refreshButtonLatest" class="refresh-btn">
<span class="refresh-icon"></span>
Refresh
</button>
</div>
<div id="gridContainerLatest" class="grid-container"></div>
<div id="paginationLatest" class="pagination"></div>
</div>
<!-- Fixed Tab Content (기존 예시 유지) -->
<div id="fixedTab" class="tab-content">
<div id="fixedGrid" class="grid-container"></div>
</div>
</div>
</div>
</div>
<div id="loadingIndicator" class="loading">
<div class="loading-content">
<div class="loading-spinner"></div>
<div class="loading-text">Loading Zero-GPU spaces...</div>
<div id="loadingError" class="loading-error">
If this takes too long, try refreshing the page.
</div>
</div>
</div>
<script>
// ------------------------------------
// GLOBAL STATE & COMMON FUNCTIONS
// ------------------------------------
const globalState = {
isLoading: false,
loadingTimeout: null,
};
function setLoading(isLoading) {
globalState.isLoading = isLoading;
document.getElementById('loadingIndicator').style.display = isLoading ? 'flex' : 'none';
const refreshButtons = document.querySelectorAll('.refresh-btn');
refreshButtons.forEach(btn => {
if (isLoading) {
btn.classList.add('refreshing');
} else {
btn.classList.remove('refreshing');
}
});
if (isLoading) {
clearTimeout(globalState.loadingTimeout);
globalState.loadingTimeout = setTimeout(() => {
document.getElementById('loadingError').style.display = 'block';
}, 10000);
} else {
clearTimeout(globalState.loadingTimeout);
document.getElementById('loadingError').style.display = 'none';
}
}
function handleIframeError(iframe, owner, name, title) {
const container = iframe.parentNode;
const errorPlaceholder = document.createElement('div');
errorPlaceholder.className = 'error-placeholder';
const errorMessage = document.createElement('p');
errorMessage.textContent = `"${title}" space couldn't be loaded`;
errorPlaceholder.appendChild(errorMessage);
const directLink = document.createElement('a');
directLink.href = `https://huggingface.co/spaces/${owner}/${name}`;
directLink.target = '_blank';
directLink.textContent = 'Visit HF Space';
directLink.style.color = '#3182ce';
directLink.style.marginTop = '10px';
directLink.style.display = 'inline-block';
directLink.style.padding = '8px 16px';
directLink.style.background = '#ebf8ff';
directLink.style.borderRadius = '5px';
directLink.style.fontWeight = '600';
errorPlaceholder.appendChild(directLink);
iframe.style.display = 'none';
container.appendChild(errorPlaceholder);
}
// ------------------------------------
// IFRAME LOADER (공통)
// ------------------------------------
const iframeLoader = {
checkQueue: {},
maxAttempts: 5,
checkInterval: 5000,
startChecking: function(iframe, owner, name, title, spaceKey) {
this.checkQueue[spaceKey] = {
iframe: iframe,
owner: owner,
name: name,
title: title,
attempts: 0,
status: 'loading'
};
this.checkIframeStatus(spaceKey);
},
checkIframeStatus: function(spaceKey) {
if (!this.checkQueue[spaceKey]) return;
const item = this.checkQueue[spaceKey];
if (item.status !== 'loading') {
delete this.checkQueue[spaceKey];
return;
}
item.attempts++;
try {
if (!item.iframe || !item.iframe.parentNode) {
delete this.checkQueue[spaceKey];
return;
}
// Check if content loaded
try {
const hasContent = item.iframe.contentWindow &&
item.iframe.contentWindow.document &&
item.iframe.contentWindow.document.body;
if (hasContent && item.iframe.contentWindow.document.body.innerHTML.length > 100) {
const bodyText = item.iframe.contentWindow.document.body.textContent.toLowerCase();
if (bodyText.includes('forbidden') || bodyText.includes('404') ||
bodyText.includes('not found') || bodyText.includes('error')) {
item.status = 'error';
handleIframeError(item.iframe, item.owner, item.name, item.title);
} else {
item.status = 'success';
}
delete this.checkQueue[spaceKey];
return;
}
} catch(e) {
// Cross-origin issues can happen; not always an error
}
// Check if iframe is visible
const rect = item.iframe.getBoundingClientRect();
if (rect.width > 50 && rect.height > 50 && item.attempts > 2) {
item.status = 'success';
delete this.checkQueue[spaceKey];
return;
}
// If max attempts reached
if (item.attempts >= this.maxAttempts) {
if (item.iframe.offsetWidth > 0 && item.iframe.offsetHeight > 0) {
item.status = 'success';
} else {
item.status = 'error';
handleIframeError(item.iframe, item.owner, item.name, item.title);
}
delete this.checkQueue[spaceKey];
return;
}
// Re-check after some delay
const nextDelay = this.checkInterval * Math.pow(1.5, item.attempts - 1);
setTimeout(() => this.checkIframeStatus(spaceKey), nextDelay);
} catch (e) {
console.error('Error checking iframe status:', e);
if (item.attempts >= this.maxAttempts) {
item.status = 'error';
handleIframeError(item.iframe, item.owner, item.name, item.title);
delete this.checkQueue[spaceKey];
} else {
setTimeout(() => this.checkIframeStatus(spaceKey), this.checkInterval);
}
}
}
};
// ------------------------------------
// TRENDING TAB
// ------------------------------------
const trendingState = {
spaces: [],
currentPage: 0,
itemsPerPage: 72,
totalItems: 0,
topOwners: [],
iframeStatuses: {}
};
const trendingElements = {
searchInput: document.getElementById('searchInputTrending'),
refreshButton: document.getElementById('refreshButtonTrending'),
gridContainer: document.getElementById('gridContainerTrending'),
pagination: document.getElementById('paginationTrending'),
statsToggle: document.getElementById('statsToggle'),
statsContent: document.getElementById('statsContent'),
creatorStatsChart: document.getElementById('creatorStatsChart')
};
let chartInstance = null;
trendingElements.statsToggle.addEventListener('click', () => {
const isOpen = trendingElements.statsContent.classList.toggle('open');
trendingElements.statsToggle.textContent = isOpen ? 'Hide Stats' : 'Show Stats';
if (isOpen && trendingState.topOwners.length > 0) {
renderCreatorStats(trendingState.topOwners);
}
});
function renderCreatorStats(topOwners) {
if (chartInstance) {
chartInstance.destroy();
}
const ctx = trendingElements.creatorStatsChart.getContext('2d');
const labels = topOwners.map(item => item[0]);
const data = topOwners.map(item => item[1]);
const colors = [];
for (let i = 0; i < labels.length; i++) {
const hue = (i * 360 / labels.length) % 360;
colors.push(`hsla(${hue}, 70%, 80%, 0.7)`);
}
chartInstance = new Chart(ctx, {
type: 'bar',
data: {
labels: labels,
datasets: [{
label: 'Number of Spaces in Top 500',
data: data,
backgroundColor: colors,
borderColor: colors.map(color => color.replace('0.7', '1')),
borderWidth: 1
}]
},
options: {
indexAxis: 'y',
responsive: true,
maintainAspectRatio: false,
plugins: {
legend: { display: false },
tooltip: {
callbacks: {
title: function(tooltipItems) {
return tooltipItems[0].label;
},
label: function(context) {
return `Spaces: ${context.raw}`;
}
}
}
},
scales: {
x: {
beginAtZero: true,
title: {
display: true,
text: 'Number of Spaces'
}
},
y: {
title: {
display: true,
text: 'Creator ID'
},
ticks: {
autoSkip: false,
font: function(context) {
const defaultSize = 11;
return {
size: labels.length > 20 ? defaultSize - 1 : defaultSize
};
}
}
}
}
}
});
}
async function loadTrending(page=0) {
setLoading(true);
try {
const searchText = trendingElements.searchInput.value;
const offset = page * trendingState.itemsPerPage;
const timeoutPromise = new Promise((_, reject) =>
setTimeout(() => reject(new Error('Request timeout')), 30000)
);
const fetchPromise = fetch(
`/api/trending-spaces?search=${encodeURIComponent(searchText)}&offset=${offset}&limit=${trendingState.itemsPerPage}`
);
const response = await Promise.race([fetchPromise, timeoutPromise]);
const data = await response.json();
trendingState.spaces = data.spaces || [];
trendingState.totalItems = data.total || 0;
trendingState.currentPage = page;
trendingState.topOwners = data.top_owners || [];
renderTrendingGrid(trendingState.spaces);
renderTrendingPagination();
// 통계창 열려있다면 새 데이터로 갱신
if (trendingElements.statsContent.classList.contains('open') && trendingState.topOwners.length > 0) {
renderCreatorStats(trendingState.topOwners);
}
} catch (error) {
console.error('Error loading trending spaces:', error);
trendingElements.gridContainer.innerHTML = `
<div style="grid-column: 1/-1; text-align: center; padding: 40px;">
<div style="font-size: 3rem; margin-bottom: 20px;">⚠️</div>
<h3 style="margin-bottom: 10px;">Unable to load spaces (Trending)</h3>
<p style="color: #666;">Please try refreshing the page. If the problem persists, try again later.</p>
<button id="retryTrendingButton" style="margin-top: 20px; padding: 10px 20px; background: var(--pastel-purple); border: none; border-radius: 5px; cursor: pointer;">
Try Again
</button>
</div>
`;
document.getElementById('retryTrendingButton')?.addEventListener('click', () => loadTrending(0));
renderTrendingPagination();
} finally {
setLoading(false);
}
}
function renderTrendingGrid(spaces) {
trendingElements.gridContainer.innerHTML = '';
if (!spaces || spaces.length === 0) {
const noResultsMsg = document.createElement('p');
noResultsMsg.textContent = 'No zero-gpu spaces found matching your search.';
noResultsMsg.style.padding = '2rem';
noResultsMsg.style.textAlign = 'center';
noResultsMsg.style.fontStyle = 'italic';
noResultsMsg.style.color = '#718096';
trendingElements.gridContainer.appendChild(noResultsMsg);
return;
}
spaces.forEach((item) => {
try {
const {
url, title, likes_count, owner, name, rank,
description, avatar_url, author_name, embedUrl
} = item;
const gridItem = document.createElement('div');
gridItem.className = 'grid-item';
// 상단 헤더
const headerDiv = document.createElement('div');
headerDiv.className = 'grid-header';
const spaceHeader = document.createElement('div');
spaceHeader.className = 'space-header';
const rankBadge = document.createElement('div');
rankBadge.className = 'rank-badge';
rankBadge.textContent = `#${rank}`;
spaceHeader.appendChild(rankBadge);
const titleWrapper = document.createElement('div');
titleWrapper.style.display = 'flex';
titleWrapper.style.alignItems = 'center';
titleWrapper.style.marginLeft = '8px';
const titleEl = document.createElement('h3');
titleEl.className = 'space-title';
titleEl.textContent = title;
titleEl.title = title;
titleWrapper.appendChild(titleEl);
const zeroGpuBadge = document.createElement('span');
zeroGpuBadge.className = 'zero-gpu-badge';
zeroGpuBadge.textContent = 'ZERO GPU';
titleWrapper.appendChild(zeroGpuBadge);
spaceHeader.appendChild(titleWrapper);
headerDiv.appendChild(spaceHeader);
const metaInfo = document.createElement('div');
metaInfo.className = 'grid-meta';
metaInfo.style.display = 'flex';
metaInfo.style.justifyContent = 'space-between';
metaInfo.style.alignItems = 'center';
metaInfo.style.marginTop = '6px';
const leftMeta = document.createElement('div');
const authorSpan = document.createElement('span');
authorSpan.className = 'author-name';
authorSpan.style.marginLeft = '8px';
authorSpan.textContent = `by ${author_name}`;
leftMeta.appendChild(authorSpan);
metaInfo.appendChild(leftMeta);
const likesDiv = document.createElement('div');
likesDiv.className = 'likes-wrapper';
likesDiv.innerHTML = `<span class="likes-heart">♥</span><span>${likes_count}</span>`;
metaInfo.appendChild(likesDiv);
headerDiv.appendChild(metaInfo);
gridItem.appendChild(headerDiv);
if (description) {
const descP = document.createElement('p');
descP.className = 'desc-text';
descP.textContent = description;
gridItem.appendChild(descP);
}
const content = document.createElement('div');
content.className = 'grid-content';
const iframeContainer = document.createElement('div');
iframeContainer.className = 'iframe-container';
const iframe = document.createElement('iframe');
iframe.src = embedUrl;
iframe.title = title;
iframe.allow = 'accelerometer; camera; encrypted-media; geolocation; gyroscope;';
iframe.setAttribute('allowfullscreen', '');
iframe.setAttribute('frameborder', '0');
iframe.loading = 'lazy';
const spaceKey = `${owner}/${name}`;
trendingState.iframeStatuses[spaceKey] = 'loading';
iframe.onload = function() {
iframeLoader.startChecking(iframe, owner, name, title, spaceKey);
};
iframe.onerror = function() {
handleIframeError(iframe, owner, name, title);
trendingState.iframeStatuses[spaceKey] = 'error';
};
setTimeout(() => {
if (trendingState.iframeStatuses[spaceKey] === 'loading') {
handleIframeError(iframe, owner, name, title);
trendingState.iframeStatuses[spaceKey] = 'error';
}
}, 30000);
iframeContainer.appendChild(iframe);
content.appendChild(iframeContainer);
const actions = document.createElement('div');
actions.className = 'grid-actions';
const linkEl = document.createElement('a');
linkEl.href = url;
linkEl.target = '_blank';
linkEl.className = 'open-link';
linkEl.textContent = 'Open in new window';
actions.appendChild(linkEl);
gridItem.appendChild(content);
gridItem.appendChild(actions);
trendingElements.gridContainer.appendChild(gridItem);
} catch (err) {
console.error('Item rendering error:', err);
}
});
}
function renderTrendingPagination() {
trendingElements.pagination.innerHTML = '';
const totalPages = Math.ceil(trendingState.totalItems / trendingState.itemsPerPage);
// Previous page
const prevButton = document.createElement('button');
prevButton.className = 'pagination-button';
prevButton.textContent = 'Previous';
prevButton.disabled = (trendingState.currentPage === 0);
prevButton.addEventListener('click', () => {
if (trendingState.currentPage > 0) {
loadTrending(trendingState.currentPage - 1);
}
});
trendingElements.pagination.appendChild(prevButton);
const maxButtons = 7;
let startPage = Math.max(0, trendingState.currentPage - Math.floor(maxButtons / 2));
let endPage = Math.min(totalPages - 1, startPage + maxButtons - 1);
if (endPage - startPage + 1 < maxButtons) {
startPage = Math.max(0, endPage - maxButtons + 1);
}
for (let i = startPage; i <= endPage; i++) {
const pageButton = document.createElement('button');
pageButton.className = 'pagination-button' + (i === trendingState.currentPage ? ' active' : '');
pageButton.textContent = (i + 1);
pageButton.addEventListener('click', () => {
if (i !== trendingState.currentPage) {
loadTrending(i);
}
});
trendingElements.pagination.appendChild(pageButton);
}
// Next page
const nextButton = document.createElement('button');
nextButton.className = 'pagination-button';
nextButton.textContent = 'Next';
nextButton.disabled = (trendingState.currentPage >= totalPages - 1);
nextButton.addEventListener('click', () => {
if (trendingState.currentPage < totalPages - 1) {
loadTrending(trendingState.currentPage + 1);
}
});
trendingElements.pagination.appendChild(nextButton);
}
// ------------------------------------
// LATEST RELEASES TAB
// ------------------------------------
const latestState = {
spaces: [],
currentPage: 0,
itemsPerPage: 72,
totalItems: 0,
iframeStatuses: {}
};
const latestElements = {
searchInput: document.getElementById('searchInputLatest'),
refreshButton: document.getElementById('refreshButtonLatest'),
gridContainer: document.getElementById('gridContainerLatest'),
pagination: document.getElementById('paginationLatest')
};
async function loadLatest(page=0) {
setLoading(true);
try {
const searchText = latestElements.searchInput.value;
const offset = page * latestState.itemsPerPage;
const timeoutPromise = new Promise((_, reject) =>
setTimeout(() => reject(new Error('Request timeout')), 30000)
);
const fetchPromise = fetch(
`/api/latest-spaces?search=${encodeURIComponent(searchText)}&offset=${offset}&limit=${latestState.itemsPerPage}`
);
const response = await Promise.race([fetchPromise, timeoutPromise]);
const data = await response.json();
latestState.spaces = data.spaces || [];
latestState.totalItems = data.total || 0;
latestState.currentPage = page;
renderLatestGrid(latestState.spaces);
renderLatestPagination();
} catch (error) {
console.error('Error loading latest spaces:', error);
latestElements.gridContainer.innerHTML = `
<div style="grid-column: 1/-1; text-align: center; padding: 40px;">
<div style="font-size: 3rem; margin-bottom: 20px;">⚠️</div>
<h3 style="margin-bottom: 10px;">Unable to load spaces (Latest)</h3>
<p style="color: #666;">Please try refreshing the page. If the problem persists, try again later.</p>
<button id="retryLatestButton" style="margin-top: 20px; padding: 10px 20px; background: var(--pastel-purple); border: none; border-radius: 5px; cursor: pointer;">
Try Again
</button>
</div>
`;
document.getElementById('retryLatestButton')?.addEventListener('click', () => loadLatest(0));
renderLatestPagination();
} finally {
setLoading(false);
}
}
function renderLatestGrid(spaces) {
latestElements.gridContainer.innerHTML = '';
if (!spaces || spaces.length === 0) {
const noResultsMsg = document.createElement('p');
noResultsMsg.textContent = 'No zero-gpu spaces found matching your search.';
noResultsMsg.style.padding = '2rem';
noResultsMsg.style.textAlign = 'center';
noResultsMsg.style.fontStyle = 'italic';
noResultsMsg.style.color = '#718096';
latestElements.gridContainer.appendChild(noResultsMsg);
return;
}
spaces.forEach((item, index) => {
try {
const {
url, title, likes_count, owner, name, rank,
description, avatar_url, author_name, embedUrl
} = item;
// rank가 없으므로 Latest 탭에서는 offset+index+1 형태로 표시
const computedRank = latestState.currentPage * latestState.itemsPerPage + (index + 1);
const gridItem = document.createElement('div');
gridItem.className = 'grid-item';
// 상단 헤더
const headerDiv = document.createElement('div');
headerDiv.className = 'grid-header';
const spaceHeader = document.createElement('div');
spaceHeader.className = 'space-header';
const rankBadge = document.createElement('div');
rankBadge.className = 'rank-badge';
rankBadge.textContent = `#${computedRank}`;
spaceHeader.appendChild(rankBadge);
const titleWrapper = document.createElement('div');
titleWrapper.style.display = 'flex';
titleWrapper.style.alignItems = 'center';
titleWrapper.style.marginLeft = '8px';
const titleEl = document.createElement('h3');
titleEl.className = 'space-title';
titleEl.textContent = title;
titleEl.title = title;
titleWrapper.appendChild(titleEl);
const zeroGpuBadge = document.createElement('span');
zeroGpuBadge.className = 'zero-gpu-badge';
zeroGpuBadge.textContent = 'ZERO GPU';
titleWrapper.appendChild(zeroGpuBadge);
spaceHeader.appendChild(titleWrapper);
headerDiv.appendChild(spaceHeader);
const metaInfo = document.createElement('div');
metaInfo.className = 'grid-meta';
metaInfo.style.display = 'flex';
metaInfo.style.justifyContent = 'space-between';
metaInfo.style.alignItems = 'center';
metaInfo.style.marginTop = '6px';
const leftMeta = document.createElement('div');
const authorSpan = document.createElement('span');
authorSpan.className = 'author-name';
authorSpan.style.marginLeft = '8px';
authorSpan.textContent = `by ${author_name}`;
leftMeta.appendChild(authorSpan);
metaInfo.appendChild(leftMeta);
const likesDiv = document.createElement('div');
likesDiv.className = 'likes-wrapper';
likesDiv.innerHTML = `<span class="likes-heart">♥</span><span>${likes_count}</span>`;
metaInfo.appendChild(likesDiv);
headerDiv.appendChild(metaInfo);
gridItem.appendChild(headerDiv);
if (description) {
const descP = document.createElement('p');
descP.className = 'desc-text';
descP.textContent = description;
gridItem.appendChild(descP);
}
const content = document.createElement('div');
content.className = 'grid-content';
const iframeContainer = document.createElement('div');
iframeContainer.className = 'iframe-container';
const iframe = document.createElement('iframe');
iframe.src = embedUrl;
iframe.title = title;
iframe.allow = 'accelerometer; camera; encrypted-media; geolocation; gyroscope;';
iframe.setAttribute('allowfullscreen', '');
iframe.setAttribute('frameborder', '0');
iframe.loading = 'lazy';
const spaceKey = `${owner}/${name}`;
latestState.iframeStatuses[spaceKey] = 'loading';
iframe.onload = function() {
iframeLoader.startChecking(iframe, owner, name, title, spaceKey);
};
iframe.onerror = function() {
handleIframeError(iframe, owner, name, title);
latestState.iframeStatuses[spaceKey] = 'error';
};
setTimeout(() => {
if (latestState.iframeStatuses[spaceKey] === 'loading') {
handleIframeError(iframe, owner, name, title);
latestState.iframeStatuses[spaceKey] = 'error';
}
}, 30000);
iframeContainer.appendChild(iframe);
content.appendChild(iframeContainer);
const actions = document.createElement('div');
actions.className = 'grid-actions';
const linkEl = document.createElement('a');
linkEl.href = url;
linkEl.target = '_blank';
linkEl.className = 'open-link';
linkEl.textContent = 'Open in new window';
actions.appendChild(linkEl);
gridItem.appendChild(content);
gridItem.appendChild(actions);
latestElements.gridContainer.appendChild(gridItem);
} catch (err) {
console.error('Item rendering error (Latest):', err);
}
});
}
function renderLatestPagination() {
latestElements.pagination.innerHTML = '';
const totalPages = Math.ceil(latestState.totalItems / latestState.itemsPerPage);
// Previous page
const prevButton = document.createElement('button');
prevButton.className = 'pagination-button';
prevButton.textContent = 'Previous';
prevButton.disabled = (latestState.currentPage === 0);
prevButton.addEventListener('click', () => {
if (latestState.currentPage > 0) {
loadLatest(latestState.currentPage - 1);
}
});
latestElements.pagination.appendChild(prevButton);
const maxButtons = 7;
let startPage = Math.max(0, latestState.currentPage - Math.floor(maxButtons / 2));
let endPage = Math.min(totalPages - 1, startPage + maxButtons - 1);
if (endPage - startPage + 1 < maxButtons) {
startPage = Math.max(0, endPage - maxButtons + 1);
}
for (let i = startPage; i <= endPage; i++) {
const pageButton = document.createElement('button');
pageButton.className = 'pagination-button' + (i === latestState.currentPage ? ' active' : '');
pageButton.textContent = (i + 1);
pageButton.addEventListener('click', () => {
if (i !== latestState.currentPage) {
loadLatest(i);
}
});
latestElements.pagination.appendChild(pageButton);
}
// Next page
const nextButton = document.createElement('button');
nextButton.className = 'pagination-button';
nextButton.textContent = 'Next';
nextButton.disabled = (latestState.currentPage >= totalPages - 1);
nextButton.addEventListener('click', () => {
if (latestState.currentPage < totalPages - 1) {
loadLatest(latestState.currentPage + 1);
}
});
latestElements.pagination.appendChild(nextButton);
}
// ------------------------------------
// FIXED TAB
// ------------------------------------
const fixedGridContainer = document.getElementById('fixedGrid');
function renderFixedGrid() {
fixedGridContainer.innerHTML = '';
const staticSpaces = [
{
url: "https://huggingface.co/spaces/ginipick/spaces-research-think",
title: "Spaces Research Analysis",
likes_count: 0,
owner: "ginipick",
name: "3D-LLAMA",
rank: 1
},
{
url: "https://huggingface.co/spaces/ginipick/spaces-research-think",
title: "Spaces Research ",
likes_count: 0,
owner: "ginipick",
name: "3D-LLAMA",
rank: 2
},
{
url: "https://huggingface.co/spaces/ginigen/3D-LLAMA",
title: "3D-LLAMA",
likes_count: 999,
owner: "ginigen",
name: "3D-LLAMA",
rank: 3
},
];
if (!staticSpaces || staticSpaces.length === 0) {
const noResultsMsg = document.createElement('p');
noResultsMsg.textContent = 'No spaces to display.';
noResultsMsg.style.padding = '2rem';
noResultsMsg.style.textAlign = 'center';
noResultsMsg.style.fontStyle = 'italic';
noResultsMsg.style.color = '#718096';
fixedGridContainer.appendChild(noResultsMsg);
return;
}
staticSpaces.forEach((item) => {
try {
const { url, title, likes_count, owner, name, rank } = item;
const gridItem = document.createElement('div');
gridItem.className = 'grid-item';
const header = document.createElement('div');
header.className = 'grid-header';
const headerTop = document.createElement('div');
headerTop.className = 'grid-header-top';
// 로봇 이모지 + 타이틀 함께 표시
const leftWrapper = document.createElement('div');
leftWrapper.style.display = 'flex';
leftWrapper.style.alignItems = 'center';
const emojiAvatar = document.createElement('div');
emojiAvatar.className = 'emoji-avatar';
emojiAvatar.textContent = '🤖';
leftWrapper.appendChild(emojiAvatar);
const titleEl = document.createElement('h3');
titleEl.textContent = title;
titleEl.title = title;
leftWrapper.appendChild(titleEl);
headerTop.appendChild(leftWrapper);
const rankBadge = document.createElement('div');
rankBadge.className = 'rank-badge';
rankBadge.textContent = `#${rank}`;
headerTop.appendChild(rankBadge);
header.appendChild(headerTop);
const metaInfo = document.createElement('div');
metaInfo.className = 'grid-meta';
const ownerEl = document.createElement('div');
ownerEl.className = 'owner-info';
ownerEl.textContent = `by ${owner}`;
metaInfo.appendChild(ownerEl);
const likesCounter = document.createElement('div');
likesCounter.className = 'likes-counter';
likesCounter.innerHTML = '♥ <span>' + likes_count + '</span>';
metaInfo.appendChild(likesCounter);
header.appendChild(metaInfo);
gridItem.appendChild(header);
const content = document.createElement('div');
content.className = 'grid-content';
const iframeContainer = document.createElement('div');
iframeContainer.className = 'iframe-container';
const iframe = document.createElement('iframe');
iframe.src = "https://" + owner.toLowerCase() + "-" + name.toLowerCase() + ".hf.space";
iframe.title = title;
iframe.allow = 'accelerometer; camera; encrypted-media; geolocation; gyroscope;';
iframe.setAttribute('allowfullscreen', '');
iframe.setAttribute('frameborder', '0');
iframe.loading = 'lazy';
iframe.onload = function() {
iframeLoader.startChecking(iframe, owner, name, title, `${owner}/${name}`);
};
iframe.onerror = function() {
handleIframeError(iframe, owner, name, title);
};
setTimeout(() => {
if (iframe.offsetWidth === 0 || iframe.offsetHeight === 0) {
handleIframeError(iframe, owner, name, title);
}
}, 30000);
iframeContainer.appendChild(iframe);
content.appendChild(iframeContainer);
const actions = document.createElement('div');
actions.className = 'grid-actions';
const linkEl = document.createElement('a');
linkEl.href = url;
linkEl.target = '_blank';
linkEl.className = 'open-link';
linkEl.textContent = 'Open in new window';
actions.appendChild(linkEl);
gridItem.appendChild(content);
gridItem.appendChild(actions);
fixedGridContainer.appendChild(gridItem);
} catch (error) {
console.error('Fixed tab rendering error:', error);
}
});
}
// ------------------------------------
// TAB HANDLERS
// ------------------------------------
const tabTrendingButton = document.getElementById('tabTrendingButton');
const tabLatestButton = document.getElementById('tabLatestButton');
const tabFixedButton = document.getElementById('tabFixedButton');
const trendingTab = document.getElementById('trendingTab');
const latestTab = document.getElementById('latestTab');
const fixedTab = document.getElementById('fixedTab');
tabTrendingButton.addEventListener('click', () => {
tabTrendingButton.classList.add('active');
tabLatestButton.classList.remove('active');
tabFixedButton.classList.remove('active');
trendingTab.classList.add('active');
latestTab.classList.remove('active');
fixedTab.classList.remove('active');
loadTrending(trendingState.currentPage);
});
tabLatestButton.addEventListener('click', () => {
tabLatestButton.classList.add('active');
tabTrendingButton.classList.remove('active');
tabFixedButton.classList.remove('active');
latestTab.classList.add('active');
trendingTab.classList.remove('active');
fixedTab.classList.remove('active');
loadLatest(latestState.currentPage);
});
tabFixedButton.addEventListener('click', () => {
tabFixedButton.classList.add('active');
tabTrendingButton.classList.remove('active');
tabLatestButton.classList.remove('active');
fixedTab.classList.add('active');
trendingTab.classList.remove('active');
latestTab.classList.remove('active');
renderFixedGrid();
});
// ------------------------------------
// EVENT LISTENERS
// ------------------------------------
trendingElements.searchInput.addEventListener('input', () => {
clearTimeout(trendingState.searchTimeout);
trendingState.searchTimeout = setTimeout(() => loadTrending(0), 300);
});
trendingElements.searchInput.addEventListener('keyup', (event) => {
if (event.key === 'Enter') {
loadTrending(0);
}
});
trendingElements.refreshButton.addEventListener('click', () => loadTrending(0));
latestElements.searchInput.addEventListener('input', () => {
clearTimeout(latestState.searchTimeout);
latestState.searchTimeout = setTimeout(() => loadLatest(0), 300);
});
latestElements.searchInput.addEventListener('keyup', (event) => {
if (event.key === 'Enter') {
loadLatest(0);
}
});
latestElements.refreshButton.addEventListener('click', () => loadLatest(0));
window.addEventListener('load', function() {
// 첫 진입시 Trending 탭 먼저 로드
setTimeout(() => loadTrending(0), 500);
});
setTimeout(() => {
if (globalState.isLoading) {
setLoading(false);
// 타임아웃 시 메시지 표시
trendingElements.gridContainer.innerHTML = `
<div style="grid-column: 1/-1; text-align: center; padding: 40px;">
<div style="font-size: 3rem; margin-bottom: 20px;">⏱️</div>
<h3 style="margin-bottom: 10px;">Loading is taking longer than expected</h3>
<p style="color: #666;">Please try refreshing the page.</p>
<button onClick="window.location.reload()" style="margin-top: 20px; padding: 10px 20px; background: var(--pastel-purple); border: none; border-radius: 5px; cursor: pointer;">
Reload Page
</button>
</div>
`;
}
}, 20000);
</script>
</body>
</html>
''')
app.run(host='0.0.0.0', port=7860)