openfree commited on
Commit
23bcd62
·
verified ·
1 Parent(s): cab4b44

Create app.py

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