seawolf2357 commited on
Commit
03dcabd
·
verified ·
1 Parent(s): cf37bae

Update app.py

Browse files
Files changed (1) hide show
  1. app.py +120 -305
app.py CHANGED
@@ -1,11 +1,9 @@
1
- from flask import Flask, render_template, request, redirect, url_for, jsonify, session
2
  import requests
3
  import os
4
- from datetime import timedelta
5
 
6
  app = Flask(__name__)
7
- app.secret_key = os.urandom(24) # Session encryption key
8
- app.permanent_session_lifetime = timedelta(days=7) # Session duration
9
 
10
  # Huggingface URL list
11
  HUGGINGFACE_URLS = [
@@ -38,79 +36,45 @@ def transform_url(url):
38
  return f"https://{rest.replace('/', '-')}.hf.space"
39
  return url
40
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
41
  # Extract title from the last part of URL
42
  def extract_title(url):
43
  parts = url.split("/")
44
  title = parts[-1] if parts else ""
45
  return title.replace("_", " ").replace("-", " ")
46
 
47
- # Huggingface token validation
48
- def validate_token(token):
49
- headers = {"Authorization": f"Bearer {token}"}
50
-
51
- # Try whoami-v2 endpoint first
52
- try:
53
- response = requests.get("https://huggingface.co/api/whoami-v2", headers=headers)
54
- if response.ok:
55
- return True, response.json()
56
- except Exception as e:
57
- print(f"whoami-v2 token validation error: {e}")
58
-
59
- # Try the original whoami endpoint
60
- try:
61
- response = requests.get("https://huggingface.co/api/whoami", headers=headers)
62
- if response.ok:
63
- return True, response.json()
64
- except Exception as e:
65
- print(f"whoami token validation error: {e}")
66
-
67
- return False, None
68
 
69
  # Homepage route
70
  @app.route('/')
71
  def home():
72
  return render_template('index.html')
73
 
74
- # Login API
75
- @app.route('/api/login', methods=['POST'])
76
- def login():
77
- token = request.form.get('token', '')
78
-
79
- if not token:
80
- return jsonify({'success': False, 'message': '토큰을 입력해주세요.'})
81
-
82
- is_valid, user_info = validate_token(token)
83
-
84
- if not is_valid or not user_info:
85
- return jsonify({'success': False, 'message': '유효하지 않은 토큰입니다.'})
86
-
87
- # Find username
88
- username = None
89
- if 'name' in user_info:
90
- username = user_info['name']
91
- elif 'user' in user_info and 'username' in user_info['user']:
92
- username = user_info['user']['username']
93
- elif 'username' in user_info:
94
- username = user_info['username']
95
- else:
96
- username = '인증된 사용자'
97
-
98
- # Save to session
99
- session['token'] = token
100
- session['username'] = username
101
-
102
- return jsonify({
103
- 'success': True,
104
- 'username': username
105
- })
106
-
107
- # Logout API
108
- @app.route('/api/logout', methods=['POST'])
109
- def logout():
110
- session.pop('token', None)
111
- session.pop('username', None)
112
- return jsonify({'success': True})
113
-
114
  # URL list API
115
  @app.route('/api/urls', methods=['GET'])
116
  def get_urls():
@@ -119,27 +83,29 @@ def get_urls():
119
  results = []
120
  for url in HUGGINGFACE_URLS:
121
  title = extract_title(url)
 
122
  transformed_url = transform_url(url)
123
 
 
 
 
124
  if search_query and search_query not in url.lower() and search_query not in title.lower():
125
  continue
126
 
 
 
 
127
  results.append({
128
  'url': url,
129
  'embedUrl': transformed_url,
130
- 'title': title
 
 
 
131
  })
132
 
133
  return jsonify(results)
134
 
135
- # Session status API
136
- @app.route('/api/session-status', methods=['GET'])
137
- def session_status():
138
- return jsonify({
139
- 'logged_in': 'token' in session,
140
- 'username': session.get('username')
141
- })
142
-
143
  if __name__ == '__main__':
144
  # Create templates folder
145
  os.makedirs('templates', exist_ok=True)
@@ -175,13 +141,13 @@ if __name__ == '__main__':
175
  border-radius: 8px;
176
  margin-bottom: 1rem;
177
  box-shadow: 0 2px 10px rgba(0,0,0,0.05);
 
178
  }
179
 
180
- .user-controls {
181
- display: flex;
182
- justify-content: space-between;
183
- align-items: center;
184
- flex-wrap: wrap;
185
  }
186
 
187
  .filter-controls {
@@ -190,54 +156,18 @@ if __name__ == '__main__':
190
  border-radius: 8px;
191
  margin-bottom: 1rem;
192
  box-shadow: 0 2px 10px rgba(0,0,0,0.05);
 
 
 
193
  }
194
 
195
- input[type="password"],
196
  input[type="text"] {
197
  padding: 0.7rem;
198
  border: 1px solid #ddd;
199
  border-radius: 4px;
200
  margin-right: 5px;
201
  font-size: 1rem;
202
- width: 250px;
203
- }
204
-
205
- button {
206
- padding: 0.7rem 1.2rem;
207
- background-color: #4CAF50;
208
- color: white;
209
- border: none;
210
- border-radius: 4px;
211
- cursor: pointer;
212
- font-size: 1rem;
213
- transition: background-color 0.2s;
214
- }
215
-
216
- button:hover {
217
- background-color: #45a049;
218
- }
219
-
220
- button.logout {
221
- background-color: #f44336;
222
- }
223
-
224
- button.logout:hover {
225
- background-color: #d32f2f;
226
- }
227
-
228
- .token-help {
229
- margin-top: 0.5rem;
230
- font-size: 0.9rem;
231
- color: #666;
232
- }
233
-
234
- .token-help a {
235
- color: #4CAF50;
236
- text-decoration: none;
237
- }
238
-
239
- .token-help a:hover {
240
- text-decoration: underline;
241
  }
242
 
243
  .grid-container {
@@ -254,6 +184,7 @@ if __name__ == '__main__':
254
  display: flex;
255
  flex-direction: column;
256
  height: 600px;
 
257
  }
258
 
259
  .grid-header {
@@ -263,6 +194,12 @@ if __name__ == '__main__':
263
  display: flex;
264
  justify-content: space-between;
265
  align-items: center;
 
 
 
 
 
 
266
  }
267
 
268
  .grid-header h3 {
@@ -275,10 +212,33 @@ if __name__ == '__main__':
275
  text-overflow: ellipsis;
276
  }
277
 
 
 
 
 
 
 
 
 
 
 
 
278
  .open-link {
279
  color: #4CAF50;
280
  text-decoration: none;
281
  font-size: 0.9rem;
 
 
 
 
 
 
 
 
 
 
 
 
282
  }
283
 
284
  .grid-content {
@@ -296,24 +256,6 @@ if __name__ == '__main__':
296
  border: none;
297
  }
298
 
299
- .status-message {
300
- padding: 1rem;
301
- border-radius: 8px;
302
- margin-bottom: 1rem;
303
- display: none;
304
- box-shadow: 0 2px 10px rgba(0,0,0,0.05);
305
- }
306
-
307
- .success {
308
- background-color: #dff0d8;
309
- color: #3c763d;
310
- }
311
-
312
- .error {
313
- background-color: #f2dede;
314
- color: #a94442;
315
- }
316
-
317
  .loading {
318
  position: fixed;
319
  top: 0;
@@ -342,60 +284,37 @@ if __name__ == '__main__':
342
  100% { transform: rotate(360deg); }
343
  }
344
 
345
- .login-section {
346
- margin-top: 1rem;
347
- }
348
-
349
- .logged-in-section {
350
- display: none;
351
- margin-top: 1rem;
352
- }
353
-
354
  @media (max-width: 768px) {
355
- .user-controls {
356
  flex-direction: column;
357
  align-items: flex-start;
358
  }
359
 
360
- .user-controls > div {
361
- margin-bottom: 1rem;
 
362
  }
363
 
364
  .grid-container {
365
  grid-template-columns: 1fr;
366
  }
 
 
 
 
367
  }
368
  </style>
369
  </head>
370
  <body>
371
  <div class="container">
372
  <div class="header">
373
- <div class="user-controls">
374
- <div>
375
- <span>허깅페이스 계정: </span>
376
- <span id="currentUser">로그인되지 않음</span>
377
- </div>
378
-
379
- <div id="loginSection" class="login-section">
380
- <input type="password" id="tokenInput" placeholder="허깅페이스 API 토큰 입력" />
381
- <button id="loginButton">인증하기</button>
382
- <div class="token-help">
383
- API 토큰은 <a href="https://huggingface.co/settings/tokens" target="_blank">허깅페이스 토큰 페이지</a>에서 생성할 수 있습니다.
384
- </div>
385
- </div>
386
-
387
- <div id="loggedInSection" class="logged-in-section">
388
- <button id="logoutButton" class="logout">로그아웃</button>
389
- </div>
390
- </div>
391
  </div>
392
 
393
  <div class="filter-controls">
394
  <input type="text" id="searchInput" placeholder="URL 또는 제목으로 검색" />
395
  </div>
396
 
397
- <div id="statusMessage" class="status-message"></div>
398
-
399
  <div id="gridContainer" class="grid-container"></div>
400
  </div>
401
 
@@ -406,21 +325,13 @@ if __name__ == '__main__':
406
  <script>
407
  // DOM element references
408
  const elements = {
409
- tokenInput: document.getElementById('tokenInput'),
410
- loginButton: document.getElementById('loginButton'),
411
- logoutButton: document.getElementById('logoutButton'),
412
- currentUser: document.getElementById('currentUser'),
413
  gridContainer: document.getElementById('gridContainer'),
414
  loadingIndicator: document.getElementById('loadingIndicator'),
415
- statusMessage: document.getElementById('statusMessage'),
416
- searchInput: document.getElementById('searchInput'),
417
- loginSection: document.getElementById('loginSection'),
418
- loggedInSection: document.getElementById('loggedInSection')
419
  };
420
 
421
  // Application state
422
  const state = {
423
- username: null,
424
  isLoading: false
425
  };
426
 
@@ -430,18 +341,6 @@ if __name__ == '__main__':
430
  elements.loadingIndicator.style.display = isLoading ? 'flex' : 'none';
431
  }
432
 
433
- // Display status message
434
- function showMessage(message, isError = false) {
435
- elements.statusMessage.textContent = message;
436
- elements.statusMessage.className = `status-message ${isError ? 'error' : 'success'}`;
437
- elements.statusMessage.style.display = 'block';
438
-
439
- // Hide message after 3 seconds
440
- setTimeout(() => {
441
- elements.statusMessage.style.display = 'none';
442
- }, 3000);
443
- }
444
-
445
  // API error handling
446
  async function handleApiResponse(response) {
447
  if (!response.ok) {
@@ -451,100 +350,6 @@ if __name__ == '__main__':
451
  return response.json();
452
  }
453
 
454
- // Check session status
455
- async function checkSessionStatus() {
456
- try {
457
- const response = await fetch('/api/session-status');
458
- const data = await handleApiResponse(response);
459
-
460
- if (data.logged_in) {
461
- state.username = data.username;
462
- elements.currentUser.textContent = data.username;
463
- elements.loginSection.style.display = 'none';
464
- elements.loggedInSection.style.display = 'block';
465
-
466
- // Load URL list
467
- loadUrls();
468
- }
469
- } catch (error) {
470
- console.error('세션 상태 확인 오류:', error);
471
- }
472
- }
473
-
474
- // Login process
475
- async function login(token) {
476
- if (!token.trim()) {
477
- showMessage('토큰을 입력해주세요.', true);
478
- return;
479
- }
480
-
481
- setLoading(true);
482
-
483
- try {
484
- const formData = new FormData();
485
- formData.append('token', token);
486
-
487
- const response = await fetch('/api/login', {
488
- method: 'POST',
489
- body: formData
490
- });
491
-
492
- const data = await handleApiResponse(response);
493
-
494
- if (data.success) {
495
- state.username = data.username;
496
-
497
- elements.currentUser.textContent = state.username;
498
- elements.loginSection.style.display = 'none';
499
- elements.loggedInSection.style.display = 'block';
500
-
501
- showMessage(`${state.username}님으로 로그인되었습니다.`);
502
-
503
- // Load URL list
504
- loadUrls();
505
- } else {
506
- showMessage(data.message || '로그인에 실패했습니다.', true);
507
- }
508
- } catch (error) {
509
- console.error('로그인 오류:', error);
510
- showMessage(`로그인 오류: ${error.message}`, true);
511
- } finally {
512
- setLoading(false);
513
- }
514
- }
515
-
516
- // Logout process
517
- async function logout() {
518
- setLoading(true);
519
-
520
- try {
521
- const response = await fetch('/api/logout', {
522
- method: 'POST'
523
- });
524
-
525
- const data = await handleApiResponse(response);
526
-
527
- if (data.success) {
528
- state.username = null;
529
-
530
- elements.currentUser.textContent = '로그인되지 않음';
531
- elements.tokenInput.value = '';
532
- elements.loginSection.style.display = 'block';
533
- elements.loggedInSection.style.display = 'none';
534
-
535
- showMessage('로그아웃되었습니다.');
536
-
537
- // Clear grid
538
- elements.gridContainer.innerHTML = '';
539
- }
540
- } catch (error) {
541
- console.error('로그아웃 오류:', error);
542
- showMessage(`로그아웃 오류: ${error.message}`, true);
543
- } finally {
544
- setLoading(false);
545
- }
546
- }
547
-
548
  // Load URL list
549
  async function loadUrls() {
550
  setLoading(true);
@@ -558,7 +363,7 @@ if __name__ == '__main__':
558
  renderGrid(urls);
559
  } catch (error) {
560
  console.error('URL 목록 로드 오류:', error);
561
- showMessage(`URL 로드 오류: ${error.message}`, true);
562
  } finally {
563
  setLoading(false);
564
  }
@@ -578,26 +383,50 @@ if __name__ == '__main__':
578
  }
579
 
580
  urls.forEach(item => {
581
- const { url, embedUrl, title } = item;
582
 
583
  // Create grid item
584
  const gridItem = document.createElement('div');
585
  gridItem.className = 'grid-item';
586
 
587
- // Header with title and link
588
  const header = document.createElement('div');
589
  header.className = 'grid-header';
590
 
 
 
 
 
591
  const titleEl = document.createElement('h3');
592
  titleEl.textContent = title;
593
- header.appendChild(titleEl);
 
 
 
 
 
 
 
 
 
 
 
594
 
 
 
 
 
 
 
 
595
  const linkEl = document.createElement('a');
596
  linkEl.href = url;
597
  linkEl.target = '_blank';
598
  linkEl.className = 'open-link';
599
  linkEl.textContent = '새 창에서 열기';
600
- header.appendChild(linkEl);
 
 
601
 
602
  // Add header to grid item
603
  gridItem.appendChild(header);
@@ -625,21 +454,7 @@ if __name__ == '__main__':
625
  });
626
  }
627
 
628
- // Event listeners
629
- elements.loginButton.addEventListener('click', () => {
630
- login(elements.tokenInput.value);
631
- });
632
-
633
- elements.logoutButton.addEventListener('click', logout);
634
-
635
- // Login with Enter key
636
- elements.tokenInput.addEventListener('keypress', (event) => {
637
- if (event.key === 'Enter') {
638
- login(elements.tokenInput.value);
639
- }
640
- });
641
-
642
- // Filter event listener
643
  elements.searchInput.addEventListener('input', () => {
644
  // Debounce input to prevent API calls on every keystroke
645
  clearTimeout(state.searchTimeout);
@@ -647,7 +462,7 @@ if __name__ == '__main__':
647
  });
648
 
649
  // Initialize
650
- checkSessionStatus();
651
  </script>
652
  </body>
653
  </html>
 
1
+ from flask import Flask, render_template, request, jsonify
2
  import requests
3
  import os
4
+ import random
5
 
6
  app = Flask(__name__)
 
 
7
 
8
  # Huggingface URL list
9
  HUGGINGFACE_URLS = [
 
36
  return f"https://{rest.replace('/', '-')}.hf.space"
37
  return url
38
 
39
+ # Extract model/space info from URL
40
+ def extract_model_info(url):
41
+ parts = url.split('/')
42
+ if len(parts) < 6:
43
+ return None
44
+
45
+ if parts[3] == 'spaces' or parts[3] == 'models':
46
+ return {
47
+ 'type': parts[3],
48
+ 'owner': parts[4],
49
+ 'repo': parts[5],
50
+ 'full_id': f"{parts[4]}/{parts[5]}"
51
+ }
52
+ elif len(parts) >= 5:
53
+ # Other URL format
54
+ return {
55
+ 'type': 'models', # Default
56
+ 'owner': parts[3],
57
+ 'repo': parts[4],
58
+ 'full_id': f"{parts[3]}/{parts[4]}"
59
+ }
60
+
61
+ return None
62
+
63
  # Extract title from the last part of URL
64
  def extract_title(url):
65
  parts = url.split("/")
66
  title = parts[-1] if parts else ""
67
  return title.replace("_", " ").replace("-", " ")
68
 
69
+ # Generate random likes count (since we're removing the actual likes functionality)
70
+ def generate_likes_count():
71
+ return random.randint(10, 500)
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
72
 
73
  # Homepage route
74
  @app.route('/')
75
  def home():
76
  return render_template('index.html')
77
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
78
  # URL list API
79
  @app.route('/api/urls', methods=['GET'])
80
  def get_urls():
 
83
  results = []
84
  for url in HUGGINGFACE_URLS:
85
  title = extract_title(url)
86
+ model_info = extract_model_info(url)
87
  transformed_url = transform_url(url)
88
 
89
+ if not model_info:
90
+ continue
91
+
92
  if search_query and search_query not in url.lower() and search_query not in title.lower():
93
  continue
94
 
95
+ # Generate random likes count
96
+ likes_count = generate_likes_count()
97
+
98
  results.append({
99
  'url': url,
100
  'embedUrl': transformed_url,
101
+ 'title': title,
102
+ 'model_info': model_info,
103
+ 'likes_count': likes_count,
104
+ 'owner': model_info['owner'] # Include owner ID
105
  })
106
 
107
  return jsonify(results)
108
 
 
 
 
 
 
 
 
 
109
  if __name__ == '__main__':
110
  # Create templates folder
111
  os.makedirs('templates', exist_ok=True)
 
141
  border-radius: 8px;
142
  margin-bottom: 1rem;
143
  box-shadow: 0 2px 10px rgba(0,0,0,0.05);
144
+ text-align: center;
145
  }
146
 
147
+ .header h1 {
148
+ margin: 0;
149
+ color: #2c3e50;
150
+ font-size: 1.8rem;
 
151
  }
152
 
153
  .filter-controls {
 
156
  border-radius: 8px;
157
  margin-bottom: 1rem;
158
  box-shadow: 0 2px 10px rgba(0,0,0,0.05);
159
+ display: flex;
160
+ justify-content: center;
161
+ align-items: center;
162
  }
163
 
 
164
  input[type="text"] {
165
  padding: 0.7rem;
166
  border: 1px solid #ddd;
167
  border-radius: 4px;
168
  margin-right: 5px;
169
  font-size: 1rem;
170
+ width: 300px;
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
171
  }
172
 
173
  .grid-container {
 
184
  display: flex;
185
  flex-direction: column;
186
  height: 600px;
187
+ position: relative;
188
  }
189
 
190
  .grid-header {
 
194
  display: flex;
195
  justify-content: space-between;
196
  align-items: center;
197
+ z-index: 2;
198
+ }
199
+
200
+ .grid-header-left {
201
+ display: flex;
202
+ flex-direction: column;
203
  }
204
 
205
  .grid-header h3 {
 
212
  text-overflow: ellipsis;
213
  }
214
 
215
+ .owner-info {
216
+ font-size: 0.85rem;
217
+ color: #666;
218
+ margin-top: 3px;
219
+ }
220
+
221
+ .grid-actions {
222
+ display: flex;
223
+ align-items: center;
224
+ }
225
+
226
  .open-link {
227
  color: #4CAF50;
228
  text-decoration: none;
229
  font-size: 0.9rem;
230
+ margin-left: 10px;
231
+ }
232
+
233
+ .likes-counter {
234
+ display: flex;
235
+ align-items: center;
236
+ font-size: 0.9rem;
237
+ color: #e91e63;
238
+ }
239
+
240
+ .likes-counter span {
241
+ margin-left: 4px;
242
  }
243
 
244
  .grid-content {
 
256
  border: none;
257
  }
258
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
259
  .loading {
260
  position: fixed;
261
  top: 0;
 
284
  100% { transform: rotate(360deg); }
285
  }
286
 
 
 
 
 
 
 
 
 
 
287
  @media (max-width: 768px) {
288
+ .filter-controls {
289
  flex-direction: column;
290
  align-items: flex-start;
291
  }
292
 
293
+ .filter-controls > * {
294
+ margin-bottom: 0.5rem;
295
+ width: 100%;
296
  }
297
 
298
  .grid-container {
299
  grid-template-columns: 1fr;
300
  }
301
+
302
+ input[type="text"] {
303
+ width: 100%;
304
+ }
305
  }
306
  </style>
307
  </head>
308
  <body>
309
  <div class="container">
310
  <div class="header">
311
+ <h1>허깅페이스 스페이스 임베딩 뷰어</h1>
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
312
  </div>
313
 
314
  <div class="filter-controls">
315
  <input type="text" id="searchInput" placeholder="URL 또는 제목으로 검색" />
316
  </div>
317
 
 
 
318
  <div id="gridContainer" class="grid-container"></div>
319
  </div>
320
 
 
325
  <script>
326
  // DOM element references
327
  const elements = {
 
 
 
 
328
  gridContainer: document.getElementById('gridContainer'),
329
  loadingIndicator: document.getElementById('loadingIndicator'),
330
+ searchInput: document.getElementById('searchInput')
 
 
 
331
  };
332
 
333
  // Application state
334
  const state = {
 
335
  isLoading: false
336
  };
337
 
 
341
  elements.loadingIndicator.style.display = isLoading ? 'flex' : 'none';
342
  }
343
 
 
 
 
 
 
 
 
 
 
 
 
 
344
  // API error handling
345
  async function handleApiResponse(response) {
346
  if (!response.ok) {
 
350
  return response.json();
351
  }
352
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
353
  // Load URL list
354
  async function loadUrls() {
355
  setLoading(true);
 
363
  renderGrid(urls);
364
  } catch (error) {
365
  console.error('URL 목록 로드 오류:', error);
366
+ alert(`URL 로드 오류: ${error.message}`);
367
  } finally {
368
  setLoading(false);
369
  }
 
383
  }
384
 
385
  urls.forEach(item => {
386
+ const { url, embedUrl, title, likes_count, owner } = item;
387
 
388
  // Create grid item
389
  const gridItem = document.createElement('div');
390
  gridItem.className = 'grid-item';
391
 
392
+ // Header with title and actions
393
  const header = document.createElement('div');
394
  header.className = 'grid-header';
395
 
396
+ // Left side of header (title and owner)
397
+ const headerLeft = document.createElement('div');
398
+ headerLeft.className = 'grid-header-left';
399
+
400
  const titleEl = document.createElement('h3');
401
  titleEl.textContent = title;
402
+ headerLeft.appendChild(titleEl);
403
+
404
+ const ownerEl = document.createElement('div');
405
+ ownerEl.className = 'owner-info';
406
+ ownerEl.textContent = `by: ${owner}`;
407
+ headerLeft.appendChild(ownerEl);
408
+
409
+ header.appendChild(headerLeft);
410
+
411
+ // Actions container
412
+ const actionsDiv = document.createElement('div');
413
+ actionsDiv.className = 'grid-actions';
414
 
415
+ // Likes count
416
+ const likesCounter = document.createElement('div');
417
+ likesCounter.className = 'likes-counter';
418
+ likesCounter.innerHTML = '♥ <span>' + likes_count + '</span>';
419
+ actionsDiv.appendChild(likesCounter);
420
+
421
+ // Open link
422
  const linkEl = document.createElement('a');
423
  linkEl.href = url;
424
  linkEl.target = '_blank';
425
  linkEl.className = 'open-link';
426
  linkEl.textContent = '새 창에서 열기';
427
+ actionsDiv.appendChild(linkEl);
428
+
429
+ header.appendChild(actionsDiv);
430
 
431
  // Add header to grid item
432
  gridItem.appendChild(header);
 
454
  });
455
  }
456
 
457
+ // Filter event listeners
 
 
 
 
 
 
 
 
 
 
 
 
 
 
458
  elements.searchInput.addEventListener('input', () => {
459
  // Debounce input to prevent API calls on every keystroke
460
  clearTimeout(state.searchTimeout);
 
462
  });
463
 
464
  // Initialize
465
+ loadUrls();
466
  </script>
467
  </body>
468
  </html>