ha251 commited on
Commit
7c6e288
·
verified ·
1 Parent(s): cb3b6bd

Update miniapp_leaderboard.py

Browse files
Files changed (1) hide show
  1. miniapp_leaderboard.py +227 -76
miniapp_leaderboard.py CHANGED
@@ -19,7 +19,10 @@ HF_TOKEN = os.environ.get("HF_TOKEN") or os.environ.get("TOKEN") or os.environ.g
19
  LEADERBOARD_DATASET = (os.environ.get("LEADERBOARD_DATASET") or "").strip()
20
  MAX_ENTRIES = int(os.environ.get("MAX_ENTRIES", "500"))
21
 
22
- ENTRIES_PREFIX = "entries/"
 
 
 
23
 
24
  LEADERBOARD_COLUMNS = [
25
  "Model name",
@@ -35,6 +38,8 @@ LEADERBOARD_COLUMNS = [
35
  "Lifestyle",
36
  "Submitted at",
37
  "Submitter",
 
 
38
  ]
39
 
40
  NUMERIC_COLS = [
@@ -50,7 +55,7 @@ NUMERIC_COLS = [
50
  "Lifestyle",
51
  ]
52
 
53
- # ✅ 展示顺序:Model 最左,Avg 第二列
54
  DISPLAY_ORDER = [
55
  "Model name",
56
  "Avg",
@@ -74,6 +79,7 @@ IN_SPACES = bool(
74
  )
75
 
76
 
 
77
  def _api() -> HfApi:
78
  return HfApi(token=HF_TOKEN)
79
 
@@ -115,7 +121,7 @@ def _ensure_dataset_readable() -> tuple[bool, str]:
115
  return False, "Cannot access the dataset repo. Check token permissions."
116
 
117
 
118
- def _list_entry_files() -> list[str]:
119
  ok, _ = _ensure_dataset_readable()
120
  if not ok:
121
  return []
@@ -124,19 +130,14 @@ def _list_entry_files() -> list[str]:
124
  files = api.list_repo_files(repo_id=LEADERBOARD_DATASET, repo_type="dataset")
125
  except Exception:
126
  return []
127
-
128
- entry_files = [f for f in files if f.startswith(ENTRIES_PREFIX) and f.endswith(".json")]
129
- entry_files.sort(reverse=True)
130
- return entry_files[:MAX_ENTRIES]
131
 
132
 
133
- def _load_entries_df() -> pd.DataFrame:
134
- ok, _ = _ensure_dataset_readable()
135
- if not ok:
136
- return _empty_df()
137
-
138
  rows: list[dict] = []
139
- for filename in _list_entry_files():
140
  try:
141
  path = hf_hub_download(
142
  repo_id=LEADERBOARD_DATASET,
@@ -146,19 +147,26 @@ def _load_entries_df() -> pd.DataFrame:
146
  )
147
  with open(path, "r", encoding="utf-8") as fp:
148
  row = json.load(fp)
149
- rows.append(row)
 
 
150
  except Exception:
151
  continue
152
 
153
  if not rows:
154
- return _empty_df()
 
 
 
155
 
156
  df = pd.DataFrame(rows)
157
  for c in LEADERBOARD_COLUMNS:
158
  if c not in df.columns:
159
  df[c] = ""
 
 
160
 
161
- df = df[LEADERBOARD_COLUMNS]
162
  for c in NUMERIC_COLS:
163
  df[c] = pd.to_numeric(df[c], errors="coerce")
164
 
@@ -176,6 +184,9 @@ def _parse_hf_created_at(created_at: str) -> datetime.datetime | None:
176
 
177
 
178
  def _check_user_eligibility(username: str) -> tuple[bool, str]:
 
 
 
179
  try:
180
  r = requests.get(f"https://huggingface.co/api/users/{username}/overview", timeout=10)
181
  r.raise_for_status()
@@ -189,24 +200,27 @@ def _check_user_eligibility(username: str) -> tuple[bool, str]:
189
  if dt.tzinfo is None:
190
  dt = dt.replace(tzinfo=datetime.timezone.utc)
191
  if (now - dt).days < 30:
192
- return False, "Account must be older than 1 months to submit."
193
  return True, ""
194
  except Exception:
195
  return False, "Cannot verify Hugging Face account. Please try again later."
196
 
197
 
198
- def _submitted_today(username: str) -> bool:
199
- df = _load_entries_df()
200
- if df.empty:
201
- return False
202
  today = datetime.datetime.utcnow().date().isoformat()
203
- user_rows = df[df["Submitter"].astype(str) == username]
204
- if user_rows.empty:
205
- return False
206
- return any(str(v).startswith(today) for v in user_rows["Submitted at"].tolist())
 
 
 
 
 
 
207
 
208
 
209
- # ---------- HTML Leaderboard ----------
210
  def _fmt_cell(v):
211
  if v is None or (isinstance(v, float) and pd.isna(v)):
212
  return ""
@@ -250,7 +264,6 @@ def _render_leaderboard_html(df: pd.DataFrame, sort_col: str, sort_dir: str) ->
250
  tds.append(f'<td class="td num">{_html.escape(val)}</td>')
251
  trs.append("<tr class='tr'>" + "".join(tds) + "</tr>")
252
 
253
- # ✅ 三层表头:Model 最左,Avg 第二列
254
  return f"""
255
  <div class="table-wrap">
256
  <div class="table-scroll">
@@ -291,7 +304,7 @@ def _render_leaderboard_html(df: pd.DataFrame, sort_col: str, sort_dir: str) ->
291
 
292
 
293
  def render_lb(search_text: str, sort_col: str, sort_dir: str) -> str:
294
- df = _load_entries_df()
295
  df = _apply_search_and_sort(df, search_text, sort_col, sort_dir)
296
  return _render_leaderboard_html(df, sort_col, sort_dir)
297
 
@@ -305,8 +318,9 @@ def toggle_sort(clicked_col: str, current_col: str, current_dir: str):
305
  return clicked_col, "desc"
306
 
307
 
308
- # ---------- Submit ----------
309
  def submit(
 
310
  model_api: str,
311
  api_key: str,
312
  search_text: str,
@@ -317,11 +331,14 @@ def submit(
317
  if IN_SPACES and (profile is None or not getattr(profile, "username", None)):
318
  return "You must log in to submit.", render_lb(search_text, sort_col, sort_dir)
319
 
320
- submitter = (getattr(profile, "username", None) if profile is not None else "local") or "anonymous"
321
 
 
322
  model_api = (model_api or "").strip()
323
  api_key = (api_key or "").strip()
324
 
 
 
325
  if not model_api:
326
  return "Model API URL is required.", render_lb(search_text, sort_col, sort_dir)
327
  if not _is_valid_http_url(model_api):
@@ -338,19 +355,16 @@ def submit(
338
  if not ok:
339
  return msg, render_lb(search_text, sort_col, sort_dir)
340
 
341
- if _submitted_today(submitter):
342
  return "You have already submitted today. Please try again tomorrow.", render_lb(search_text, sort_col, sort_dir)
343
 
344
  now = datetime.datetime.utcnow().replace(microsecond=0).isoformat() + "Z"
345
  nonce = uuid.uuid4().hex[:8]
346
  safe_user = _slug(submitter)
347
-
348
- host = urlparse(model_api).netloc or "unknown"
349
- model_name = host
350
-
351
  safe_model = _slug(model_name)
352
- path_in_repo = f"{ENTRIES_PREFIX}{now[:10]}/{now}-{safe_user}-{safe_model}-{nonce}.json"
353
 
 
354
  payload = {
355
  "Model name": model_name,
356
  "Avg": None,
@@ -375,13 +389,95 @@ def submit(
375
  repo_type="dataset",
376
  path_or_fileobj=io.BytesIO(data),
377
  path_in_repo=path_in_repo,
378
- commit_message=f"miniapp: submit {submitter}/{model_name}",
379
  token=HF_TOKEN,
380
  )
381
 
382
- return "Submitted successfully.", render_lb(search_text, sort_col, sort_dir)
383
 
384
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
385
  CSS = r"""
386
  .gradio-container { max-width: 100% !important; }
387
  #page { padding: 16px; }
@@ -399,7 +495,7 @@ CSS = r"""
399
  }
400
  #searchbox textarea::placeholder, #searchbox input::placeholder { color: #9ca3af; }
401
 
402
- /* 容器 */
403
  .table-wrap{
404
  width: 100%;
405
  border: 1px solid #e5e7eb !important;
@@ -408,21 +504,19 @@ CSS = r"""
408
  }
409
  .table-scroll{ width: 100%; overflow-x: auto; }
410
 
411
- /* ===== 关键:把所有黑边框强制变浅灰 ===== */
412
  #lb_table {
413
  width: 100% !important;
414
  min-width: 1100px;
415
  border-collapse: collapse !important;
416
  border: 1px solid #e5e7eb !important;
417
  }
418
- #lb_table th,
419
- #lb_table td{
420
- border: 1px solid #e5e7eb !important; /* 覆盖所有黑线来源 */
421
  border-color: #e5e7eb !important;
422
  }
423
 
424
- /* 表头 */
425
- #lb_table thead th.th{
426
  font-family: ui-sans-serif, system-ui, -apple-system, Segoe UI, Roboto, Helvetica, Arial !important;
427
  font-weight: 600;
428
  font-size: 13px;
@@ -432,28 +526,26 @@ CSS = r"""
432
  background: #f9fafb !important;
433
  white-space: nowrap;
434
  }
435
- #lb_table thead tr.r3 th.th { background: #ffffff !important; }
436
- #lb_table th.left{ text-align:left !important; }
437
- #lb_table th.group{ color:#374151 !important; font-weight:600 !important; }
438
 
439
- /* body */
440
- #lb_table tbody td.td{
441
  font-family: ui-sans-serif, system-ui, -apple-system, Segoe UI, Roboto, Helvetica, Arial !important;
442
  font-size: 13px;
443
  color: #111827;
444
  padding: 10px 12px;
445
  background: #fff !important;
446
  }
447
- #lb_table td.num{ text-align:right !important; }
448
- #lb_table td.model{ text-align:left !important; min-width: 280px; }
449
- #lb_table tbody tr.tr:hover td.td{ background: #fafafa !important; }
450
 
451
- /* 可点击排序 */
452
- #lb_table th.clickable{ cursor:pointer; user-select:none; }
453
- #lb_table th.clickable:hover{ background:#f3f4f6 !important; }
454
 
455
- /* submit */
456
- #submit_card{
457
  width: 100%;
458
  border: 1px solid #e5e7eb !important;
459
  border-radius: 8px;
@@ -461,14 +553,9 @@ CSS = r"""
461
  background: #fff;
462
  margin-top: 14px;
463
  }
464
- #submit_card .hint{
465
- margin: 0 0 10px 0;
466
- color: #6b7280;
467
- font-size: 13px;
468
- }
469
  """
470
 
471
-
472
  with gr.Blocks(title=f"{APP_NAME} leaderboard") as demo:
473
  with gr.Column(elem_id="page"):
474
  with gr.Row(elem_id="topbar"):
@@ -483,13 +570,15 @@ with gr.Blocks(title=f"{APP_NAME} leaderboard") as demo:
483
  )
484
  refresh_btn = gr.Button("Refresh", scale=0)
485
 
 
486
  sort_col = gr.State("Avg")
487
  sort_dir = gr.State("desc")
488
 
 
489
  lb_html = gr.HTML(value=render_lb("", "Avg", "desc"))
490
 
 
491
  clicked_col = gr.Textbox(visible=False, elem_id="clicked_col")
492
-
493
  gr.HTML(
494
  """
495
  <script>
@@ -508,7 +597,6 @@ with gr.Blocks(title=f"{APP_NAME} leaderboard") as demo:
508
  };
509
  });
510
  }
511
-
512
  const obs = new MutationObserver(()=>bindClicks());
513
  obs.observe(document.body, {subtree:true, childList:true});
514
  setTimeout(bindClicks, 250);
@@ -517,6 +605,7 @@ with gr.Blocks(title=f"{APP_NAME} leaderboard") as demo:
517
  """
518
  )
519
 
 
520
  search_text.change(render_lb, inputs=[search_text, sort_col, sort_dir], outputs=[lb_html])
521
  refresh_btn.click(render_lb, inputs=[search_text, sort_col, sort_dir], outputs=[lb_html])
522
 
@@ -530,31 +619,93 @@ with gr.Blocks(title=f"{APP_NAME} leaderboard") as demo:
530
  outputs=[sort_col, sort_dir, lb_html],
531
  )
532
 
 
533
  gr.HTML(
534
  """
535
- <div id="submit_card">
536
- <div class="hint">
537
- <b>Submission</b> — Submit <b>Model API URL</b> and <b>API key</b> only.
538
- Requires login (Spaces). One submission per user per day. Account must be older than 1 months.
539
  API key will <b>not</b> be stored.
540
  </div>
541
  </div>
542
  """
543
  )
544
 
 
545
  with gr.Column():
546
  with gr.Row():
 
547
  model_api = gr.Textbox(label="Model API URL", placeholder="https://...", scale=3)
548
- api_key = gr.Textbox(label="API key", type="password", placeholder="Will not be stored", scale=2)
549
  with gr.Row():
550
- gr.LoginButton()
551
- submit_btn = gr.Button("Submit", variant="primary")
552
- status = gr.Markdown()
 
 
553
 
554
  submit_btn.click(
555
  submit,
556
- inputs=[model_api, api_key, search_text, sort_col, sort_dir],
557
- outputs=[status, lb_html],
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
558
  )
559
 
560
  demo.launch(css=CSS, ssr_mode=False)
 
19
  LEADERBOARD_DATASET = (os.environ.get("LEADERBOARD_DATASET") or "").strip()
20
  MAX_ENTRIES = int(os.environ.get("MAX_ENTRIES", "500"))
21
 
22
+ PENDING_PREFIX = "pending/"
23
+ APPROVED_PREFIX = "approved/"
24
+
25
+ OWNER_USERNAMES = {"ha251"} # ✅ 只有这些用户名能看到审核区
26
 
27
  LEADERBOARD_COLUMNS = [
28
  "Model name",
 
38
  "Lifestyle",
39
  "Submitted at",
40
  "Submitter",
41
+ # 额外字段不会显示在表格,但会写入 payload:
42
+ # "Model API"
43
  ]
44
 
45
  NUMERIC_COLS = [
 
55
  "Lifestyle",
56
  ]
57
 
58
+ # 示顺序:Model 最左,Avg 第二列
59
  DISPLAY_ORDER = [
60
  "Model name",
61
  "Avg",
 
79
  )
80
 
81
 
82
+ # ----------------- basic utils -----------------
83
  def _api() -> HfApi:
84
  return HfApi(token=HF_TOKEN)
85
 
 
121
  return False, "Cannot access the dataset repo. Check token permissions."
122
 
123
 
124
+ def _list_json_files(prefix: str) -> list[str]:
125
  ok, _ = _ensure_dataset_readable()
126
  if not ok:
127
  return []
 
130
  files = api.list_repo_files(repo_id=LEADERBOARD_DATASET, repo_type="dataset")
131
  except Exception:
132
  return []
133
+ out = [f for f in files if f.startswith(prefix) and f.endswith(".json")]
134
+ out.sort(reverse=True)
135
+ return out[:MAX_ENTRIES]
 
136
 
137
 
138
+ def _load_entries_df(prefix: str, include_filename: bool = False) -> pd.DataFrame:
 
 
 
 
139
  rows: list[dict] = []
140
+ for filename in _list_json_files(prefix):
141
  try:
142
  path = hf_hub_download(
143
  repo_id=LEADERBOARD_DATASET,
 
147
  )
148
  with open(path, "r", encoding="utf-8") as fp:
149
  row = json.load(fp)
150
+ if include_filename:
151
+ row["_filename"] = filename
152
+ rows.append(row)
153
  except Exception:
154
  continue
155
 
156
  if not rows:
157
+ df = _empty_df()
158
+ if include_filename:
159
+ df["_filename"] = []
160
+ return df
161
 
162
  df = pd.DataFrame(rows)
163
  for c in LEADERBOARD_COLUMNS:
164
  if c not in df.columns:
165
  df[c] = ""
166
+ if include_filename and "_filename" not in df.columns:
167
+ df["_filename"] = ""
168
 
169
+ df = df[LEADERBOARD_COLUMNS + (["_filename"] if include_filename else [])]
170
  for c in NUMERIC_COLS:
171
  df[c] = pd.to_numeric(df[c], errors="coerce")
172
 
 
184
 
185
 
186
  def _check_user_eligibility(username: str) -> tuple[bool, str]:
187
+ """
188
+ - Must be older than ~30 days (>= 30 days)
189
+ """
190
  try:
191
  r = requests.get(f"https://huggingface.co/api/users/{username}/overview", timeout=10)
192
  r.raise_for_status()
 
200
  if dt.tzinfo is None:
201
  dt = dt.replace(tzinfo=datetime.timezone.utc)
202
  if (now - dt).days < 30:
203
+ return False, "Account must be older than 30 days to submit."
204
  return True, ""
205
  except Exception:
206
  return False, "Cannot verify Hugging Face account. Please try again later."
207
 
208
 
209
+ def _submitted_today_anywhere(username: str) -> bool:
 
 
 
210
  today = datetime.datetime.utcnow().date().isoformat()
211
+ for prefix in (PENDING_PREFIX, APPROVED_PREFIX):
212
+ df = _load_entries_df(prefix, include_filename=False)
213
+ if df.empty:
214
+ continue
215
+ user_rows = df[df["Submitter"].astype(str) == username]
216
+ if user_rows.empty:
217
+ continue
218
+ if any(str(v).startswith(today) for v in user_rows["Submitted at"].tolist()):
219
+ return True
220
+ return False
221
 
222
 
223
+ # ----------------- leaderboard HTML -----------------
224
  def _fmt_cell(v):
225
  if v is None or (isinstance(v, float) and pd.isna(v)):
226
  return ""
 
264
  tds.append(f'<td class="td num">{_html.escape(val)}</td>')
265
  trs.append("<tr class='tr'>" + "".join(tds) + "</tr>")
266
 
 
267
  return f"""
268
  <div class="table-wrap">
269
  <div class="table-scroll">
 
304
 
305
 
306
  def render_lb(search_text: str, sort_col: str, sort_dir: str) -> str:
307
+ df = _load_entries_df(APPROVED_PREFIX, include_filename=False)
308
  df = _apply_search_and_sort(df, search_text, sort_col, sort_dir)
309
  return _render_leaderboard_html(df, sort_col, sort_dir)
310
 
 
318
  return clicked_col, "desc"
319
 
320
 
321
+ # ----------------- submit & approve -----------------
322
  def submit(
323
+ model_name: str,
324
  model_api: str,
325
  api_key: str,
326
  search_text: str,
 
331
  if IN_SPACES and (profile is None or not getattr(profile, "username", None)):
332
  return "You must log in to submit.", render_lb(search_text, sort_col, sort_dir)
333
 
334
+ submitter = getattr(profile, "username", None) or "anonymous"
335
 
336
+ model_name = (model_name or "").strip()
337
  model_api = (model_api or "").strip()
338
  api_key = (api_key or "").strip()
339
 
340
+ if not model_name:
341
+ return "Model name is required.", render_lb(search_text, sort_col, sort_dir)
342
  if not model_api:
343
  return "Model API URL is required.", render_lb(search_text, sort_col, sort_dir)
344
  if not _is_valid_http_url(model_api):
 
355
  if not ok:
356
  return msg, render_lb(search_text, sort_col, sort_dir)
357
 
358
+ if _submitted_today_anywhere(submitter):
359
  return "You have already submitted today. Please try again tomorrow.", render_lb(search_text, sort_col, sort_dir)
360
 
361
  now = datetime.datetime.utcnow().replace(microsecond=0).isoformat() + "Z"
362
  nonce = uuid.uuid4().hex[:8]
363
  safe_user = _slug(submitter)
 
 
 
 
364
  safe_model = _slug(model_name)
365
+ path_in_repo = f"{PENDING_PREFIX}{now[:10]}/{now}-{safe_user}-{safe_model}-{nonce}.json"
366
 
367
+ # api_key collected but NOT stored
368
  payload = {
369
  "Model name": model_name,
370
  "Avg": None,
 
389
  repo_type="dataset",
390
  path_or_fileobj=io.BytesIO(data),
391
  path_in_repo=path_in_repo,
392
+ commit_message=f"miniapp: submit(pending) {submitter}/{model_name}",
393
  token=HF_TOKEN,
394
  )
395
 
396
+ return "Submitted. Waiting for owner approval.", render_lb(search_text, sort_col, sort_dir)
397
 
398
 
399
+ def refresh_pending(profile: gr.OAuthProfile | None):
400
+ username = getattr(profile, "username", None) if profile else None
401
+ if username not in OWNER_USERNAMES:
402
+ # 非 owner:不返回任何 pending(且 UI 会隐藏)
403
+ return pd.DataFrame(columns=["Submitted at", "Submitter", "Model name", "Model API", "_filename"]), gr.update(
404
+ choices=[], value=None
405
+ )
406
+
407
+ df = _load_entries_df(PENDING_PREFIX, include_filename=True)
408
+ # pending 表显示更实用的列
409
+ view = df.copy()
410
+ cols = ["Submitted at", "Submitter", "Model name"]
411
+ if "Model API" in view.columns:
412
+ cols.append("Model API")
413
+ cols.append("_filename")
414
+ view = view[cols]
415
+
416
+ choices = view["_filename"].tolist() if not view.empty else []
417
+ return view, gr.update(choices=choices, value=(choices[0] if choices else None))
418
+
419
+
420
+ def approve(pending_filename: str | None, profile: gr.OAuthProfile | None, search_text: str, sort_col: str, sort_dir: str):
421
+ username = getattr(profile, "username", None) if profile else None
422
+ if username not in OWNER_USERNAMES:
423
+ # 非 owner:不允许
424
+ pending_view, dd = refresh_pending(profile)
425
+ return "Not authorized.", pending_view, dd, render_lb(search_text, sort_col, sort_dir)
426
+
427
+ pending_filename = (pending_filename or "").strip()
428
+ if not pending_filename:
429
+ pending_view, dd = refresh_pending(profile)
430
+ return "Select a pending entry.", pending_view, dd, render_lb(search_text, sort_col, sort_dir)
431
+
432
+ api = _api()
433
+ try:
434
+ local_path = hf_hub_download(
435
+ repo_id=LEADERBOARD_DATASET,
436
+ repo_type="dataset",
437
+ filename=pending_filename,
438
+ token=HF_TOKEN,
439
+ )
440
+ with open(local_path, "r", encoding="utf-8") as fp:
441
+ payload = json.load(fp)
442
+ except Exception as e:
443
+ pending_view, dd = refresh_pending(profile)
444
+ return f"Failed to read pending file: {e}", pending_view, dd, render_lb(search_text, sort_col, sort_dir)
445
+
446
+ # move to approved/
447
+ base = pending_filename[len(PENDING_PREFIX) :] if pending_filename.startswith(PENDING_PREFIX) else pending_filename
448
+ approved_filename = f"{APPROVED_PREFIX}{base}"
449
+ data = (json.dumps(payload, ensure_ascii=False, indent=2) + "\n").encode("utf-8")
450
+
451
+ try:
452
+ api.upload_file(
453
+ repo_id=LEADERBOARD_DATASET,
454
+ repo_type="dataset",
455
+ path_or_fileobj=io.BytesIO(data),
456
+ path_in_repo=approved_filename,
457
+ commit_message=f"miniapp: approve {payload.get('Submitter','')}/{payload.get('Model name','')}",
458
+ token=HF_TOKEN,
459
+ )
460
+ api.delete_file(
461
+ repo_id=LEADERBOARD_DATASET,
462
+ repo_type="dataset",
463
+ path_in_repo=pending_filename,
464
+ commit_message=f"miniapp: remove pending {pending_filename}",
465
+ token=HF_TOKEN,
466
+ )
467
+ except Exception as e:
468
+ pending_view, dd = refresh_pending(profile)
469
+ return f"Approve failed: {e}", pending_view, dd, render_lb(search_text, sort_col, sort_dir)
470
+
471
+ pending_view, dd = refresh_pending(profile)
472
+ return "Approved and published.", pending_view, dd, render_lb(search_text, sort_col, sort_dir)
473
+
474
+
475
+ def show_owner_panel(profile: gr.OAuthProfile | None):
476
+ username = getattr(profile, "username", None) if profile else None
477
+ return gr.update(visible=(username in OWNER_USERNAMES))
478
+
479
+
480
+ # ----------------- UI -----------------
481
  CSS = r"""
482
  .gradio-container { max-width: 100% !important; }
483
  #page { padding: 16px; }
 
495
  }
496
  #searchbox textarea::placeholder, #searchbox input::placeholder { color: #9ca3af; }
497
 
498
+ /* table container */
499
  .table-wrap{
500
  width: 100%;
501
  border: 1px solid #e5e7eb !important;
 
504
  }
505
  .table-scroll{ width: 100%; overflow-x: auto; }
506
 
507
+ /* HARD OVERRIDE borders to light gray */
508
  #lb_table {
509
  width: 100% !important;
510
  min-width: 1100px;
511
  border-collapse: collapse !important;
512
  border: 1px solid #e5e7eb !important;
513
  }
514
+ #lb_table th, #lb_table td {
515
+ border: 1px solid #e5e7eb !important;
 
516
  border-color: #e5e7eb !important;
517
  }
518
 
519
+ th.th{
 
520
  font-family: ui-sans-serif, system-ui, -apple-system, Segoe UI, Roboto, Helvetica, Arial !important;
521
  font-weight: 600;
522
  font-size: 13px;
 
526
  background: #f9fafb !important;
527
  white-space: nowrap;
528
  }
529
+ thead tr.r3 th.th { background: #ffffff !important; }
530
+ th.th.left{ text-align:left !important; }
531
+ th.group{ color:#374151 !important; font-weight:600 !important; }
532
 
533
+ td.td{
 
534
  font-family: ui-sans-serif, system-ui, -apple-system, Segoe UI, Roboto, Helvetica, Arial !important;
535
  font-size: 13px;
536
  color: #111827;
537
  padding: 10px 12px;
538
  background: #fff !important;
539
  }
540
+ td.num{ text-align:right !important; }
541
+ td.model{ text-align:left !important; min-width: 280px; }
542
+ tr.tr:hover td.td{ background: #fafafa !important; }
543
 
544
+ th.clickable{ cursor:pointer; user-select:none; }
545
+ th.clickable:hover{ background:#f3f4f6 !important; }
 
546
 
547
+ /* cards */
548
+ .card{
549
  width: 100%;
550
  border: 1px solid #e5e7eb !important;
551
  border-radius: 8px;
 
553
  background: #fff;
554
  margin-top: 14px;
555
  }
556
+ .muted{ color: #6b7280; font-size: 13px; margin: 0 0 10px 0; }
 
 
 
 
557
  """
558
 
 
559
  with gr.Blocks(title=f"{APP_NAME} leaderboard") as demo:
560
  with gr.Column(elem_id="page"):
561
  with gr.Row(elem_id="topbar"):
 
570
  )
571
  refresh_btn = gr.Button("Refresh", scale=0)
572
 
573
+ # state for sorting
574
  sort_col = gr.State("Avg")
575
  sort_dir = gr.State("desc")
576
 
577
+ # leaderboard (approved only)
578
  lb_html = gr.HTML(value=render_lb("", "Avg", "desc"))
579
 
580
+ # click bridge
581
  clicked_col = gr.Textbox(visible=False, elem_id="clicked_col")
 
582
  gr.HTML(
583
  """
584
  <script>
 
597
  };
598
  });
599
  }
 
600
  const obs = new MutationObserver(()=>bindClicks());
601
  obs.observe(document.body, {subtree:true, childList:true});
602
  setTimeout(bindClicks, 250);
 
605
  """
606
  )
607
 
608
+ # search/refresh
609
  search_text.change(render_lb, inputs=[search_text, sort_col, sort_dir], outputs=[lb_html])
610
  refresh_btn.click(render_lb, inputs=[search_text, sort_col, sort_dir], outputs=[lb_html])
611
 
 
619
  outputs=[sort_col, sort_dir, lb_html],
620
  )
621
 
622
+ # submission card
623
  gr.HTML(
624
  """
625
+ <div class="card">
626
+ <div class="muted">
627
+ <b>Submission</b> — Submissions go to <b>pending</b> and will appear on the leaderboard only after <b>owner approval</b>.
628
+ Requires login (Spaces). One submission per user per day. Account must be older than 30 days.
629
  API key will <b>not</b> be stored.
630
  </div>
631
  </div>
632
  """
633
  )
634
 
635
+ # submit full-width
636
  with gr.Column():
637
  with gr.Row():
638
+ model_name = gr.Textbox(label="Model name", placeholder="e.g. my-model-v1", scale=2)
639
  model_api = gr.Textbox(label="Model API URL", placeholder="https://...", scale=3)
 
640
  with gr.Row():
641
+ api_key = gr.Textbox(label="API key", type="password", placeholder="Will not be stored", scale=3)
642
+ with gr.Column(scale=1):
643
+ gr.LoginButton()
644
+ submit_btn = gr.Button("Submit", variant="primary")
645
+ submit_status = gr.Markdown()
646
 
647
  submit_btn.click(
648
  submit,
649
+ inputs=[model_name, model_api, api_key, search_text, sort_col, sort_dir],
650
+ outputs=[submit_status, lb_html],
651
+ )
652
+
653
+ # Owner panel (hidden unless owner)
654
+ owner_panel = gr.Column(visible=False)
655
+ with owner_panel:
656
+ gr.HTML(
657
+ """
658
+ <div class="card">
659
+ <div class="muted"><b>Owner review</b> — Visible only to owner accounts.</div>
660
+ </div>
661
+ """
662
+ )
663
+ pending_refresh_btn = gr.Button("Refresh pending")
664
+ pending_df = gr.Dataframe(
665
+ label="Pending submissions",
666
+ interactive=False,
667
+ wrap=True,
668
+ value=pd.DataFrame(columns=["Submitted at", "Submitter", "Model name", "Model API", "_filename"]),
669
+ )
670
+ pending_pick = gr.Dropdown(label="Select a pending entry", choices=[], value=None)
671
+ approve_btn = gr.Button("Approve and publish", variant="primary")
672
+ approve_status = gr.Markdown()
673
+
674
+ pending_refresh_btn.click(
675
+ refresh_pending,
676
+ inputs=[gr.State(None)], # placeholder, will be overridden by load/login binding below
677
+ outputs=[pending_df, pending_pick],
678
+ )
679
+
680
+ # IMPORTANT: use load/profile to control visibility + pending refresh
681
+ # In Gradio, we can wire OAuth profile by using a dummy fn that takes profile.
682
+ def _on_load(profile: gr.OAuthProfile | None, s: str, sc: str, sd: str):
683
+ # show owner panel?
684
+ vis = show_owner_panel(profile)
685
+ # also refresh pending if owner; else empty
686
+ pdf, pdd = refresh_pending(profile)
687
+ # keep leaderboard rendered (approved)
688
+ lb = render_lb(s, sc, sd)
689
+ return vis, pdf, pdd, lb
690
+
691
+ demo.load(
692
+ _on_load,
693
+ inputs=[gr.OAuthProfile(), search_text, sort_col, sort_dir],
694
+ outputs=[owner_panel, pending_df, pending_pick, lb_html],
695
+ )
696
+
697
+ # Approve wiring
698
+ approve_btn.click(
699
+ approve,
700
+ inputs=[pending_pick, gr.OAuthProfile(), search_text, sort_col, sort_dir],
701
+ outputs=[approve_status, pending_df, pending_pick, lb_html],
702
+ )
703
+
704
+ # Refresh pending button: needs profile too
705
+ pending_refresh_btn.click(
706
+ refresh_pending,
707
+ inputs=[gr.OAuthProfile()],
708
+ outputs=[pending_df, pending_pick],
709
  )
710
 
711
  demo.launch(css=CSS, ssr_mode=False)