milwright commited on
Commit
af86b7d
·
1 Parent(s): f1bb203

add leaderboard persistent storage and fix styling

Browse files
.env.example ADDED
@@ -0,0 +1,13 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # OpenRouter API Key (for AI functionality)
2
+ OPENROUTER_API_KEY=your_openrouter_key_here
3
+
4
+ # Hugging Face API Token (for leaderboard persistence)
5
+ # Get your token from: https://huggingface.co/settings/tokens
6
+ # Required permissions: write access to datasets
7
+ HF_TOKEN=your_hf_token_here
8
+ # OR use HF_API_KEY (both work)
9
+ HF_API_KEY=your_hf_token_here
10
+
11
+ # Hugging Face Leaderboard Repository (optional, defaults to zmuhls/cloze-reader-leaderboard)
12
+ # Format: username/repo-name
13
+ HF_LEADERBOARD_REPO=your_username/cloze-reader-leaderboard
HF_LEADERBOARD_SETUP.md ADDED
@@ -0,0 +1,222 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # Hugging Face Leaderboard Setup
2
+
3
+ This document explains how the HF Hub leaderboard integration works and how to set it up.
4
+
5
+ ## Overview
6
+
7
+ The leaderboard system now supports two modes:
8
+ 1. **HF Hub Mode** (persistent, shared across all users)
9
+ 2. **localStorage Mode** (fallback, local to browser)
10
+
11
+ When HF Hub is available, all leaderboard entries are:
12
+ - Saved to a HF Hub dataset repository
13
+ - Automatically synced across all users
14
+ - Version controlled with commit history
15
+ - Persistent across sessions and devices
16
+
17
+ ## Setup Instructions
18
+
19
+ ### 1. Get Hugging Face Token
20
+
21
+ 1. Go to https://huggingface.co/settings/tokens
22
+ 2. Create a new token with **write** access
23
+ 3. Copy the token value
24
+
25
+ ### 2. Configure Environment Variables
26
+
27
+ Create a `.env` file in the project root (copy from `.env.example`):
28
+
29
+ ```bash
30
+ # Hugging Face API Token
31
+ HF_TOKEN=hf_your_token_here
32
+
33
+ # Optional: Custom repository name (defaults to zmuhls/cloze-reader-leaderboard)
34
+ HF_LEADERBOARD_REPO=your_username/your-repo-name
35
+ ```
36
+
37
+ ### 3. Install Dependencies
38
+
39
+ ```bash
40
+ pip install -r requirements.txt
41
+ ```
42
+
43
+ This will install `huggingface-hub` along with other dependencies.
44
+
45
+ ### 4. Run the Server
46
+
47
+ ```bash
48
+ # Production mode (with HF backend)
49
+ make dev-python
50
+ # OR
51
+ python app.py
52
+
53
+ # The server will:
54
+ # 1. Check for HF_TOKEN in environment
55
+ # 2. Create the HF dataset repository if it doesn't exist
56
+ # 3. Initialize with empty leaderboard
57
+ # 4. Handle all read/write operations via API endpoints
58
+ ```
59
+
60
+ ### 5. Test the Integration
61
+
62
+ Visit `http://localhost:7860` and:
63
+ 1. Play the game to reach Level 2
64
+ 2. Enter your initials
65
+ 3. Check browser console for HF sync messages:
66
+ - `🔧 LEADERBOARD: Using HF Hub backend`
67
+ - `📤 LEADERBOARD: Saved to HF Hub`
68
+ - `📥 LEADERBOARD: Synced from HF Hub`
69
+
70
+ ## Architecture
71
+
72
+ ### Backend (Python)
73
+
74
+ **`hf_leaderboard.py`** - HF Hub integration service
75
+ - Creates/manages HF dataset repository
76
+ - Handles leaderboard read/write/update operations
77
+ - Auto-commits changes to HF Hub
78
+ - Uses `huggingface_hub` Python library
79
+
80
+ **`app.py`** - FastAPI endpoints
81
+ - `GET /api/leaderboard` - Fetch current leaderboard
82
+ - `POST /api/leaderboard/add` - Add new entry
83
+ - `POST /api/leaderboard/update` - Replace entire leaderboard
84
+ - `DELETE /api/leaderboard/clear` - Clear all entries (admin)
85
+
86
+ ### Frontend (JavaScript)
87
+
88
+ **`src/hfLeaderboardAPI.js`** - API client
89
+ - Communicates with FastAPI backend
90
+ - Handles network errors gracefully
91
+ - Provides async methods for all operations
92
+
93
+ **`src/leaderboardService.js`** - Service layer (updated)
94
+ - Checks HF availability on init
95
+ - Syncs from HF Hub on page load
96
+ - Saves to both HF and localStorage
97
+ - Falls back to localStorage if HF unavailable
98
+
99
+ ## Data Format
100
+
101
+ Leaderboard entries are stored as JSON on HF Hub:
102
+
103
+ ```json
104
+ {
105
+ "leaderboard": [
106
+ {
107
+ "initials": "ABC",
108
+ "level": 5,
109
+ "round": 2,
110
+ "passagesPassed": 18,
111
+ "date": "2025-01-31T12:00:00.000Z"
112
+ }
113
+ ],
114
+ "last_updated": "2025-01-31T12:00:00.000Z",
115
+ "version": "1.0"
116
+ }
117
+ ```
118
+
119
+ ## How It Works
120
+
121
+ ### On Page Load
122
+ 1. Frontend checks if backend is available (`/api/leaderboard`)
123
+ 2. If available, sets `useHF = true`
124
+ 3. Syncs leaderboard from HF Hub to localStorage
125
+ 4. User sees most recent global leaderboard
126
+
127
+ ### When Player Achieves High Score
128
+ 1. Entry saved to localStorage immediately (fast UI update)
129
+ 2. Entry sent to backend API (`POST /api/leaderboard/add`)
130
+ 3. Backend adds entry to HF Hub with auto-commit
131
+ 4. All other users will see updated leaderboard on next load
132
+
133
+ ### Fallback Behavior
134
+ If HF backend is unavailable (no token, network error, etc.):
135
+ - System automatically uses localStorage only
136
+ - Leaderboard still works, just not shared
137
+ - Console shows: `⚠️ LEADERBOARD: HF backend unavailable, using localStorage`
138
+
139
+ ## Viewing Your HF Dataset
140
+
141
+ After first save, visit:
142
+ ```
143
+ https://huggingface.co/datasets/YOUR_USERNAME/cloze-reader-leaderboard
144
+ ```
145
+
146
+ You'll see:
147
+ - `leaderboard.json` file
148
+ - Commit history of all updates
149
+ - Public leaderboard accessible to all
150
+
151
+ ## Production Deployment
152
+
153
+ ### Hugging Face Spaces
154
+ If deploying on HF Spaces, the token is automatically available:
155
+ ```python
156
+ # HF Spaces provides HF_TOKEN automatically
157
+ # No .env file needed!
158
+ ```
159
+
160
+ ### Other Platforms (Vercel, Railway, etc.)
161
+ Add `HF_TOKEN` to your platform's environment variables:
162
+ - Vercel: Project Settings → Environment Variables
163
+ - Railway: Project → Variables
164
+ - Render: Environment → Environment Variables
165
+
166
+ ## Security Notes
167
+
168
+ - ✅ HF Token stored securely in backend environment
169
+ - ✅ Token never exposed to frontend/browser
170
+ - ✅ All HF operations go through backend API
171
+ - ✅ CORS configured for security
172
+ - ⚠️ Leaderboard dataset should be **public** (read access for all users)
173
+ - ⚠️ Write access controlled via backend (users can't directly modify HF dataset)
174
+
175
+ ## Troubleshooting
176
+
177
+ ### "No HF token available for writing"
178
+ - Check `.env` file has `HF_TOKEN` or `HF_API_KEY`
179
+ - Restart server after adding token
180
+ - Verify token has write permissions
181
+
182
+ ### "Failed to create HF repo"
183
+ - Check token permissions (need write access)
184
+ - Verify repository name format (username/repo-name)
185
+ - Check if repo already exists
186
+
187
+ ### Leaderboard not syncing
188
+ - Check browser console for error messages
189
+ - Verify backend is running (`http://localhost:7860/api/leaderboard`)
190
+ - Check network tab for failed API calls
191
+ - Ensure HF Hub is accessible (not blocked by firewall)
192
+
193
+ ### Backend logs show errors
194
+ ```bash
195
+ # Check logs for detailed error messages
196
+ python app.py
197
+
198
+ # Look for:
199
+ # ✅ "Using existing HF dataset: ..."
200
+ # ✅ "Leaderboard saved to HF Hub: ..."
201
+ # ❌ "Failed to upload to HF Hub: ..."
202
+ ```
203
+
204
+ ## Development vs Production
205
+
206
+ **Local Development** (`make dev` - static server):
207
+ - Uses localStorage only
208
+ - No backend, no HF sync
209
+ - Fast iteration, no setup needed
210
+
211
+ **Production** (`make dev-python` - FastAPI server):
212
+ - Uses HF Hub backend
213
+ - Requires token and backend running
214
+ - Shared global leaderboard
215
+
216
+ ## API Documentation
217
+
218
+ Once server is running, visit:
219
+ - FastAPI docs: `http://localhost:7860/docs`
220
+ - Alternative docs: `http://localhost:7860/redoc`
221
+
222
+ Interactive API testing available through Swagger UI!
app.py CHANGED
@@ -1,14 +1,54 @@
1
- from fastapi import FastAPI
2
  from fastapi.responses import HTMLResponse, RedirectResponse
3
  from fastapi.staticfiles import StaticFiles
 
 
 
4
  import os
5
  from dotenv import load_dotenv
 
6
 
7
  # Load environment variables from .env file
8
  load_dotenv()
9
 
 
 
 
 
 
 
 
10
  app = FastAPI()
11
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
12
  # Mount static files
13
  app.mount("/src", StaticFiles(directory="src"), name="src")
14
 
@@ -39,6 +79,89 @@ async def read_root():
39
 
40
  return HTMLResponse(content=html_content)
41
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
42
  if __name__ == "__main__":
43
  import uvicorn
44
  uvicorn.run(app, host="0.0.0.0", port=7860)
 
1
+ from fastapi import FastAPI, HTTPException
2
  from fastapi.responses import HTMLResponse, RedirectResponse
3
  from fastapi.staticfiles import StaticFiles
4
+ from fastapi.middleware.cors import CORSMiddleware
5
+ from pydantic import BaseModel
6
+ from typing import List, Optional
7
  import os
8
  from dotenv import load_dotenv
9
+ import logging
10
 
11
  # Load environment variables from .env file
12
  load_dotenv()
13
 
14
+ # Import HF Leaderboard Service
15
+ from hf_leaderboard import HFLeaderboardService
16
+
17
+ # Configure logging
18
+ logging.basicConfig(level=logging.INFO)
19
+ logger = logging.getLogger(__name__)
20
+
21
  app = FastAPI()
22
 
23
+ # Add CORS middleware for local development
24
+ app.add_middleware(
25
+ CORSMiddleware,
26
+ allow_origins=["*"],
27
+ allow_credentials=True,
28
+ allow_methods=["*"],
29
+ allow_headers=["*"],
30
+ )
31
+
32
+ # Initialize HF Leaderboard Service
33
+ hf_token = os.getenv("HF_TOKEN") or os.getenv("HF_API_KEY")
34
+ hf_leaderboard = HFLeaderboardService(
35
+ repo_id=os.getenv("HF_LEADERBOARD_REPO", "zmuhls/cloze-reader-leaderboard"),
36
+ token=hf_token
37
+ )
38
+
39
+ # Pydantic models for API
40
+ class LeaderboardEntry(BaseModel):
41
+ initials: str
42
+ level: int
43
+ round: int
44
+ passagesPassed: int
45
+ date: str
46
+
47
+ class LeaderboardResponse(BaseModel):
48
+ success: bool
49
+ leaderboard: List[LeaderboardEntry]
50
+ message: Optional[str] = None
51
+
52
  # Mount static files
53
  app.mount("/src", StaticFiles(directory="src"), name="src")
54
 
 
79
 
80
  return HTMLResponse(content=html_content)
81
 
82
+
83
+ # ===== LEADERBOARD API ENDPOINTS =====
84
+
85
+ @app.get("/api/leaderboard", response_model=LeaderboardResponse)
86
+ async def get_leaderboard():
87
+ """
88
+ Get current leaderboard data from HF Hub
89
+ """
90
+ try:
91
+ leaderboard = hf_leaderboard.get_leaderboard()
92
+ return {
93
+ "success": True,
94
+ "leaderboard": leaderboard,
95
+ "message": f"Retrieved {len(leaderboard)} entries"
96
+ }
97
+ except Exception as e:
98
+ logger.error(f"Error fetching leaderboard: {e}")
99
+ raise HTTPException(status_code=500, detail=str(e))
100
+
101
+
102
+ @app.post("/api/leaderboard/add")
103
+ async def add_leaderboard_entry(entry: LeaderboardEntry):
104
+ """
105
+ Add new entry to leaderboard
106
+ """
107
+ try:
108
+ success = hf_leaderboard.add_entry(entry.dict())
109
+ if success:
110
+ return {
111
+ "success": True,
112
+ "message": f"Added {entry.initials} to leaderboard"
113
+ }
114
+ else:
115
+ raise HTTPException(status_code=500, detail="Failed to add entry")
116
+ except ValueError as e:
117
+ raise HTTPException(status_code=403, detail=str(e))
118
+ except Exception as e:
119
+ logger.error(f"Error adding entry: {e}")
120
+ raise HTTPException(status_code=500, detail=str(e))
121
+
122
+
123
+ @app.post("/api/leaderboard/update")
124
+ async def update_leaderboard(entries: List[LeaderboardEntry]):
125
+ """
126
+ Update entire leaderboard (replace all data)
127
+ """
128
+ try:
129
+ success = hf_leaderboard.update_leaderboard([e.dict() for e in entries])
130
+ if success:
131
+ return {
132
+ "success": True,
133
+ "message": "Leaderboard updated successfully"
134
+ }
135
+ else:
136
+ raise HTTPException(status_code=500, detail="Failed to update leaderboard")
137
+ except ValueError as e:
138
+ raise HTTPException(status_code=403, detail=str(e))
139
+ except Exception as e:
140
+ logger.error(f"Error updating leaderboard: {e}")
141
+ raise HTTPException(status_code=500, detail=str(e))
142
+
143
+
144
+ @app.delete("/api/leaderboard/clear")
145
+ async def clear_leaderboard():
146
+ """
147
+ Clear all leaderboard data (admin only)
148
+ """
149
+ try:
150
+ success = hf_leaderboard.clear_leaderboard()
151
+ if success:
152
+ return {
153
+ "success": True,
154
+ "message": "Leaderboard cleared"
155
+ }
156
+ else:
157
+ raise HTTPException(status_code=500, detail="Failed to clear leaderboard")
158
+ except ValueError as e:
159
+ raise HTTPException(status_code=403, detail=str(e))
160
+ except Exception as e:
161
+ logger.error(f"Error clearing leaderboard: {e}")
162
+ raise HTTPException(status_code=500, detail=str(e))
163
+
164
+
165
  if __name__ == "__main__":
166
  import uvicorn
167
  uvicorn.run(app, host="0.0.0.0", port=7860)
hf_leaderboard.py ADDED
@@ -0,0 +1,227 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ Hugging Face Leaderboard Service
3
+ Manages leaderboard data persistence using HF Hub
4
+ """
5
+
6
+ import json
7
+ import os
8
+ from datetime import datetime
9
+ from typing import List, Dict, Optional
10
+ from huggingface_hub import HfApi, hf_hub_download, HfFolder
11
+ import logging
12
+
13
+ logger = logging.getLogger(__name__)
14
+
15
+
16
+ class HFLeaderboardService:
17
+ """
18
+ Service for managing leaderboard data on Hugging Face Hub
19
+ Stores leaderboard as a JSON file in a HF dataset
20
+ """
21
+
22
+ def __init__(self, repo_id: str = "zmuhls/cloze-reader-leaderboard", token: Optional[str] = None):
23
+ """
24
+ Initialize HF Leaderboard Service
25
+
26
+ Args:
27
+ repo_id: HF Hub repository ID (format: username/repo-name)
28
+ token: HF API token (if not provided, uses HF_TOKEN env var)
29
+ """
30
+ self.repo_id = repo_id
31
+ self.token = token or os.getenv("HF_TOKEN") or os.getenv("HF_API_KEY")
32
+ self.api = HfApi()
33
+ self.leaderboard_file = "leaderboard.json"
34
+
35
+ if not self.token:
36
+ logger.warning("No HF token provided. Read-only mode (if repo is public)")
37
+
38
+ # Ensure repo exists
39
+ self._ensure_repo_exists()
40
+
41
+ def _ensure_repo_exists(self):
42
+ """Create the HF dataset repository if it doesn't exist"""
43
+ if not self.token:
44
+ return
45
+
46
+ try:
47
+ # Try to get repo info
48
+ self.api.repo_info(repo_id=self.repo_id, repo_type="dataset", token=self.token)
49
+ logger.info(f"Using existing HF dataset: {self.repo_id}")
50
+ except Exception as e:
51
+ # Repo doesn't exist, create it
52
+ try:
53
+ self.api.create_repo(
54
+ repo_id=self.repo_id,
55
+ repo_type="dataset",
56
+ token=self.token,
57
+ private=False,
58
+ exist_ok=True
59
+ )
60
+ logger.info(f"Created HF dataset: {self.repo_id}")
61
+
62
+ # Initialize with empty leaderboard
63
+ self._save_to_hub([])
64
+ except Exception as create_error:
65
+ logger.error(f"Failed to create HF repo: {create_error}")
66
+
67
+ def _save_to_hub(self, data: List[Dict]):
68
+ """
69
+ Save leaderboard data to HF Hub
70
+
71
+ Args:
72
+ data: List of leaderboard entries
73
+ """
74
+ if not self.token:
75
+ raise ValueError("No HF token available for writing")
76
+
77
+ # Create temporary file
78
+ temp_file = f"/tmp/{self.leaderboard_file}"
79
+ with open(temp_file, "w") as f:
80
+ json.dump({
81
+ "leaderboard": data,
82
+ "last_updated": datetime.utcnow().isoformat(),
83
+ "version": "1.0"
84
+ }, f, indent=2)
85
+
86
+ # Upload to HF Hub
87
+ try:
88
+ self.api.upload_file(
89
+ path_or_fileobj=temp_file,
90
+ path_in_repo=self.leaderboard_file,
91
+ repo_id=self.repo_id,
92
+ repo_type="dataset",
93
+ token=self.token,
94
+ commit_message=f"Update leaderboard - {datetime.utcnow().strftime('%Y-%m-%d %H:%M:%S')} UTC"
95
+ )
96
+ logger.info(f"Leaderboard saved to HF Hub: {self.repo_id}")
97
+ except Exception as e:
98
+ logger.error(f"Failed to upload to HF Hub: {e}")
99
+ raise
100
+ finally:
101
+ # Clean up temp file
102
+ if os.path.exists(temp_file):
103
+ os.remove(temp_file)
104
+
105
+ def _load_from_hub(self) -> List[Dict]:
106
+ """
107
+ Load leaderboard data from HF Hub
108
+
109
+ Returns:
110
+ List of leaderboard entries
111
+ """
112
+ try:
113
+ # Download file from HF Hub
114
+ file_path = hf_hub_download(
115
+ repo_id=self.repo_id,
116
+ filename=self.leaderboard_file,
117
+ repo_type="dataset",
118
+ token=self.token
119
+ )
120
+
121
+ with open(file_path, "r") as f:
122
+ data = json.load(f)
123
+ return data.get("leaderboard", [])
124
+ except Exception as e:
125
+ logger.warning(f"Failed to load from HF Hub: {e}. Returning empty leaderboard.")
126
+ return []
127
+
128
+ def get_leaderboard(self) -> List[Dict]:
129
+ """
130
+ Get current leaderboard data
131
+
132
+ Returns:
133
+ List of leaderboard entries sorted by rank
134
+ """
135
+ return self._load_from_hub()
136
+
137
+ def add_entry(self, entry: Dict) -> bool:
138
+ """
139
+ Add new entry to leaderboard
140
+
141
+ Args:
142
+ entry: Leaderboard entry with keys: initials, level, round, passagesPassed, date
143
+
144
+ Returns:
145
+ True if successful, False otherwise
146
+ """
147
+ if not self.token:
148
+ raise ValueError("No HF token available for writing")
149
+
150
+ try:
151
+ # Load current leaderboard
152
+ leaderboard = self._load_from_hub()
153
+
154
+ # Add new entry
155
+ leaderboard.append(entry)
156
+
157
+ # Sort leaderboard (highest level first, then round, then passages)
158
+ leaderboard = self._sort_leaderboard(leaderboard)
159
+
160
+ # Keep only top 10
161
+ leaderboard = leaderboard[:10]
162
+
163
+ # Save back to hub
164
+ self._save_to_hub(leaderboard)
165
+
166
+ logger.info(f"Added entry to leaderboard: {entry['initials']} - Level {entry['level']}")
167
+ return True
168
+ except Exception as e:
169
+ logger.error(f"Failed to add entry: {e}")
170
+ return False
171
+
172
+ def update_leaderboard(self, leaderboard: List[Dict]) -> bool:
173
+ """
174
+ Replace entire leaderboard with new data
175
+
176
+ Args:
177
+ leaderboard: Complete leaderboard data
178
+
179
+ Returns:
180
+ True if successful, False otherwise
181
+ """
182
+ if not self.token:
183
+ raise ValueError("No HF token available for writing")
184
+
185
+ try:
186
+ # Sort and limit to top 10
187
+ sorted_board = self._sort_leaderboard(leaderboard)[:10]
188
+ self._save_to_hub(sorted_board)
189
+ return True
190
+ except Exception as e:
191
+ logger.error(f"Failed to update leaderboard: {e}")
192
+ return False
193
+
194
+ def _sort_leaderboard(self, entries: List[Dict]) -> List[Dict]:
195
+ """
196
+ Sort leaderboard entries by performance
197
+
198
+ Args:
199
+ entries: List of leaderboard entries
200
+
201
+ Returns:
202
+ Sorted list (best first)
203
+ """
204
+ return sorted(entries, key=lambda x: (
205
+ -x.get('level', 0), # Higher level is better
206
+ -x.get('round', 0), # Higher round is better
207
+ -x.get('passagesPassed', 0), # More passages is better
208
+ x.get('date', '') # Newer is better (date sorts ascending)
209
+ ))
210
+
211
+ def clear_leaderboard(self) -> bool:
212
+ """
213
+ Clear all leaderboard data (admin function)
214
+
215
+ Returns:
216
+ True if successful, False otherwise
217
+ """
218
+ if not self.token:
219
+ raise ValueError("No HF token available for writing")
220
+
221
+ try:
222
+ self._save_to_hub([])
223
+ logger.info("Leaderboard cleared")
224
+ return True
225
+ except Exception as e:
226
+ logger.error(f"Failed to clear leaderboard: {e}")
227
+ return False
index.html CHANGED
@@ -58,6 +58,7 @@
58
  <button type="button" id="hint-btn" class="typewriter-button">
59
  Show Hints
60
  </button>
 
61
  <button id="leaderboard-btn" class="leaderboard-footer-btn" title="View leaderboard">
62
  🏅
63
  </button>
 
58
  <button type="button" id="hint-btn" class="typewriter-button">
59
  Show Hints
60
  </button>
61
+ <div class="controls-divider"></div>
62
  <button id="leaderboard-btn" class="leaderboard-footer-btn" title="View leaderboard">
63
  🏅
64
  </button>
requirements.txt CHANGED
@@ -1,3 +1,4 @@
1
  fastapi==0.104.1
2
  uvicorn[standard]==0.24.0
3
- python-dotenv==1.0.0
 
 
1
  fastapi==0.104.1
2
  uvicorn[standard]==0.24.0
3
+ python-dotenv==1.0.0
4
+ huggingface-hub==0.20.3
src/hfLeaderboardAPI.js ADDED
@@ -0,0 +1,164 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ /**
2
+ * HF Leaderboard API Client
3
+ * Communicates with FastAPI backend for HF Hub leaderboard persistence
4
+ */
5
+
6
+ export class HFLeaderboardAPI {
7
+ constructor(baseUrl = '') {
8
+ // If no baseUrl provided, use current origin (works for both dev and production)
9
+ this.baseUrl = baseUrl || window.location.origin;
10
+ }
11
+
12
+ /**
13
+ * Get leaderboard from HF Hub
14
+ * @returns {Promise<Array>} Array of leaderboard entries
15
+ */
16
+ async getLeaderboard() {
17
+ try {
18
+ const response = await fetch(`${this.baseUrl}/api/leaderboard`, {
19
+ method: 'GET',
20
+ headers: {
21
+ 'Content-Type': 'application/json'
22
+ }
23
+ });
24
+
25
+ if (!response.ok) {
26
+ throw new Error(`HTTP ${response.status}: ${response.statusText}`);
27
+ }
28
+
29
+ const data = await response.json();
30
+
31
+ if (data.success) {
32
+ console.log('📥 HF API: Retrieved leaderboard', {
33
+ entries: data.leaderboard.length,
34
+ message: data.message
35
+ });
36
+ return data.leaderboard;
37
+ } else {
38
+ throw new Error(data.message || 'Failed to retrieve leaderboard');
39
+ }
40
+ } catch (error) {
41
+ console.error('❌ HF API: Error fetching leaderboard:', error);
42
+ throw error;
43
+ }
44
+ }
45
+
46
+ /**
47
+ * Add new entry to leaderboard
48
+ * @param {Object} entry - Leaderboard entry {initials, level, round, passagesPassed, date}
49
+ * @returns {Promise<Object>} Response object
50
+ */
51
+ async addEntry(entry) {
52
+ try {
53
+ const response = await fetch(`${this.baseUrl}/api/leaderboard/add`, {
54
+ method: 'POST',
55
+ headers: {
56
+ 'Content-Type': 'application/json'
57
+ },
58
+ body: JSON.stringify(entry)
59
+ });
60
+
61
+ if (!response.ok) {
62
+ const errorData = await response.json().catch(() => ({ detail: response.statusText }));
63
+ throw new Error(`HTTP ${response.status}: ${errorData.detail || response.statusText}`);
64
+ }
65
+
66
+ const data = await response.json();
67
+
68
+ console.log('✅ HF API: Added entry', {
69
+ initials: entry.initials,
70
+ level: entry.level,
71
+ message: data.message
72
+ });
73
+
74
+ return data;
75
+ } catch (error) {
76
+ console.error('❌ HF API: Error adding entry:', error);
77
+ throw error;
78
+ }
79
+ }
80
+
81
+ /**
82
+ * Update entire leaderboard
83
+ * @param {Array} entries - Array of leaderboard entries
84
+ * @returns {Promise<Object>} Response object
85
+ */
86
+ async updateLeaderboard(entries) {
87
+ try {
88
+ const response = await fetch(`${this.baseUrl}/api/leaderboard/update`, {
89
+ method: 'POST',
90
+ headers: {
91
+ 'Content-Type': 'application/json'
92
+ },
93
+ body: JSON.stringify(entries)
94
+ });
95
+
96
+ if (!response.ok) {
97
+ const errorData = await response.json().catch(() => ({ detail: response.statusText }));
98
+ throw new Error(`HTTP ${response.status}: ${errorData.detail || response.statusText}`);
99
+ }
100
+
101
+ const data = await response.json();
102
+
103
+ console.log('✅ HF API: Updated leaderboard', {
104
+ entries: entries.length,
105
+ message: data.message
106
+ });
107
+
108
+ return data;
109
+ } catch (error) {
110
+ console.error('❌ HF API: Error updating leaderboard:', error);
111
+ throw error;
112
+ }
113
+ }
114
+
115
+ /**
116
+ * Clear all leaderboard data (admin function)
117
+ * @returns {Promise<Object>} Response object
118
+ */
119
+ async clearLeaderboard() {
120
+ try {
121
+ const response = await fetch(`${this.baseUrl}/api/leaderboard/clear`, {
122
+ method: 'DELETE',
123
+ headers: {
124
+ 'Content-Type': 'application/json'
125
+ }
126
+ });
127
+
128
+ if (!response.ok) {
129
+ const errorData = await response.json().catch(() => ({ detail: response.statusText }));
130
+ throw new Error(`HTTP ${response.status}: ${errorData.detail || response.statusText}`);
131
+ }
132
+
133
+ const data = await response.json();
134
+
135
+ console.log('✅ HF API: Cleared leaderboard', {
136
+ message: data.message
137
+ });
138
+
139
+ return data;
140
+ } catch (error) {
141
+ console.error('❌ HF API: Error clearing leaderboard:', error);
142
+ throw error;
143
+ }
144
+ }
145
+
146
+ /**
147
+ * Check if HF backend is available
148
+ * @returns {Promise<boolean>} True if backend is reachable
149
+ */
150
+ async isAvailable() {
151
+ try {
152
+ const response = await fetch(`${this.baseUrl}/api/leaderboard`, {
153
+ method: 'GET',
154
+ headers: {
155
+ 'Content-Type': 'application/json'
156
+ }
157
+ });
158
+ return response.ok;
159
+ } catch (error) {
160
+ console.warn('⚠️ HF API: Backend not available, will use localStorage fallback');
161
+ return false;
162
+ }
163
+ }
164
+ }
src/leaderboardService.js CHANGED
@@ -1,9 +1,11 @@
1
  /**
2
  * Leaderboard Service
3
- * Manages high scores, player stats, and localStorage persistence
4
  * Following arcade conventions with 3-letter initials and top 10 tracking
5
  */
6
 
 
 
7
  export class LeaderboardService {
8
  constructor() {
9
  this.storageKeys = {
@@ -14,9 +16,49 @@ export class LeaderboardService {
14
 
15
  this.maxEntries = 10;
16
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
17
  // Reset all data on initialization (fresh start each session)
18
  this.resetAll();
19
  this.initializeStorage();
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
20
  }
21
 
22
  /**
@@ -223,7 +265,7 @@ export class LeaderboardService {
223
  /**
224
  * Add a new entry to the leaderboard
225
  */
226
- addEntry(initials, level, round, passagesPassed) {
227
  const validInitials = this.validateInitials(initials);
228
  if (!validInitials) {
229
  console.error('Invalid initials:', initials);
@@ -246,7 +288,19 @@ export class LeaderboardService {
246
  const trimmed = sorted.slice(0, this.maxEntries);
247
  this.saveLeaderboard(trimmed);
248
 
249
- return sorted.findIndex(entry => entry === newEntry) + 1; // Return rank
 
 
 
 
 
 
 
 
 
 
 
 
250
  }
251
 
252
  /**
 
1
  /**
2
  * Leaderboard Service
3
+ * Manages high scores, player stats, with HF Hub persistence and localStorage fallback
4
  * Following arcade conventions with 3-letter initials and top 10 tracking
5
  */
6
 
7
+ import { HFLeaderboardAPI } from './hfLeaderboardAPI.js';
8
+
9
  export class LeaderboardService {
10
  constructor() {
11
  this.storageKeys = {
 
16
 
17
  this.maxEntries = 10;
18
 
19
+ // Initialize HF API client
20
+ this.hfAPI = new HFLeaderboardAPI();
21
+ this.useHF = false; // Will be set based on availability check
22
+
23
+ // Check HF availability and initialize
24
+ this.initializeAsync();
25
+ }
26
+
27
+ /**
28
+ * Async initialization to check HF availability
29
+ */
30
+ async initializeAsync() {
31
+ try {
32
+ this.useHF = await this.hfAPI.isAvailable();
33
+ console.log(`🔧 LEADERBOARD: Using ${this.useHF ? 'HF Hub backend' : 'localStorage only'}`);
34
+ } catch (error) {
35
+ console.warn('⚠️ LEADERBOARD: HF backend unavailable, using localStorage', error);
36
+ this.useHF = false;
37
+ }
38
+
39
  // Reset all data on initialization (fresh start each session)
40
  this.resetAll();
41
  this.initializeStorage();
42
+
43
+ // If HF is available, sync from HF to localStorage
44
+ if (this.useHF) {
45
+ await this.syncFromHF();
46
+ }
47
+ }
48
+
49
+ /**
50
+ * Sync leaderboard from HF Hub to localStorage
51
+ */
52
+ async syncFromHF() {
53
+ try {
54
+ const hfLeaderboard = await this.hfAPI.getLeaderboard();
55
+ if (hfLeaderboard && hfLeaderboard.length > 0) {
56
+ this.saveLeaderboard(hfLeaderboard);
57
+ console.log('📥 LEADERBOARD: Synced from HF Hub', { entries: hfLeaderboard.length });
58
+ }
59
+ } catch (error) {
60
+ console.error('❌ LEADERBOARD: Failed to sync from HF', error);
61
+ }
62
  }
63
 
64
  /**
 
265
  /**
266
  * Add a new entry to the leaderboard
267
  */
268
+ async addEntry(initials, level, round, passagesPassed) {
269
  const validInitials = this.validateInitials(initials);
270
  if (!validInitials) {
271
  console.error('Invalid initials:', initials);
 
288
  const trimmed = sorted.slice(0, this.maxEntries);
289
  this.saveLeaderboard(trimmed);
290
 
291
+ const rank = sorted.findIndex(entry => entry === newEntry) + 1;
292
+
293
+ // If HF is available, also save to HF Hub
294
+ if (this.useHF) {
295
+ try {
296
+ await this.hfAPI.addEntry(newEntry);
297
+ console.log('📤 LEADERBOARD: Saved to HF Hub', { initials: validInitials, level, rank });
298
+ } catch (error) {
299
+ console.error('❌ LEADERBOARD: Failed to save to HF, localStorage only', error);
300
+ }
301
+ }
302
+
303
+ return rank; // Return rank
304
  }
305
 
306
  /**
src/styles.css CHANGED
@@ -428,28 +428,39 @@
428
 
429
  /* Leaderboard button in footer */
430
  .leaderboard-footer-btn {
431
- background: none;
432
- border: none;
 
 
433
  padding: 8px 16px;
434
  cursor: pointer;
435
  font-size: 1.5rem;
436
  line-height: 1;
437
- transition: transform 0.2s ease, filter 0.2s ease;
438
  display: flex;
439
  align-items: center;
440
  justify-content: center;
441
- position: absolute;
442
- right: 16px;
443
- top: 50%;
444
- transform: translateY(-50%);
 
 
445
  }
446
 
447
  .leaderboard-footer-btn:hover {
448
- transform: translateY(-50%) scale(1.2);
 
 
 
 
449
  }
450
 
451
  .leaderboard-footer-btn:active {
452
- transform: translateY(-50%) scale(1.05);
 
 
 
453
  }
454
 
455
 
@@ -477,8 +488,9 @@
477
 
478
  .leaderboard-footer-btn {
479
  font-size: 1.25rem;
480
- padding: 6px 12px;
481
- right: 12px;
 
482
  }
483
  }
484
 
@@ -502,8 +514,8 @@
502
 
503
  .leaderboard-footer-btn {
504
  font-size: 1.1rem;
505
- padding: 4px 10px;
506
- right: 8px;
507
  }
508
  }
509
 
@@ -735,34 +747,39 @@
735
  gap: 12px;
736
  padding: 14px 16px;
737
  background: var(--aged-paper-dark);
738
- border: 2px solid rgba(0, 0, 0, 0.2);
739
  border-radius: 6px;
740
  font-family: 'Courier New', monospace;
741
  transition: all 0.2s ease;
 
742
  }
743
 
744
  /* Rank Colors */
745
  .leaderboard-entry.rank-gold {
746
- background: linear-gradient(135deg, #fff7e6 0%, #ffeaa7 100%);
747
- border-color: #d4af37;
748
- border-width: 3px;
749
- box-shadow: 0 2px 8px rgba(212, 175, 55, 0.3);
750
  }
751
 
752
  .leaderboard-entry.rank-silver {
753
- background: linear-gradient(135deg, #fff9f2 0%, #f5deb3 100%);
754
- border-color: #c9a869;
755
- border-width: 2px;
 
756
  }
757
 
758
  .leaderboard-entry.rank-standard {
759
- background: var(--aged-paper-dark);
 
 
760
  }
761
 
762
  .leaderboard-entry.player-entry {
763
- background: linear-gradient(135deg, #fff9f0 0%, #ffecd1 100%);
764
- border-color: #d4a574;
765
- border-width: 3px;
 
766
  }
767
 
768
  .leaderboard-entry:hover {
 
428
 
429
  /* Leaderboard button in footer */
430
  .leaderboard-footer-btn {
431
+ background-color: var(--aged-paper-dark);
432
+ color: var(--typewriter-ink);
433
+ border: 2px solid black;
434
+ border-radius: 0 8px 8px 0;
435
  padding: 8px 16px;
436
  cursor: pointer;
437
  font-size: 1.5rem;
438
  line-height: 1;
439
+ transition: all 0.15s ease;
440
  display: flex;
441
  align-items: center;
442
  justify-content: center;
443
+ flex: 0 0 auto;
444
+ min-width: 60px;
445
+ min-height: 44px;
446
+ box-shadow:
447
+ 0 3px 0 rgba(0, 0, 0, 0.3),
448
+ 0 4px 8px rgba(0, 0, 0, 0.1);
449
  }
450
 
451
  .leaderboard-footer-btn:hover {
452
+ background-color: rgba(0, 0, 0, 0.05);
453
+ transform: translateY(-1px);
454
+ box-shadow:
455
+ 0 4px 0 rgba(0, 0, 0, 0.3),
456
+ 0 6px 12px rgba(0, 0, 0, 0.15);
457
  }
458
 
459
  .leaderboard-footer-btn:active {
460
+ transform: translateY(2px);
461
+ box-shadow:
462
+ 0 1px 0 rgba(0, 0, 0, 0.3),
463
+ 0 2px 4px rgba(0, 0, 0, 0.1);
464
  }
465
 
466
 
 
488
 
489
  .leaderboard-footer-btn {
490
  font-size: 1.25rem;
491
+ padding: 12px 10px;
492
+ min-width: 50px;
493
+ min-height: 48px;
494
  }
495
  }
496
 
 
514
 
515
  .leaderboard-footer-btn {
516
  font-size: 1.1rem;
517
+ padding: 12px 8px;
518
+ min-width: 48px;
519
  }
520
  }
521
 
 
747
  gap: 12px;
748
  padding: 14px 16px;
749
  background: var(--aged-paper-dark);
750
+ border: 3px solid rgba(0, 0, 0, 0.4);
751
  border-radius: 6px;
752
  font-family: 'Courier New', monospace;
753
  transition: all 0.2s ease;
754
+ box-shadow: 0 2px 4px rgba(0, 0, 0, 0.15);
755
  }
756
 
757
  /* Rank Colors */
758
  .leaderboard-entry.rank-gold {
759
+ background: linear-gradient(135deg, #fff7e6 0%, #ffd700 100%);
760
+ border-color: #b8860b;
761
+ border-width: 4px;
762
+ box-shadow: 0 4px 12px rgba(184, 134, 11, 0.4), inset 0 1px 0 rgba(255, 255, 255, 0.5);
763
  }
764
 
765
  .leaderboard-entry.rank-silver {
766
+ background: linear-gradient(135deg, #fff9f2 0%, #e6d7b8 100%);
767
+ border-color: #a0826d;
768
+ border-width: 3px;
769
+ box-shadow: 0 3px 8px rgba(160, 130, 109, 0.3);
770
  }
771
 
772
  .leaderboard-entry.rank-standard {
773
+ background: #ffffff;
774
+ border-color: rgba(0, 0, 0, 0.3);
775
+ border-width: 2px;
776
  }
777
 
778
  .leaderboard-entry.player-entry {
779
+ background: linear-gradient(135deg, #fff9f0 0%, #ffd89b 100%);
780
+ border-color: #cc8800;
781
+ border-width: 4px;
782
+ box-shadow: 0 4px 12px rgba(204, 136, 0, 0.35);
783
  }
784
 
785
  .leaderboard-entry:hover {