Spaces:
Sleeping
Sleeping
milwright commited on
Commit ·
a6d0aac
0
Parent(s):
clean production deployment with comprehensive readme
Browse files- .dockerignore +53 -0
- .env.example +13 -0
- .gitignore +84 -0
- Dockerfile +14 -0
- Makefile +47 -0
- README.md +312 -0
- app.py +638 -0
- apple-touch-icon.png +3 -0
- docker-compose.yml +27 -0
- favicon.png +3 -0
- favicon.svg +12 -0
- icon-192.png +3 -0
- icon-512.png +3 -0
- icon.png +3 -0
- icon.svg +23 -0
- index.html +88 -0
- requirements.txt +5 -0
- site.webmanifest +12 -0
- src/aiService.js +736 -0
- src/analyticsService.js +350 -0
- src/app.js +592 -0
- src/bookDataService.js +631 -0
- src/chatInterface.js +335 -0
- src/clozeGameEngine.js +970 -0
- src/conversationManager.js +165 -0
- src/hfLeaderboardAPI.js +295 -0
- src/init-env.js +14 -0
- src/leaderboardService.js +429 -0
- src/leaderboardUI.js +590 -0
- src/styles.css +1242 -0
- src/welcomeOverlay.js +106 -0
.dockerignore
ADDED
|
@@ -0,0 +1,53 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# Node modules
|
| 2 |
+
node_modules/
|
| 3 |
+
npm-debug.log*
|
| 4 |
+
yarn-debug.log*
|
| 5 |
+
|
| 6 |
+
# Git
|
| 7 |
+
.git/
|
| 8 |
+
.gitignore
|
| 9 |
+
|
| 10 |
+
# Environment files
|
| 11 |
+
.env
|
| 12 |
+
.env.*
|
| 13 |
+
|
| 14 |
+
# IDE files
|
| 15 |
+
.vscode/
|
| 16 |
+
.idea/
|
| 17 |
+
*.swp
|
| 18 |
+
*.swo
|
| 19 |
+
|
| 20 |
+
# OS files
|
| 21 |
+
.DS_Store
|
| 22 |
+
Thumbs.db
|
| 23 |
+
|
| 24 |
+
# Logs
|
| 25 |
+
*.log
|
| 26 |
+
logs/
|
| 27 |
+
|
| 28 |
+
# Temporary files
|
| 29 |
+
tmp/
|
| 30 |
+
temp/
|
| 31 |
+
.aider*
|
| 32 |
+
|
| 33 |
+
# Documentation
|
| 34 |
+
*.md
|
| 35 |
+
!README.md
|
| 36 |
+
|
| 37 |
+
# Planning documents
|
| 38 |
+
*_PLAN.md
|
| 39 |
+
IMPLEMENTATION_*.md
|
| 40 |
+
|
| 41 |
+
# Python cache
|
| 42 |
+
__pycache__/
|
| 43 |
+
*.pyc
|
| 44 |
+
*.pyo
|
| 45 |
+
*.pyd
|
| 46 |
+
|
| 47 |
+
# Coverage
|
| 48 |
+
coverage/
|
| 49 |
+
.nyc_output/
|
| 50 |
+
|
| 51 |
+
# Build artifacts
|
| 52 |
+
dist/
|
| 53 |
+
build/
|
.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
|
.gitignore
ADDED
|
@@ -0,0 +1,84 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# Dependencies
|
| 2 |
+
node_modules/
|
| 3 |
+
npm-debug.log*
|
| 4 |
+
yarn-debug.log*
|
| 5 |
+
yarn-error.log*
|
| 6 |
+
|
| 7 |
+
# Build outputs
|
| 8 |
+
dist/
|
| 9 |
+
.parcel-cache/
|
| 10 |
+
|
| 11 |
+
# Environment variables
|
| 12 |
+
.env
|
| 13 |
+
.env.local
|
| 14 |
+
.env.development.local
|
| 15 |
+
.env.test.local
|
| 16 |
+
.env.production.local
|
| 17 |
+
|
| 18 |
+
# IDE and editor files
|
| 19 |
+
.vscode/
|
| 20 |
+
.idea/
|
| 21 |
+
*.swp
|
| 22 |
+
*.swo
|
| 23 |
+
*~
|
| 24 |
+
|
| 25 |
+
# OS generated files
|
| 26 |
+
.DS_Store
|
| 27 |
+
.DS_Store?
|
| 28 |
+
._*
|
| 29 |
+
.Spotlight-V100
|
| 30 |
+
.Trashes
|
| 31 |
+
ehthumbs.db
|
| 32 |
+
Thumbs.db
|
| 33 |
+
|
| 34 |
+
# Logs
|
| 35 |
+
logs
|
| 36 |
+
*.log
|
| 37 |
+
|
| 38 |
+
# Runtime data
|
| 39 |
+
pids
|
| 40 |
+
*.pid
|
| 41 |
+
*.seed
|
| 42 |
+
*.pid.lock
|
| 43 |
+
|
| 44 |
+
# Leaderboard data (stored in dedicated HF Space)
|
| 45 |
+
leaderboard.json
|
| 46 |
+
|
| 47 |
+
# Optional npm cache directory
|
| 48 |
+
.npm
|
| 49 |
+
|
| 50 |
+
# Optional eslint cache
|
| 51 |
+
.eslintcache
|
| 52 |
+
|
| 53 |
+
# Coverage directory used by tools like istanbul
|
| 54 |
+
coverage/
|
| 55 |
+
|
| 56 |
+
# Temporary folders
|
| 57 |
+
tmp/
|
| 58 |
+
temp/
|
| 59 |
+
.aider*
|
| 60 |
+
|
| 61 |
+
# Python
|
| 62 |
+
__pycache__/
|
| 63 |
+
*.py[cod]
|
| 64 |
+
*$py.class
|
| 65 |
+
*.so
|
| 66 |
+
|
| 67 |
+
# Planning documents
|
| 68 |
+
HF_DATASET_INTEGRATION_PLAN.md
|
| 69 |
+
*_PLAN.md
|
| 70 |
+
IMPLEMENTATION_*.md
|
| 71 |
+
|
| 72 |
+
# Local configuration files
|
| 73 |
+
*.local
|
| 74 |
+
.env
|
| 75 |
+
|
| 76 |
+
# Development and testing files
|
| 77 |
+
favicon.ico
|
| 78 |
+
package-lock.json
|
| 79 |
+
test-*.html
|
| 80 |
+
|
| 81 |
+
# Temporary files and screenshots
|
| 82 |
+
Screenshot*.png
|
| 83 |
+
*-screenshot.png
|
| 84 |
+
error-screenshot.png
|
Dockerfile
ADDED
|
@@ -0,0 +1,14 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# Read the doc: https://huggingface.co/docs/hub/spaces-sdks-docker
|
| 2 |
+
FROM python:3.9
|
| 3 |
+
|
| 4 |
+
RUN useradd -m -u 1000 user
|
| 5 |
+
USER user
|
| 6 |
+
ENV PATH="/home/user/.local/bin:$PATH"
|
| 7 |
+
|
| 8 |
+
WORKDIR /app
|
| 9 |
+
|
| 10 |
+
COPY --chown=user ./requirements.txt requirements.txt
|
| 11 |
+
RUN pip install --no-cache-dir --upgrade -r requirements.txt
|
| 12 |
+
|
| 13 |
+
COPY --chown=user . /app
|
| 14 |
+
CMD ["uvicorn", "app:app", "--host", "0.0.0.0", "--port", "7860"]
|
Makefile
ADDED
|
@@ -0,0 +1,47 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
.PHONY: help dev dev-python dev-docker build test clean install docker-build docker-run docker-dev
|
| 2 |
+
|
| 3 |
+
help: ## Show this help message
|
| 4 |
+
@echo "Available commands:"
|
| 5 |
+
@grep -E '^[a-zA-Z_-]+:.*?## .*$$' $(MAKEFILE_LIST) | sort | awk 'BEGIN {FS = ":.*?## "}; {printf " \033[36m%-15s\033[0m %s\n", $$1, $$2}'
|
| 6 |
+
|
| 7 |
+
install: ## Install dependencies
|
| 8 |
+
@echo "Installing Python dependencies..."
|
| 9 |
+
pip install -r requirements.txt
|
| 10 |
+
@echo "No Node dependencies to install (vanilla JS)"
|
| 11 |
+
|
| 12 |
+
dev: ## Start local static server (for frontend only)
|
| 13 |
+
@echo "Starting local static server on http://localhost:8000"
|
| 14 |
+
python -m http.server 8000
|
| 15 |
+
|
| 16 |
+
dev-python: ## Start FastAPI development server
|
| 17 |
+
@echo "Starting FastAPI server on http://localhost:7860"
|
| 18 |
+
python app.py
|
| 19 |
+
|
| 20 |
+
dev-docker: ## Start development environment with Docker Compose
|
| 21 |
+
docker-compose --profile dev up --build
|
| 22 |
+
|
| 23 |
+
build: ## Build the application (no-op for vanilla JS)
|
| 24 |
+
@echo "No build step needed for vanilla JS application"
|
| 25 |
+
|
| 26 |
+
test: ## Run tests (placeholder)
|
| 27 |
+
@echo "No tests configured yet"
|
| 28 |
+
|
| 29 |
+
clean: ## Clean temporary files
|
| 30 |
+
find . -type f -name "*.pyc" -delete
|
| 31 |
+
find . -type d -name "__pycache__" -delete
|
| 32 |
+
find . -type d -name "node_modules" -exec rm -rf {} + 2>/dev/null || true
|
| 33 |
+
|
| 34 |
+
docker-build: ## Build Docker image
|
| 35 |
+
docker build -t cloze-reader .
|
| 36 |
+
|
| 37 |
+
docker-run: ## Run Docker container
|
| 38 |
+
docker run -p 7860:7860 --env-file .env cloze-reader
|
| 39 |
+
|
| 40 |
+
docker-dev: ## Start with docker-compose
|
| 41 |
+
docker-compose up --build
|
| 42 |
+
|
| 43 |
+
logs: ## Show Docker logs
|
| 44 |
+
docker-compose logs -f
|
| 45 |
+
|
| 46 |
+
stop: ## Stop Docker containers
|
| 47 |
+
docker-compose down
|
README.md
ADDED
|
@@ -0,0 +1,312 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# Cloze Reader: Recovering Deep Reading Through Algorithmic Assessment
|
| 2 |
+
|
| 3 |
+
## Introduction
|
| 4 |
+
|
| 5 |
+
The Cloze Reader transforms classic literature from the public domain into interactive vocabulary exercises powered by large language models. The application uses Google's Gemma-3 architecture to analyze passages from Project Gutenberg, select contextually appropriate words for deletion, and provide adaptive guidance through a conversational interface. Rather than generating novel text, the system surfaces forgotten public domain literature and invites users back to sustained engagement with individual texts—an act of critical resistance against generative systems designed for infinite, undifferentiated content production.
|
| 6 |
+
|
| 7 |
+
This project stages a recursive relationship: language models trained on prediction tasks are now used to generate prediction exercises for human readers. It forces us to ask what happens when assessment methodology and training methodology become instrumentalized through identical computational systems, when algorithmic selection replaces human pedagogical judgment, and when the boundary between human and machine comprehension becomes difficult to locate.
|
| 8 |
+
|
| 9 |
+
## Historical Context: Two Parallel Histories That Have Collapsed Into Each Other
|
| 10 |
+
|
| 11 |
+
### The Educational Assessment History: 1953
|
| 12 |
+
|
| 13 |
+
Wilson L. Taylor, a reading researcher, published a foundational paper introducing the cloze procedure as a tool for measuring reading comprehension. The methodology is deceptively simple: systematically delete words from a passage, ask readers to fill in the blanks, and score accuracy. Taylor argued that successful completion requires more than mere vocabulary recall. Instead, cloze tests measure contextual understanding—the capacity to integrate syntactic, semantic, pragmatic, and discourse cues to reconstruct deleted language.
|
| 14 |
+
|
| 15 |
+
The appeal was immediate and substantial. By the 1960s, cloze testing had become standard in American educational assessment, literacy research, and second-language instruction. The procedure offered educators what they desperately wanted: efficiency (many items from a single passage), objectivity (matching against answer keys rather than interpreting essays), and empirical rigor. Cloze tests promised to bypass the messiness of subjective evaluation while producing quantifiable measures of reading ability.
|
| 16 |
+
|
| 17 |
+
Cloze testing became embedded in the architecture of standardized testing across the United States. It shaped how literacy was defined, measured, and valued. The procedure was not merely an assessment instrument—it codified assumptions about what reading comprehension was and how it should be operationalized for institutional purposes.
|
| 18 |
+
|
| 19 |
+
### The AI Training History: 2018
|
| 20 |
+
|
| 21 |
+
In October 2018, researchers at Google, Facebook, and University of Washington published BERT: Pre-training of Deep Bidirectional Transformers for Language Understanding. The paper introduced masked language modeling (MLM) as a pre-training objective. The approach: randomly mask 15% of tokens in a sequence, train the model to predict the missing tokens from context, and iterate across internet-scale text corpora.
|
| 22 |
+
|
| 23 |
+
Remarkably, the researchers did not reference Taylor or invoke the language of educational assessment. They did not frame their work as a reinvention of the cloze procedure. Yet they had independently converged on cloze methodology as the core training objective for modern transformer models. The theoretical justification was different (learning contextualized embeddings rather than measuring comprehension), but the operational logic was identical: masking creates inference demands; understanding means predicting from context; success can be scored deterministically.
|
| 24 |
+
|
| 25 |
+
BERT and its successors (RoBERTa, ELECTRA, and countless variants) demonstrated that masked prediction is a powerful inductive bias for learning language. Models trained on MLM objectives developed robust representations of language structure, semantic relationships, and pragmatic patterns. The technique scaled to billions of parameters and trillions of tokens. It became foundational to modern large language models and consumer-facing AI systems.
|
| 26 |
+
|
| 27 |
+
The methodology migrated from educational psychology to computational linguistics because both domains had discovered something fundamental: understanding language, whether human or algorithmic, is fundamentally about prediction from context.
|
| 28 |
+
|
| 29 |
+
### The Convergence: Training Becomes Assessment, Assessment Becomes Training
|
| 30 |
+
|
| 31 |
+
Cloze testing and masked language modeling operate on identical premises:
|
| 32 |
+
|
| 33 |
+
- **Masking creates inference demands**: Removing a token forces reliance on surrounding structure rather than surface pattern matching.
|
| 34 |
+
- **Context integration**: Success requires synthesizing information across syntactic, semantic, and discourse boundaries.
|
| 35 |
+
- **Efficiency and scalability**: Many items can be processed rapidly; scoring is deterministic (for humans, match vs. non-match; for models, cross-entropy loss).
|
| 36 |
+
- **Variable difficulty**: Some blanks are trivial to fill (limited candidates, high probability); others require deep contextual reasoning.
|
| 37 |
+
|
| 38 |
+
Recent research explicitly draws these connections. Matsumori et al. (2023) used masked language models to generate open cloze questions for L2 English learners, treating MLM-equipped models as natural question generators. Ondov et al. (2024, NAACL) demonstrated that "the cloze training objective of Masked Language Models makes them a natural choice for generating plausible distractors for human cloze questions." Zhang & Hashimoto (2021) analyzed the inductive biases learned by masked tokens in transformer models, showing that these models acquire statistical and syntactic dependencies—precisely the linguistic phenomena that cloze tests aim to measure in human readers.
|
| 39 |
+
|
| 40 |
+
The Cloze Reader stages this convergence in a different way. While Gemma-3 (the model powering the system) was trained on next-token prediction rather than masked language modeling, the conceptual framework remains: we are using a language model trained on prediction tasks to generate prediction exercises for human learners. The system demonstrates that cloze generation and evaluation methodologies—once separate domains—are now instrumentalized through the same computational substrate. The boundary between training methodology and evaluation methodology has become permeable.
|
| 41 |
+
|
| 42 |
+
## Critical Framework: What This Convergence Reveals
|
| 43 |
+
|
| 44 |
+
### Recursive Assessment
|
| 45 |
+
|
| 46 |
+
Traditional assessment assumed a clean separation: educators design instruments; students complete them; data informs pedagogy. The Cloze Reader collapses this boundary. The same prediction task that trains state-of-the-art language models now serves as an assessment tool. This recursion raises uncomfortable questions. Can a model trained on surface pattern prediction—albeit at scale and with sophisticated architectures—generate assessment instruments that genuinely measure comprehension? Or does it reproduce the same surface-level pattern matching that enabled its own training? When human users solve cloze exercises generated by such a model, are they engaging in comprehension or replicating the model's own heuristics?
|
| 47 |
+
|
| 48 |
+
### Standardization Versus Serendipity
|
| 49 |
+
|
| 50 |
+
Educational cloze testing sought psychometric validity. Practitioners conducted readability studies, piloted passages with student populations, refined difficulty levels, and designed items to discriminate across ability ranges. The entire enterprise was about controlling and calibrating difficulty through human expertise.
|
| 51 |
+
|
| 52 |
+
The Cloze Reader abandons this control. It uses Gemma models trained on internet-scale text, applies them to the full Project Gutenberg corpus (70,000+ books with no curricular planning), and relies on statistical algorithms to select words for deletion. Difficulty emerges from the algorithmic interaction between passage content and model parameters, not from designed pedagogical intent. The same passage might yield wildly different difficulty levels depending on which words the model selects. What does "appropriate difficulty" mean when the system that determines it is a black box trained on patterns that nobody fully understands?
|
| 53 |
+
|
| 54 |
+
### Surface Cues Versus Deep Comprehension
|
| 55 |
+
|
| 56 |
+
Educational researchers have long critiqued cloze testing on the grounds that it measures local, sentence-level inference rather than global text comprehension. A reader might successfully complete a cloze passage by exploiting surface regularities and syntactic patterns without grasping the broader meaning of the text. Similarly, critics of masked language modeling note that models can achieve high accuracy by exploiting spurious statistical correlations rather than developing genuine semantic understanding. When a model trained on surface patterns generates cloze exercises, and human users solve those exercises using similar heuristics, where is comprehension happening? Are we measuring something real, or are we staging an elaborate performance of mutual statistical prediction?
|
| 57 |
+
|
| 58 |
+
### Authority and Transparency in Assessment Design
|
| 59 |
+
|
| 60 |
+
For most of the twentieth century, assessment authority was vested in institutional expertise. Teachers selected texts. Curriculum experts designed items. Psychometricians validated instruments. Authority was hierarchical, but it was also documentable: you could point to a specific teacher's choice or a specific item-analysis report. Assessment design was legible, even if it was unequally distributed.
|
| 61 |
+
|
| 62 |
+
The Cloze Reader distributes authority differently. The models it uses (Gemma-3) are open-weight models that anyone can download, inspect, and run locally. The system is designed for interrogation rather than institutional gatekeeping. You can examine the model's outputs, trace its decisions, modify its behavior, and deploy it on your own hardware. But this distribution of authority comes at a cost. Assessment design is now black-boxed in model weights rather than documented in curriculum guides. Patterns that determine difficulty are learned from billions of internet text samples rather than articulated through expert judgment. Authority is distributed, but it is also obscured.
|
| 63 |
+
|
| 64 |
+
### The Project Gutenberg Paradox
|
| 65 |
+
|
| 66 |
+
Project Gutenberg texts occupy a peculiar position in contemporary information ecology. These works are public domain—legally and technically available to everyone. Yet they are remarkably absent from popular consciousness. Most readers encounter these texts, if at all, through abridged excerpts in educational settings. The original, uncut texts languish in digital repositories.
|
| 67 |
+
|
| 68 |
+
Simultaneously, these same texts have been appropriated relentlessly as training data for large language models. Every major AI system trained on internet-scale corpora has incorporated Project Gutenberg texts. Commercial models have profited from and learned from this public archive without returning anything to the public sphere. The texts that should be most widely read have become most thoroughly invisible—present only as statistical patterns embedded in proprietary model weights.
|
| 69 |
+
|
| 70 |
+
The Cloze Reader inverts this trajectory. It surfaces Project Gutenberg texts. It makes them the primary content, not auxiliary training material. It asks users to read actual passages, carefully selected for quality but not processed or summarized. The design philosophy is explicit: bring people back to deep reading practices, precisely through engagement with a fill-in-the-blank game that generates genuinely novel exercises for each reader, each session. Unlike generative AI systems designed to produce infinite novel text, the Cloze Reader is designed to deepen engagement with finite, singular texts.
|
| 71 |
+
|
| 72 |
+
This is not a return to pre-digital reading practices. The system is thoroughly computational. But it is a return to the idea that reading means sustained engagement with specific texts, that repetition and variation can deepen understanding, and that computational systems can serve that deepening rather than obscuring it.
|
| 73 |
+
|
| 74 |
+
## Architecture and Data Flow
|
| 75 |
+
|
| 76 |
+
### System Overview
|
| 77 |
+
|
| 78 |
+
The Cloze Reader is a vanilla JavaScript application with a minimal FastAPI backend. The entire frontend runs in the browser using ES6 modules; there is no build step. This architectural choice prioritizes transparency and modifiability—users can inspect every component without specialized tooling.
|
| 79 |
+
|
| 80 |
+
### Application Flow
|
| 81 |
+
|
| 82 |
+
When a user loads the application, the initialization sequence follows a precise path:
|
| 83 |
+
|
| 84 |
+
1. **Welcome Overlay** displays onboarding instructions for first-time users.
|
| 85 |
+
2. **Game Engine Initialization** loads book data and AI services.
|
| 86 |
+
3. **First Round Generation** creates the initial cloze exercise.
|
| 87 |
+
4. **UI Activation** reveals the interactive game interface.
|
| 88 |
+
|
| 89 |
+
```tree
|
| 90 |
+
Page Load → app.js initialization
|
| 91 |
+
├─ welcomeOverlay.js → First-time user onboarding
|
| 92 |
+
├─ clozeGameEngine.js → Game state initialization
|
| 93 |
+
│ ├─ bookDataService.js → Stream from Hugging Face Datasets API
|
| 94 |
+
│ │ ├─ Primary: manu/project_gutenberg (70,000+ books)
|
| 95 |
+
│ │ └─ Fallback: Local embedded canonical works
|
| 96 |
+
│ ├─ aiService.js → Gemma-3-27b model selection and word generation
|
| 97 |
+
│ │ ├─ Level-based vocabulary constraints
|
| 98 |
+
│ │ ├─ Passage analysis and candidate filtering
|
| 99 |
+
│ │ └─ Distribution algorithm for blank placement
|
| 100 |
+
│ └─ Content quality filtering (statistical analysis)
|
| 101 |
+
└─ leaderboardService.js → localStorage persistence
|
| 102 |
+
|
| 103 |
+
User Interaction Loop:
|
| 104 |
+
├─ Input validation → app.js
|
| 105 |
+
├─ Chat interface → chatInterface.js
|
| 106 |
+
│ └─ conversationManager.js → AI conversation state
|
| 107 |
+
├─ Answer submission → clozeGameEngine.js scoring
|
| 108 |
+
└─ Round progression → Level advancement logic
|
| 109 |
+
```
|
| 110 |
+
|
| 111 |
+
### Core Module Interactions
|
| 112 |
+
|
| 113 |
+
**bookDataService.js** streams text from the Hugging Face Datasets API, accessing the manu/project_gutenberg dataset containing 70,000+ public domain texts. When streaming fails or APIs become unavailable, the system falls back to locally embedded canonical works (Pride and Prejudice, Tom Sawyer, Great Expectations, and others). Book selection is level-aware: levels 1-2 draw from 1900s publications; levels 3-4 from the 1800s; levels 5+ from any historical period.
|
| 114 |
+
|
| 115 |
+
**aiService.js** handles all AI operations using Gemma-3-27b via OpenRouter. The same model handles word selection, hint generation, literary contextualization, and conversational responses. For local deployment, the system automatically switches to Gemma-3-12b when `?local=true` is appended to the URL, connecting to port 1234 (compatible with LM Studio and similar tools).
|
| 116 |
+
|
| 117 |
+
Word selection follows a sophisticated multi-step process: level-based constraints define vocabulary difficulty ranges; passage analysis identifies candidate words while avoiding capitalized words, proper nouns, sentence beginnings, and artifacts; a distribution algorithm ensures blanks are spread throughout the passage; validation filtering confirms words exist in the passage and meet length requirements.
|
| 118 |
+
|
| 119 |
+
**chatInterface.js** provides word-level assistance through a modal-based interface. Users can ask questions about grammar, meaning, context, or request clues. The system preserves chat history per blank across the entire round, allowing for progressive disclosure of information.
|
| 120 |
+
|
| 121 |
+
**leaderboardService.js** manages high scores, player statistics, and localStorage persistence. The system tracks highest level reached, round numbers, total passages passed and attempted, and longest consecutive success streaks. Leaderboard data resets on each page load (ensuring fresh competition), but performance statistics persist within a session.
|
| 122 |
+
|
| 123 |
+
### Content Quality Filtering
|
| 124 |
+
|
| 125 |
+
The passage extraction system includes sophisticated quality detection to avoid dictionary entries, academic references, technical documentation, and formatting artifacts. The system analyzes capitalization ratios, punctuation density, and sentence structure. It applies pattern recognition for academic material (citations, abbreviations, etymology brackets, Roman numeral references). It detects dictionary-specific formatting (hash symbols used for entry organization) and technical terminology. A progressive scoring system rejects passages above a quality threshold.
|
| 126 |
+
|
| 127 |
+
### Difficulty Progression
|
| 128 |
+
|
| 129 |
+
The game implements level-aware difficulty scaling:
|
| 130 |
+
|
| 131 |
+
- **Levels 1-5**: 1 blank per passage, easier vocabulary (4-7 letters), full hints available
|
| 132 |
+
- **Levels 6-10**: 2 blanks per passage, medium vocabulary (4-10 letters), partial hints
|
| 133 |
+
- **Levels 11+**: 3 blanks per passage, challenging vocabulary (5-14 letters), minimal hints
|
| 134 |
+
|
| 135 |
+
Level advancement requires passing a single round. Each round contains two passages. Scoring is strict: for 1 blank, 100% accuracy; for 2 blanks, both correct; for 3+ blanks, all but one correct.
|
| 136 |
+
|
| 137 |
+
## Data Source and Content Pipeline
|
| 138 |
+
|
| 139 |
+
### Hugging Face Datasets Integration
|
| 140 |
+
|
| 141 |
+
The primary data source is the manu/project_gutenberg dataset available at [https://huggingface.co/datasets/manu/project_gutenberg](https://huggingface.co/datasets/manu/project_gutenberg). This dataset contains full texts from 70,000+ public domain works, updated continuously as new texts enter the public domain. The system uses lazy loading and on-demand processing, streaming excerpts rather than loading entire books into memory.
|
| 142 |
+
|
| 143 |
+
### Content Processing
|
| 144 |
+
|
| 145 |
+
Books undergo sophisticated cleaning to remove Project Gutenberg metadata (scanning notes, OCR artifacts, structural markers), chapter headers, page numbers, and formatting noise. The system identifies the actual narrative content and validates it for sufficient length and narrative structure. This processing is deferred until a book is selected for gameplay, optimizing overall performance.
|
| 146 |
+
|
| 147 |
+
### Quality Validation
|
| 148 |
+
|
| 149 |
+
Passages are subject to multiple quality checks. The system analyzes statistical properties (capitalization density, punctuation distribution, sentence length variance), applies pattern detection for reference material and technical content, and screens for dictionary-specific formatting. A passage that fails quality validation is rejected and the system selects a different excerpt or book.
|
| 150 |
+
|
| 151 |
+
## Technology Stack and Deployment
|
| 152 |
+
|
| 153 |
+
### Frontend Architecture
|
| 154 |
+
|
| 155 |
+
The application uses vanilla JavaScript with ES6 modules. There is no build process. This architectural choice is intentional: it keeps the machinery visible and modifiable rather than obscured behind tooling. The application is Progressive Web App (PWA) compatible, with manifest configuration supporting installation on mobile devices.
|
| 156 |
+
|
| 157 |
+
### Backend and API Integration
|
| 158 |
+
|
| 159 |
+
The FastAPI server (`app.py`) serves static files and injects API credentials securely into the browser via meta tags. This approach allows the system to handle sensitive credentials (like OpenRouter API keys) without exposing them in client-side code.
|
| 160 |
+
|
| 161 |
+
### AI Models
|
| 162 |
+
|
| 163 |
+
**Production Deployment**: Gemma-3-27b via OpenRouter API
|
| 164 |
+
|
| 165 |
+
**Local Deployment**: Gemma-3-12b on port 1234 (LM Studio, ollama, or OpenAI-compatible servers)
|
| 166 |
+
|
| 167 |
+
The model selection prioritizes:
|
| 168 |
+
|
| 169 |
+
- **Open-weight models**: Enables local deployment and inspection
|
| 170 |
+
- **Consistent architecture**: Same model family across production and local deployment
|
| 171 |
+
- **Performance**: Sufficient scale for sophisticated language tasks while remaining computationally feasible
|
| 172 |
+
|
| 173 |
+
### State Management
|
| 174 |
+
|
| 175 |
+
All game state persists in `localStorage`. There is no backend database. Storage keys are:
|
| 176 |
+
|
| 177 |
+
- `cloze-reader-leaderboard`: Top 10 high scores
|
| 178 |
+
- `cloze-reader-player`: Player profile (initials, session metadata)
|
| 179 |
+
- `cloze-reader-stats`: Comprehensive performance analytics
|
| 180 |
+
|
| 181 |
+
This client-side approach ensures the assessment system is fully auditable. All data transformations occur transparently in browser-side code, not on remote servers.
|
| 182 |
+
|
| 183 |
+
## Quick Start
|
| 184 |
+
|
| 185 |
+
### Docker Deployment (Recommended)
|
| 186 |
+
|
| 187 |
+
```bash
|
| 188 |
+
# Build the image
|
| 189 |
+
docker build -t cloze-reader .
|
| 190 |
+
|
| 191 |
+
# Run with OpenRouter API key
|
| 192 |
+
docker run -p 7860:7860 -e OPENROUTER_API_KEY=your_key_here cloze-reader
|
| 193 |
+
|
| 194 |
+
# Access at http://localhost:7860
|
| 195 |
+
```
|
| 196 |
+
|
| 197 |
+
### Local Python Development
|
| 198 |
+
|
| 199 |
+
```bash
|
| 200 |
+
# Install dependencies
|
| 201 |
+
pip install -r requirements.txt
|
| 202 |
+
|
| 203 |
+
# Start FastAPI server
|
| 204 |
+
python app.py
|
| 205 |
+
|
| 206 |
+
# Access at http://localhost:7860
|
| 207 |
+
```
|
| 208 |
+
|
| 209 |
+
### Local Development with Simple HTTP Server
|
| 210 |
+
|
| 211 |
+
```bash
|
| 212 |
+
# Minimal setup without FastAPI
|
| 213 |
+
python -m http.server 8000
|
| 214 |
+
|
| 215 |
+
# Access at http://localhost:8000
|
| 216 |
+
```
|
| 217 |
+
|
| 218 |
+
### Local LLM Integration
|
| 219 |
+
|
| 220 |
+
To run with a local language model server (no API key required):
|
| 221 |
+
|
| 222 |
+
```bash
|
| 223 |
+
# Start local LLM server on port 1234 (LM Studio, ollama, or similar)
|
| 224 |
+
# Then access the application with:
|
| 225 |
+
http://localhost:8000?local=true
|
| 226 |
+
```
|
| 227 |
+
|
| 228 |
+
### Environment Variables
|
| 229 |
+
|
| 230 |
+
- `OPENROUTER_API_KEY`: Required for production deployment (get from [https://openrouter.ai](https://openrouter.ai))
|
| 231 |
+
- `HF_API_KEY`: Optional, for accessing Hugging Face APIs
|
| 232 |
+
- `HF_TOKEN`: Optional, for Hugging Face Hub leaderboard integration
|
| 233 |
+
|
| 234 |
+
## Development Commands
|
| 235 |
+
|
| 236 |
+
Use the provided Makefile for convenience:
|
| 237 |
+
|
| 238 |
+
```bash
|
| 239 |
+
make install # Install Python and Node.js dependencies
|
| 240 |
+
make dev # Start development server (simple HTTP)
|
| 241 |
+
make dev-python # Start FastAPI development server
|
| 242 |
+
make docker-build # Build Docker image
|
| 243 |
+
make docker-run # Run Docker container
|
| 244 |
+
make docker-dev # Full Docker development environment
|
| 245 |
+
make clean # Clean build artifacts and cache
|
| 246 |
+
make logs # View Docker container logs
|
| 247 |
+
make stop # Stop Docker containers
|
| 248 |
+
```
|
| 249 |
+
|
| 250 |
+
## Critical Questions This System Poses
|
| 251 |
+
|
| 252 |
+
1. **Recursive Collapse**: What happens when training methodology becomes assessment tool, and assessment data becomes training data? Where is the meaningful boundary?
|
| 253 |
+
|
| 254 |
+
2. **Pedagogical Authority**: Can algorithmic selection of passages and words, guided by statistical patterns learned from internet-scale corpora, capture what human educators mean by "appropriate difficulty"?
|
| 255 |
+
|
| 256 |
+
3. **The Nature of Comprehension**: When a model trained on surface pattern prediction generates cloze exercises, and humans solve those exercises using similar heuristics, what is being measured? Is comprehension happening, or are we staging an elaborate performance of mutual statistical inference?
|
| 257 |
+
|
| 258 |
+
4. **Institutional Authority**: Traditional assessment vested authority in documented expert judgment. This system distributes authority through open-weight models and client-side code, making it interrogable rather than institutional. What is gained or lost in this distribution?
|
| 259 |
+
|
| 260 |
+
5. **Reading in the Age of Algorithmic Text Generation**: When generative systems produce infinite novel text, what does it mean to ask users to engage deeply with finite, singular public domain texts? Is this a form of resistance, or capitulation to the same systems that trained the models?
|
| 261 |
+
|
| 262 |
+
6. **Data Extraction and Repatriation**: Public domain texts have been appropriated relentlessly for AI training. What does it mean to surface these texts, return them to visibility, and ask people to read them directly rather than through algorithmic summaries?
|
| 263 |
+
|
| 264 |
+
## Technical Details and Error Handling
|
| 265 |
+
|
| 266 |
+
### AI Service Resilience
|
| 267 |
+
|
| 268 |
+
The system implements multiple fallback layers:
|
| 269 |
+
|
| 270 |
+
1. **Retry with exponential backoff**: Up to 3 attempts with increasing delays
|
| 271 |
+
2. **Response extraction hierarchy**: Primary (message.content), secondary (reasoning field), tertiary (reasoning_details array), final (regex pattern matching)
|
| 272 |
+
3. **Manual word selection**: If AI fails, the system falls back to statistical selection based on content analysis
|
| 273 |
+
4. **Generic hint generation**: Fallback responses based on question type when AI service is unavailable
|
| 274 |
+
|
| 275 |
+
### Content Service Resilience
|
| 276 |
+
|
| 277 |
+
1. **HF API availability check**: Test connection before streaming attempts
|
| 278 |
+
2. **Preloaded content**: Cached books for immediate access
|
| 279 |
+
3. **Local book fallback**: 10 embedded classics guarantee offline functionality
|
| 280 |
+
4. **Quality validation retry**: Multiple attempts with different passage selections if quality filtering rejects initial selections
|
| 281 |
+
5. **Timeout handling**: 15-second request timeout with automatic fallback to sequential processing
|
| 282 |
+
|
| 283 |
+
## On This Codebase's Design Philosophy
|
| 284 |
+
|
| 285 |
+
The technical decisions in this codebase reflect the conceptual interests outlined above:
|
| 286 |
+
|
| 287 |
+
- **Vanilla JavaScript, no build step**: Keeps machinery visible rather than obscured behind tooling
|
| 288 |
+
- **Open-weight Gemma models**: Enables inspection, local deployment, and interrogation of the assessment generator
|
| 289 |
+
- **Streaming from Project Gutenberg**: Makes the pipeline reproducible without proprietary content
|
| 290 |
+
- **Local LLM support**: Removes dependency on API providers; you can run the entire system on personal hardware
|
| 291 |
+
- **No backend database**: All state is client-side localStorage; the assessment system is fully auditable
|
| 292 |
+
- **Vanilla HTML and CSS**: Aesthetic choices reflect mid-century design rather than contemporary web trends, creating temporal distance between the user and algorithmic systems
|
| 293 |
+
|
| 294 |
+
The goal is not to resolve the tensions between human assessment and algorithmic generation, between deep reading and computational efficiency, between transparent pedagogy and black-boxed AI. Instead, the goal is to **stage these tensions**—to make them experientially tangible through gameplay. Every passage you encounter is a collision between seventy years of educational assessment theory and seven years of masked language modeling. Every blank you fill is an act of both comprehension and statistical prediction.
|
| 295 |
+
|
| 296 |
+
## References and Further Reading
|
| 297 |
+
|
| 298 |
+
Recent research connecting cloze assessment and masked language modeling:
|
| 299 |
+
|
| 300 |
+
- Matsumori, A., et al. (2023). CLOZER: Generating open cloze questions with masked language models. Proceedings of the 2023 Conference on Empirical Methods in Natural Language Processing.
|
| 301 |
+
- Ondov, B., et al. (2024). Masked language models as natural generators for cloze questions. Proceedings of the 2024 Conference of the North American Chapter of the Association for Computational Linguistics (NAACL).
|
| 302 |
+
- Zhang, Y., & Hashimoto, K. (2021). What do language models learn about the structure of their language? Proceedings of the 59th Annual Meeting of the Association for Computational Linguistics.
|
| 303 |
+
|
| 304 |
+
## Attribution
|
| 305 |
+
|
| 306 |
+
This project was created by [Zach Muhlbauer](https://huggingface.co/milwright) at the CUNY Graduate Center. The application draws on extensive research in both educational assessment and natural language processing, bringing two disciplinary histories into dialogue through interactive gameplay.
|
| 307 |
+
|
| 308 |
+
For more information, visit the development space at [https://huggingface.co/spaces/milwright/cloze-reader](https://huggingface.co/spaces/milwright/cloze-reader) or the underlying dataset at [https://huggingface.co/datasets/manu/project_gutenberg](https://huggingface.co/datasets/manu/project_gutenberg).
|
| 309 |
+
|
| 310 |
+
---
|
| 311 |
+
|
| 312 |
+
*The Cloze Reader invites you to encounter public domain literature on its own terms, to practice contextual reasoning in real time, and to confront the strange convergence between how humans and machines understand language through prediction.*
|
app.py
ADDED
|
@@ -0,0 +1,638 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
from fastapi import FastAPI, HTTPException, Query
|
| 2 |
+
from fastapi.responses import HTMLResponse, RedirectResponse, FileResponse, JSONResponse
|
| 3 |
+
from fastapi.staticfiles import StaticFiles
|
| 4 |
+
from fastapi.middleware.cors import CORSMiddleware
|
| 5 |
+
from pydantic import BaseModel, Field
|
| 6 |
+
from typing import List, Optional
|
| 7 |
+
import os
|
| 8 |
+
import time
|
| 9 |
+
import json
|
| 10 |
+
import asyncio
|
| 11 |
+
import urllib.request
|
| 12 |
+
import urllib.parse
|
| 13 |
+
from dotenv import load_dotenv
|
| 14 |
+
import logging
|
| 15 |
+
|
| 16 |
+
# Load environment variables from .env file
|
| 17 |
+
load_dotenv()
|
| 18 |
+
|
| 19 |
+
# Import Leaderboard Services (Redis primary, HF fallback)
|
| 20 |
+
from redis_leaderboard import RedisLeaderboardService
|
| 21 |
+
from redis_analytics import RedisAnalyticsService
|
| 22 |
+
|
| 23 |
+
# Configure logging
|
| 24 |
+
logging.basicConfig(level=logging.INFO)
|
| 25 |
+
logger = logging.getLogger(__name__)
|
| 26 |
+
|
| 27 |
+
app = FastAPI()
|
| 28 |
+
|
| 29 |
+
# Add CORS middleware for local development
|
| 30 |
+
app.add_middleware(
|
| 31 |
+
CORSMiddleware,
|
| 32 |
+
allow_origins=["*"],
|
| 33 |
+
allow_credentials=True,
|
| 34 |
+
allow_methods=["*"],
|
| 35 |
+
allow_headers=["*"],
|
| 36 |
+
)
|
| 37 |
+
|
| 38 |
+
# Initialize Leaderboard Service (Redis primary, HF Space fallback)
|
| 39 |
+
# REDIS_URL is auto-injected by Railway when Redis plugin is added
|
| 40 |
+
try:
|
| 41 |
+
leaderboard_service = RedisLeaderboardService(
|
| 42 |
+
redis_url=os.getenv("REDIS_URL"),
|
| 43 |
+
hf_fallback_url="https://milwright-cloze-leaderboard.hf.space",
|
| 44 |
+
hf_token=os.getenv("HF_TOKEN"),
|
| 45 |
+
)
|
| 46 |
+
if leaderboard_service.is_redis_available():
|
| 47 |
+
logger.info("Leaderboard using Redis (primary) with HF Space (fallback)")
|
| 48 |
+
else:
|
| 49 |
+
logger.info("Leaderboard using HF Space (Redis unavailable)")
|
| 50 |
+
except Exception as e:
|
| 51 |
+
logger.warning(f"Could not initialize Leaderboard Service: {e}")
|
| 52 |
+
logger.warning("Leaderboard will use localStorage fallback only")
|
| 53 |
+
leaderboard_service = None
|
| 54 |
+
|
| 55 |
+
# Initialize Analytics Service (Redis)
|
| 56 |
+
try:
|
| 57 |
+
analytics_service = RedisAnalyticsService(redis_url=os.getenv("REDIS_URL"))
|
| 58 |
+
if analytics_service.is_available():
|
| 59 |
+
logger.info("Analytics Service using Redis")
|
| 60 |
+
else:
|
| 61 |
+
logger.info("Analytics Service unavailable (Redis not connected)")
|
| 62 |
+
except Exception as e:
|
| 63 |
+
logger.warning(f"Could not initialize Analytics Service: {e}")
|
| 64 |
+
analytics_service = None
|
| 65 |
+
|
| 66 |
+
# Pydantic models for API
|
| 67 |
+
class LeaderboardEntry(BaseModel):
|
| 68 |
+
initials: str
|
| 69 |
+
level: int
|
| 70 |
+
round: int
|
| 71 |
+
passagesPassed: int
|
| 72 |
+
date: str
|
| 73 |
+
|
| 74 |
+
class LeaderboardResponse(BaseModel):
|
| 75 |
+
success: bool
|
| 76 |
+
leaderboard: List[LeaderboardEntry]
|
| 77 |
+
message: Optional[str] = None
|
| 78 |
+
|
| 79 |
+
|
| 80 |
+
# Pydantic models for Analytics API
|
| 81 |
+
class WordAnalytics(BaseModel):
|
| 82 |
+
word: str
|
| 83 |
+
length: Optional[int] = None
|
| 84 |
+
attemptsToCorrect: int = 1
|
| 85 |
+
# Avoid mutable default list
|
| 86 |
+
hintsUsed: List[str] = Field(default_factory=list)
|
| 87 |
+
finalCorrect: bool = False
|
| 88 |
+
|
| 89 |
+
|
| 90 |
+
class PassageAnalytics(BaseModel):
|
| 91 |
+
passageId: str
|
| 92 |
+
sessionId: str
|
| 93 |
+
bookTitle: str
|
| 94 |
+
bookAuthor: str
|
| 95 |
+
level: int
|
| 96 |
+
round: int
|
| 97 |
+
words: List[WordAnalytics]
|
| 98 |
+
totalBlanks: int
|
| 99 |
+
correctOnFirstTry: int
|
| 100 |
+
totalHintsUsed: int
|
| 101 |
+
passed: bool
|
| 102 |
+
timestamp: Optional[str] = None
|
| 103 |
+
|
| 104 |
+
# Mount static files
|
| 105 |
+
app.mount("/src", StaticFiles(directory="src"), name="src")
|
| 106 |
+
|
| 107 |
+
@app.get("/icon.png")
|
| 108 |
+
async def get_icon():
|
| 109 |
+
"""Serve the app icon locally if available, else fallback to GitHub."""
|
| 110 |
+
local_icon = "icon.png"
|
| 111 |
+
if os.path.exists(local_icon):
|
| 112 |
+
return FileResponse(local_icon, media_type="image/png")
|
| 113 |
+
# Fallback to GitHub-hosted icon
|
| 114 |
+
return RedirectResponse(url="https://media.githubusercontent.com/media/milwrite/cloze-reader/main/icon.png")
|
| 115 |
+
|
| 116 |
+
@app.get("/favicon.png")
|
| 117 |
+
async def get_favicon_png():
|
| 118 |
+
"""Serve favicon as PNG by pointing to the canonical PNG icon."""
|
| 119 |
+
return await get_icon()
|
| 120 |
+
|
| 121 |
+
@app.get("/favicon.ico")
|
| 122 |
+
async def get_favicon_ico():
|
| 123 |
+
"""Serve an ICO route that points to our PNG so browsers can find it."""
|
| 124 |
+
# Many browsers request /favicon.ico explicitly; return PNG is acceptable
|
| 125 |
+
return await get_favicon_png()
|
| 126 |
+
|
| 127 |
+
@app.get("/favicon.svg")
|
| 128 |
+
async def get_favicon_svg():
|
| 129 |
+
"""Serve SVG favicon for browsers that support it."""
|
| 130 |
+
# Prefer `icon.svg` if available
|
| 131 |
+
for candidate in ["favicon.svg", "icon.svg"]:
|
| 132 |
+
if os.path.exists(candidate):
|
| 133 |
+
return FileResponse(candidate, media_type="image/svg+xml")
|
| 134 |
+
# If missing, fall back to PNG icon
|
| 135 |
+
return await get_favicon_png()
|
| 136 |
+
|
| 137 |
+
@app.get("/icon.svg")
|
| 138 |
+
async def get_icon_svg():
|
| 139 |
+
"""Serve the SVG icon at /icon.svg if present, else fallback to PNG."""
|
| 140 |
+
candidate = "icon.svg"
|
| 141 |
+
if os.path.exists(candidate):
|
| 142 |
+
return FileResponse(candidate, media_type="image/svg+xml")
|
| 143 |
+
return await get_icon()
|
| 144 |
+
|
| 145 |
+
@app.get("/apple-touch-icon.png")
|
| 146 |
+
async def get_apple_touch_icon():
|
| 147 |
+
"""Serve Apple touch icon, fallback to main icon."""
|
| 148 |
+
candidate = "apple-touch-icon.png"
|
| 149 |
+
if os.path.exists(candidate):
|
| 150 |
+
return FileResponse(candidate, media_type="image/png")
|
| 151 |
+
return await get_icon()
|
| 152 |
+
|
| 153 |
+
@app.get("/site.webmanifest")
|
| 154 |
+
async def site_manifest():
|
| 155 |
+
"""Serve the web app manifest if present, else a minimal generated one."""
|
| 156 |
+
manifest_path = "site.webmanifest"
|
| 157 |
+
if os.path.exists(manifest_path):
|
| 158 |
+
return FileResponse(manifest_path, media_type="application/manifest+json")
|
| 159 |
+
# Minimal default manifest
|
| 160 |
+
content = {
|
| 161 |
+
"name": "Cloze Reader",
|
| 162 |
+
"short_name": "Cloze",
|
| 163 |
+
"icons": [
|
| 164 |
+
{"src": "./icon-192.png", "type": "image/png", "sizes": "192x192"},
|
| 165 |
+
{"src": "./icon-512.png", "type": "image/png", "sizes": "512x512"}
|
| 166 |
+
],
|
| 167 |
+
"start_url": "./",
|
| 168 |
+
"display": "standalone",
|
| 169 |
+
"background_color": "#ffffff",
|
| 170 |
+
"theme_color": "#2c2826"
|
| 171 |
+
}
|
| 172 |
+
return JSONResponse(content=content, media_type="application/manifest+json")
|
| 173 |
+
|
| 174 |
+
@app.get("/icon-192.png")
|
| 175 |
+
async def get_icon_192():
|
| 176 |
+
path = "icon-192.png"
|
| 177 |
+
if os.path.exists(path):
|
| 178 |
+
return FileResponse(path, media_type="image/png")
|
| 179 |
+
return await get_icon()
|
| 180 |
+
|
| 181 |
+
@app.get("/icon-512.png")
|
| 182 |
+
async def get_icon_512():
|
| 183 |
+
path = "icon-512.png"
|
| 184 |
+
if os.path.exists(path):
|
| 185 |
+
return FileResponse(path, media_type="image/png")
|
| 186 |
+
return await get_icon()
|
| 187 |
+
|
| 188 |
+
@app.get("/admin")
|
| 189 |
+
async def admin_dashboard():
|
| 190 |
+
"""Serve the analytics admin dashboard"""
|
| 191 |
+
with open("admin.html", "r") as f:
|
| 192 |
+
return HTMLResponse(content=f.read())
|
| 193 |
+
|
| 194 |
+
|
| 195 |
+
@app.get("/")
|
| 196 |
+
async def read_root():
|
| 197 |
+
# Read the HTML file and inject environment variables
|
| 198 |
+
with open("index.html", "r") as f:
|
| 199 |
+
html_content = f.read()
|
| 200 |
+
|
| 201 |
+
# Inject environment variables as a script
|
| 202 |
+
openrouter_key = os.getenv("OPENROUTER_API_KEY", "")
|
| 203 |
+
hf_key = os.getenv("HF_API_KEY", "")
|
| 204 |
+
|
| 205 |
+
# Create a CSP-compliant way to inject the keys
|
| 206 |
+
env_script = f"""
|
| 207 |
+
<meta name="openrouter-key" content="{openrouter_key}">
|
| 208 |
+
<meta name="hf-key" content="{hf_key}">
|
| 209 |
+
<script src="./src/init-env.js"></script>
|
| 210 |
+
"""
|
| 211 |
+
|
| 212 |
+
# Insert the script before closing head tag
|
| 213 |
+
html_content = html_content.replace("</head>", env_script + "</head>")
|
| 214 |
+
|
| 215 |
+
return HTMLResponse(content=html_content)
|
| 216 |
+
|
| 217 |
+
|
| 218 |
+
# ===== LEADERBOARD API ENDPOINTS =====
|
| 219 |
+
|
| 220 |
+
@app.get("/api/leaderboard", response_model=LeaderboardResponse)
|
| 221 |
+
async def get_leaderboard():
|
| 222 |
+
"""
|
| 223 |
+
Get current leaderboard data (Redis primary, HF Space fallback)
|
| 224 |
+
"""
|
| 225 |
+
if not leaderboard_service:
|
| 226 |
+
return {
|
| 227 |
+
"success": True,
|
| 228 |
+
"leaderboard": [],
|
| 229 |
+
"message": "Leaderboard service not available (using localStorage only)"
|
| 230 |
+
}
|
| 231 |
+
|
| 232 |
+
try:
|
| 233 |
+
leaderboard = leaderboard_service.get_leaderboard()
|
| 234 |
+
return {
|
| 235 |
+
"success": True,
|
| 236 |
+
"leaderboard": leaderboard,
|
| 237 |
+
"message": f"Retrieved {len(leaderboard)} entries"
|
| 238 |
+
}
|
| 239 |
+
except Exception as e:
|
| 240 |
+
logger.error(f"Error fetching leaderboard: {e}")
|
| 241 |
+
raise HTTPException(status_code=500, detail=str(e))
|
| 242 |
+
|
| 243 |
+
|
| 244 |
+
@app.post("/api/leaderboard/add")
|
| 245 |
+
async def add_leaderboard_entry(entry: LeaderboardEntry):
|
| 246 |
+
"""
|
| 247 |
+
Add new entry to leaderboard
|
| 248 |
+
"""
|
| 249 |
+
if not leaderboard_service:
|
| 250 |
+
raise HTTPException(status_code=503, detail="Leaderboard service not available")
|
| 251 |
+
|
| 252 |
+
try:
|
| 253 |
+
success = leaderboard_service.add_entry(entry.dict())
|
| 254 |
+
if success:
|
| 255 |
+
return {
|
| 256 |
+
"success": True,
|
| 257 |
+
"message": f"Added {entry.initials} to leaderboard"
|
| 258 |
+
}
|
| 259 |
+
else:
|
| 260 |
+
raise HTTPException(status_code=500, detail="Failed to add entry")
|
| 261 |
+
except ValueError as e:
|
| 262 |
+
raise HTTPException(status_code=403, detail=str(e))
|
| 263 |
+
except Exception as e:
|
| 264 |
+
logger.error(f"Error adding entry: {e}")
|
| 265 |
+
raise HTTPException(status_code=500, detail=str(e))
|
| 266 |
+
|
| 267 |
+
|
| 268 |
+
@app.post("/api/leaderboard/update")
|
| 269 |
+
async def update_leaderboard(entries: List[LeaderboardEntry]):
|
| 270 |
+
"""
|
| 271 |
+
Update entire leaderboard (replace all data)
|
| 272 |
+
"""
|
| 273 |
+
if not leaderboard_service:
|
| 274 |
+
raise HTTPException(status_code=503, detail="Leaderboard service not available")
|
| 275 |
+
|
| 276 |
+
try:
|
| 277 |
+
success = leaderboard_service.update_leaderboard([e.dict() for e in entries])
|
| 278 |
+
if success:
|
| 279 |
+
return {
|
| 280 |
+
"success": True,
|
| 281 |
+
"message": "Leaderboard updated successfully"
|
| 282 |
+
}
|
| 283 |
+
else:
|
| 284 |
+
raise HTTPException(status_code=500, detail="Failed to update leaderboard")
|
| 285 |
+
except ValueError as e:
|
| 286 |
+
raise HTTPException(status_code=403, detail=str(e))
|
| 287 |
+
except Exception as e:
|
| 288 |
+
logger.error(f"Error updating leaderboard: {e}")
|
| 289 |
+
raise HTTPException(status_code=500, detail=str(e))
|
| 290 |
+
|
| 291 |
+
|
| 292 |
+
@app.delete("/api/leaderboard/clear")
|
| 293 |
+
async def clear_leaderboard():
|
| 294 |
+
"""
|
| 295 |
+
Clear all leaderboard data (admin only)
|
| 296 |
+
"""
|
| 297 |
+
if not leaderboard_service:
|
| 298 |
+
raise HTTPException(status_code=503, detail="Leaderboard service not available")
|
| 299 |
+
|
| 300 |
+
try:
|
| 301 |
+
success = leaderboard_service.clear_leaderboard()
|
| 302 |
+
if success:
|
| 303 |
+
return {
|
| 304 |
+
"success": True,
|
| 305 |
+
"message": "Leaderboard cleared"
|
| 306 |
+
}
|
| 307 |
+
else:
|
| 308 |
+
raise HTTPException(status_code=500, detail="Failed to clear leaderboard")
|
| 309 |
+
except ValueError as e:
|
| 310 |
+
raise HTTPException(status_code=403, detail=str(e))
|
| 311 |
+
except Exception as e:
|
| 312 |
+
logger.error(f"Error clearing leaderboard: {e}")
|
| 313 |
+
raise HTTPException(status_code=500, detail=str(e))
|
| 314 |
+
|
| 315 |
+
|
| 316 |
+
@app.post("/api/leaderboard/seed-from-hf")
|
| 317 |
+
async def seed_leaderboard_from_hf():
|
| 318 |
+
"""
|
| 319 |
+
Force re-seed Redis leaderboard from HF Space (admin function).
|
| 320 |
+
Use this to migrate existing HF Space data to Redis.
|
| 321 |
+
"""
|
| 322 |
+
if not leaderboard_service:
|
| 323 |
+
raise HTTPException(status_code=503, detail="Leaderboard service not available")
|
| 324 |
+
|
| 325 |
+
try:
|
| 326 |
+
success = leaderboard_service.force_seed_from_hf()
|
| 327 |
+
if success:
|
| 328 |
+
leaderboard = leaderboard_service.get_leaderboard()
|
| 329 |
+
return {
|
| 330 |
+
"success": True,
|
| 331 |
+
"message": f"Seeded Redis with {len(leaderboard)} entries from HF Space",
|
| 332 |
+
"entries": len(leaderboard)
|
| 333 |
+
}
|
| 334 |
+
else:
|
| 335 |
+
return {
|
| 336 |
+
"success": False,
|
| 337 |
+
"message": "No entries found in HF Space to seed"
|
| 338 |
+
}
|
| 339 |
+
except Exception as e:
|
| 340 |
+
logger.error(f"Error seeding leaderboard from HF: {e}")
|
| 341 |
+
raise HTTPException(status_code=500, detail=str(e))
|
| 342 |
+
|
| 343 |
+
|
| 344 |
+
# ===== ANALYTICS API ENDPOINTS =====
|
| 345 |
+
|
| 346 |
+
@app.post("/api/analytics/passage")
|
| 347 |
+
async def record_passage_analytics(data: PassageAnalytics):
|
| 348 |
+
"""
|
| 349 |
+
Record a completed passage attempt with analytics data.
|
| 350 |
+
Called by frontend when a passage is completed (pass or fail).
|
| 351 |
+
"""
|
| 352 |
+
if not analytics_service or not analytics_service.is_available():
|
| 353 |
+
# Gracefully degrade - don't fail the game if analytics unavailable
|
| 354 |
+
return {
|
| 355 |
+
"success": False,
|
| 356 |
+
"message": "Analytics service unavailable"
|
| 357 |
+
}
|
| 358 |
+
|
| 359 |
+
try:
|
| 360 |
+
# Convert Pydantic models to dicts
|
| 361 |
+
data_dict = data.dict()
|
| 362 |
+
data_dict["words"] = [w.dict() for w in data.words]
|
| 363 |
+
|
| 364 |
+
entry_id = analytics_service.record_passage(data_dict)
|
| 365 |
+
if entry_id:
|
| 366 |
+
return {
|
| 367 |
+
"success": True,
|
| 368 |
+
"entryId": entry_id,
|
| 369 |
+
"message": "Passage analytics recorded"
|
| 370 |
+
}
|
| 371 |
+
else:
|
| 372 |
+
return {
|
| 373 |
+
"success": False,
|
| 374 |
+
"message": "Failed to record analytics"
|
| 375 |
+
}
|
| 376 |
+
except Exception as e:
|
| 377 |
+
logger.error(f"Error recording passage analytics: {e}")
|
| 378 |
+
# Don't raise - analytics failure shouldn't break gameplay
|
| 379 |
+
return {
|
| 380 |
+
"success": False,
|
| 381 |
+
"message": str(e)
|
| 382 |
+
}
|
| 383 |
+
|
| 384 |
+
|
| 385 |
+
@app.get("/api/analytics/summary")
|
| 386 |
+
async def get_analytics_summary():
|
| 387 |
+
"""
|
| 388 |
+
Get aggregate analytics statistics for admin dashboard.
|
| 389 |
+
Returns totals, hardest/easiest words, and popular books.
|
| 390 |
+
"""
|
| 391 |
+
if not analytics_service:
|
| 392 |
+
return {
|
| 393 |
+
"success": True,
|
| 394 |
+
"data": {
|
| 395 |
+
"totalPassages": 0,
|
| 396 |
+
"totalSessions": 0,
|
| 397 |
+
"hardestWords": [],
|
| 398 |
+
"easiestWords": [],
|
| 399 |
+
"popularBooks": []
|
| 400 |
+
},
|
| 401 |
+
"message": "Analytics service unavailable"
|
| 402 |
+
}
|
| 403 |
+
|
| 404 |
+
try:
|
| 405 |
+
summary = analytics_service.get_summary()
|
| 406 |
+
return {
|
| 407 |
+
"success": True,
|
| 408 |
+
"data": summary,
|
| 409 |
+
"message": f"Retrieved analytics summary"
|
| 410 |
+
}
|
| 411 |
+
except Exception as e:
|
| 412 |
+
logger.error(f"Error getting analytics summary: {e}")
|
| 413 |
+
raise HTTPException(status_code=500, detail=str(e))
|
| 414 |
+
|
| 415 |
+
|
| 416 |
+
@app.get("/api/analytics/recent")
|
| 417 |
+
async def get_recent_analytics(count: int = 50):
|
| 418 |
+
"""
|
| 419 |
+
Get recent passage attempts for admin review.
|
| 420 |
+
|
| 421 |
+
Args:
|
| 422 |
+
count: Number of recent entries to retrieve (default: 50, max: 200)
|
| 423 |
+
"""
|
| 424 |
+
if not analytics_service:
|
| 425 |
+
return {
|
| 426 |
+
"success": True,
|
| 427 |
+
"passages": [],
|
| 428 |
+
"message": "Analytics service unavailable"
|
| 429 |
+
}
|
| 430 |
+
|
| 431 |
+
# Cap at 200 entries
|
| 432 |
+
count = min(count, 200)
|
| 433 |
+
|
| 434 |
+
try:
|
| 435 |
+
passages = analytics_service.get_recent_passages(count)
|
| 436 |
+
return {
|
| 437 |
+
"success": True,
|
| 438 |
+
"passages": passages,
|
| 439 |
+
"count": len(passages),
|
| 440 |
+
"message": f"Retrieved {len(passages)} recent passages"
|
| 441 |
+
}
|
| 442 |
+
except Exception as e:
|
| 443 |
+
logger.error(f"Error getting recent analytics: {e}")
|
| 444 |
+
raise HTTPException(status_code=500, detail=str(e))
|
| 445 |
+
|
| 446 |
+
|
| 447 |
+
@app.get("/api/analytics/export")
|
| 448 |
+
async def export_all_analytics():
|
| 449 |
+
"""
|
| 450 |
+
Export all analytics data as JSON (admin function).
|
| 451 |
+
Use for backup or external analysis.
|
| 452 |
+
"""
|
| 453 |
+
if not analytics_service:
|
| 454 |
+
return {
|
| 455 |
+
"success": False,
|
| 456 |
+
"passages": [],
|
| 457 |
+
"message": "Analytics service unavailable"
|
| 458 |
+
}
|
| 459 |
+
|
| 460 |
+
try:
|
| 461 |
+
all_data = analytics_service.export_all()
|
| 462 |
+
return {
|
| 463 |
+
"success": True,
|
| 464 |
+
"passages": all_data,
|
| 465 |
+
"count": len(all_data),
|
| 466 |
+
"message": f"Exported {len(all_data)} passage records"
|
| 467 |
+
}
|
| 468 |
+
except Exception as e:
|
| 469 |
+
logger.error(f"Error exporting analytics: {e}")
|
| 470 |
+
raise HTTPException(status_code=500, detail=str(e))
|
| 471 |
+
|
| 472 |
+
|
| 473 |
+
@app.get("/api/analytics/word/{word}")
|
| 474 |
+
async def get_word_statistics(word: str):
|
| 475 |
+
"""
|
| 476 |
+
Get statistics for a specific word.
|
| 477 |
+
Shows how often the word was correct on first try vs needing retries.
|
| 478 |
+
"""
|
| 479 |
+
if not analytics_service:
|
| 480 |
+
return {
|
| 481 |
+
"success": True,
|
| 482 |
+
"data": {"word": word, "firstTryCount": 0, "retryCount": 0},
|
| 483 |
+
"message": "Analytics service unavailable"
|
| 484 |
+
}
|
| 485 |
+
|
| 486 |
+
try:
|
| 487 |
+
stats = analytics_service.get_word_stats(word)
|
| 488 |
+
return {
|
| 489 |
+
"success": True,
|
| 490 |
+
"data": stats,
|
| 491 |
+
"message": f"Retrieved stats for '{word}'"
|
| 492 |
+
}
|
| 493 |
+
except Exception as e:
|
| 494 |
+
logger.error(f"Error getting word stats: {e}")
|
| 495 |
+
raise HTTPException(status_code=500, detail=str(e))
|
| 496 |
+
|
| 497 |
+
|
| 498 |
+
@app.delete("/api/analytics/clear")
|
| 499 |
+
async def clear_all_analytics():
|
| 500 |
+
"""
|
| 501 |
+
Clear all analytics data (admin function).
|
| 502 |
+
WARNING: This permanently deletes all recorded analytics.
|
| 503 |
+
"""
|
| 504 |
+
if not analytics_service:
|
| 505 |
+
raise HTTPException(status_code=503, detail="Analytics service unavailable")
|
| 506 |
+
|
| 507 |
+
try:
|
| 508 |
+
success = analytics_service.clear_analytics()
|
| 509 |
+
if success:
|
| 510 |
+
return {
|
| 511 |
+
"success": True,
|
| 512 |
+
"message": "All analytics data cleared"
|
| 513 |
+
}
|
| 514 |
+
else:
|
| 515 |
+
raise HTTPException(status_code=500, detail="Failed to clear analytics")
|
| 516 |
+
except Exception as e:
|
| 517 |
+
logger.error(f"Error clearing analytics: {e}")
|
| 518 |
+
raise HTTPException(status_code=500, detail=str(e))
|
| 519 |
+
|
| 520 |
+
|
| 521 |
+
if __name__ == "__main__":
|
| 522 |
+
import uvicorn
|
| 523 |
+
uvicorn.run(app, host="0.0.0.0", port=7860)
|
| 524 |
+
|
| 525 |
+
|
| 526 |
+
# ================== HF DATASETS PROXY ENDPOINTS ==================
|
| 527 |
+
|
| 528 |
+
HF_DATASETS_BASE = "https://datasets-server.huggingface.co"
|
| 529 |
+
|
| 530 |
+
# very small in-memory cache suitable for single-process app
|
| 531 |
+
_proxy_cache = {
|
| 532 |
+
"splits": {}, # key -> {value, ts, ttl}
|
| 533 |
+
"rows": {},
|
| 534 |
+
}
|
| 535 |
+
|
| 536 |
+
|
| 537 |
+
def _cache_get(bucket: str, key: str):
|
| 538 |
+
entry = _proxy_cache.get(bucket, {}).get(key)
|
| 539 |
+
if not entry:
|
| 540 |
+
return None
|
| 541 |
+
if time.time() - entry["ts"] > entry["ttl"]:
|
| 542 |
+
try:
|
| 543 |
+
del _proxy_cache[bucket][key]
|
| 544 |
+
except Exception:
|
| 545 |
+
pass
|
| 546 |
+
return None
|
| 547 |
+
return entry["value"]
|
| 548 |
+
|
| 549 |
+
|
| 550 |
+
def _cache_set(bucket: str, key: str, value, ttl: int):
|
| 551 |
+
_proxy_cache.setdefault(bucket, {})[key] = {
|
| 552 |
+
"value": value,
|
| 553 |
+
"ts": time.time(),
|
| 554 |
+
"ttl": ttl,
|
| 555 |
+
}
|
| 556 |
+
|
| 557 |
+
|
| 558 |
+
def _fetch_sync(url: str, timeout: float = 3.0):
|
| 559 |
+
req = urllib.request.Request(
|
| 560 |
+
url,
|
| 561 |
+
headers={
|
| 562 |
+
"Accept": "application/json",
|
| 563 |
+
"User-Agent": "cloze-reader/1.0 (+fastapi-proxy)",
|
| 564 |
+
},
|
| 565 |
+
)
|
| 566 |
+
with urllib.request.urlopen(req, timeout=timeout) as resp:
|
| 567 |
+
status = resp.getcode()
|
| 568 |
+
body = resp.read()
|
| 569 |
+
return status, body
|
| 570 |
+
|
| 571 |
+
|
| 572 |
+
async def _fetch_json(url: str, timeout: float = 3.0):
|
| 573 |
+
try:
|
| 574 |
+
status, body = await asyncio.to_thread(_fetch_sync, url, timeout)
|
| 575 |
+
if status != 200:
|
| 576 |
+
raise HTTPException(status_code=status, detail=f"Upstream returned {status}")
|
| 577 |
+
return json.loads(body.decode("utf-8"))
|
| 578 |
+
except HTTPException:
|
| 579 |
+
raise
|
| 580 |
+
except Exception as e:
|
| 581 |
+
raise HTTPException(status_code=502, detail=f"Upstream fetch failed: {e}")
|
| 582 |
+
|
| 583 |
+
|
| 584 |
+
@app.get("/api/books/splits")
|
| 585 |
+
async def proxy_hf_splits(
|
| 586 |
+
dataset: str = Query(..., description="HF dataset repo id, e.g. manu/project_gutenberg"),
|
| 587 |
+
cache_ttl: int = Query(300, description="Cache TTL seconds (default 300)"),
|
| 588 |
+
):
|
| 589 |
+
"""Proxy the HF datasets splits endpoint with caching and timeout.
|
| 590 |
+
|
| 591 |
+
Example: /api/books/splits?dataset=manu/project_gutenberg
|
| 592 |
+
"""
|
| 593 |
+
dataset_q = urllib.parse.quote(dataset, safe="")
|
| 594 |
+
url = f"{HF_DATASETS_BASE}/splits?dataset={dataset_q}"
|
| 595 |
+
|
| 596 |
+
cached = _cache_get("splits", url)
|
| 597 |
+
if cached is not None:
|
| 598 |
+
return cached
|
| 599 |
+
|
| 600 |
+
data = await _fetch_json(url, timeout=3.0)
|
| 601 |
+
_cache_set("splits", url, data, ttl=max(1, cache_ttl))
|
| 602 |
+
return data
|
| 603 |
+
|
| 604 |
+
|
| 605 |
+
@app.get("/api/books/rows")
|
| 606 |
+
async def proxy_hf_rows(
|
| 607 |
+
dataset: str = Query(...),
|
| 608 |
+
config: str = Query("default"),
|
| 609 |
+
split: str = Query("en"),
|
| 610 |
+
offset: int = Query(0, ge=0, le=1000000),
|
| 611 |
+
length: int = Query(1, ge=1, le=50),
|
| 612 |
+
cache_ttl: int = Query(60, description="Cache TTL seconds for identical queries (default 60)"),
|
| 613 |
+
):
|
| 614 |
+
"""Proxy the HF datasets rows endpoint with short timeout and small cache.
|
| 615 |
+
|
| 616 |
+
Example:
|
| 617 |
+
/api/books/rows?dataset=manu/project_gutenberg&config=default&split=en&offset=0&length=2
|
| 618 |
+
"""
|
| 619 |
+
params = {
|
| 620 |
+
"dataset": dataset,
|
| 621 |
+
"config": config,
|
| 622 |
+
"split": split,
|
| 623 |
+
"offset": str(offset),
|
| 624 |
+
"length": str(length),
|
| 625 |
+
}
|
| 626 |
+
qs = urllib.parse.urlencode(params)
|
| 627 |
+
url = f"{HF_DATASETS_BASE}/rows?{qs}"
|
| 628 |
+
|
| 629 |
+
cached = _cache_get("rows", url)
|
| 630 |
+
if cached is not None:
|
| 631 |
+
return cached
|
| 632 |
+
|
| 633 |
+
# Allow longer timeout for HF API which can be slow under load
|
| 634 |
+
# 15s should handle most cases without client-side abort racing
|
| 635 |
+
data = await _fetch_json(url, timeout=15.0)
|
| 636 |
+
# Cache briefly to smooth bursts; rows vary by offset so cache is typically small
|
| 637 |
+
_cache_set("rows", url, data, ttl=max(1, cache_ttl))
|
| 638 |
+
return data
|
apple-touch-icon.png
ADDED
|
|
Git LFS Details
|
docker-compose.yml
ADDED
|
@@ -0,0 +1,27 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
version: '3.8'
|
| 2 |
+
|
| 3 |
+
services:
|
| 4 |
+
cloze-reader:
|
| 5 |
+
build: .
|
| 6 |
+
ports:
|
| 7 |
+
- "7860:7860"
|
| 8 |
+
environment:
|
| 9 |
+
- OPENROUTER_API_KEY=${OPENROUTER_API_KEY:-}
|
| 10 |
+
- HF_API_KEY=${HF_API_KEY:-}
|
| 11 |
+
volumes:
|
| 12 |
+
- ./src:/app/src:ro
|
| 13 |
+
- ./index.html:/app/index.html:ro
|
| 14 |
+
restart: unless-stopped
|
| 15 |
+
|
| 16 |
+
dev-server:
|
| 17 |
+
image: python:3.9-slim
|
| 18 |
+
working_dir: /app
|
| 19 |
+
ports:
|
| 20 |
+
- "8000:8000"
|
| 21 |
+
volumes:
|
| 22 |
+
- .:/app
|
| 23 |
+
command: python -m http.server 8000
|
| 24 |
+
environment:
|
| 25 |
+
- PYTHONUNBUFFERED=1
|
| 26 |
+
profiles:
|
| 27 |
+
- dev
|
favicon.png
ADDED
|
|
Git LFS Details
|
favicon.svg
ADDED
|
|
icon-192.png
ADDED
|
|
Git LFS Details
|
icon-512.png
ADDED
|
|
Git LFS Details
|
icon.png
ADDED
|
|
Git LFS Details
|
icon.svg
ADDED
|
|
index.html
ADDED
|
@@ -0,0 +1,88 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
<!DOCTYPE html>
|
| 2 |
+
<html lang="en">
|
| 3 |
+
<head>
|
| 4 |
+
<meta charset="UTF-8">
|
| 5 |
+
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
| 6 |
+
<title>Cloze Reader</title>
|
| 7 |
+
<script src="https://cdn.tailwindcss.com"></script>
|
| 8 |
+
<link href="https://fonts.googleapis.com/css2?family=Special+Elite&display=swap" rel="stylesheet">
|
| 9 |
+
<link href="./src/styles.css" rel="stylesheet">
|
| 10 |
+
<link rel="icon" type="image/svg+xml" href="./favicon.svg">
|
| 11 |
+
<link rel="icon" type="image/png" href="./favicon.png">
|
| 12 |
+
<link rel="shortcut icon" href="./favicon.ico">
|
| 13 |
+
<link rel="apple-touch-icon" href="./apple-touch-icon.png">
|
| 14 |
+
<link rel="manifest" href="./site.webmanifest">
|
| 15 |
+
</head>
|
| 16 |
+
<body class="min-h-screen">
|
| 17 |
+
<div id="app" class="container mx-auto px-4 py-8 max-w-4xl">
|
| 18 |
+
<header class="text-center mb-8">
|
| 19 |
+
<div class="flex items-center justify-center mb-4 gap-3">
|
| 20 |
+
<img src="https://media.githubusercontent.com/media/milwrite/cloze-reader/main/icon.png" alt="Cloze Reader" class="w-12 h-12">
|
| 21 |
+
<h1 class="text-4xl font-bold typewriter-text">Cloze Reader</h1>
|
| 22 |
+
</div>
|
| 23 |
+
<p class="typewriter-subtitle text-center">Fill in the blanks to practice reading comprehension</p>
|
| 24 |
+
</header>
|
| 25 |
+
|
| 26 |
+
<main id="game-container" class="space-y-6">
|
| 27 |
+
<div id="loading" class="text-center py-8">
|
| 28 |
+
<p class="text-lg loading-text">Loading passages...</p>
|
| 29 |
+
</div>
|
| 30 |
+
|
| 31 |
+
<div id="game-area" class="paper-sheet rounded-lg p-6 hidden">
|
| 32 |
+
<div class="paper-content">
|
| 33 |
+
<div class="flex justify-between items-center mb-4">
|
| 34 |
+
<div id="book-info" class="biblio-info"></div>
|
| 35 |
+
<div class="flex gap-2 items-center">
|
| 36 |
+
<div id="streak-info" class="round-badge px-3 py-1 rounded-full text-sm hidden"></div>
|
| 37 |
+
<div id="round-info" class="round-badge px-3 py-1 rounded-full text-sm"></div>
|
| 38 |
+
</div>
|
| 39 |
+
</div>
|
| 40 |
+
<div id="contextualization" class="context-box mb-4 p-3 rounded-lg"></div>
|
| 41 |
+
<div id="passage-content" class="prose max-w-none mb-6"></div>
|
| 42 |
+
<div id="hints-section" class="hints-box mb-4 p-3 rounded-lg">
|
| 43 |
+
<div class="text-sm font-semibold mb-2">👉👈 Hints:</div>
|
| 44 |
+
<div id="hints-list" class="text-sm space-y-1"></div>
|
| 45 |
+
</div>
|
| 46 |
+
<div id="controls" class="hidden">
|
| 47 |
+
<!-- Controls moved to sticky footer -->
|
| 48 |
+
</div>
|
| 49 |
+
<div id="result" class="mt-4 text-center result-text"></div>
|
| 50 |
+
</div>
|
| 51 |
+
</div>
|
| 52 |
+
</main>
|
| 53 |
+
</div>
|
| 54 |
+
|
| 55 |
+
<!-- Sticky Control Panel -->
|
| 56 |
+
<div id="sticky-controls" class="sticky-controls hidden">
|
| 57 |
+
<div class="controls-inner">
|
| 58 |
+
<button type="button" id="submit-btn" class="typewriter-button">
|
| 59 |
+
Submit
|
| 60 |
+
</button>
|
| 61 |
+
<button type="button" id="skip-btn" class="typewriter-button hidden">
|
| 62 |
+
Skip
|
| 63 |
+
</button>
|
| 64 |
+
<button type="button" id="next-btn" class="typewriter-button hidden">
|
| 65 |
+
Next Passage
|
| 66 |
+
</button>
|
| 67 |
+
<div class="controls-divider"></div>
|
| 68 |
+
<button type="button" id="hint-btn" class="typewriter-button">
|
| 69 |
+
Show Hints
|
| 70 |
+
</button>
|
| 71 |
+
<div class="controls-divider"></div>
|
| 72 |
+
<button type="button" id="leaderboard-btn" class="leaderboard-footer-btn" title="View leaderboard">
|
| 73 |
+
🏅
|
| 74 |
+
</button>
|
| 75 |
+
</div>
|
| 76 |
+
</div>
|
| 77 |
+
|
| 78 |
+
<script src="./src/app.js?v=20260109-2" type="module"></script>
|
| 79 |
+
<script type="module">
|
| 80 |
+
// Load test runner and ranking interface only in test mode
|
| 81 |
+
const urlParams = new URLSearchParams(window.location.search);
|
| 82 |
+
if (urlParams.get('testMode') === 'true') {
|
| 83 |
+
import('./src/testGameRunner.js');
|
| 84 |
+
import('./src/userRankingInterface.js');
|
| 85 |
+
}
|
| 86 |
+
</script>
|
| 87 |
+
</body>
|
| 88 |
+
</html>
|
requirements.txt
ADDED
|
@@ -0,0 +1,5 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
fastapi==0.104.1
|
| 2 |
+
uvicorn[standard]==0.24.0
|
| 3 |
+
python-dotenv==1.0.0
|
| 4 |
+
redis>=5.0.0
|
| 5 |
+
httpx>=0.25.0
|
site.webmanifest
ADDED
|
@@ -0,0 +1,12 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
{
|
| 2 |
+
"name": "Cloze Reader",
|
| 3 |
+
"short_name": "Cloze",
|
| 4 |
+
"icons": [
|
| 5 |
+
{ "src": "./icon-192.png", "type": "image/png", "sizes": "192x192" },
|
| 6 |
+
{ "src": "./icon-512.png", "type": "image/png", "sizes": "512x512" }
|
| 7 |
+
],
|
| 8 |
+
"start_url": "./",
|
| 9 |
+
"display": "standalone",
|
| 10 |
+
"background_color": "#ffffff",
|
| 11 |
+
"theme_color": "#2c2826"
|
| 12 |
+
}
|
src/aiService.js
ADDED
|
@@ -0,0 +1,736 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
class OpenRouterService {
|
| 2 |
+
constructor() {
|
| 3 |
+
// Check for local LLM mode
|
| 4 |
+
this.isLocalMode = this.checkLocalMode();
|
| 5 |
+
this.apiUrl = this.isLocalMode ? 'http://localhost:1234/v1/chat/completions' : 'https://openrouter.ai/api/v1/chat/completions';
|
| 6 |
+
this.apiKey = this.getApiKey();
|
| 7 |
+
|
| 8 |
+
// Single model configuration: Gemma-3-27b for all operations
|
| 9 |
+
this.hintModel = this.isLocalMode ? 'gemma-3-12b' : 'google/gemma-3-27b-it';
|
| 10 |
+
this.primaryModel = this.isLocalMode ? 'gemma-3-12b' : 'google/gemma-3-27b-it';
|
| 11 |
+
this.model = this.primaryModel; // Default model for backward compatibility
|
| 12 |
+
|
| 13 |
+
console.log('🤖 AI Service initialized', {
|
| 14 |
+
mode: this.isLocalMode ? 'Local LLM' : 'OpenRouter',
|
| 15 |
+
url: this.apiUrl,
|
| 16 |
+
primaryModel: this.primaryModel,
|
| 17 |
+
hintModel: this.hintModel
|
| 18 |
+
});
|
| 19 |
+
}
|
| 20 |
+
|
| 21 |
+
// Helper: Extract content from API response (handles reasoning mode variants)
|
| 22 |
+
_extractContentFromResponse(data) {
|
| 23 |
+
const msg = data?.choices?.[0]?.message;
|
| 24 |
+
if (!msg) return null;
|
| 25 |
+
return msg.content || msg.reasoning || msg.reasoning_details?.[0]?.text || null;
|
| 26 |
+
}
|
| 27 |
+
|
| 28 |
+
// Helper: Build word map from passage for validation
|
| 29 |
+
_createPassageWordMap(passage) {
|
| 30 |
+
const passageWords = passage.split(/\s+/);
|
| 31 |
+
const map = new Map();
|
| 32 |
+
passageWords.forEach((word, idx) => {
|
| 33 |
+
const clean = word.replace(/[^\w]/g, '');
|
| 34 |
+
const lower = clean.toLowerCase();
|
| 35 |
+
const isCapitalized = clean.length > 0 && clean[0] === clean[0].toUpperCase();
|
| 36 |
+
if (!isCapitalized && idx >= 10) {
|
| 37 |
+
if (!map.has(lower)) map.set(lower, []);
|
| 38 |
+
map.get(lower).push(idx);
|
| 39 |
+
}
|
| 40 |
+
});
|
| 41 |
+
return map;
|
| 42 |
+
}
|
| 43 |
+
|
| 44 |
+
// Helper: Validate words against passage and level constraints
|
| 45 |
+
// Note: Length constraints relaxed to ensure playability - difficulty comes from
|
| 46 |
+
// AI word selection prompts ("easy/common" vs "challenging"), not strict length limits
|
| 47 |
+
_validateWords(words, passageWordMap, level, passageText = null) {
|
| 48 |
+
return words.filter(word => {
|
| 49 |
+
if (!/[a-zA-Z]/.test(word)) return false;
|
| 50 |
+
const clean = word.replace(/[^a-zA-Z]/g, '');
|
| 51 |
+
if (!clean.length) return false;
|
| 52 |
+
if (/^(from|to|and)(the|a)$/i.test(clean)) return false;
|
| 53 |
+
if (!passageWordMap.has(clean.toLowerCase())) return false;
|
| 54 |
+
if (passageText && passageText.includes(word.toUpperCase()) && word === word.toUpperCase()) return false;
|
| 55 |
+
// Relaxed: 4-12 letters for levels 1-4, 4-14 for level 5+
|
| 56 |
+
// Matches manual fallback in clozeGameEngine.js selectWordsManually()
|
| 57 |
+
if (level <= 4) return clean.length >= 4 && clean.length <= 12;
|
| 58 |
+
return clean.length >= 4 && clean.length <= 14;
|
| 59 |
+
});
|
| 60 |
+
}
|
| 61 |
+
|
| 62 |
+
// Helper: Clean up AI response artifacts
|
| 63 |
+
_cleanupAIResponse(content) {
|
| 64 |
+
return content
|
| 65 |
+
.replace(/^\s*["']|["']\s*$/g, '')
|
| 66 |
+
.replace(/^\s*[:;.!?]+\s*/, '')
|
| 67 |
+
.replace(/\*+/g, '')
|
| 68 |
+
.replace(/_+/g, '')
|
| 69 |
+
.replace(/#+\s*/g, '')
|
| 70 |
+
.replace(/\s+/g, ' ')
|
| 71 |
+
.trim();
|
| 72 |
+
}
|
| 73 |
+
|
| 74 |
+
checkLocalMode() {
|
| 75 |
+
if (typeof window !== 'undefined' && window.location) {
|
| 76 |
+
const urlParams = new URLSearchParams(window.location.search);
|
| 77 |
+
return urlParams.get('local') === 'true';
|
| 78 |
+
}
|
| 79 |
+
return false;
|
| 80 |
+
}
|
| 81 |
+
|
| 82 |
+
getApiKey() {
|
| 83 |
+
// Local mode doesn't need API key
|
| 84 |
+
if (this.isLocalMode) {
|
| 85 |
+
return 'local-mode-no-key';
|
| 86 |
+
}
|
| 87 |
+
if (typeof process !== 'undefined' && process.env && process.env.OPENROUTER_API_KEY) {
|
| 88 |
+
return process.env.OPENROUTER_API_KEY;
|
| 89 |
+
}
|
| 90 |
+
if (typeof window !== 'undefined' && window.OPENROUTER_API_KEY) {
|
| 91 |
+
return window.OPENROUTER_API_KEY;
|
| 92 |
+
}
|
| 93 |
+
// console.warn('No API key found in getApiKey()');
|
| 94 |
+
return '';
|
| 95 |
+
}
|
| 96 |
+
|
| 97 |
+
setApiKey(key) {
|
| 98 |
+
this.apiKey = key;
|
| 99 |
+
}
|
| 100 |
+
|
| 101 |
+
async retryRequest(requestFn, maxRetries = 3, delayMs = 500) {
|
| 102 |
+
for (let attempt = 1; attempt <= maxRetries; attempt++) {
|
| 103 |
+
try {
|
| 104 |
+
return await requestFn();
|
| 105 |
+
} catch (error) {
|
| 106 |
+
if (attempt === maxRetries) {
|
| 107 |
+
throw error; // Final attempt failed, throw the error
|
| 108 |
+
}
|
| 109 |
+
// Wait before retrying, with exponential backoff
|
| 110 |
+
const delay = delayMs * Math.pow(2, attempt - 1);
|
| 111 |
+
await new Promise(resolve => setTimeout(resolve, delay));
|
| 112 |
+
}
|
| 113 |
+
}
|
| 114 |
+
}
|
| 115 |
+
|
| 116 |
+
async generateContextualHint(prompt) {
|
| 117 |
+
// Check for API key at runtime
|
| 118 |
+
const currentKey = this.getApiKey();
|
| 119 |
+
if (currentKey && !this.apiKey) {
|
| 120 |
+
this.apiKey = currentKey;
|
| 121 |
+
}
|
| 122 |
+
|
| 123 |
+
if (!this.apiKey) {
|
| 124 |
+
return 'API key required for hints';
|
| 125 |
+
}
|
| 126 |
+
|
| 127 |
+
try {
|
| 128 |
+
const headers = {
|
| 129 |
+
'Content-Type': 'application/json'
|
| 130 |
+
};
|
| 131 |
+
|
| 132 |
+
// Only add auth headers for OpenRouter
|
| 133 |
+
if (!this.isLocalMode) {
|
| 134 |
+
headers['Authorization'] = `Bearer ${this.apiKey}`;
|
| 135 |
+
headers['HTTP-Referer'] = window.location.origin;
|
| 136 |
+
headers['X-Title'] = 'Cloze Reader';
|
| 137 |
+
}
|
| 138 |
+
|
| 139 |
+
const response = await fetch(this.apiUrl, {
|
| 140 |
+
method: 'POST',
|
| 141 |
+
headers,
|
| 142 |
+
body: JSON.stringify({
|
| 143 |
+
model: this.hintModel, // Use Gemma-3-27b for hints
|
| 144 |
+
messages: [{
|
| 145 |
+
role: 'system',
|
| 146 |
+
content: 'You are a helpful assistant that provides hints for word puzzles. Never reveal the answer word directly.'
|
| 147 |
+
}, {
|
| 148 |
+
role: 'user',
|
| 149 |
+
content: prompt
|
| 150 |
+
}],
|
| 151 |
+
max_tokens: 150,
|
| 152 |
+
temperature: 0.7,
|
| 153 |
+
// Try to disable reasoning mode for hints
|
| 154 |
+
response_format: { type: "text" }
|
| 155 |
+
})
|
| 156 |
+
});
|
| 157 |
+
|
| 158 |
+
if (!response.ok) {
|
| 159 |
+
throw new Error(`API request failed: ${response.status}`);
|
| 160 |
+
}
|
| 161 |
+
|
| 162 |
+
const data = await response.json();
|
| 163 |
+
|
| 164 |
+
|
| 165 |
+
// Check if data and choices exist before accessing
|
| 166 |
+
if (!data || !data.choices || data.choices.length === 0) {
|
| 167 |
+
console.error('Invalid API response structure:', data);
|
| 168 |
+
return 'Unable to generate hint at this time';
|
| 169 |
+
}
|
| 170 |
+
|
| 171 |
+
// Check if message exists
|
| 172 |
+
if (!data.choices[0].message) {
|
| 173 |
+
console.error('No message in API response');
|
| 174 |
+
return 'Unable to generate hint at this time';
|
| 175 |
+
}
|
| 176 |
+
|
| 177 |
+
// Extract content from response (handles reasoning mode variants)
|
| 178 |
+
let content = this._extractContentFromResponse(data);
|
| 179 |
+
|
| 180 |
+
if (!content) {
|
| 181 |
+
console.error('No content found in hint response');
|
| 182 |
+
// Provide a generic hint based on the prompt type
|
| 183 |
+
if (prompt.toLowerCase().includes('synonym')) {
|
| 184 |
+
return 'Think of a word that means something similar';
|
| 185 |
+
} else if (prompt.toLowerCase().includes('definition')) {
|
| 186 |
+
return 'Consider what this word means in context';
|
| 187 |
+
} else if (prompt.toLowerCase().includes('category')) {
|
| 188 |
+
return 'Think about what type or category this word belongs to';
|
| 189 |
+
} else {
|
| 190 |
+
return 'Consider the context around the blank';
|
| 191 |
+
}
|
| 192 |
+
}
|
| 193 |
+
|
| 194 |
+
content = content.trim();
|
| 195 |
+
|
| 196 |
+
// For OSS-20B, extract hint from reasoning text if needed
|
| 197 |
+
if (content.includes('The user') || content.includes('We need to')) {
|
| 198 |
+
// This looks like reasoning text, try to extract the actual hint
|
| 199 |
+
// Look for text about synonyms, definitions, or clues
|
| 200 |
+
const hintPatterns = [
|
| 201 |
+
/synonym[s]?.*?(?:is|are|include[s]?|would be)\s+([^.]+)/i,
|
| 202 |
+
/means?\s+([^.]+)/i,
|
| 203 |
+
/refers? to\s+([^.]+)/i,
|
| 204 |
+
/describes?\s+([^.]+)/i,
|
| 205 |
+
];
|
| 206 |
+
|
| 207 |
+
for (const pattern of hintPatterns) {
|
| 208 |
+
const match = content.match(pattern);
|
| 209 |
+
if (match) {
|
| 210 |
+
content = match[1];
|
| 211 |
+
break;
|
| 212 |
+
}
|
| 213 |
+
}
|
| 214 |
+
|
| 215 |
+
// If still has reasoning markers, just return a fallback
|
| 216 |
+
if (content.includes('The user') || content.includes('We need to')) {
|
| 217 |
+
return 'Think about words that mean something similar';
|
| 218 |
+
}
|
| 219 |
+
}
|
| 220 |
+
|
| 221 |
+
// Clean up AI response artifacts
|
| 222 |
+
return this._cleanupAIResponse(content);
|
| 223 |
+
} catch (error) {
|
| 224 |
+
console.error('Error generating contextual hint:', error);
|
| 225 |
+
return 'Unable to generate hint at this time';
|
| 226 |
+
}
|
| 227 |
+
}
|
| 228 |
+
|
| 229 |
+
|
| 230 |
+
async selectSignificantWords(passage, count, level = 1) {
|
| 231 |
+
|
| 232 |
+
// Check for API key at runtime in case it was loaded after initialization
|
| 233 |
+
const currentKey = this.getApiKey();
|
| 234 |
+
if (currentKey && !this.apiKey) {
|
| 235 |
+
this.apiKey = currentKey;
|
| 236 |
+
}
|
| 237 |
+
|
| 238 |
+
|
| 239 |
+
if (!this.apiKey) {
|
| 240 |
+
console.error('No API key for word selection');
|
| 241 |
+
throw new Error('API key required for word selection');
|
| 242 |
+
}
|
| 243 |
+
|
| 244 |
+
// Define level-based constraints (relaxed length to ensure playability)
|
| 245 |
+
let wordLengthConstraint, difficultyGuidance;
|
| 246 |
+
if (level <= 2) {
|
| 247 |
+
wordLengthConstraint = "4-12 letters";
|
| 248 |
+
difficultyGuidance = "Select EASY vocabulary words - common, everyday words that most readers know.";
|
| 249 |
+
} else if (level <= 4) {
|
| 250 |
+
wordLengthConstraint = "4-12 letters";
|
| 251 |
+
difficultyGuidance = "Select MEDIUM difficulty words - mix of common and moderately challenging vocabulary.";
|
| 252 |
+
} else {
|
| 253 |
+
wordLengthConstraint = "4-14 letters";
|
| 254 |
+
difficultyGuidance = "Select CHALLENGING words - sophisticated vocabulary that requires strong reading skills.";
|
| 255 |
+
}
|
| 256 |
+
|
| 257 |
+
try {
|
| 258 |
+
return await this.retryRequest(async () => {
|
| 259 |
+
const response = await fetch(this.apiUrl, {
|
| 260 |
+
method: 'POST',
|
| 261 |
+
headers: {
|
| 262 |
+
'Content-Type': 'application/json',
|
| 263 |
+
'Authorization': `Bearer ${this.apiKey}`,
|
| 264 |
+
'HTTP-Referer': window.location.origin,
|
| 265 |
+
'X-Title': 'Cloze Reader'
|
| 266 |
+
},
|
| 267 |
+
body: JSON.stringify({
|
| 268 |
+
model: this.primaryModel, // Use Gemma-3-12b for word selection
|
| 269 |
+
messages: [{
|
| 270 |
+
role: 'system',
|
| 271 |
+
content: 'Select words for a cloze exercise. Return ONLY a JSON array of words, nothing else.'
|
| 272 |
+
}, {
|
| 273 |
+
role: 'user',
|
| 274 |
+
content: `Select ${count} ${level <= 2 ? 'easy' : level <= 4 ? 'medium' : 'challenging'} words (${wordLengthConstraint}) from this passage.
|
| 275 |
+
|
| 276 |
+
CRITICAL RULES:
|
| 277 |
+
- Select EXACT words that appear in the passage (copy them exactly as written)
|
| 278 |
+
- ONLY select lowercase words (no capitalized words, no proper nouns)
|
| 279 |
+
- ONLY select words from the MIDDLE or END of the passage (skip the first ~10 words)
|
| 280 |
+
- Words must be ${wordLengthConstraint}
|
| 281 |
+
- Choose nouns, verbs, or adjectives
|
| 282 |
+
- AVOID compound words like "courthouse" or "steamboat" - choose single, verifiable words with semantic inbetweenness
|
| 283 |
+
- AVOID indexes, tables of contents, and capitalized content
|
| 284 |
+
- Return ONLY a JSON array like ["word1", "word2"]
|
| 285 |
+
|
| 286 |
+
Passage: "${passage}"`
|
| 287 |
+
}],
|
| 288 |
+
max_tokens: 200,
|
| 289 |
+
temperature: 0.5,
|
| 290 |
+
// Try to disable reasoning mode for word selection
|
| 291 |
+
response_format: { type: "text" }
|
| 292 |
+
})
|
| 293 |
+
});
|
| 294 |
+
|
| 295 |
+
if (!response.ok) {
|
| 296 |
+
throw new Error(`API request failed: ${response.status}`);
|
| 297 |
+
}
|
| 298 |
+
|
| 299 |
+
const data = await response.json();
|
| 300 |
+
|
| 301 |
+
// Check for OpenRouter error response
|
| 302 |
+
if (data.error) {
|
| 303 |
+
console.error('OpenRouter API error for word selection:', data.error);
|
| 304 |
+
throw new Error(`OpenRouter API error: ${data.error.message || JSON.stringify(data.error)}`);
|
| 305 |
+
}
|
| 306 |
+
|
| 307 |
+
// Log the full response to debug structure
|
| 308 |
+
|
| 309 |
+
// Check if response has expected structure
|
| 310 |
+
if (!data.choices || !data.choices[0] || !data.choices[0].message) {
|
| 311 |
+
console.error('Invalid word selection API response structure:', data);
|
| 312 |
+
console.error('Choices[0]:', data.choices?.[0]);
|
| 313 |
+
throw new Error('API response missing expected structure');
|
| 314 |
+
}
|
| 315 |
+
|
| 316 |
+
// Extract content from response (handles reasoning mode variants)
|
| 317 |
+
let content = this._extractContentFromResponse(data);
|
| 318 |
+
|
| 319 |
+
if (!content) {
|
| 320 |
+
console.error('No content found in API response');
|
| 321 |
+
throw new Error('API response missing content');
|
| 322 |
+
}
|
| 323 |
+
|
| 324 |
+
content = content.trim();
|
| 325 |
+
|
| 326 |
+
// Clean up local LLM artifacts
|
| 327 |
+
if (this.isLocalMode) {
|
| 328 |
+
content = this.cleanLocalLLMResponse(content);
|
| 329 |
+
}
|
| 330 |
+
|
| 331 |
+
// Try to parse as JSON array
|
| 332 |
+
try {
|
| 333 |
+
let words;
|
| 334 |
+
|
| 335 |
+
// Try to parse JSON first
|
| 336 |
+
try {
|
| 337 |
+
// Check if content contains JSON array anywhere in it
|
| 338 |
+
const jsonMatch = content.match(/\[[\s\S]*?\]/);
|
| 339 |
+
if (jsonMatch) {
|
| 340 |
+
words = JSON.parse(jsonMatch[0]);
|
| 341 |
+
} else {
|
| 342 |
+
words = JSON.parse(content);
|
| 343 |
+
}
|
| 344 |
+
} catch {
|
| 345 |
+
// If not JSON, check if this is reasoning text from OSS-20B
|
| 346 |
+
if (content.includes('pick') || content.includes('Let\'s')) {
|
| 347 |
+
// Extract words from reasoning text
|
| 348 |
+
// Look for quoted words or words after "pick"
|
| 349 |
+
const quotedWords = content.match(/"([^"]+)"/g);
|
| 350 |
+
if (quotedWords) {
|
| 351 |
+
words = quotedWords.map(w => w.replace(/"/g, ''));
|
| 352 |
+
} else {
|
| 353 |
+
// Look for pattern like "Let's pick 'word'" or "pick word"
|
| 354 |
+
const pickMatch = content.match(/pick\s+['"]?(\w+)['"]?/i);
|
| 355 |
+
if (pickMatch) {
|
| 356 |
+
words = [pickMatch[1]];
|
| 357 |
+
} else {
|
| 358 |
+
// For local LLM, try comma-separated
|
| 359 |
+
if (this.isLocalMode && content.includes(',')) {
|
| 360 |
+
words = content.split(',').map(w => w.trim());
|
| 361 |
+
} else {
|
| 362 |
+
// Single word
|
| 363 |
+
words = [content.trim()];
|
| 364 |
+
}
|
| 365 |
+
}
|
| 366 |
+
}
|
| 367 |
+
} else if (this.isLocalMode) {
|
| 368 |
+
// For local LLM, try comma-separated
|
| 369 |
+
if (content.includes(',')) {
|
| 370 |
+
words = content.split(',').map(w => w.trim());
|
| 371 |
+
} else {
|
| 372 |
+
// Single word
|
| 373 |
+
words = [content.trim()];
|
| 374 |
+
}
|
| 375 |
+
} else {
|
| 376 |
+
throw new Error('Could not parse words from response');
|
| 377 |
+
}
|
| 378 |
+
}
|
| 379 |
+
|
| 380 |
+
if (Array.isArray(words)) {
|
| 381 |
+
// Create passage word map and validate words
|
| 382 |
+
const passageWordMap = this._createPassageWordMap(passage);
|
| 383 |
+
const validWords = this._validateWords(words, passageWordMap, level);
|
| 384 |
+
|
| 385 |
+
if (validWords.length > 0) {
|
| 386 |
+
return validWords.slice(0, count);
|
| 387 |
+
} else {
|
| 388 |
+
console.warn(`No words met requirements for level ${level}`);
|
| 389 |
+
throw new Error(`No valid words for level ${level}`);
|
| 390 |
+
}
|
| 391 |
+
}
|
| 392 |
+
} catch (e) {
|
| 393 |
+
// If not valid JSON, try to extract words from the response
|
| 394 |
+
const matches = content.match(/"([^"]+)"/g);
|
| 395 |
+
if (matches) {
|
| 396 |
+
const words = matches.map(m => m.replace(/"/g, ''));
|
| 397 |
+
|
| 398 |
+
// Create passage word map and validate words
|
| 399 |
+
const passageWordMap = this._createPassageWordMap(passage);
|
| 400 |
+
const validWords = this._validateWords(words, passageWordMap, level);
|
| 401 |
+
|
| 402 |
+
if (validWords.length > 0) {
|
| 403 |
+
return validWords.slice(0, count);
|
| 404 |
+
} else {
|
| 405 |
+
throw new Error(`No valid words for level ${level}`);
|
| 406 |
+
}
|
| 407 |
+
}
|
| 408 |
+
}
|
| 409 |
+
|
| 410 |
+
throw new Error('Failed to parse AI response');
|
| 411 |
+
});
|
| 412 |
+
} catch (error) {
|
| 413 |
+
console.error('Error selecting words with AI:', error);
|
| 414 |
+
throw error;
|
| 415 |
+
}
|
| 416 |
+
}
|
| 417 |
+
|
| 418 |
+
async processBothPassages(passage1, book1, passage2, book2, blanksPerPassage, level = 1) {
|
| 419 |
+
// Process both passages in a single API call to avoid rate limits
|
| 420 |
+
const currentKey = this.getApiKey();
|
| 421 |
+
if (currentKey && !this.apiKey) {
|
| 422 |
+
this.apiKey = currentKey;
|
| 423 |
+
}
|
| 424 |
+
|
| 425 |
+
if (!this.apiKey) {
|
| 426 |
+
throw new Error('API key required for passage processing');
|
| 427 |
+
}
|
| 428 |
+
|
| 429 |
+
// Define level-based constraints (relaxed length to ensure playability)
|
| 430 |
+
let wordLengthConstraint, difficultyGuidance;
|
| 431 |
+
if (level <= 2) {
|
| 432 |
+
wordLengthConstraint = "4-12 letters";
|
| 433 |
+
difficultyGuidance = "Select EASY vocabulary words - common, everyday words that most readers know.";
|
| 434 |
+
} else if (level <= 4) {
|
| 435 |
+
wordLengthConstraint = "4-12 letters";
|
| 436 |
+
difficultyGuidance = "Select MEDIUM difficulty words - mix of common and moderately challenging vocabulary.";
|
| 437 |
+
} else {
|
| 438 |
+
wordLengthConstraint = "4-14 letters";
|
| 439 |
+
difficultyGuidance = "Select CHALLENGING words - sophisticated vocabulary that requires strong reading skills.";
|
| 440 |
+
}
|
| 441 |
+
|
| 442 |
+
try {
|
| 443 |
+
// Add timeout controller to prevent aborted operations
|
| 444 |
+
const controller = new AbortController();
|
| 445 |
+
const timeoutId = setTimeout(() => controller.abort(), 15000); // 15 second timeout
|
| 446 |
+
|
| 447 |
+
const headers = {
|
| 448 |
+
'Content-Type': 'application/json'
|
| 449 |
+
};
|
| 450 |
+
|
| 451 |
+
// Only add auth headers for OpenRouter
|
| 452 |
+
if (!this.isLocalMode) {
|
| 453 |
+
headers['Authorization'] = `Bearer ${this.apiKey}`;
|
| 454 |
+
headers['HTTP-Referer'] = window.location.origin;
|
| 455 |
+
headers['X-Title'] = 'Cloze Reader';
|
| 456 |
+
}
|
| 457 |
+
|
| 458 |
+
const response = await fetch(this.apiUrl, {
|
| 459 |
+
method: 'POST',
|
| 460 |
+
headers,
|
| 461 |
+
signal: controller.signal,
|
| 462 |
+
body: JSON.stringify({
|
| 463 |
+
model: this.primaryModel, // Use Gemma-3-12b for batch processing
|
| 464 |
+
messages: [{
|
| 465 |
+
role: 'system',
|
| 466 |
+
content: 'Process passages for cloze exercises. Return ONLY a JSON object.'
|
| 467 |
+
}, {
|
| 468 |
+
role: 'user',
|
| 469 |
+
content: `Select ${blanksPerPassage} ${level <= 2 ? 'easy' : level <= 4 ? 'medium' : 'challenging'} words (${wordLengthConstraint}) from each passage.
|
| 470 |
+
|
| 471 |
+
CRITICAL RULES:
|
| 472 |
+
- ONLY select lowercase words (no capitalized words, no proper nouns)
|
| 473 |
+
- ONLY select words from the MIDDLE or END of each passage (skip the first ~10 words)
|
| 474 |
+
- Words must be ${wordLengthConstraint}
|
| 475 |
+
- AVOID compound words like "courthouse" or "steamboat" - choose single, verifiable words with semantic inbetweenness
|
| 476 |
+
- AVOID indexes, tables of contents, and capitalized content
|
| 477 |
+
|
| 478 |
+
Passage 1 ("${book1.title}" by ${book1.author}):
|
| 479 |
+
${passage1}
|
| 480 |
+
|
| 481 |
+
Passage 2 ("${book2.title}" by ${book2.author}):
|
| 482 |
+
${passage2}
|
| 483 |
+
|
| 484 |
+
Return JSON: {"passage1": {"words": [${blanksPerPassage} words], "context": "one sentence about book"}, "passage2": {"words": [${blanksPerPassage} words], "context": "one sentence about book"}}`
|
| 485 |
+
}],
|
| 486 |
+
max_tokens: 800,
|
| 487 |
+
temperature: 0.5,
|
| 488 |
+
response_format: { type: "text" }
|
| 489 |
+
})
|
| 490 |
+
});
|
| 491 |
+
|
| 492 |
+
// Clear timeout on successful response
|
| 493 |
+
clearTimeout(timeoutId);
|
| 494 |
+
|
| 495 |
+
if (!response.ok) {
|
| 496 |
+
throw new Error(`API request failed: ${response.status}`);
|
| 497 |
+
}
|
| 498 |
+
|
| 499 |
+
const data = await response.json();
|
| 500 |
+
|
| 501 |
+
// Check for error response
|
| 502 |
+
if (data.error) {
|
| 503 |
+
console.error('OpenRouter API error for batch processing:', data.error);
|
| 504 |
+
throw new Error(`OpenRouter API error: ${data.error.message || JSON.stringify(data.error)}`);
|
| 505 |
+
}
|
| 506 |
+
|
| 507 |
+
|
| 508 |
+
// Check if response has expected structure
|
| 509 |
+
if (!data.choices || !data.choices[0] || !data.choices[0].message) {
|
| 510 |
+
console.error('Invalid batch API response structure:', data);
|
| 511 |
+
console.error('Choices[0]:', data.choices?.[0]);
|
| 512 |
+
throw new Error('API response missing expected structure');
|
| 513 |
+
}
|
| 514 |
+
|
| 515 |
+
// Extract content from response (handles reasoning mode variants)
|
| 516 |
+
let content = this._extractContentFromResponse(data);
|
| 517 |
+
|
| 518 |
+
if (!content) {
|
| 519 |
+
console.error('No content found in batch API response');
|
| 520 |
+
throw new Error('API response missing content');
|
| 521 |
+
}
|
| 522 |
+
|
| 523 |
+
content = content.trim();
|
| 524 |
+
|
| 525 |
+
try {
|
| 526 |
+
// Try to extract JSON from the response
|
| 527 |
+
// Sometimes the model returns JSON wrapped in markdown code blocks
|
| 528 |
+
const jsonMatch = content.match(/```json\s*([\s\S]*?)\s*```/) || content.match(/```\s*([\s\S]*?)\s*```/);
|
| 529 |
+
let jsonString = jsonMatch ? jsonMatch[1] : content;
|
| 530 |
+
|
| 531 |
+
// Clean up the JSON string
|
| 532 |
+
jsonString = jsonString
|
| 533 |
+
.replace(/^\s*```json\s*/, '')
|
| 534 |
+
.replace(/\s*```\s*$/, '')
|
| 535 |
+
.trim();
|
| 536 |
+
|
| 537 |
+
// Try to fix common JSON issues
|
| 538 |
+
// Fix trailing commas in arrays
|
| 539 |
+
jsonString = jsonString.replace(/,(\s*])/g, '$1');
|
| 540 |
+
|
| 541 |
+
// Check for truncated strings (unterminated quotes)
|
| 542 |
+
const quoteCount = (jsonString.match(/"/g) || []).length;
|
| 543 |
+
if (quoteCount % 2 !== 0) {
|
| 544 |
+
// Add missing closing quote
|
| 545 |
+
jsonString += '"';
|
| 546 |
+
}
|
| 547 |
+
|
| 548 |
+
// Check if JSON is truncated (missing closing braces)
|
| 549 |
+
const openBraces = (jsonString.match(/{/g) || []).length;
|
| 550 |
+
const closeBraces = (jsonString.match(/}/g) || []).length;
|
| 551 |
+
|
| 552 |
+
if (openBraces > closeBraces) {
|
| 553 |
+
// Add missing closing braces
|
| 554 |
+
jsonString += '}'.repeat(openBraces - closeBraces);
|
| 555 |
+
}
|
| 556 |
+
|
| 557 |
+
// Remove any trailing garbage after the last closing brace
|
| 558 |
+
const lastBrace = jsonString.lastIndexOf('}');
|
| 559 |
+
if (lastBrace !== -1 && lastBrace < jsonString.length - 1) {
|
| 560 |
+
jsonString = jsonString.substring(0, lastBrace + 1);
|
| 561 |
+
}
|
| 562 |
+
|
| 563 |
+
const parsed = JSON.parse(jsonString);
|
| 564 |
+
|
| 565 |
+
// Validate the structure
|
| 566 |
+
if (!parsed.passage1 || !parsed.passage2) {
|
| 567 |
+
console.error('Parsed response missing expected structure:', parsed);
|
| 568 |
+
throw new Error('Response missing passage1 or passage2');
|
| 569 |
+
}
|
| 570 |
+
|
| 571 |
+
// Ensure words arrays exist and are arrays
|
| 572 |
+
if (!Array.isArray(parsed.passage1.words)) {
|
| 573 |
+
parsed.passage1.words = [];
|
| 574 |
+
}
|
| 575 |
+
if (!Array.isArray(parsed.passage2.words)) {
|
| 576 |
+
parsed.passage2.words = [];
|
| 577 |
+
}
|
| 578 |
+
|
| 579 |
+
// Filter out empty strings from words arrays (caused by trailing commas)
|
| 580 |
+
parsed.passage1.words = parsed.passage1.words.filter(word => word && word.trim() !== '');
|
| 581 |
+
parsed.passage2.words = parsed.passage2.words.filter(word => word && word.trim() !== '');
|
| 582 |
+
|
| 583 |
+
// Validate words using helper methods
|
| 584 |
+
const map1 = this._createPassageWordMap(passage1);
|
| 585 |
+
const map2 = this._createPassageWordMap(passage2);
|
| 586 |
+
parsed.passage1.words = this._validateWords(parsed.passage1.words, map1, level, passage1);
|
| 587 |
+
parsed.passage2.words = this._validateWords(parsed.passage2.words, map2, level, passage2);
|
| 588 |
+
|
| 589 |
+
return parsed;
|
| 590 |
+
} catch (e) {
|
| 591 |
+
console.error('Failed to parse batch response:', e);
|
| 592 |
+
console.error('Raw content:', content);
|
| 593 |
+
|
| 594 |
+
// Try to extract any usable data from the partial response
|
| 595 |
+
try {
|
| 596 |
+
// Extract passage contexts using regex
|
| 597 |
+
const context1Match = content.match(/"context":\s*"([^"]+)"/);
|
| 598 |
+
const context2Match = content.match(/"passage2"[\s\S]*?"context":\s*"([^"]+)"/);
|
| 599 |
+
|
| 600 |
+
// Extract words arrays using regex
|
| 601 |
+
const words1Match = content.match(/"words":\s*\[([^\]]+)\]/);
|
| 602 |
+
const words2Match = content.match(/"passage2"[\s\S]*?"words":\s*\[([^\]]+)\]/);
|
| 603 |
+
|
| 604 |
+
const extractWords = (match) => {
|
| 605 |
+
if (!match) return [];
|
| 606 |
+
try {
|
| 607 |
+
return JSON.parse(`[${match[1]}]`);
|
| 608 |
+
} catch {
|
| 609 |
+
return match[1].split(',').map(w => w.trim().replace(/['"]/g, ''));
|
| 610 |
+
}
|
| 611 |
+
};
|
| 612 |
+
|
| 613 |
+
return {
|
| 614 |
+
passage1: {
|
| 615 |
+
words: extractWords(words1Match),
|
| 616 |
+
context: context1Match ? context1Match[1] : `From "${book1.title}" by ${book1.author}`
|
| 617 |
+
},
|
| 618 |
+
passage2: {
|
| 619 |
+
words: extractWords(words2Match),
|
| 620 |
+
context: context2Match ? context2Match[1] : `From "${book2.title}" by ${book2.author}`
|
| 621 |
+
}
|
| 622 |
+
};
|
| 623 |
+
} catch (extractError) {
|
| 624 |
+
console.error('Failed to extract partial data:', extractError);
|
| 625 |
+
throw new Error('Invalid API response format');
|
| 626 |
+
}
|
| 627 |
+
}
|
| 628 |
+
} catch (error) {
|
| 629 |
+
// Clear timeout in error case too
|
| 630 |
+
if (typeof timeoutId !== 'undefined') {
|
| 631 |
+
clearTimeout(timeoutId);
|
| 632 |
+
}
|
| 633 |
+
|
| 634 |
+
// Handle specific abort error
|
| 635 |
+
if (error.name === 'AbortError') {
|
| 636 |
+
console.error('Batch processing timed out after 15 seconds');
|
| 637 |
+
throw new Error('Request timed out - falling back to sequential processing');
|
| 638 |
+
}
|
| 639 |
+
|
| 640 |
+
console.error('Error processing passages:', error);
|
| 641 |
+
throw error;
|
| 642 |
+
}
|
| 643 |
+
}
|
| 644 |
+
|
| 645 |
+
async generateContextualization(title, author, passage) {
|
| 646 |
+
|
| 647 |
+
// Check for API key at runtime
|
| 648 |
+
const currentKey = this.getApiKey();
|
| 649 |
+
if (currentKey && !this.apiKey) {
|
| 650 |
+
this.apiKey = currentKey;
|
| 651 |
+
}
|
| 652 |
+
|
| 653 |
+
|
| 654 |
+
if (!this.apiKey) {
|
| 655 |
+
return `A passage from ${author}'s "${title}"`;
|
| 656 |
+
}
|
| 657 |
+
|
| 658 |
+
try {
|
| 659 |
+
return await this.retryRequest(async () => {
|
| 660 |
+
const response = await fetch(this.apiUrl, {
|
| 661 |
+
method: 'POST',
|
| 662 |
+
headers: {
|
| 663 |
+
'Content-Type': 'application/json',
|
| 664 |
+
'Authorization': `Bearer ${this.apiKey}`,
|
| 665 |
+
'HTTP-Referer': window.location.origin,
|
| 666 |
+
'X-Title': 'Cloze Reader'
|
| 667 |
+
},
|
| 668 |
+
body: JSON.stringify({
|
| 669 |
+
model: this.primaryModel, // Use Gemma-3-27b for contextualization
|
| 670 |
+
messages: [{
|
| 671 |
+
role: 'system',
|
| 672 |
+
content: 'Provide a single contextual insight about the passage: historical context, literary technique, thematic observation, or relevant fact. Be specific and direct. Maximum 25 words. Do not use dashes or em-dashes. Output ONLY the insight itself with no preamble, acknowledgments, or meta-commentary.'
|
| 673 |
+
}, {
|
| 674 |
+
role: 'user',
|
| 675 |
+
content: `From "${title}" by ${author}:\n\n${passage}`
|
| 676 |
+
}],
|
| 677 |
+
max_tokens: 150,
|
| 678 |
+
temperature: 0.7,
|
| 679 |
+
response_format: { type: "text" }
|
| 680 |
+
})
|
| 681 |
+
});
|
| 682 |
+
|
| 683 |
+
if (!response.ok) {
|
| 684 |
+
const errorText = await response.text();
|
| 685 |
+
console.error('Contextualization API error:', response.status, errorText);
|
| 686 |
+
throw new Error(`API request failed: ${response.status}`);
|
| 687 |
+
}
|
| 688 |
+
|
| 689 |
+
const data = await response.json();
|
| 690 |
+
|
| 691 |
+
// Check for OpenRouter error response
|
| 692 |
+
if (data.error) {
|
| 693 |
+
console.error('OpenRouter API error for contextualization:', data.error);
|
| 694 |
+
throw new Error(`OpenRouter API error: ${data.error.message || JSON.stringify(data.error)}`);
|
| 695 |
+
}
|
| 696 |
+
|
| 697 |
+
|
| 698 |
+
// Check if response has expected structure
|
| 699 |
+
if (!data.choices || !data.choices[0] || !data.choices[0].message) {
|
| 700 |
+
console.error('Invalid contextualization API response structure:', data);
|
| 701 |
+
console.error('Choices[0]:', data.choices?.[0]);
|
| 702 |
+
throw new Error('API response missing expected structure');
|
| 703 |
+
}
|
| 704 |
+
|
| 705 |
+
// Extract content from response (handles reasoning mode variants)
|
| 706 |
+
let content = this._extractContentFromResponse(data);
|
| 707 |
+
|
| 708 |
+
if (!content) {
|
| 709 |
+
console.error('No content found in context API response');
|
| 710 |
+
throw new Error('API response missing content');
|
| 711 |
+
}
|
| 712 |
+
|
| 713 |
+
// Clean up AI response artifacts
|
| 714 |
+
content = this._cleanupAIResponse(content.trim());
|
| 715 |
+
|
| 716 |
+
return content;
|
| 717 |
+
});
|
| 718 |
+
} catch (error) {
|
| 719 |
+
console.error('Error getting contextualization:', error);
|
| 720 |
+
return `A passage from ${author}'s "${title}"`;
|
| 721 |
+
}
|
| 722 |
+
}
|
| 723 |
+
|
| 724 |
+
cleanLocalLLMResponse(content) {
|
| 725 |
+
// Remove common artifacts from local LLM responses
|
| 726 |
+
return content
|
| 727 |
+
.replace(/\["?/g, '') // Remove opening bracket and quote
|
| 728 |
+
.replace(/"?\]/g, '') // Remove closing quote and bracket
|
| 729 |
+
.replace(/^[>"|']+/g, '') // Remove leading > or quotes
|
| 730 |
+
.replace(/[>"|']+$/g, '') // Remove trailing > or quotes
|
| 731 |
+
.replace(/\\n/g, ' ') // Replace escaped newlines
|
| 732 |
+
.trim();
|
| 733 |
+
}
|
| 734 |
+
}
|
| 735 |
+
|
| 736 |
+
export { OpenRouterService as AIService };
|
src/analyticsService.js
ADDED
|
@@ -0,0 +1,350 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
/**
|
| 2 |
+
* Analytics Service
|
| 3 |
+
* Frontend client for tracking passage attempts, word difficulty, and hint usage.
|
| 4 |
+
* Sends summary data to backend Redis analytics service.
|
| 5 |
+
*/
|
| 6 |
+
|
| 7 |
+
export class AnalyticsService {
|
| 8 |
+
constructor() {
|
| 9 |
+
// Generate unique session ID for this browser session
|
| 10 |
+
this.sessionId = this._generateUUID();
|
| 11 |
+
|
| 12 |
+
// Current passage tracking state
|
| 13 |
+
this.currentPassage = null;
|
| 14 |
+
|
| 15 |
+
// Base URL - uses same origin as the app
|
| 16 |
+
this.baseUrl = window.location.origin;
|
| 17 |
+
|
| 18 |
+
}
|
| 19 |
+
|
| 20 |
+
/**
|
| 21 |
+
* Generate a UUID v4
|
| 22 |
+
*/
|
| 23 |
+
_generateUUID() {
|
| 24 |
+
// Use crypto.randomUUID if available, otherwise fallback
|
| 25 |
+
if (typeof crypto !== 'undefined' && crypto.randomUUID) {
|
| 26 |
+
return crypto.randomUUID();
|
| 27 |
+
}
|
| 28 |
+
// Fallback for older browsers
|
| 29 |
+
return 'xxxxxxxx-xxxx-4xxx-yxxx-xxxxxxxxxxxx'.replace(/[xy]/g, function(c) {
|
| 30 |
+
const r = Math.random() * 16 | 0;
|
| 31 |
+
const v = c === 'x' ? r : (r & 0x3 | 0x8);
|
| 32 |
+
return v.toString(16);
|
| 33 |
+
});
|
| 34 |
+
}
|
| 35 |
+
|
| 36 |
+
/**
|
| 37 |
+
* Start tracking a new passage attempt.
|
| 38 |
+
* Call this when a new passage is loaded.
|
| 39 |
+
*
|
| 40 |
+
* @param {Object} book - Book info {title, author}
|
| 41 |
+
* @param {Array} blanks - Array of blank objects with originalWord
|
| 42 |
+
* @param {number} level - Current game level
|
| 43 |
+
* @param {number} round - Current round number
|
| 44 |
+
*/
|
| 45 |
+
startPassage(book, blanks, level, round) {
|
| 46 |
+
this.currentPassage = {
|
| 47 |
+
passageId: this._generateUUID(),
|
| 48 |
+
sessionId: this.sessionId,
|
| 49 |
+
bookTitle: book?.title || 'Unknown',
|
| 50 |
+
bookAuthor: book?.author || 'Unknown',
|
| 51 |
+
level: level || 1,
|
| 52 |
+
round: round || 1,
|
| 53 |
+
words: blanks.map(blank => ({
|
| 54 |
+
word: blank.originalWord || '',
|
| 55 |
+
length: (blank.originalWord || '').length,
|
| 56 |
+
attemptsToCorrect: 0,
|
| 57 |
+
hintsUsed: [],
|
| 58 |
+
finalCorrect: false
|
| 59 |
+
})),
|
| 60 |
+
startTime: Date.now()
|
| 61 |
+
};
|
| 62 |
+
|
| 63 |
+
console.debug('📊 Analytics: Started passage', {
|
| 64 |
+
passageId: this.currentPassage.passageId,
|
| 65 |
+
book: this.currentPassage.bookTitle,
|
| 66 |
+
blanks: this.currentPassage.words.length,
|
| 67 |
+
level,
|
| 68 |
+
round
|
| 69 |
+
});
|
| 70 |
+
}
|
| 71 |
+
|
| 72 |
+
/**
|
| 73 |
+
* Record an attempt on a specific word.
|
| 74 |
+
* Call this each time the user submits an answer for a blank.
|
| 75 |
+
*
|
| 76 |
+
* @param {number} blankIndex - Index of the blank in the passage
|
| 77 |
+
* @param {boolean} correct - Whether the attempt was correct
|
| 78 |
+
*/
|
| 79 |
+
recordAttempt(blankIndex, correct) {
|
| 80 |
+
if (!this.currentPassage) {
|
| 81 |
+
console.warn('📊 Analytics: No active passage to record attempt');
|
| 82 |
+
return;
|
| 83 |
+
}
|
| 84 |
+
|
| 85 |
+
if (blankIndex < 0 || blankIndex >= this.currentPassage.words.length) {
|
| 86 |
+
console.warn('📊 Analytics: Invalid blank index', blankIndex);
|
| 87 |
+
return;
|
| 88 |
+
}
|
| 89 |
+
|
| 90 |
+
const wordData = this.currentPassage.words[blankIndex];
|
| 91 |
+
wordData.attemptsToCorrect++;
|
| 92 |
+
|
| 93 |
+
if (correct) {
|
| 94 |
+
wordData.finalCorrect = true;
|
| 95 |
+
}
|
| 96 |
+
|
| 97 |
+
console.debug('📊 Analytics: Recorded attempt', {
|
| 98 |
+
word: wordData.word,
|
| 99 |
+
attempt: wordData.attemptsToCorrect,
|
| 100 |
+
correct
|
| 101 |
+
});
|
| 102 |
+
}
|
| 103 |
+
|
| 104 |
+
/**
|
| 105 |
+
* Record all attempts at once (batch mode).
|
| 106 |
+
* Use when results come in as an array.
|
| 107 |
+
*
|
| 108 |
+
* @param {Array} results - Array of {blankIndex, isCorrect} objects
|
| 109 |
+
*/
|
| 110 |
+
recordAttemptsBatch(results) {
|
| 111 |
+
if (!this.currentPassage) {
|
| 112 |
+
console.warn('📊 Analytics: No active passage to record attempts');
|
| 113 |
+
return;
|
| 114 |
+
}
|
| 115 |
+
|
| 116 |
+
results.forEach(result => {
|
| 117 |
+
if (result.blankIndex !== undefined) {
|
| 118 |
+
this.recordAttempt(result.blankIndex, result.isCorrect);
|
| 119 |
+
}
|
| 120 |
+
});
|
| 121 |
+
}
|
| 122 |
+
|
| 123 |
+
/**
|
| 124 |
+
* Record a hint request for a specific word.
|
| 125 |
+
*
|
| 126 |
+
* @param {number} blankIndex - Index of the blank
|
| 127 |
+
* @param {string} hintType - Type of hint requested (e.g., 'part_of_speech', 'synonym', 'first_letter')
|
| 128 |
+
*/
|
| 129 |
+
recordHint(blankIndex, hintType) {
|
| 130 |
+
if (!this.currentPassage) {
|
| 131 |
+
console.warn('📊 Analytics: No active passage to record hint');
|
| 132 |
+
return;
|
| 133 |
+
}
|
| 134 |
+
|
| 135 |
+
if (blankIndex < 0 || blankIndex >= this.currentPassage.words.length) {
|
| 136 |
+
console.warn('📊 Analytics: Invalid blank index for hint', blankIndex);
|
| 137 |
+
return;
|
| 138 |
+
}
|
| 139 |
+
|
| 140 |
+
const wordData = this.currentPassage.words[blankIndex];
|
| 141 |
+
wordData.hintsUsed.push(hintType || 'unknown');
|
| 142 |
+
|
| 143 |
+
console.debug('📊 Analytics: Recorded hint', {
|
| 144 |
+
word: wordData.word,
|
| 145 |
+
hintType,
|
| 146 |
+
totalHints: wordData.hintsUsed.length
|
| 147 |
+
});
|
| 148 |
+
}
|
| 149 |
+
|
| 150 |
+
/**
|
| 151 |
+
* Complete the current passage and send analytics to backend.
|
| 152 |
+
*
|
| 153 |
+
* @param {boolean} passed - Whether the user passed the passage
|
| 154 |
+
* @returns {Promise<Object>} - Response from analytics API
|
| 155 |
+
*/
|
| 156 |
+
async completePassage(passed) {
|
| 157 |
+
if (!this.currentPassage) {
|
| 158 |
+
console.warn('📊 Analytics: No active passage to complete');
|
| 159 |
+
return { success: false, message: 'No active passage' };
|
| 160 |
+
}
|
| 161 |
+
|
| 162 |
+
// Calculate summary statistics
|
| 163 |
+
const totalBlanks = this.currentPassage.words.length;
|
| 164 |
+
const correctOnFirstTry = this.currentPassage.words.filter(
|
| 165 |
+
w => w.attemptsToCorrect === 1 && w.finalCorrect
|
| 166 |
+
).length;
|
| 167 |
+
const totalHintsUsed = this.currentPassage.words.reduce(
|
| 168 |
+
(sum, w) => sum + w.hintsUsed.length, 0
|
| 169 |
+
);
|
| 170 |
+
|
| 171 |
+
const data = {
|
| 172 |
+
passageId: this.currentPassage.passageId,
|
| 173 |
+
sessionId: this.currentPassage.sessionId,
|
| 174 |
+
bookTitle: this.currentPassage.bookTitle,
|
| 175 |
+
bookAuthor: this.currentPassage.bookAuthor,
|
| 176 |
+
level: this.currentPassage.level,
|
| 177 |
+
round: this.currentPassage.round,
|
| 178 |
+
words: this.currentPassage.words,
|
| 179 |
+
totalBlanks,
|
| 180 |
+
correctOnFirstTry,
|
| 181 |
+
totalHintsUsed,
|
| 182 |
+
passed,
|
| 183 |
+
timestamp: new Date().toISOString()
|
| 184 |
+
};
|
| 185 |
+
|
| 186 |
+
console.debug('📊 Analytics: Completing passage', {
|
| 187 |
+
passageId: data.passageId,
|
| 188 |
+
passed,
|
| 189 |
+
correctOnFirstTry,
|
| 190 |
+
totalBlanks,
|
| 191 |
+
totalHintsUsed
|
| 192 |
+
});
|
| 193 |
+
|
| 194 |
+
// Clear current passage state
|
| 195 |
+
this.currentPassage = null;
|
| 196 |
+
|
| 197 |
+
// Send to backend
|
| 198 |
+
try {
|
| 199 |
+
const response = await fetch(`${this.baseUrl}/api/analytics/passage`, {
|
| 200 |
+
method: 'POST',
|
| 201 |
+
headers: {
|
| 202 |
+
'Content-Type': 'application/json'
|
| 203 |
+
},
|
| 204 |
+
body: JSON.stringify(data)
|
| 205 |
+
});
|
| 206 |
+
|
| 207 |
+
if (!response.ok) {
|
| 208 |
+
throw new Error(`HTTP ${response.status}: ${response.statusText}`);
|
| 209 |
+
}
|
| 210 |
+
|
| 211 |
+
const result = await response.json();
|
| 212 |
+
return result;
|
| 213 |
+
|
| 214 |
+
} catch (error) {
|
| 215 |
+
// Don't throw - analytics failure shouldn't break the game
|
| 216 |
+
console.warn('📊 Analytics: Failed to send (non-critical)', error.message);
|
| 217 |
+
return { success: false, message: error.message };
|
| 218 |
+
}
|
| 219 |
+
}
|
| 220 |
+
|
| 221 |
+
/**
|
| 222 |
+
* Cancel tracking for current passage without sending.
|
| 223 |
+
* Use if the user abandons a passage mid-attempt.
|
| 224 |
+
*/
|
| 225 |
+
cancelPassage() {
|
| 226 |
+
if (this.currentPassage) {
|
| 227 |
+
console.debug('📊 Analytics: Cancelled passage', {
|
| 228 |
+
passageId: this.currentPassage.passageId
|
| 229 |
+
});
|
| 230 |
+
this.currentPassage = null;
|
| 231 |
+
}
|
| 232 |
+
}
|
| 233 |
+
|
| 234 |
+
/**
|
| 235 |
+
* Check if there's an active passage being tracked.
|
| 236 |
+
* @returns {boolean}
|
| 237 |
+
*/
|
| 238 |
+
isTrackingPassage() {
|
| 239 |
+
return this.currentPassage !== null;
|
| 240 |
+
}
|
| 241 |
+
|
| 242 |
+
/**
|
| 243 |
+
* Get current passage statistics (for UI display).
|
| 244 |
+
* @returns {Object|null}
|
| 245 |
+
*/
|
| 246 |
+
getCurrentStats() {
|
| 247 |
+
if (!this.currentPassage) return null;
|
| 248 |
+
|
| 249 |
+
const totalBlanks = this.currentPassage.words.length;
|
| 250 |
+
const correctOnFirstTry = this.currentPassage.words.filter(
|
| 251 |
+
w => w.attemptsToCorrect === 1 && w.finalCorrect
|
| 252 |
+
).length;
|
| 253 |
+
const totalCorrect = this.currentPassage.words.filter(w => w.finalCorrect).length;
|
| 254 |
+
const totalHintsUsed = this.currentPassage.words.reduce(
|
| 255 |
+
(sum, w) => sum + w.hintsUsed.length, 0
|
| 256 |
+
);
|
| 257 |
+
|
| 258 |
+
return {
|
| 259 |
+
passageId: this.currentPassage.passageId,
|
| 260 |
+
bookTitle: this.currentPassage.bookTitle,
|
| 261 |
+
level: this.currentPassage.level,
|
| 262 |
+
round: this.currentPassage.round,
|
| 263 |
+
totalBlanks,
|
| 264 |
+
correctOnFirstTry,
|
| 265 |
+
totalCorrect,
|
| 266 |
+
totalHintsUsed,
|
| 267 |
+
words: this.currentPassage.words.map(w => ({
|
| 268 |
+
word: w.word,
|
| 269 |
+
attempts: w.attemptsToCorrect,
|
| 270 |
+
hintsUsed: w.hintsUsed.length,
|
| 271 |
+
correct: w.finalCorrect
|
| 272 |
+
}))
|
| 273 |
+
};
|
| 274 |
+
}
|
| 275 |
+
|
| 276 |
+
// ===== ADMIN API METHODS =====
|
| 277 |
+
|
| 278 |
+
/**
|
| 279 |
+
* Get analytics summary (admin dashboard data).
|
| 280 |
+
* @returns {Promise<Object>}
|
| 281 |
+
*/
|
| 282 |
+
async getSummary() {
|
| 283 |
+
try {
|
| 284 |
+
const response = await fetch(`${this.baseUrl}/api/analytics/summary`);
|
| 285 |
+
if (!response.ok) {
|
| 286 |
+
throw new Error(`HTTP ${response.status}`);
|
| 287 |
+
}
|
| 288 |
+
return await response.json();
|
| 289 |
+
} catch (error) {
|
| 290 |
+
console.error('📊 Analytics: Failed to get summary', error);
|
| 291 |
+
throw error;
|
| 292 |
+
}
|
| 293 |
+
}
|
| 294 |
+
|
| 295 |
+
/**
|
| 296 |
+
* Get recent passage attempts.
|
| 297 |
+
* @param {number} count - Number of entries (max 200)
|
| 298 |
+
* @returns {Promise<Object>}
|
| 299 |
+
*/
|
| 300 |
+
async getRecentPassages(count = 50) {
|
| 301 |
+
try {
|
| 302 |
+
const response = await fetch(`${this.baseUrl}/api/analytics/recent?count=${count}`);
|
| 303 |
+
if (!response.ok) {
|
| 304 |
+
throw new Error(`HTTP ${response.status}`);
|
| 305 |
+
}
|
| 306 |
+
return await response.json();
|
| 307 |
+
} catch (error) {
|
| 308 |
+
console.error('📊 Analytics: Failed to get recent passages', error);
|
| 309 |
+
throw error;
|
| 310 |
+
}
|
| 311 |
+
}
|
| 312 |
+
|
| 313 |
+
/**
|
| 314 |
+
* Export all analytics data.
|
| 315 |
+
* @returns {Promise<Object>}
|
| 316 |
+
*/
|
| 317 |
+
async exportAll() {
|
| 318 |
+
try {
|
| 319 |
+
const response = await fetch(`${this.baseUrl}/api/analytics/export`);
|
| 320 |
+
if (!response.ok) {
|
| 321 |
+
throw new Error(`HTTP ${response.status}`);
|
| 322 |
+
}
|
| 323 |
+
return await response.json();
|
| 324 |
+
} catch (error) {
|
| 325 |
+
console.error('📊 Analytics: Failed to export', error);
|
| 326 |
+
throw error;
|
| 327 |
+
}
|
| 328 |
+
}
|
| 329 |
+
|
| 330 |
+
/**
|
| 331 |
+
* Get statistics for a specific word.
|
| 332 |
+
* @param {string} word
|
| 333 |
+
* @returns {Promise<Object>}
|
| 334 |
+
*/
|
| 335 |
+
async getWordStats(word) {
|
| 336 |
+
try {
|
| 337 |
+
const response = await fetch(`${this.baseUrl}/api/analytics/word/${encodeURIComponent(word)}`);
|
| 338 |
+
if (!response.ok) {
|
| 339 |
+
throw new Error(`HTTP ${response.status}`);
|
| 340 |
+
}
|
| 341 |
+
return await response.json();
|
| 342 |
+
} catch (error) {
|
| 343 |
+
console.error('📊 Analytics: Failed to get word stats', error);
|
| 344 |
+
throw error;
|
| 345 |
+
}
|
| 346 |
+
}
|
| 347 |
+
}
|
| 348 |
+
|
| 349 |
+
// Export singleton instance
|
| 350 |
+
export const analyticsService = new AnalyticsService();
|
src/app.js
ADDED
|
@@ -0,0 +1,592 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
// Main application entry point
|
| 2 |
+
import ClozeGame from './clozeGameEngine.js';
|
| 3 |
+
import ChatUI from './chatInterface.js';
|
| 4 |
+
import WelcomeOverlay from './welcomeOverlay.js';
|
| 5 |
+
import { LeaderboardUI } from './leaderboardUI.js';
|
| 6 |
+
import { analyticsService } from './analyticsService.js';
|
| 7 |
+
|
| 8 |
+
class App {
|
| 9 |
+
constructor() {
|
| 10 |
+
this.game = new ClozeGame();
|
| 11 |
+
this.chatUI = new ChatUI(this.game);
|
| 12 |
+
this.welcomeOverlay = new WelcomeOverlay();
|
| 13 |
+
this.leaderboardUI = new LeaderboardUI(this.game.leaderboardService);
|
| 14 |
+
this.analytics = analyticsService;
|
| 15 |
+
// Prevent double-trigger on rapid Skip clicks
|
| 16 |
+
this._skipInProgress = false;
|
| 17 |
+
this.lastRevealWasSkip = false; // Controls reveal styling after skip
|
| 18 |
+
this.elements = {
|
| 19 |
+
loading: document.getElementById('loading'),
|
| 20 |
+
gameArea: document.getElementById('game-area'),
|
| 21 |
+
stickyControls: document.getElementById('sticky-controls'),
|
| 22 |
+
bookInfo: document.getElementById('book-info'),
|
| 23 |
+
roundInfo: document.getElementById('round-info'),
|
| 24 |
+
streakInfo: document.getElementById('streak-info'),
|
| 25 |
+
contextualization: document.getElementById('contextualization'),
|
| 26 |
+
passageContent: document.getElementById('passage-content'),
|
| 27 |
+
hintsSection: document.getElementById('hints-section'),
|
| 28 |
+
hintsList: document.getElementById('hints-list'),
|
| 29 |
+
skipBtn: document.getElementById('skip-btn'),
|
| 30 |
+
submitBtn: document.getElementById('submit-btn'),
|
| 31 |
+
nextBtn: document.getElementById('next-btn'),
|
| 32 |
+
hintBtn: document.getElementById('hint-btn'),
|
| 33 |
+
result: document.getElementById('result'),
|
| 34 |
+
leaderboardBtn: document.getElementById('leaderboard-btn')
|
| 35 |
+
};
|
| 36 |
+
|
| 37 |
+
this.currentResults = null;
|
| 38 |
+
this.isRetrying = false; // Track if we're in retry mode
|
| 39 |
+
this.setupEventListeners();
|
| 40 |
+
}
|
| 41 |
+
|
| 42 |
+
async initialize() {
|
| 43 |
+
try {
|
| 44 |
+
this.showLoading(true);
|
| 45 |
+
await this.game.initialize();
|
| 46 |
+
await this.startNewGame();
|
| 47 |
+
this.showLoading(false);
|
| 48 |
+
} catch (error) {
|
| 49 |
+
console.error('Failed to initialize app:', error);
|
| 50 |
+
this.showError('Failed to load the game. Please refresh and try again.');
|
| 51 |
+
}
|
| 52 |
+
}
|
| 53 |
+
|
| 54 |
+
setupEventListeners() {
|
| 55 |
+
if (this.elements.skipBtn) {
|
| 56 |
+
this.elements.skipBtn.addEventListener('click', () => {
|
| 57 |
+
// Ensure any unexpected async errors are surfaced but don’t crash UI
|
| 58 |
+
Promise.resolve(this.handleSkip()).catch(err => console.error('handleSkip (event) ERROR:', err));
|
| 59 |
+
});
|
| 60 |
+
} else {
|
| 61 |
+
}
|
| 62 |
+
this.elements.submitBtn.addEventListener('click', () => this.handleSubmit());
|
| 63 |
+
this.elements.nextBtn.addEventListener('click', () => this.handleNext());
|
| 64 |
+
this.elements.hintBtn.addEventListener('click', () => this.toggleHints());
|
| 65 |
+
|
| 66 |
+
// Leaderboard button
|
| 67 |
+
if (this.elements.leaderboardBtn) {
|
| 68 |
+
this.elements.leaderboardBtn.addEventListener('click', (e) => {
|
| 69 |
+
e.stopPropagation();
|
| 70 |
+
this.leaderboardUI.show();
|
| 71 |
+
});
|
| 72 |
+
}
|
| 73 |
+
|
| 74 |
+
// Note: Enter key handling is done per-input in setupInputListeners()
|
| 75 |
+
}
|
| 76 |
+
|
| 77 |
+
async startNewGame() {
|
| 78 |
+
try {
|
| 79 |
+
const roundData = await this.game.startNewRound();
|
| 80 |
+
this.displayRound(roundData);
|
| 81 |
+
this.resetUI();
|
| 82 |
+
} catch (error) {
|
| 83 |
+
console.error('Error starting new game:', error);
|
| 84 |
+
this.showError('Could not load a new passage. Please try again.');
|
| 85 |
+
}
|
| 86 |
+
}
|
| 87 |
+
|
| 88 |
+
displayRound(roundData) {
|
| 89 |
+
// Start analytics tracking for this passage
|
| 90 |
+
this.analytics.startPassage(
|
| 91 |
+
{ title: roundData.title, author: roundData.author },
|
| 92 |
+
roundData.blanks,
|
| 93 |
+
this.game.currentLevel,
|
| 94 |
+
this.game.currentRound
|
| 95 |
+
);
|
| 96 |
+
|
| 97 |
+
// Reset retry state
|
| 98 |
+
this.isRetrying = false;
|
| 99 |
+
|
| 100 |
+
// Show book information
|
| 101 |
+
this.elements.bookInfo.innerHTML = `
|
| 102 |
+
<strong>${roundData.title}</strong> by ${roundData.author}
|
| 103 |
+
`;
|
| 104 |
+
|
| 105 |
+
// Show level information
|
| 106 |
+
const blanksCount = roundData.blanks.length;
|
| 107 |
+
const levelInfo = `Level ${this.game.currentLevel} • ${blanksCount} blank${blanksCount > 1 ? 's' : ''}`;
|
| 108 |
+
|
| 109 |
+
this.elements.roundInfo.innerHTML = levelInfo;
|
| 110 |
+
|
| 111 |
+
// Update streak display
|
| 112 |
+
this.updateStreakDisplay();
|
| 113 |
+
|
| 114 |
+
// Show contextualization from AI agent
|
| 115 |
+
this.elements.contextualization.innerHTML = `
|
| 116 |
+
<div class="flex items-start gap-2">
|
| 117 |
+
<span class="text-blue-600">📜</span>
|
| 118 |
+
<span>${roundData.contextualization || 'Loading context...'}</span>
|
| 119 |
+
</div>
|
| 120 |
+
`;
|
| 121 |
+
|
| 122 |
+
// Render the cloze text with input fields and chat buttons
|
| 123 |
+
const clozeHtml = this.game.renderClozeTextWithChat();
|
| 124 |
+
this.elements.passageContent.innerHTML = `<p>${clozeHtml}</p>`;
|
| 125 |
+
|
| 126 |
+
// Store hints for later display
|
| 127 |
+
this.currentHints = roundData.hints || [];
|
| 128 |
+
this.populateHints();
|
| 129 |
+
|
| 130 |
+
// Hide hints initially
|
| 131 |
+
this.elements.hintsSection.style.display = 'none';
|
| 132 |
+
|
| 133 |
+
// Set up input field listeners
|
| 134 |
+
this.setupInputListeners();
|
| 135 |
+
|
| 136 |
+
// Set up chat buttons
|
| 137 |
+
this.chatUI.setupChatButtons();
|
| 138 |
+
}
|
| 139 |
+
|
| 140 |
+
setupInputListeners() {
|
| 141 |
+
const inputs = this.elements.passageContent.querySelectorAll('.cloze-input');
|
| 142 |
+
|
| 143 |
+
inputs.forEach((input, index) => {
|
| 144 |
+
input.addEventListener('input', () => {
|
| 145 |
+
// Remove any previous styling
|
| 146 |
+
input.classList.remove('correct', 'incorrect');
|
| 147 |
+
this.updateSubmitButton();
|
| 148 |
+
});
|
| 149 |
+
|
| 150 |
+
input.addEventListener('keydown', (e) => {
|
| 151 |
+
if (e.key === 'Enter') {
|
| 152 |
+
e.preventDefault();
|
| 153 |
+
|
| 154 |
+
// Move to next input or submit if last
|
| 155 |
+
const nextInput = inputs[index + 1];
|
| 156 |
+
if (nextInput) {
|
| 157 |
+
nextInput.focus();
|
| 158 |
+
} else {
|
| 159 |
+
this.handleSubmit();
|
| 160 |
+
}
|
| 161 |
+
}
|
| 162 |
+
});
|
| 163 |
+
});
|
| 164 |
+
|
| 165 |
+
// Focus first input
|
| 166 |
+
if (inputs.length > 0) {
|
| 167 |
+
inputs[0].focus();
|
| 168 |
+
}
|
| 169 |
+
}
|
| 170 |
+
|
| 171 |
+
updateSubmitButton() {
|
| 172 |
+
const inputs = this.elements.passageContent.querySelectorAll('.cloze-input');
|
| 173 |
+
// Only check non-disabled (non-locked) inputs
|
| 174 |
+
const nonLockedInputs = Array.from(inputs).filter(input => !input.disabled);
|
| 175 |
+
const allFilled = nonLockedInputs.every(input => input.value.trim() !== '');
|
| 176 |
+
this.elements.submitBtn.disabled = !allFilled || nonLockedInputs.length === 0;
|
| 177 |
+
}
|
| 178 |
+
|
| 179 |
+
handleSubmit() {
|
| 180 |
+
const inputs = this.elements.passageContent.querySelectorAll('.cloze-input');
|
| 181 |
+
const answers = Array.from(inputs).map(input => input.value.trim());
|
| 182 |
+
|
| 183 |
+
// Check if all non-locked fields are filled
|
| 184 |
+
const nonLockedInputs = Array.from(inputs).filter((input, index) => !this.game.isBlankLocked(index));
|
| 185 |
+
const nonLockedAnswers = nonLockedInputs.map(input => input.value.trim());
|
| 186 |
+
|
| 187 |
+
if (nonLockedAnswers.some(answer => answer === '')) {
|
| 188 |
+
alert('Please fill in all blanks before submitting.');
|
| 189 |
+
return;
|
| 190 |
+
}
|
| 191 |
+
|
| 192 |
+
// Submit answers and get results
|
| 193 |
+
this.currentResults = this.game.submitAnswers(answers);
|
| 194 |
+
|
| 195 |
+
// Record attempts in analytics only for blanks attempted in this submission
|
| 196 |
+
this.currentResults.results.forEach(result => {
|
| 197 |
+
if (result.attemptedThisRound) {
|
| 198 |
+
this.analytics.recordAttempt(result.blankIndex, result.isCorrect);
|
| 199 |
+
}
|
| 200 |
+
});
|
| 201 |
+
|
| 202 |
+
// Handle retry vs final display
|
| 203 |
+
if (this.currentResults.canRetry) {
|
| 204 |
+
this.displayRetryUI(this.currentResults);
|
| 205 |
+
} else {
|
| 206 |
+
// Final submission - send analytics
|
| 207 |
+
this.sendAnalytics(this.currentResults.passed);
|
| 208 |
+
this.displayResults(this.currentResults);
|
| 209 |
+
}
|
| 210 |
+
}
|
| 211 |
+
|
| 212 |
+
/**
|
| 213 |
+
* Display UI for retry mode - lock correct answers, highlight wrong ones
|
| 214 |
+
*/
|
| 215 |
+
displayRetryUI(results) {
|
| 216 |
+
this.isRetrying = true;
|
| 217 |
+
// Ensure skip can be used during this retry cycle
|
| 218 |
+
this._skipInProgress = false;
|
| 219 |
+
const inputs = this.elements.passageContent.querySelectorAll('.cloze-input');
|
| 220 |
+
|
| 221 |
+
results.results.forEach((result, index) => {
|
| 222 |
+
const input = inputs[index];
|
| 223 |
+
if (!input) return;
|
| 224 |
+
|
| 225 |
+
if (result.isCorrect || result.isLocked) {
|
| 226 |
+
// Lock correct answers - show green background and disable
|
| 227 |
+
input.classList.add('correct');
|
| 228 |
+
input.classList.remove('incorrect');
|
| 229 |
+
input.style.backgroundColor = '#dcfce7'; // Light green
|
| 230 |
+
input.style.borderColor = '#16a34a';
|
| 231 |
+
input.disabled = true;
|
| 232 |
+
input.value = result.correctAnswer;
|
| 233 |
+
} else {
|
| 234 |
+
// Highlight wrong answers - show red border, keep editable
|
| 235 |
+
input.classList.add('incorrect');
|
| 236 |
+
input.classList.remove('correct');
|
| 237 |
+
input.style.backgroundColor = '#fef2f2'; // Light red
|
| 238 |
+
input.style.borderColor = '#dc2626';
|
| 239 |
+
input.disabled = false;
|
| 240 |
+
// Clear wrong answer for retry
|
| 241 |
+
input.value = '';
|
| 242 |
+
}
|
| 243 |
+
});
|
| 244 |
+
|
| 245 |
+
// Update result message
|
| 246 |
+
this.elements.result.textContent = results.feedbackText;
|
| 247 |
+
this.elements.result.className = 'mt-4 text-center font-semibold text-amber-600';
|
| 248 |
+
|
| 249 |
+
// Update submit button text and show skip button
|
| 250 |
+
this.elements.submitBtn.textContent = 'Try Again';
|
| 251 |
+
this.elements.submitBtn.disabled = true; // Will be enabled when user types
|
| 252 |
+
if (this.elements.skipBtn) {
|
| 253 |
+
this.elements.skipBtn.classList.remove('hidden');
|
| 254 |
+
this.elements.skipBtn.disabled = false; // Ensure skip is clickable on retries
|
| 255 |
+
this.elements.skipBtn.removeAttribute('disabled');
|
| 256 |
+
this.elements.skipBtn.setAttribute('aria-disabled', 'false');
|
| 257 |
+
} else {
|
| 258 |
+
}
|
| 259 |
+
|
| 260 |
+
// Focus first wrong input
|
| 261 |
+
const firstWrongInput = Array.from(inputs).find(
|
| 262 |
+
(input, index) => results.retryableIndices.includes(index)
|
| 263 |
+
);
|
| 264 |
+
if (firstWrongInput) {
|
| 265 |
+
firstWrongInput.focus();
|
| 266 |
+
}
|
| 267 |
+
|
| 268 |
+
// Don't update streak display during retry - wait for final result
|
| 269 |
+
}
|
| 270 |
+
|
| 271 |
+
/**
|
| 272 |
+
* Handle skip button - skip passage and move to next
|
| 273 |
+
*/
|
| 274 |
+
async handleSkip() {
|
| 275 |
+
console.log('handleSkip: START - v2');
|
| 276 |
+
|
| 277 |
+
try {
|
| 278 |
+
// Debounce multiple rapid clicks
|
| 279 |
+
if (this._skipInProgress) {
|
| 280 |
+
console.warn('handleSkip: already in progress, ignoring');
|
| 281 |
+
return;
|
| 282 |
+
}
|
| 283 |
+
this._skipInProgress = true;
|
| 284 |
+
if (this.elements?.skipBtn) {
|
| 285 |
+
this.elements.skipBtn.disabled = true;
|
| 286 |
+
}
|
| 287 |
+
// Early exit if no game or blanks
|
| 288 |
+
if (!this.game || !Array.isArray(this.game.blanks) || this.game.blanks.length === 0) {
|
| 289 |
+
console.warn('handleSkip: No game/blanks, going to next passage');
|
| 290 |
+
this.handleNext();
|
| 291 |
+
return;
|
| 292 |
+
}
|
| 293 |
+
|
| 294 |
+
// Send analytics as failed (non-blocking)
|
| 295 |
+
console.log('📊 Sending analytics (skip - passage failed)...');
|
| 296 |
+
this.sendAnalytics(false).catch(err => console.warn('📊 Analytics (skip) failed non-critically:', err));
|
| 297 |
+
|
| 298 |
+
// Use engine to force-complete this passage to ensure consistent results/state
|
| 299 |
+
const finalResults = this.game.forceCompletePassage();
|
| 300 |
+
console.log('handleSkip: forceCompletePassage results:', finalResults);
|
| 301 |
+
|
| 302 |
+
// Show the results (which reveals correct answers)
|
| 303 |
+
console.log('handleSkip: calling displayResults');
|
| 304 |
+
this.lastRevealWasSkip = true;
|
| 305 |
+
this.displayResults(finalResults);
|
| 306 |
+
console.log('handleSkip: displayResults completed');
|
| 307 |
+
|
| 308 |
+
// Hide skip button
|
| 309 |
+
this.elements.skipBtn.classList.add('hidden');
|
| 310 |
+
|
| 311 |
+
// Hide submit button
|
| 312 |
+
this.elements.submitBtn.style.display = 'none';
|
| 313 |
+
|
| 314 |
+
// Show the next button immediately after skip
|
| 315 |
+
this.elements.nextBtn.classList.remove('hidden');
|
| 316 |
+
} catch (error) {
|
| 317 |
+
console.error('handleSkip ERROR:', error);
|
| 318 |
+
// Fallback: attempt to force-complete and reveal, or at least enable Next
|
| 319 |
+
try {
|
| 320 |
+
if (this.game?.forceCompletePassage) {
|
| 321 |
+
const fallbackResults = this.game.forceCompletePassage();
|
| 322 |
+
this.lastRevealWasSkip = true;
|
| 323 |
+
this.displayResults(fallbackResults);
|
| 324 |
+
} else {
|
| 325 |
+
this.elements.nextBtn.classList.remove('hidden');
|
| 326 |
+
}
|
| 327 |
+
} catch (fallbackError) {
|
| 328 |
+
console.warn('handleSkip fallback failed; showing Next button:', fallbackError);
|
| 329 |
+
this.elements.nextBtn.classList.remove('hidden');
|
| 330 |
+
}
|
| 331 |
+
} finally {
|
| 332 |
+
// Reset guard so future rounds can skip normally
|
| 333 |
+
this._skipInProgress = false;
|
| 334 |
+
}
|
| 335 |
+
}
|
| 336 |
+
|
| 337 |
+
/**
|
| 338 |
+
* Send analytics data to backend
|
| 339 |
+
*/
|
| 340 |
+
async sendAnalytics(passed) {
|
| 341 |
+
try {
|
| 342 |
+
await this.analytics.completePassage(passed);
|
| 343 |
+
} catch (error) {
|
| 344 |
+
// Non-critical - don't break gameplay
|
| 345 |
+
console.warn('📊 Analytics send failed (non-critical):', error);
|
| 346 |
+
}
|
| 347 |
+
}
|
| 348 |
+
|
| 349 |
+
displayResults(results) {
|
| 350 |
+
let message = `Score: ${results.correct}/${results.total}`;
|
| 351 |
+
|
| 352 |
+
if (results.passed) {
|
| 353 |
+
// Check if level was just advanced
|
| 354 |
+
if (results.justAdvancedLevel) {
|
| 355 |
+
message += ` - Level ${results.currentLevel} unlocked!`;
|
| 356 |
+
|
| 357 |
+
// Check for milestone notification (every 5 levels)
|
| 358 |
+
if (results.currentLevel % 5 === 0) {
|
| 359 |
+
this.leaderboardUI.showMilestoneNotification(results.currentLevel);
|
| 360 |
+
}
|
| 361 |
+
|
| 362 |
+
// Check for high score
|
| 363 |
+
this.checkForHighScore();
|
| 364 |
+
} else {
|
| 365 |
+
message += ` - Passed!`;
|
| 366 |
+
}
|
| 367 |
+
this.elements.result.className = 'mt-4 text-center font-semibold text-green-600';
|
| 368 |
+
} else {
|
| 369 |
+
message += ` - Failed (need ${results.requiredCorrect} correct)`;
|
| 370 |
+
this.elements.result.className = 'mt-4 text-center font-semibold text-red-600';
|
| 371 |
+
}
|
| 372 |
+
|
| 373 |
+
this.elements.result.textContent = message;
|
| 374 |
+
|
| 375 |
+
// Always reveal answers at the end of each round
|
| 376 |
+
this.revealAnswersInPlace(results.results);
|
| 377 |
+
|
| 378 |
+
// Show next button and hide submit/skip buttons
|
| 379 |
+
this.elements.submitBtn.style.display = 'none';
|
| 380 |
+
if (this.elements.skipBtn) {
|
| 381 |
+
this.elements.skipBtn.classList.add('hidden');
|
| 382 |
+
}
|
| 383 |
+
this.elements.nextBtn.classList.remove('hidden');
|
| 384 |
+
|
| 385 |
+
// Update streak display after processing results
|
| 386 |
+
this.updateStreakDisplay();
|
| 387 |
+
}
|
| 388 |
+
|
| 389 |
+
updateStreakDisplay() {
|
| 390 |
+
const stats = this.game.leaderboardService.getPlayerStats();
|
| 391 |
+
const currentStreak = stats.currentStreak;
|
| 392 |
+
|
| 393 |
+
if (currentStreak > 0) {
|
| 394 |
+
this.elements.streakInfo.innerHTML = `🔥 ${currentStreak} streak`;
|
| 395 |
+
this.elements.streakInfo.classList.remove('hidden');
|
| 396 |
+
} else {
|
| 397 |
+
this.elements.streakInfo.classList.add('hidden');
|
| 398 |
+
}
|
| 399 |
+
}
|
| 400 |
+
|
| 401 |
+
highlightAnswers(results) {
|
| 402 |
+
const inputs = this.elements.passageContent.querySelectorAll('.cloze-input');
|
| 403 |
+
|
| 404 |
+
results.forEach((result, index) => {
|
| 405 |
+
const input = inputs[index];
|
| 406 |
+
if (input) {
|
| 407 |
+
if (result.isCorrect) {
|
| 408 |
+
input.classList.add('correct');
|
| 409 |
+
} else {
|
| 410 |
+
input.classList.add('incorrect');
|
| 411 |
+
// Show correct answer as placeholder or title
|
| 412 |
+
input.title = `Correct answer: ${result.correctAnswer}`;
|
| 413 |
+
}
|
| 414 |
+
input.disabled = true;
|
| 415 |
+
}
|
| 416 |
+
});
|
| 417 |
+
}
|
| 418 |
+
|
| 419 |
+
async handleNext() {
|
| 420 |
+
try {
|
| 421 |
+
// Show loading immediately with specific message
|
| 422 |
+
this.showLoading(true, 'Loading passages...');
|
| 423 |
+
|
| 424 |
+
// Clear chat history when starting new round
|
| 425 |
+
this.chatUI.clearChatHistory();
|
| 426 |
+
|
| 427 |
+
// Always show loading for at least 1 second for smooth UX
|
| 428 |
+
const startTime = Date.now();
|
| 429 |
+
|
| 430 |
+
// Load next round
|
| 431 |
+
const roundData = await this.game.nextRound();
|
| 432 |
+
|
| 433 |
+
// Ensure loading is shown for at least half a second
|
| 434 |
+
const elapsedTime = Date.now() - startTime;
|
| 435 |
+
if (elapsedTime < 500) {
|
| 436 |
+
await new Promise(resolve => setTimeout(resolve, 500 - elapsedTime));
|
| 437 |
+
}
|
| 438 |
+
|
| 439 |
+
this.displayRound(roundData);
|
| 440 |
+
this.resetUI();
|
| 441 |
+
this.showLoading(false);
|
| 442 |
+
} catch (error) {
|
| 443 |
+
console.error('Error loading next round:', error);
|
| 444 |
+
this.showError('Could not load next round. Please try again.');
|
| 445 |
+
}
|
| 446 |
+
}
|
| 447 |
+
|
| 448 |
+
// Reveal correct answers immediately after submission
|
| 449 |
+
revealAnswersInPlace(results) {
|
| 450 |
+
const inputs = this.elements.passageContent.querySelectorAll('.cloze-input');
|
| 451 |
+
// Remove any previously rendered external labels to avoid duplicates
|
| 452 |
+
const existingLabels = this.elements.passageContent.querySelectorAll('.correct-answer-reveal');
|
| 453 |
+
existingLabels.forEach(node => node.remove());
|
| 454 |
+
|
| 455 |
+
results.forEach((result, index) => {
|
| 456 |
+
const input = inputs[index];
|
| 457 |
+
if (input) {
|
| 458 |
+
// Always set the correct answer in the input
|
| 459 |
+
input.value = result.correctAnswer;
|
| 460 |
+
|
| 461 |
+
if (this.lastRevealWasSkip) {
|
| 462 |
+
// Neutral reveal on skip: no red/green styling
|
| 463 |
+
input.classList.remove('correct', 'incorrect');
|
| 464 |
+
input.style.backgroundColor = '';
|
| 465 |
+
input.style.borderColor = '';
|
| 466 |
+
} else {
|
| 467 |
+
// Normal reveal with visual feedback
|
| 468 |
+
if (result.isCorrect) {
|
| 469 |
+
input.classList.add('correct');
|
| 470 |
+
input.classList.remove('incorrect');
|
| 471 |
+
input.style.backgroundColor = '#dcfce7'; // Light green
|
| 472 |
+
input.style.borderColor = '#16a34a'; // Green border
|
| 473 |
+
} else {
|
| 474 |
+
input.classList.add('incorrect');
|
| 475 |
+
input.classList.remove('correct');
|
| 476 |
+
input.style.backgroundColor = '#fef2f2'; // Light red
|
| 477 |
+
input.style.borderColor = '#dc2626'; // Red border
|
| 478 |
+
}
|
| 479 |
+
}
|
| 480 |
+
input.disabled = true;
|
| 481 |
+
}
|
| 482 |
+
});
|
| 483 |
+
}
|
| 484 |
+
|
| 485 |
+
populateHints() {
|
| 486 |
+
if (!this.currentHints || this.currentHints.length === 0) {
|
| 487 |
+
this.elements.hintsList.innerHTML = '<div class="text-yellow-600">No hints available for this passage.</div>';
|
| 488 |
+
return;
|
| 489 |
+
}
|
| 490 |
+
|
| 491 |
+
const hintsHtml = this.currentHints.map((hintData, index) =>
|
| 492 |
+
`<div class="flex items-start gap-2">
|
| 493 |
+
<span class="font-semibold text-yellow-800">${index + 1}.</span>
|
| 494 |
+
<span>${hintData.hint}</span>
|
| 495 |
+
</div>`
|
| 496 |
+
).join('');
|
| 497 |
+
|
| 498 |
+
this.elements.hintsList.innerHTML = hintsHtml;
|
| 499 |
+
}
|
| 500 |
+
|
| 501 |
+
toggleHints() {
|
| 502 |
+
const isHidden = this.elements.hintsSection.style.display === 'none';
|
| 503 |
+
this.elements.hintsSection.style.display = isHidden ? 'block' : 'none';
|
| 504 |
+
this.elements.hintBtn.textContent = isHidden ? 'Hide Hints' : 'Show Hints';
|
| 505 |
+
}
|
| 506 |
+
|
| 507 |
+
resetUI() {
|
| 508 |
+
this.elements.result.textContent = '';
|
| 509 |
+
this.elements.submitBtn.style.display = 'inline-block';
|
| 510 |
+
this.elements.submitBtn.disabled = true;
|
| 511 |
+
this.elements.submitBtn.textContent = 'Submit'; // Reset button text
|
| 512 |
+
if (this.elements.skipBtn) {
|
| 513 |
+
this.elements.skipBtn.classList.add('hidden'); // Hide skip button
|
| 514 |
+
this.elements.skipBtn.disabled = false; // Re-enable for new round
|
| 515 |
+
this.elements.skipBtn.removeAttribute('disabled');
|
| 516 |
+
this.elements.skipBtn.setAttribute('aria-disabled', 'false');
|
| 517 |
+
}
|
| 518 |
+
this.elements.nextBtn.classList.add('hidden');
|
| 519 |
+
this.elements.hintsSection.style.display = 'none';
|
| 520 |
+
this.elements.hintBtn.textContent = 'Show Hints';
|
| 521 |
+
this.currentResults = null;
|
| 522 |
+
this.currentHints = [];
|
| 523 |
+
this.isRetrying = false; // Reset retry state
|
| 524 |
+
this._skipInProgress = false; // Allow skip again in new round
|
| 525 |
+
this.lastRevealWasSkip = false;
|
| 526 |
+
}
|
| 527 |
+
|
| 528 |
+
showLoading(show, message = 'Loading passages...') {
|
| 529 |
+
if (show) {
|
| 530 |
+
this.elements.loading.innerHTML = `
|
| 531 |
+
<div class="text-center py-8">
|
| 532 |
+
<p class="text-lg loading-text">${message}</p>
|
| 533 |
+
</div>
|
| 534 |
+
`;
|
| 535 |
+
this.elements.loading.classList.remove('hidden');
|
| 536 |
+
this.elements.gameArea.classList.add('hidden');
|
| 537 |
+
this.elements.stickyControls.classList.add('hidden');
|
| 538 |
+
} else {
|
| 539 |
+
this.elements.loading.classList.add('hidden');
|
| 540 |
+
this.elements.gameArea.classList.remove('hidden');
|
| 541 |
+
this.elements.stickyControls.classList.remove('hidden');
|
| 542 |
+
}
|
| 543 |
+
}
|
| 544 |
+
|
| 545 |
+
showError(message) {
|
| 546 |
+
this.elements.loading.innerHTML = `
|
| 547 |
+
<div class="text-center py-8">
|
| 548 |
+
<p class="text-lg text-red-600 mb-4">${message}</p>
|
| 549 |
+
<button onclick="location.reload()" class="px-4 py-2 bg-blue-600 text-white rounded hover:bg-blue-700">
|
| 550 |
+
Reload
|
| 551 |
+
</button>
|
| 552 |
+
</div>
|
| 553 |
+
`;
|
| 554 |
+
this.elements.loading.classList.remove('hidden');
|
| 555 |
+
this.elements.gameArea.classList.add('hidden');
|
| 556 |
+
}
|
| 557 |
+
|
| 558 |
+
checkForHighScore() {
|
| 559 |
+
// Check if current score qualifies for leaderboard
|
| 560 |
+
if (this.game.checkForHighScore()) {
|
| 561 |
+
const rank = this.game.getHighScoreRank();
|
| 562 |
+
const stats = this.game.leaderboardService.getStats();
|
| 563 |
+
|
| 564 |
+
// Always show initials entry when achieving a high score
|
| 565 |
+
// Pre-fills with previous initials if available, allowing changes
|
| 566 |
+
this.leaderboardUI.showInitialsEntry(
|
| 567 |
+
stats.highestLevel,
|
| 568 |
+
stats.roundAtHighestLevel,
|
| 569 |
+
rank,
|
| 570 |
+
(initials) => {
|
| 571 |
+
// Save to leaderboard
|
| 572 |
+
const finalRank = this.game.addToLeaderboard(initials);
|
| 573 |
+
}
|
| 574 |
+
);
|
| 575 |
+
}
|
| 576 |
+
}
|
| 577 |
+
}
|
| 578 |
+
|
| 579 |
+
// Initialize the app when DOM is loaded
|
| 580 |
+
document.addEventListener('DOMContentLoaded', () => {
|
| 581 |
+
const app = new App();
|
| 582 |
+
|
| 583 |
+
// Show welcome overlay immediately before any loading
|
| 584 |
+
app.welcomeOverlay.show();
|
| 585 |
+
|
| 586 |
+
app.initialize();
|
| 587 |
+
|
| 588 |
+
// Expose API key setter for browser console
|
| 589 |
+
window.setOpenRouterKey = (key) => {
|
| 590 |
+
app.game.chatService.aiService.setApiKey(key);
|
| 591 |
+
};
|
| 592 |
+
});
|
src/bookDataService.js
ADDED
|
@@ -0,0 +1,631 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
// Hugging Face Project Gutenberg Dataset Service
|
| 2 |
+
class HuggingFaceDatasetService {
|
| 3 |
+
constructor() {
|
| 4 |
+
// Use Hugging Face Datasets API for streaming
|
| 5 |
+
this.datasetName = 'manu/project_gutenberg';
|
| 6 |
+
this.apiBase = 'https://datasets-server.huggingface.co';
|
| 7 |
+
this.proxyBase = '/api/books'; // server-side proxy for CORS-safe HF access
|
| 8 |
+
this.hfConfig = 'default';
|
| 9 |
+
this.hfSplit = 'en';
|
| 10 |
+
this.books = [];
|
| 11 |
+
this.isLoaded = false;
|
| 12 |
+
this.streamingEnabled = false;
|
| 13 |
+
this.cache = new Map();
|
| 14 |
+
this.preloadedBooks = [];
|
| 15 |
+
this.usedBooks = new Set(); // Track books used this session
|
| 16 |
+
}
|
| 17 |
+
|
| 18 |
+
// Local fallback books for when HF streaming is unavailable
|
| 19 |
+
getSampleBooks() {
|
| 20 |
+
return [
|
| 21 |
+
{
|
| 22 |
+
id: 1,
|
| 23 |
+
title: "Pride and Prejudice",
|
| 24 |
+
author: "Jane Austen",
|
| 25 |
+
year: 1813,
|
| 26 |
+
text: "It is a truth universally acknowledged, that a single man in possession of a good fortune, must be in want of a wife. However little known the feelings or views of such a man may be on his first entering a neighbourhood, this truth is so well fixed in the minds of the surrounding families, that he is considered the rightful property of some one or other of their daughters. \"My dear Mr. Bennet,\" said his lady to him one day, \"have you heard that Netherfield Park is let at last?\" Mr. Bennet replied that he had not. \"But it is,\" returned she; \"for Mrs. Long has just been here, and she told me all about it.\" Mr. Bennet made no answer. \"Do you not want to know who has taken it?\" cried his wife impatiently. \"You want to tell me, and I have no objection to hearing it.\" This was invitation enough."
|
| 27 |
+
},
|
| 28 |
+
{
|
| 29 |
+
id: 2,
|
| 30 |
+
title: "The Adventures of Tom Sawyer",
|
| 31 |
+
author: "Mark Twain",
|
| 32 |
+
year: 1876,
|
| 33 |
+
text: "\"Tom!\" No answer. \"Tom!\" No answer. \"What's gone with that boy, I wonder? You TOM!\" No answer. The old lady pulled her spectacles down and looked over them about the room; then she put them up and looked out under them. She seldom or never looked through them for so small a thing as a boy; they were her state pair, the pride of her heart, and were built for \"style,\" not service--she could have seen through a pair of stove-lids just as well. She looked perplexed for a moment, and then said, not fiercely, but still loud enough for the furniture to hear: \"Well, I lay if I get hold of you I'll--\""
|
| 34 |
+
},
|
| 35 |
+
{
|
| 36 |
+
id: 3,
|
| 37 |
+
title: "Great Expectations",
|
| 38 |
+
author: "Charles Dickens",
|
| 39 |
+
year: 1861,
|
| 40 |
+
text: "My father's family name being Pirrip, and my Christian name Philip, my infant tongue could make of both names nothing longer or more explicit than Pip. So, I called myself Pip, and came to be called Pip. I give Pirrip as my father's family name, on the authority of his tombstone and my sister,--Mrs. Joe Gargery, who married the blacksmith. As I never saw my father or my mother, and never saw any likeness of them (for their days were long before the days of photographs), my first fancies regarding what they were like were unreasonably derived from their tombstones."
|
| 41 |
+
},
|
| 42 |
+
{
|
| 43 |
+
id: 4,
|
| 44 |
+
title: "Alice's Adventures in Wonderland",
|
| 45 |
+
author: "Lewis Carroll",
|
| 46 |
+
year: 1865,
|
| 47 |
+
text: "Alice was beginning to get very tired of sitting by her sister on the bank, and of having nothing to do: once or twice she had peeped into the book her sister was reading, but it had no pictures or conversations in it, 'and what is the use of a book,' thought Alice 'without pictures or conversation?' So she was considering in her own mind (as well as she could, for the hot day made her feel very sleepy and stupid), whether the pleasure of making a daisy-chain would be worth the trouble of getting up and picking the daisies, when suddenly a White Rabbit with pink eyes ran close by her."
|
| 48 |
+
},
|
| 49 |
+
{
|
| 50 |
+
id: 5,
|
| 51 |
+
title: "The Picture of Dorian Gray",
|
| 52 |
+
author: "Oscar Wilde",
|
| 53 |
+
year: 1890,
|
| 54 |
+
text: "The studio was filled with the rich odour of roses, and when the strong summer wind stirred, amidst the trees of the garden, there came through the open door the heavy scent of the lilac, or the more delicate perfume of the pink-flowering thorn. From the corner of the divan of Persian saddle-bags on which he was lying, smoking, as was his custom, innumerable cigarettes, Lord Henry Wotton could just catch the gleam of the honey-sweet and honey-coloured blossoms of a laburnum, whose tremulous branches seemed hardly able to bear the burden of a beauty so flamelike as theirs."
|
| 55 |
+
},
|
| 56 |
+
{
|
| 57 |
+
id: 6,
|
| 58 |
+
title: "Moby Dick",
|
| 59 |
+
author: "Herman Melville",
|
| 60 |
+
year: 1851,
|
| 61 |
+
text: "Call me Ishmael. Some years ago—never mind how long precisely—having little or no money in my purse, and nothing particular to interest me on shore, I thought I would sail about a little and see the watery part of the world. It is a way I have of driving off the spleen and regulating the circulation. Whenever I find myself growing grim about the mouth; whenever it is a damp, drizzly November in my soul; whenever I find myself involuntarily pausing before coffin warehouses, and bringing up the rear of every funeral I meet; and especially whenever my hypos get such an upper hand of me, that it requires a strong moral principle to prevent me from deliberately stepping into the street, and methodically knocking people's hats off—then, I account it high time to get to sea as soon as possible."
|
| 62 |
+
},
|
| 63 |
+
{
|
| 64 |
+
id: 7,
|
| 65 |
+
title: "Jane Eyre",
|
| 66 |
+
author: "Charlotte Bronte",
|
| 67 |
+
year: 1847,
|
| 68 |
+
text: "There was no possibility of taking a walk that day. We had been wandering, indeed, in the leafless shrubbery an hour in the morning; but since dinner (Mrs. Reed, when there was no company, dined early) the cold winter wind had brought with it clouds so sombre, and a rain so penetrating, that further out-door exercise was now out of the question. I was glad of it: I never liked long walks, especially on chilly afternoons: dreadful to me was the coming home in the raw twilight, with nipped fingers and toes, and a heart saddened by the chidings of Bessie, the nurse, and humbled by the consciousness of my physical inferiority to Eliza, John, and Georgiana Reed."
|
| 69 |
+
},
|
| 70 |
+
{
|
| 71 |
+
id: 8,
|
| 72 |
+
title: "The Count of Monte Cristo",
|
| 73 |
+
author: "Alexandre Dumas",
|
| 74 |
+
year: 1844,
|
| 75 |
+
text: "On the first Monday of February, 1815, the watchtower at Marseilles signaled the arrival of the three-master Pharaon from Smyrna, Trieste, and Naples. As was customary, the pilot immediately left the port and steered toward the château d'If to conduct the ship through the narrow passage that leads to the harbor. However, a young sailor of about nineteen or twenty years, standing on the ship's bow, had signaled the pilot even before he had time to ask the traditional questions that are exchanged between the pilot and the captain. The young man had already assumed command, being the ship's owner and captain."
|
| 76 |
+
},
|
| 77 |
+
{
|
| 78 |
+
id: 9,
|
| 79 |
+
title: "Wuthering Heights",
|
| 80 |
+
author: "Emily Bronte",
|
| 81 |
+
year: 1847,
|
| 82 |
+
text: "I have just returned from a visit to my landlord—the solitary neighbour that I shall be troubled with. This is certainly a beautiful country! In all England, I do not believe that I could have fixed on a situation so completely removed from the stir of society. A perfect misanthropist's Heaven: and Mr. Heathcliff and I are such a suitable pair to divide the desolation between us. A capital fellow! He little imagined how my heart warmed towards him when I beheld his black eyes withdraw so suspiciously under their brows, as I rode up, and when his fingers sheltered themselves, with a jealous resolution, still further in his waistcoat, as I announced my name."
|
| 83 |
+
},
|
| 84 |
+
{
|
| 85 |
+
id: 10,
|
| 86 |
+
title: "Frankenstein",
|
| 87 |
+
author: "Mary Shelley",
|
| 88 |
+
year: 1818,
|
| 89 |
+
text: "It was on a dreary night of November that I beheld the accomplishment of my toils. With an anxiety that almost amounted to agony, I collected the instruments of life around me, that I might infuse a spark of being into the lifeless thing that lay at my feet. It was already one in the morning; the rain pattered dismally against the panes, and my candle was nearly burnt out, when, by the glimmer of the half-extinguished light, I saw the dull yellow eye of the creature open; it breathed hard, and a convulsive motion agitated its limbs. How can I describe my emotions at this catastrophe, or how delineate the wretch whom with such infinite pains and care I had endeavoured to form?"
|
| 90 |
+
}
|
| 91 |
+
];
|
| 92 |
+
}
|
| 93 |
+
|
| 94 |
+
async loadDataset() {
|
| 95 |
+
try {
|
| 96 |
+
// Try to connect to HF Datasets API
|
| 97 |
+
await this.initializeStreaming();
|
| 98 |
+
|
| 99 |
+
if (this.streamingEnabled) {
|
| 100 |
+
// Preload some books for immediate access
|
| 101 |
+
await this.preloadBooks(2);
|
| 102 |
+
if (this.preloadedBooks.length > 0) {
|
| 103 |
+
} else {
|
| 104 |
+
// Fast fallback if HF is slow/unavailable
|
| 105 |
+
console.warn('HF streaming returned 0 books; falling back to local samples for this session');
|
| 106 |
+
this.streamingEnabled = false;
|
| 107 |
+
this.books = this.getSampleBooks();
|
| 108 |
+
}
|
| 109 |
+
} else {
|
| 110 |
+
// Fall back to local samples
|
| 111 |
+
this.books = this.getSampleBooks();
|
| 112 |
+
}
|
| 113 |
+
|
| 114 |
+
this.isLoaded = true;
|
| 115 |
+
return this.books;
|
| 116 |
+
} catch (error) {
|
| 117 |
+
console.error('Error loading dataset:', error);
|
| 118 |
+
// Ensure we always have local fallback
|
| 119 |
+
this.books = this.getSampleBooks();
|
| 120 |
+
this.isLoaded = true;
|
| 121 |
+
return this.books;
|
| 122 |
+
}
|
| 123 |
+
}
|
| 124 |
+
|
| 125 |
+
async initializeStreaming() {
|
| 126 |
+
try {
|
| 127 |
+
// Test HF Datasets API availability
|
| 128 |
+
const testUrl = `${this.proxyBase}/splits?dataset=${encodeURIComponent(this.datasetName)}`;
|
| 129 |
+
const response = await this.fetchWithTimeout(testUrl, { timeoutMs: 3000 });
|
| 130 |
+
|
| 131 |
+
if (response.ok) {
|
| 132 |
+
const data = await response.json();
|
| 133 |
+
// Check if English split is available
|
| 134 |
+
let chosenSplit = null;
|
| 135 |
+
let chosenConfig = null;
|
| 136 |
+
const splits = Array.isArray(data.splits) ? data.splits : [];
|
| 137 |
+
// Prefer explicit English split on default config
|
| 138 |
+
const enDefault = splits.find(s => s.split === 'en' && s.config === 'default');
|
| 139 |
+
if (enDefault) {
|
| 140 |
+
chosenSplit = 'en';
|
| 141 |
+
chosenConfig = 'default';
|
| 142 |
+
} else {
|
| 143 |
+
// Otherwise prefer 'train' on default config
|
| 144 |
+
const trainDefault = splits.find(s => s.split === 'train' && s.config === 'default');
|
| 145 |
+
if (trainDefault) {
|
| 146 |
+
chosenSplit = 'train';
|
| 147 |
+
chosenConfig = 'default';
|
| 148 |
+
} else if (splits.length > 0) {
|
| 149 |
+
// Last resort: take the first available split/config
|
| 150 |
+
chosenSplit = splits[0].split;
|
| 151 |
+
chosenConfig = splits[0].config;
|
| 152 |
+
}
|
| 153 |
+
}
|
| 154 |
+
|
| 155 |
+
this.hfSplit = chosenSplit || this.hfSplit;
|
| 156 |
+
this.hfConfig = chosenConfig || this.hfConfig;
|
| 157 |
+
this.streamingEnabled = Boolean(chosenSplit);
|
| 158 |
+
if (this.streamingEnabled) {
|
| 159 |
+
}
|
| 160 |
+
}
|
| 161 |
+
} catch (error) {
|
| 162 |
+
console.warn('HF Datasets API test failed:', error);
|
| 163 |
+
this.streamingEnabled = false;
|
| 164 |
+
}
|
| 165 |
+
}
|
| 166 |
+
|
| 167 |
+
async preloadBooks(count = 2) {
|
| 168 |
+
if (!this.streamingEnabled) return;
|
| 169 |
+
|
| 170 |
+
try {
|
| 171 |
+
// Use random offset to avoid always getting the same books
|
| 172 |
+
const randomOffset = Math.floor(Math.random() * 1000);
|
| 173 |
+
const url = `${this.proxyBase}/rows?dataset=${encodeURIComponent(this.datasetName)}&config=${encodeURIComponent(this.hfConfig)}&split=${encodeURIComponent(this.hfSplit)}&offset=${randomOffset}&length=${count}`;
|
| 174 |
+
|
| 175 |
+
// Use retry logic with 20s timeout to handle slow HF API
|
| 176 |
+
const response = await this.retryFetch(
|
| 177 |
+
() => this.fetchWithTimeout(url, { timeoutMs: 20000 }),
|
| 178 |
+
3, // max retries
|
| 179 |
+
1000 // base delay 1s
|
| 180 |
+
);
|
| 181 |
+
|
| 182 |
+
if (response.ok) {
|
| 183 |
+
const data = await response.json();
|
| 184 |
+
|
| 185 |
+
// Check if data has expected structure
|
| 186 |
+
if (!data.rows || !Array.isArray(data.rows)) {
|
| 187 |
+
console.error('Unexpected HF API response structure:', data);
|
| 188 |
+
return;
|
| 189 |
+
}
|
| 190 |
+
|
| 191 |
+
|
| 192 |
+
this.preloadedBooks = data.rows
|
| 193 |
+
.map(row => {
|
| 194 |
+
try {
|
| 195 |
+
return this.processHFBookLazy(row.row);
|
| 196 |
+
} catch (e) {
|
| 197 |
+
console.warn('Error processing book:', e);
|
| 198 |
+
return null;
|
| 199 |
+
}
|
| 200 |
+
})
|
| 201 |
+
.filter(book => book !== null);
|
| 202 |
+
|
| 203 |
+
} else {
|
| 204 |
+
console.error(`HF API request failed: ${response.status} ${response.statusText}`);
|
| 205 |
+
}
|
| 206 |
+
} catch (error) {
|
| 207 |
+
console.warn('Failed to preload books after retries:', error);
|
| 208 |
+
}
|
| 209 |
+
}
|
| 210 |
+
|
| 211 |
+
processHFBookLazy(rowData) {
|
| 212 |
+
// Minimal processing - defer text cleaning and validation until book is selected
|
| 213 |
+
const rawText = rowData.text || '';
|
| 214 |
+
|
| 215 |
+
// Do basic metadata extraction to get proper title/author
|
| 216 |
+
const extractedMetadata = this.extractMetadata(rawText);
|
| 217 |
+
const title = extractedMetadata.title || rowData.title || 'Classic Literature';
|
| 218 |
+
const author = extractedMetadata.author || rowData.author || 'Unknown Author';
|
| 219 |
+
|
| 220 |
+
return {
|
| 221 |
+
id: rowData.id || Math.random().toString(36),
|
| 222 |
+
title: title,
|
| 223 |
+
author: author,
|
| 224 |
+
rawText: rawText,
|
| 225 |
+
text: null, // Will clean when needed
|
| 226 |
+
language: rowData.language || 'en',
|
| 227 |
+
source: 'project_gutenberg',
|
| 228 |
+
processed: false
|
| 229 |
+
};
|
| 230 |
+
}
|
| 231 |
+
|
| 232 |
+
async processBookOnDemand(book) {
|
| 233 |
+
if (book.processed) return book;
|
| 234 |
+
|
| 235 |
+
const startTime = Date.now();
|
| 236 |
+
|
| 237 |
+
// Clean text when actually needed
|
| 238 |
+
const cleanedText = this.cleanProjectGutenbergText(book.rawText);
|
| 239 |
+
|
| 240 |
+
book.text = cleanedText;
|
| 241 |
+
book.processed = true;
|
| 242 |
+
|
| 243 |
+
// Validate after processing
|
| 244 |
+
if (!this.isValidForCloze(book)) {
|
| 245 |
+
return null;
|
| 246 |
+
}
|
| 247 |
+
|
| 248 |
+
return book;
|
| 249 |
+
}
|
| 250 |
+
|
| 251 |
+
|
| 252 |
+
cleanProjectGutenbergText(text) {
|
| 253 |
+
if (!text) return '';
|
| 254 |
+
|
| 255 |
+
let cleaned = text;
|
| 256 |
+
|
| 257 |
+
// Remove Project Gutenberg start markers and everything before
|
| 258 |
+
const startPatterns = [
|
| 259 |
+
/\*\*\* START OF .*? \*\*\*/i,
|
| 260 |
+
/\*\*\*START OF .*?\*\*\*/i,
|
| 261 |
+
/START OF THE PROJECT GUTENBERG/i,
|
| 262 |
+
/GUTENBERG.*?EBOOK/i
|
| 263 |
+
];
|
| 264 |
+
|
| 265 |
+
for (const pattern of startPatterns) {
|
| 266 |
+
const match = cleaned.match(pattern);
|
| 267 |
+
if (match) {
|
| 268 |
+
const startIndex = match.index + match[0].length;
|
| 269 |
+
// Skip to next line
|
| 270 |
+
const nextLine = cleaned.indexOf('\n', startIndex);
|
| 271 |
+
if (nextLine !== -1) {
|
| 272 |
+
cleaned = cleaned.substring(nextLine + 1);
|
| 273 |
+
}
|
| 274 |
+
break;
|
| 275 |
+
}
|
| 276 |
+
}
|
| 277 |
+
|
| 278 |
+
// Remove Project Gutenberg end markers and everything after
|
| 279 |
+
const endPatterns = [
|
| 280 |
+
/\*\*\* END OF .*? \*\*\*/i,
|
| 281 |
+
/\*\*\*END OF .*?\*\*\*/i,
|
| 282 |
+
/END OF THE PROJECT GUTENBERG/i,
|
| 283 |
+
/End of the Project Gutenberg/i
|
| 284 |
+
];
|
| 285 |
+
|
| 286 |
+
for (const pattern of endPatterns) {
|
| 287 |
+
const match = cleaned.match(pattern);
|
| 288 |
+
if (match) {
|
| 289 |
+
cleaned = cleaned.substring(0, match.index);
|
| 290 |
+
break;
|
| 291 |
+
}
|
| 292 |
+
}
|
| 293 |
+
|
| 294 |
+
// Remove common Project Gutenberg artifacts and scanning notes
|
| 295 |
+
cleaned = cleaned
|
| 296 |
+
.replace(/\r\n/g, '\n') // Normalize line endings
|
| 297 |
+
.replace(/produced from images generously.*?\n/gi, '') // Remove scanning notes
|
| 298 |
+
.replace(/^.*page\s+scan\s+source:.*$/gmi, '') // Remove IA page scan source lines
|
| 299 |
+
.replace(/^\s*https?:\/\/\S+.*$/gmi, '') // Remove standalone URL lines
|
| 300 |
+
.replace(/\n\s*\n\s*\n+/g, '\n\n') // Remove excessive line breaks
|
| 301 |
+
.replace(/^\s*CHAPTER.*$/gm, '') // Remove chapter headers
|
| 302 |
+
.replace(/^\s*Chapter.*$/gm, '') // Remove chapter headers
|
| 303 |
+
.replace(/^\s*\d+\s*$/gm, '') // Remove page numbers
|
| 304 |
+
.replace(/^\s*\[.*?\]\s*$/gm, '') // Remove bracketed notes
|
| 305 |
+
.replace(/^\s*_.*_\s*$/gm, '') // Remove italic notes
|
| 306 |
+
.replace(/[_*]/g, '') // Remove underscores and asterisks
|
| 307 |
+
.trim();
|
| 308 |
+
|
| 309 |
+
// Find the actual start of narrative content
|
| 310 |
+
const lines = cleaned.split('\n');
|
| 311 |
+
let contentStart = 0;
|
| 312 |
+
|
| 313 |
+
for (let i = 0; i < Math.min(80, lines.length); i++) {
|
| 314 |
+
const line = lines[i].trim();
|
| 315 |
+
|
| 316 |
+
// Skip empty lines, title pages, and metadata
|
| 317 |
+
const looksAllCaps = /^[^a-z]*[A-Z][A-Z\s'.,:&;-]*$/.test(line) && line.length <= 60;
|
| 318 |
+
const looksPublisher = /(PUBLISHER|PRESS|NEW YORK|LONDON|BOSTON|PARIS|MURRAY STREET|COMPANY|LIMITED)/i.test(line);
|
| 319 |
+
const looksFrontMatter = /(^BY\s+[A-Z .'-]{2,}$|A NOVEL|REVISED AND CORRECTED|COPYRIGHT|Entered according to Act of Congress)/i.test(line);
|
| 320 |
+
const looksScanOrUrl = /(archive\.org|Internet Archive|Google|HathiTrust|scann?ed|page\s+scan|https?:\/\/|www\.)/i.test(line);
|
| 321 |
+
|
| 322 |
+
if (!line ||
|
| 323 |
+
line.includes('Title:') ||
|
| 324 |
+
line.includes('Author:') ||
|
| 325 |
+
line.includes('Release Date:') ||
|
| 326 |
+
line.includes('Language:') ||
|
| 327 |
+
line.includes('Character set') ||
|
| 328 |
+
line.includes('www.gutenberg') ||
|
| 329 |
+
line.includes('Project Gutenberg') ||
|
| 330 |
+
looksAllCaps ||
|
| 331 |
+
looksPublisher ||
|
| 332 |
+
looksFrontMatter ||
|
| 333 |
+
looksScanOrUrl ||
|
| 334 |
+
line.length < 20) {
|
| 335 |
+
contentStart = i + 1;
|
| 336 |
+
continue;
|
| 337 |
+
}
|
| 338 |
+
|
| 339 |
+
// Found actual content
|
| 340 |
+
break;
|
| 341 |
+
}
|
| 342 |
+
|
| 343 |
+
if (contentStart > 0 && contentStart < lines.length) {
|
| 344 |
+
cleaned = lines.slice(contentStart).join('\n').trim();
|
| 345 |
+
}
|
| 346 |
+
|
| 347 |
+
return cleaned;
|
| 348 |
+
}
|
| 349 |
+
|
| 350 |
+
extractMetadata(text) {
|
| 351 |
+
const metadata = { title: 'Classic Literature', author: 'Unknown Author' };
|
| 352 |
+
|
| 353 |
+
if (!text) return metadata;
|
| 354 |
+
|
| 355 |
+
// Look for the standard Project Gutenberg header format
|
| 356 |
+
const firstLine = text.split('\n')[0].trim();
|
| 357 |
+
|
| 358 |
+
// Parse the standard format: "The Project Gutenberg EBook of [TITLE], by [AUTHOR]"
|
| 359 |
+
const pgMatch = firstLine.match(/^.*?The Project Gutenberg EBook of (.+?),\s*by\s+(.+?)$/i);
|
| 360 |
+
if (pgMatch) {
|
| 361 |
+
const title = pgMatch[1].trim();
|
| 362 |
+
const author = pgMatch[2].trim();
|
| 363 |
+
|
| 364 |
+
if (title && this.isValidTitle(title)) {
|
| 365 |
+
metadata.title = this.cleanMetadataField(title);
|
| 366 |
+
}
|
| 367 |
+
if (author && this.isValidAuthor(author)) {
|
| 368 |
+
metadata.author = this.cleanMetadataField(author);
|
| 369 |
+
}
|
| 370 |
+
|
| 371 |
+
return metadata;
|
| 372 |
+
}
|
| 373 |
+
|
| 374 |
+
// Fallback: Look for explicit Title: and Author: fields in first 50 lines
|
| 375 |
+
const lines = text.split('\n').slice(0, 50);
|
| 376 |
+
|
| 377 |
+
for (let i = 0; i < lines.length; i++) {
|
| 378 |
+
const line = lines[i].trim();
|
| 379 |
+
|
| 380 |
+
if (line.startsWith('Title:')) {
|
| 381 |
+
const title = line.replace('Title:', '').trim();
|
| 382 |
+
if (title && title.length > 1) {
|
| 383 |
+
metadata.title = this.cleanMetadataField(title);
|
| 384 |
+
}
|
| 385 |
+
} else if (line.startsWith('Author:')) {
|
| 386 |
+
const author = line.replace('Author:', '').trim();
|
| 387 |
+
if (author && author.length > 1) {
|
| 388 |
+
metadata.author = this.cleanMetadataField(author);
|
| 389 |
+
}
|
| 390 |
+
}
|
| 391 |
+
}
|
| 392 |
+
|
| 393 |
+
return metadata;
|
| 394 |
+
}
|
| 395 |
+
|
| 396 |
+
cleanMetadataField(field) {
|
| 397 |
+
return field
|
| 398 |
+
.replace(/\[.*?\]/g, '') // Remove bracketed info
|
| 399 |
+
.replace(/\s+/g, ' ') // Normalize whitespace
|
| 400 |
+
.trim();
|
| 401 |
+
}
|
| 402 |
+
|
| 403 |
+
|
| 404 |
+
isValidTitle(title) {
|
| 405 |
+
if (!title || title.length < 3 || title.length > 100) return false;
|
| 406 |
+
// Avoid fragments that are clearly not titles
|
| 407 |
+
if (title.includes('Project Gutenberg') ||
|
| 408 |
+
title.includes('www.') ||
|
| 409 |
+
title.includes('produced from') ||
|
| 410 |
+
title.includes('images generously')) return false;
|
| 411 |
+
return true;
|
| 412 |
+
}
|
| 413 |
+
|
| 414 |
+
isValidAuthor(author) {
|
| 415 |
+
if (!author || author.length < 3 || author.length > 50) return false;
|
| 416 |
+
// Basic validation - should look like a name
|
| 417 |
+
if (author.includes('Project Gutenberg') ||
|
| 418 |
+
author.includes('www.') ||
|
| 419 |
+
author.includes('produced from')) return false;
|
| 420 |
+
return true;
|
| 421 |
+
}
|
| 422 |
+
|
| 423 |
+
isValidForCloze(book) {
|
| 424 |
+
if (!book.text) return false;
|
| 425 |
+
|
| 426 |
+
const textLength = book.text.length;
|
| 427 |
+
|
| 428 |
+
// Basic length criteria
|
| 429 |
+
if (textLength < 2000) return false; // Minimum readable length
|
| 430 |
+
if (textLength > 500000) return false; // Too long for performance
|
| 431 |
+
|
| 432 |
+
// Check for excessive formatting (likely reference material)
|
| 433 |
+
const lineBreakRatio = (book.text.match(/\n\n/g) || []).length / textLength;
|
| 434 |
+
if (lineBreakRatio > 0.05) return false; // Fragmentation threshold
|
| 435 |
+
|
| 436 |
+
// Ensure it has actual narrative content
|
| 437 |
+
const sentenceCount = (book.text.match(/[.!?]+/g) || []).length;
|
| 438 |
+
if (sentenceCount < 10) return false; // Sentence requirement
|
| 439 |
+
|
| 440 |
+
// Sample text for quality check (first 5000 chars should be representative)
|
| 441 |
+
const sampleText = book.text.substring(0, 5000);
|
| 442 |
+
|
| 443 |
+
// Check for index/TOC patterns
|
| 444 |
+
const indexPatterns = [
|
| 445 |
+
'CONTENTS', 'INDEX', 'CHAPTER', 'Volume', 'Vol.',
|
| 446 |
+
'Part I', 'Part II', 'BOOK I', 'APPENDIX'
|
| 447 |
+
];
|
| 448 |
+
const indexCount = indexPatterns.reduce((count, pattern) =>
|
| 449 |
+
count + (sampleText.match(new RegExp(pattern, 'gi')) || []).length, 0
|
| 450 |
+
);
|
| 451 |
+
const indexRatio = indexCount / (sampleText.split(/\s+/).length || 1);
|
| 452 |
+
|
| 453 |
+
if (indexRatio > 0.05) {
|
| 454 |
+
return false;
|
| 455 |
+
}
|
| 456 |
+
|
| 457 |
+
// Check for catalog/bibliography patterns
|
| 458 |
+
if (book.title && (
|
| 459 |
+
book.title.toLowerCase().includes('index') ||
|
| 460 |
+
book.title.toLowerCase().includes('catalog') ||
|
| 461 |
+
book.title.toLowerCase().includes('bibliography') ||
|
| 462 |
+
book.title.toLowerCase().includes('contents')
|
| 463 |
+
)) {
|
| 464 |
+
return false;
|
| 465 |
+
}
|
| 466 |
+
|
| 467 |
+
return true;
|
| 468 |
+
}
|
| 469 |
+
|
| 470 |
+
async getRandomBook() {
|
| 471 |
+
if (!this.isLoaded) {
|
| 472 |
+
throw new Error('Dataset not loaded');
|
| 473 |
+
}
|
| 474 |
+
|
| 475 |
+
// First, try to find a successfully processed HF book
|
| 476 |
+
if (this.streamingEnabled && this.preloadedBooks.length > 0) {
|
| 477 |
+
const availableHFBooks = this.preloadedBooks.filter(book =>
|
| 478 |
+
!this.usedBooks.has(this.getBookId(book))
|
| 479 |
+
);
|
| 480 |
+
|
| 481 |
+
for (const book of availableHFBooks) {
|
| 482 |
+
const processedBook = await this.processBookOnDemand(book);
|
| 483 |
+
if (processedBook) {
|
| 484 |
+
this.usedBooks.add(this.getBookId(processedBook));
|
| 485 |
+
return processedBook;
|
| 486 |
+
}
|
| 487 |
+
}
|
| 488 |
+
|
| 489 |
+
// If no HF books worked, try streaming
|
| 490 |
+
const streamedBook = await this.getStreamingBook();
|
| 491 |
+
if (streamedBook) {
|
| 492 |
+
this.usedBooks.add(this.getBookId(streamedBook));
|
| 493 |
+
return streamedBook;
|
| 494 |
+
}
|
| 495 |
+
}
|
| 496 |
+
|
| 497 |
+
// Fallback to local samples
|
| 498 |
+
const fallbackBooks = this.books.length > 0 ? this.books : this.getSampleBooks();
|
| 499 |
+
const availableBooks = fallbackBooks.filter(book =>
|
| 500 |
+
!this.usedBooks.has(this.getBookId(book))
|
| 501 |
+
);
|
| 502 |
+
|
| 503 |
+
if (availableBooks.length > 0) {
|
| 504 |
+
const randomIndex = Math.floor(Math.random() * availableBooks.length);
|
| 505 |
+
const book = availableBooks[randomIndex];
|
| 506 |
+
this.usedBooks.add(this.getBookId(book));
|
| 507 |
+
return book;
|
| 508 |
+
}
|
| 509 |
+
|
| 510 |
+
// If all books used, clear cache and start over
|
| 511 |
+
this.usedBooks.clear();
|
| 512 |
+
return this.getRandomBook();
|
| 513 |
+
}
|
| 514 |
+
|
| 515 |
+
getBookId(book) {
|
| 516 |
+
// Create unique ID from title and author to track duplicates
|
| 517 |
+
return `${book.title}_${book.author}`.replace(/\s+/g, '_').toLowerCase();
|
| 518 |
+
}
|
| 519 |
+
|
| 520 |
+
async getStreamingBook() {
|
| 521 |
+
// Use preloaded books for immediate access
|
| 522 |
+
if (this.preloadedBooks.length > 0) {
|
| 523 |
+
const randomIndex = Math.floor(Math.random() * this.preloadedBooks.length);
|
| 524 |
+
let book = this.preloadedBooks[randomIndex];
|
| 525 |
+
|
| 526 |
+
// Process on demand if needed
|
| 527 |
+
if (!book.processed) {
|
| 528 |
+
book = await this.processBookOnDemand(book);
|
| 529 |
+
}
|
| 530 |
+
|
| 531 |
+
return book;
|
| 532 |
+
}
|
| 533 |
+
|
| 534 |
+
// If no preloaded books, try to fetch directly with retry
|
| 535 |
+
try {
|
| 536 |
+
if (!this.streamingEnabled) return null;
|
| 537 |
+
const offset = Math.floor(Math.random() * 1000);
|
| 538 |
+
const url = `${this.proxyBase}/rows?dataset=${encodeURIComponent(this.datasetName)}&config=${encodeURIComponent(this.hfConfig)}&split=${encodeURIComponent(this.hfSplit)}&offset=${offset}&length=1`;
|
| 539 |
+
|
| 540 |
+
const response = await this.retryFetch(
|
| 541 |
+
() => this.fetchWithTimeout(url, { timeoutMs: 10000 }),
|
| 542 |
+
2, // fewer retries for on-demand fetch
|
| 543 |
+
500
|
| 544 |
+
);
|
| 545 |
+
|
| 546 |
+
if (response.ok) {
|
| 547 |
+
const data = await response.json();
|
| 548 |
+
if (data.rows && data.rows.length > 0) {
|
| 549 |
+
const book = this.processHFBookLazy(data.rows[0].row);
|
| 550 |
+
return await this.processBookOnDemand(book);
|
| 551 |
+
}
|
| 552 |
+
}
|
| 553 |
+
} catch (error) {
|
| 554 |
+
console.warn('Direct streaming failed after retries:', error);
|
| 555 |
+
}
|
| 556 |
+
|
| 557 |
+
return null;
|
| 558 |
+
}
|
| 559 |
+
|
| 560 |
+
async getBookByLevelCriteria(level) {
|
| 561 |
+
return await this.getRandomBook();
|
| 562 |
+
}
|
| 563 |
+
|
| 564 |
+
|
| 565 |
+
|
| 566 |
+
getBookById(id) {
|
| 567 |
+
// Search in both preloaded and local books
|
| 568 |
+
const allBooks = [...this.preloadedBooks, ...this.books];
|
| 569 |
+
return allBooks.find(book => book.id === id);
|
| 570 |
+
}
|
| 571 |
+
|
| 572 |
+
searchBooks(query) {
|
| 573 |
+
if (!query) return [...this.preloadedBooks, ...this.books];
|
| 574 |
+
|
| 575 |
+
const lowerQuery = query.toLowerCase();
|
| 576 |
+
const allBooks = [...this.preloadedBooks, ...this.books];
|
| 577 |
+
return allBooks.filter(book =>
|
| 578 |
+
book.title.toLowerCase().includes(lowerQuery) ||
|
| 579 |
+
book.author.toLowerCase().includes(lowerQuery)
|
| 580 |
+
);
|
| 581 |
+
}
|
| 582 |
+
|
| 583 |
+
// Health check for streaming status
|
| 584 |
+
getStatus() {
|
| 585 |
+
return {
|
| 586 |
+
streamingEnabled: this.streamingEnabled,
|
| 587 |
+
preloadedBooks: this.preloadedBooks.length,
|
| 588 |
+
localBooks: this.books.length,
|
| 589 |
+
totalAvailable: this.preloadedBooks.length + this.books.length,
|
| 590 |
+
source: this.streamingEnabled ? 'HuggingFace Datasets' : 'Local Samples'
|
| 591 |
+
};
|
| 592 |
+
}
|
| 593 |
+
|
| 594 |
+
// Refresh preloaded books cache
|
| 595 |
+
async refreshCache() {
|
| 596 |
+
if (this.streamingEnabled) {
|
| 597 |
+
await this.preloadBooks(20);
|
| 598 |
+
}
|
| 599 |
+
}
|
| 600 |
+
|
| 601 |
+
// Small helper for fast-fail network calls
|
| 602 |
+
async fetchWithTimeout(resource, options = {}) {
|
| 603 |
+
const { timeoutMs = 5000, ...rest } = options;
|
| 604 |
+
const controller = new AbortController();
|
| 605 |
+
const id = setTimeout(() => controller.abort(), timeoutMs);
|
| 606 |
+
try {
|
| 607 |
+
return await fetch(resource, { ...rest, signal: controller.signal });
|
| 608 |
+
} finally {
|
| 609 |
+
clearTimeout(id);
|
| 610 |
+
}
|
| 611 |
+
}
|
| 612 |
+
|
| 613 |
+
// Retry wrapper with exponential backoff
|
| 614 |
+
async retryFetch(fetchFn, maxRetries = 3, baseDelayMs = 500) {
|
| 615 |
+
let lastError;
|
| 616 |
+
for (let attempt = 1; attempt <= maxRetries; attempt++) {
|
| 617 |
+
try {
|
| 618 |
+
return await fetchFn();
|
| 619 |
+
} catch (error) {
|
| 620 |
+
lastError = error;
|
| 621 |
+
if (attempt < maxRetries) {
|
| 622 |
+
const delay = baseDelayMs * Math.pow(2, attempt - 1);
|
| 623 |
+
await new Promise(resolve => setTimeout(resolve, delay));
|
| 624 |
+
}
|
| 625 |
+
}
|
| 626 |
+
}
|
| 627 |
+
throw lastError;
|
| 628 |
+
}
|
| 629 |
+
}
|
| 630 |
+
|
| 631 |
+
export default new HuggingFaceDatasetService();
|
src/chatInterface.js
ADDED
|
@@ -0,0 +1,335 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
// Chat UI components for contextual hints
|
| 2 |
+
import { analyticsService } from './analyticsService.js';
|
| 3 |
+
|
| 4 |
+
class ChatUI {
|
| 5 |
+
constructor(gameLogic) {
|
| 6 |
+
this.game = gameLogic;
|
| 7 |
+
this.activeChatBlank = null;
|
| 8 |
+
this.chatModal = null;
|
| 9 |
+
this.isOpen = false;
|
| 10 |
+
this.messageHistory = new Map(); // blankId -> array of messages for persistent history
|
| 11 |
+
this.analytics = analyticsService;
|
| 12 |
+
this.setupChatModal();
|
| 13 |
+
}
|
| 14 |
+
|
| 15 |
+
// Create and setup chat modal
|
| 16 |
+
setupChatModal() {
|
| 17 |
+
// Create modal HTML
|
| 18 |
+
const modalHTML = `
|
| 19 |
+
<div id="chat-modal" class="fixed inset-0 bg-black bg-opacity-50 z-50 hidden flex items-center justify-center">
|
| 20 |
+
<div class="bg-white rounded-lg shadow-xl max-w-md w-full mx-4 max-h-[80vh] flex flex-col">
|
| 21 |
+
<!-- Header -->
|
| 22 |
+
<div class="flex items-center justify-between p-4 border-b">
|
| 23 |
+
<h3 id="chat-title" class="text-lg font-semibold text-gray-900">
|
| 24 |
+
Chat about Word #1
|
| 25 |
+
</h3>
|
| 26 |
+
<button id="chat-close" class="text-gray-400 hover:text-gray-600">
|
| 27 |
+
<svg class="w-6 h-6" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
| 28 |
+
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M6 18L18 6M6 6l12 12"></path>
|
| 29 |
+
</svg>
|
| 30 |
+
</button>
|
| 31 |
+
</div>
|
| 32 |
+
|
| 33 |
+
<!-- Chat messages area -->
|
| 34 |
+
<div id="chat-messages" class="flex-1 overflow-y-auto p-4 min-h-[200px] max-h-[400px]">
|
| 35 |
+
<div class="text-center text-gray-500 text-sm">
|
| 36 |
+
Ask me anything about this word! I can help with meaning, context, grammar, or give you hints.
|
| 37 |
+
</div>
|
| 38 |
+
</div>
|
| 39 |
+
|
| 40 |
+
<!-- Suggested questions -->
|
| 41 |
+
<div id="suggested-questions" class="px-4 py-2 border-t border-gray-100">
|
| 42 |
+
<div id="suggestion-buttons" class="flex flex-wrap gap-1">
|
| 43 |
+
<!-- Suggestion buttons will be inserted here -->
|
| 44 |
+
</div>
|
| 45 |
+
</div>
|
| 46 |
+
|
| 47 |
+
<!-- Question dropdown area -->
|
| 48 |
+
<div class="p-4 border-t">
|
| 49 |
+
<!-- Dropdown for all devices -->
|
| 50 |
+
<select id="question-dropdown" class="w-full p-2 border rounded mb-4">
|
| 51 |
+
<option value="">Select a question...</option>
|
| 52 |
+
</select>
|
| 53 |
+
</div>
|
| 54 |
+
</div>
|
| 55 |
+
</div>
|
| 56 |
+
`;
|
| 57 |
+
|
| 58 |
+
// Insert modal into page
|
| 59 |
+
document.body.insertAdjacentHTML('beforeend', modalHTML);
|
| 60 |
+
|
| 61 |
+
this.chatModal = document.getElementById('chat-modal');
|
| 62 |
+
this.setupEventListeners();
|
| 63 |
+
}
|
| 64 |
+
|
| 65 |
+
// Setup event listeners for chat modal
|
| 66 |
+
setupEventListeners() {
|
| 67 |
+
const closeBtn = document.getElementById('chat-close');
|
| 68 |
+
|
| 69 |
+
// Close modal
|
| 70 |
+
closeBtn.addEventListener('click', () => this.closeChat());
|
| 71 |
+
this.chatModal.addEventListener('click', (e) => {
|
| 72 |
+
if (e.target === this.chatModal) this.closeChat();
|
| 73 |
+
});
|
| 74 |
+
|
| 75 |
+
// ESC key to close
|
| 76 |
+
document.addEventListener('keydown', (e) => {
|
| 77 |
+
if (e.key === 'Escape' && this.isOpen) this.closeChat();
|
| 78 |
+
});
|
| 79 |
+
}
|
| 80 |
+
|
| 81 |
+
// Open chat for specific blank
|
| 82 |
+
async openChat(blankIndex) {
|
| 83 |
+
this.activeChatBlank = blankIndex;
|
| 84 |
+
this.isOpen = true;
|
| 85 |
+
|
| 86 |
+
// Update title
|
| 87 |
+
const title = document.getElementById('chat-title');
|
| 88 |
+
title.textContent = `Help with Word #${blankIndex + 1}`;
|
| 89 |
+
|
| 90 |
+
// Restore previous messages or show intro
|
| 91 |
+
this.restoreMessages(blankIndex);
|
| 92 |
+
|
| 93 |
+
// Load question buttons
|
| 94 |
+
this.loadQuestionButtons();
|
| 95 |
+
|
| 96 |
+
// Show modal
|
| 97 |
+
this.chatModal.classList.remove('hidden');
|
| 98 |
+
}
|
| 99 |
+
|
| 100 |
+
// Close chat modal
|
| 101 |
+
closeChat() {
|
| 102 |
+
this.isOpen = false;
|
| 103 |
+
this.chatModal.classList.add('hidden');
|
| 104 |
+
this.activeChatBlank = null;
|
| 105 |
+
}
|
| 106 |
+
|
| 107 |
+
// Clear messages and show intro
|
| 108 |
+
clearMessages() {
|
| 109 |
+
const messagesContainer = document.getElementById('chat-messages');
|
| 110 |
+
messagesContainer.innerHTML = `
|
| 111 |
+
<div class="text-center text-gray-500 text-sm mb-4">
|
| 112 |
+
Choose a question below to get help with this word.
|
| 113 |
+
</div>
|
| 114 |
+
`;
|
| 115 |
+
}
|
| 116 |
+
|
| 117 |
+
// Restore messages for a specific blank or show intro
|
| 118 |
+
restoreMessages(blankIndex) {
|
| 119 |
+
const messagesContainer = document.getElementById('chat-messages');
|
| 120 |
+
const blankId = `blank_${blankIndex}`;
|
| 121 |
+
const history = this.messageHistory.get(blankId);
|
| 122 |
+
|
| 123 |
+
if (history && history.length > 0) {
|
| 124 |
+
// Restore previous messages
|
| 125 |
+
messagesContainer.innerHTML = '';
|
| 126 |
+
history.forEach(msg => {
|
| 127 |
+
this.displayMessage(msg.sender, msg.content, msg.isUser);
|
| 128 |
+
});
|
| 129 |
+
} else {
|
| 130 |
+
// Show intro for new conversation
|
| 131 |
+
this.clearMessages();
|
| 132 |
+
}
|
| 133 |
+
}
|
| 134 |
+
|
| 135 |
+
// Display a message without storing it (used for restoration)
|
| 136 |
+
displayMessage(sender, content, isUser) {
|
| 137 |
+
const messagesContainer = document.getElementById('chat-messages');
|
| 138 |
+
const alignment = isUser ? 'flex justify-end' : 'flex justify-start';
|
| 139 |
+
const messageClass = isUser
|
| 140 |
+
? 'bg-blue-500 text-white'
|
| 141 |
+
: 'bg-gray-100 text-gray-900';
|
| 142 |
+
const displaySender = isUser ? 'You' : sender;
|
| 143 |
+
|
| 144 |
+
const messageHTML = `
|
| 145 |
+
<div class="mb-3 ${alignment}">
|
| 146 |
+
<div class="${messageClass} rounded-lg px-3 py-2 max-w-[80%]">
|
| 147 |
+
<div class="text-xs font-medium mb-1">${displaySender}</div>
|
| 148 |
+
<div class="text-sm">${this.escapeHtml(content)}</div>
|
| 149 |
+
</div>
|
| 150 |
+
</div>
|
| 151 |
+
`;
|
| 152 |
+
|
| 153 |
+
messagesContainer.insertAdjacentHTML('beforeend', messageHTML);
|
| 154 |
+
messagesContainer.scrollTop = messagesContainer.scrollHeight;
|
| 155 |
+
}
|
| 156 |
+
|
| 157 |
+
// Clear all chat history (called when round ends)
|
| 158 |
+
clearChatHistory() {
|
| 159 |
+
this.messageHistory.clear();
|
| 160 |
+
}
|
| 161 |
+
|
| 162 |
+
// Load question dropdown with disabled state for used questions
|
| 163 |
+
loadQuestionButtons() {
|
| 164 |
+
const dropdown = document.getElementById('question-dropdown');
|
| 165 |
+
const questions = this.game.getSuggestedQuestionsForBlank(this.activeChatBlank);
|
| 166 |
+
|
| 167 |
+
// Clear existing content
|
| 168 |
+
dropdown.innerHTML = '<option value="">Select a question...</option>';
|
| 169 |
+
|
| 170 |
+
// Build dropdown options
|
| 171 |
+
questions.forEach(question => {
|
| 172 |
+
const isDisabled = question.used;
|
| 173 |
+
const optionText = isDisabled ? `${question.text} ✓` : question.text;
|
| 174 |
+
|
| 175 |
+
// Add all options but mark used ones as disabled
|
| 176 |
+
const option = document.createElement('option');
|
| 177 |
+
option.value = isDisabled ? '' : question.type;
|
| 178 |
+
option.textContent = optionText;
|
| 179 |
+
option.disabled = isDisabled;
|
| 180 |
+
option.style.color = isDisabled ? '#9CA3AF' : '#111827';
|
| 181 |
+
dropdown.appendChild(option);
|
| 182 |
+
});
|
| 183 |
+
|
| 184 |
+
// Add change listener to dropdown
|
| 185 |
+
dropdown.addEventListener('change', (e) => {
|
| 186 |
+
if (e.target.value) {
|
| 187 |
+
this.askQuestion(e.target.value);
|
| 188 |
+
e.target.value = ''; // Reset dropdown
|
| 189 |
+
}
|
| 190 |
+
});
|
| 191 |
+
}
|
| 192 |
+
|
| 193 |
+
// Ask a specific question
|
| 194 |
+
async askQuestion(questionType) {
|
| 195 |
+
if (this.activeChatBlank === null) return;
|
| 196 |
+
|
| 197 |
+
// Get current user input for the blank
|
| 198 |
+
const currentInput = this.getCurrentBlankInput();
|
| 199 |
+
|
| 200 |
+
// Get the actual question text from the button that was clicked
|
| 201 |
+
const questions = this.game.getSuggestedQuestionsForBlank(this.activeChatBlank);
|
| 202 |
+
const selectedQuestion = questions.find(q => q.type === questionType);
|
| 203 |
+
const questionText = selectedQuestion ? selectedQuestion.text : this.getQuestionText(questionType);
|
| 204 |
+
|
| 205 |
+
// Show question and loading
|
| 206 |
+
this.addMessageToChat('You', questionText, true);
|
| 207 |
+
this.showTypingIndicator();
|
| 208 |
+
|
| 209 |
+
try {
|
| 210 |
+
// Send to chat service with question type
|
| 211 |
+
const response = await this.game.askQuestionAboutBlank(
|
| 212 |
+
this.activeChatBlank,
|
| 213 |
+
questionType,
|
| 214 |
+
currentInput
|
| 215 |
+
);
|
| 216 |
+
|
| 217 |
+
this.hideTypingIndicator();
|
| 218 |
+
|
| 219 |
+
if (response.success) {
|
| 220 |
+
// Make sure we're displaying the response string, not the object
|
| 221 |
+
const responseText = typeof response.response === 'string'
|
| 222 |
+
? response.response
|
| 223 |
+
: response.response.response || 'Sorry, I had trouble with that question.';
|
| 224 |
+
this.addMessageToChat('Cluemaster', responseText, false);
|
| 225 |
+
|
| 226 |
+
// Track hint usage in analytics
|
| 227 |
+
this.analytics.recordHint(this.activeChatBlank, questionType);
|
| 228 |
+
|
| 229 |
+
// Refresh question buttons to show the used question as disabled
|
| 230 |
+
this.loadQuestionButtons();
|
| 231 |
+
} else {
|
| 232 |
+
this.addMessageToChat('Cluemaster', response.message || 'Sorry, I had trouble with that question.', false);
|
| 233 |
+
}
|
| 234 |
+
|
| 235 |
+
} catch (error) {
|
| 236 |
+
this.hideTypingIndicator();
|
| 237 |
+
console.error('Chat error:', error);
|
| 238 |
+
this.addMessageToChat('Cluemaster', 'Sorry, I encountered an error. Please try again.', false);
|
| 239 |
+
}
|
| 240 |
+
}
|
| 241 |
+
|
| 242 |
+
// Get question text for display
|
| 243 |
+
getQuestionText(questionType) {
|
| 244 |
+
const questions = {
|
| 245 |
+
'grammar': 'What type of word is this?',
|
| 246 |
+
'meaning': 'What does this word mean?',
|
| 247 |
+
'context': 'Why does this word fit here?',
|
| 248 |
+
'clue': 'Give me a clue'
|
| 249 |
+
};
|
| 250 |
+
return questions[questionType] || questions['clue'];
|
| 251 |
+
}
|
| 252 |
+
|
| 253 |
+
// Get current input for the active blank
|
| 254 |
+
getCurrentBlankInput() {
|
| 255 |
+
const input = document.querySelector(`input[data-blank-index="${this.activeChatBlank}"]`);
|
| 256 |
+
return input ? input.value.trim() : '';
|
| 257 |
+
}
|
| 258 |
+
|
| 259 |
+
// Add message to chat display and store in history
|
| 260 |
+
addMessageToChat(sender, content, isUser) {
|
| 261 |
+
// Store message in history for current blank
|
| 262 |
+
if (this.activeChatBlank !== null) {
|
| 263 |
+
const blankId = `blank_${this.activeChatBlank}`;
|
| 264 |
+
if (!this.messageHistory.has(blankId)) {
|
| 265 |
+
this.messageHistory.set(blankId, []);
|
| 266 |
+
}
|
| 267 |
+
|
| 268 |
+
// Change "Tutor" to "Cluemaster" for display and storage
|
| 269 |
+
const displaySender = sender === 'Tutor' ? 'Cluemaster' : sender;
|
| 270 |
+
|
| 271 |
+
this.messageHistory.get(blankId).push({
|
| 272 |
+
sender: displaySender,
|
| 273 |
+
content: content,
|
| 274 |
+
isUser: isUser,
|
| 275 |
+
timestamp: Date.now()
|
| 276 |
+
});
|
| 277 |
+
}
|
| 278 |
+
|
| 279 |
+
// Display the message
|
| 280 |
+
this.displayMessage(sender === 'Tutor' ? 'Cluemaster' : sender, content, isUser);
|
| 281 |
+
}
|
| 282 |
+
|
| 283 |
+
// Show typing indicator
|
| 284 |
+
showTypingIndicator() {
|
| 285 |
+
const messagesContainer = document.getElementById('chat-messages');
|
| 286 |
+
const typingHTML = `
|
| 287 |
+
<div id="typing-indicator" class="mb-3 mr-auto max-w-[80%]">
|
| 288 |
+
<div class="bg-gray-100 text-gray-900 rounded-lg px-3 py-2">
|
| 289 |
+
<div class="text-xs font-medium mb-1">Cluemaster</div>
|
| 290 |
+
<div class="text-sm">
|
| 291 |
+
<span class="typing-dots">
|
| 292 |
+
<span>.</span><span>.</span><span>.</span>
|
| 293 |
+
</span>
|
| 294 |
+
</div>
|
| 295 |
+
</div>
|
| 296 |
+
</div>
|
| 297 |
+
`;
|
| 298 |
+
|
| 299 |
+
messagesContainer.insertAdjacentHTML('beforeend', typingHTML);
|
| 300 |
+
messagesContainer.scrollTop = messagesContainer.scrollHeight;
|
| 301 |
+
}
|
| 302 |
+
|
| 303 |
+
// Hide typing indicator
|
| 304 |
+
hideTypingIndicator() {
|
| 305 |
+
const indicator = document.getElementById('typing-indicator');
|
| 306 |
+
if (indicator) indicator.remove();
|
| 307 |
+
}
|
| 308 |
+
|
| 309 |
+
// Escape HTML to prevent XSS
|
| 310 |
+
escapeHtml(text) {
|
| 311 |
+
const div = document.createElement('div');
|
| 312 |
+
div.textContent = text;
|
| 313 |
+
return div.innerHTML;
|
| 314 |
+
}
|
| 315 |
+
|
| 316 |
+
// Setup chat buttons for blanks
|
| 317 |
+
setupChatButtons() {
|
| 318 |
+
// Remove existing listeners
|
| 319 |
+
document.querySelectorAll('.chat-button').forEach(btn => {
|
| 320 |
+
btn.replaceWith(btn.cloneNode(true));
|
| 321 |
+
});
|
| 322 |
+
|
| 323 |
+
// Add new listeners
|
| 324 |
+
document.querySelectorAll('.chat-button').forEach(btn => {
|
| 325 |
+
btn.addEventListener('click', (e) => {
|
| 326 |
+
e.preventDefault();
|
| 327 |
+
e.stopPropagation();
|
| 328 |
+
const blankIndex = parseInt(btn.dataset.blankIndex);
|
| 329 |
+
this.openChat(blankIndex);
|
| 330 |
+
});
|
| 331 |
+
});
|
| 332 |
+
}
|
| 333 |
+
}
|
| 334 |
+
|
| 335 |
+
export default ChatUI;
|
src/clozeGameEngine.js
ADDED
|
@@ -0,0 +1,970 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
// Core game logic for minimal cloze reader
|
| 2 |
+
import bookDataService from './bookDataService.js';
|
| 3 |
+
import { AIService } from './aiService.js';
|
| 4 |
+
import ChatService from './conversationManager.js';
|
| 5 |
+
import { LeaderboardService } from './leaderboardService.js';
|
| 6 |
+
|
| 7 |
+
const aiService = new AIService();
|
| 8 |
+
|
| 9 |
+
class ClozeGame {
|
| 10 |
+
constructor() {
|
| 11 |
+
this.currentBook = null;
|
| 12 |
+
this.originalText = '';
|
| 13 |
+
this.clozeText = '';
|
| 14 |
+
this.blanks = [];
|
| 15 |
+
this.userAnswers = [];
|
| 16 |
+
this.score = 0;
|
| 17 |
+
this.currentRound = 1;
|
| 18 |
+
this.currentLevel = 1; // Track difficulty level separately from round
|
| 19 |
+
this.contextualization = '';
|
| 20 |
+
this.hints = [];
|
| 21 |
+
this.chatService = new ChatService(aiService);
|
| 22 |
+
this.lastResults = null; // Store results for answer revelation
|
| 23 |
+
this.leaderboardService = new LeaderboardService();
|
| 24 |
+
this.passagesPassedAtCurrentLevel = 0; // Track progress toward level advancement
|
| 25 |
+
|
| 26 |
+
// Multiple retry support
|
| 27 |
+
this.attemptCounts = {}; // blankIndex -> number of attempts
|
| 28 |
+
this.lockedBlanks = new Set(); // blanks that are correct and locked
|
| 29 |
+
this.maxRetries = 5; // Maximum retry attempts before forcing completion
|
| 30 |
+
|
| 31 |
+
}
|
| 32 |
+
|
| 33 |
+
// --- User-visible framing helpers ---
|
| 34 |
+
getBlanksPerPassage(level = this.currentLevel) {
|
| 35 |
+
if (level <= 5) return 1;
|
| 36 |
+
if (level <= 10) return 2;
|
| 37 |
+
return 3;
|
| 38 |
+
}
|
| 39 |
+
|
| 40 |
+
getProgressSnapshot() {
|
| 41 |
+
return {
|
| 42 |
+
round: this.currentRound,
|
| 43 |
+
level: this.currentLevel,
|
| 44 |
+
blanksPerPassage: this.getBlanksPerPassage()
|
| 45 |
+
};
|
| 46 |
+
}
|
| 47 |
+
|
| 48 |
+
formatProgressText(snapshot = this.getProgressSnapshot()) {
|
| 49 |
+
const blanksLabel = `${snapshot.blanksPerPassage} blank${snapshot.blanksPerPassage > 1 ? 's' : ''}`;
|
| 50 |
+
return `Level ${snapshot.level} • ${blanksLabel}`;
|
| 51 |
+
}
|
| 52 |
+
|
| 53 |
+
formatAdvancementText({ passed, correctCount, requiredCorrect, justAdvancedLevel }) {
|
| 54 |
+
if (passed) {
|
| 55 |
+
if (justAdvancedLevel) {
|
| 56 |
+
return `✓ Passed • Level up! Welcome to Level ${this.currentLevel}`;
|
| 57 |
+
}
|
| 58 |
+
return `✓ Passed • Advancing to next level!`;
|
| 59 |
+
}
|
| 60 |
+
return `Try again • Need ${requiredCorrect}/${this.blanks.length} correct (you got ${correctCount})`;
|
| 61 |
+
}
|
| 62 |
+
|
| 63 |
+
async initialize() {
|
| 64 |
+
try {
|
| 65 |
+
await bookDataService.loadDataset();
|
| 66 |
+
} catch (error) {
|
| 67 |
+
console.error('Failed to initialize game:', error);
|
| 68 |
+
throw error;
|
| 69 |
+
}
|
| 70 |
+
}
|
| 71 |
+
|
| 72 |
+
async startNewRound() {
|
| 73 |
+
try {
|
| 74 |
+
|
| 75 |
+
// Reset retry tracking for new round
|
| 76 |
+
this.attemptCounts = {};
|
| 77 |
+
this.lockedBlanks = new Set();
|
| 78 |
+
|
| 79 |
+
// Get one book for this round based on current level criteria
|
| 80 |
+
const book = await bookDataService.getBookByLevelCriteria(this.currentLevel);
|
| 81 |
+
|
| 82 |
+
// Extract passage from book
|
| 83 |
+
const passage = this.extractCoherentPassage(book.text);
|
| 84 |
+
|
| 85 |
+
// Store book and passage (normalize whitespace to prevent compound words)
|
| 86 |
+
this.currentBook = book;
|
| 87 |
+
this.originalText = passage.trim().replace(/\s+/g, ' ');
|
| 88 |
+
|
| 89 |
+
// Create cloze text using AI
|
| 90 |
+
try {
|
| 91 |
+
await this.createClozeText();
|
| 92 |
+
await this.generateContextualization();
|
| 93 |
+
} catch (error) {
|
| 94 |
+
console.warn('AI processing failed:', error);
|
| 95 |
+
throw error;
|
| 96 |
+
}
|
| 97 |
+
|
| 98 |
+
const snapshot = this.getProgressSnapshot();
|
| 99 |
+
return {
|
| 100 |
+
title: this.currentBook.title,
|
| 101 |
+
author: this.currentBook.author,
|
| 102 |
+
text: this.clozeText,
|
| 103 |
+
blanks: this.blanks,
|
| 104 |
+
contextualization: this.contextualization,
|
| 105 |
+
hints: this.hints,
|
| 106 |
+
progressText: this.formatProgressText(snapshot)
|
| 107 |
+
};
|
| 108 |
+
} catch (error) {
|
| 109 |
+
console.error('Error starting new round:', error);
|
| 110 |
+
throw error;
|
| 111 |
+
}
|
| 112 |
+
}
|
| 113 |
+
|
| 114 |
+
extractCoherentPassage(text) {
|
| 115 |
+
// Simple elegant solution: start from middle third of book where actual content is
|
| 116 |
+
const textLength = text.length;
|
| 117 |
+
const startFromMiddle = Math.floor(textLength * 0.3); // Skip first 30%
|
| 118 |
+
const endAtThreeQuarters = Math.floor(textLength * 0.8); // Stop before last 20%
|
| 119 |
+
|
| 120 |
+
let attempts = 0;
|
| 121 |
+
let passage = '';
|
| 122 |
+
|
| 123 |
+
while (attempts < 8) {
|
| 124 |
+
// Random position in the middle section
|
| 125 |
+
const availableLength = endAtThreeQuarters - startFromMiddle;
|
| 126 |
+
const randomOffset = Math.floor(Math.random() * Math.max(0, availableLength - 1000));
|
| 127 |
+
const startIndex = startFromMiddle + randomOffset;
|
| 128 |
+
|
| 129 |
+
// Extract longer initial passage for better sentence completion
|
| 130 |
+
passage = text.substring(startIndex, startIndex + 1000);
|
| 131 |
+
|
| 132 |
+
// Clean up start - find first complete sentence that starts with capital letter
|
| 133 |
+
const firstSentenceMatch = passage.match(/[.!?]\s+([A-Z][^.!?]*)/);
|
| 134 |
+
if (firstSentenceMatch && firstSentenceMatch.index < 200) {
|
| 135 |
+
// Start from the capital letter after punctuation
|
| 136 |
+
passage = passage.substring(firstSentenceMatch.index + firstSentenceMatch[0].length - firstSentenceMatch[1].length);
|
| 137 |
+
} else {
|
| 138 |
+
// If no good sentence break found, find first capital letter
|
| 139 |
+
const firstCapitalMatch = passage.match(/[A-Z][^.!?]*/);
|
| 140 |
+
if (firstCapitalMatch) {
|
| 141 |
+
passage = passage.substring(firstCapitalMatch.index);
|
| 142 |
+
}
|
| 143 |
+
}
|
| 144 |
+
|
| 145 |
+
// Clean up end - ensure we end at a complete sentence
|
| 146 |
+
const sentences = passage.split(/(?<=[.!?])\s+/);
|
| 147 |
+
if (sentences.length > 1) {
|
| 148 |
+
// Remove the last sentence if it might be incomplete
|
| 149 |
+
sentences.pop();
|
| 150 |
+
passage = sentences.join(' ');
|
| 151 |
+
}
|
| 152 |
+
|
| 153 |
+
// Immediate rejects for known front-matter/meta patterns
|
| 154 |
+
const frontMatterRegexes = [
|
| 155 |
+
/(archive\.org|Internet Archive|HathiTrust|Google)/i,
|
| 156 |
+
/page\s+scan\s+source/i,
|
| 157 |
+
/Entered according to Act of Congress/i,
|
| 158 |
+
/COPYRIGHT/i,
|
| 159 |
+
/PUBLISHER|PRESS|MURRAY STREET|NEW YORK|LONDON|BOSTON/i,
|
| 160 |
+
/\bBY\s+[A-Z .'-]{2,}\b/,
|
| 161 |
+
/\bA NOVEL\b/i,
|
| 162 |
+
/https?:\/\//i,
|
| 163 |
+
];
|
| 164 |
+
if (frontMatterRegexes.some(r => r.test(passage))) {
|
| 165 |
+
attempts++;
|
| 166 |
+
continue;
|
| 167 |
+
}
|
| 168 |
+
|
| 169 |
+
// Enhanced quality check based on narrative flow characteristics
|
| 170 |
+
const words = passage.split(/\s+/);
|
| 171 |
+
const totalWords = words.length;
|
| 172 |
+
|
| 173 |
+
// Count various quality indicators
|
| 174 |
+
const capsWords = words.filter(w => w.length > 1 && w === w.toUpperCase() && !/^\d+$/.test(w));
|
| 175 |
+
const capsCount = capsWords.length;
|
| 176 |
+
const numbersCount = words.filter(w => /\d/.test(w)).length;
|
| 177 |
+
const shortWords = words.filter(w => w.length <= 3).length;
|
| 178 |
+
const punctuationMarks = (passage.match(/[;:()[\]{}—–]/g) || []).length;
|
| 179 |
+
const sentenceList = passage.split(/[.!?]+/).filter(s => s.trim().length > 10);
|
| 180 |
+
const lines = passage.split('\n').filter(l => l.trim());
|
| 181 |
+
|
| 182 |
+
// Debug logging for caps detection
|
| 183 |
+
if (capsCount > 5) {
|
| 184 |
+
}
|
| 185 |
+
|
| 186 |
+
// Count excessive dashes (n-dashes, m-dashes, hyphens in sequence)
|
| 187 |
+
const dashSequences = (passage.match(/[-—–]{3,}/g) || []).length;
|
| 188 |
+
const totalDashes = (passage.match(/[-—–]/g) || []).length;
|
| 189 |
+
|
| 190 |
+
// Count additional formatting patterns
|
| 191 |
+
const asteriskSequences = (passage.match(/\*{3,}/g) || []).length;
|
| 192 |
+
const asteriskLines = (passage.match(/^\s*\*+\s*$/gm) || []).length;
|
| 193 |
+
const underscoreSequences = (passage.match(/_{3,}/g) || []).length;
|
| 194 |
+
const equalSequences = (passage.match(/={3,}/g) || []).length;
|
| 195 |
+
const pipeCount = (passage.match(/\|/g) || []).length;
|
| 196 |
+
const numberedLines = (passage.match(/^\s*\d+[\.\)]\s/gm) || []).length;
|
| 197 |
+
const parenthesesCount = (passage.match(/[()]/g) || []).length;
|
| 198 |
+
const squareBrackets = (passage.match(/[\[\]]/g) || []).length;
|
| 199 |
+
|
| 200 |
+
// Dictionary/glossary patterns
|
| 201 |
+
const hashSymbols = (passage.match(/#/g) || []).length;
|
| 202 |
+
const abbreviationPattern = /\b(n\.|adj\.|adv\.|v\.|pl\.|sg\.|cf\.|e\.g\.|i\.e\.|etc\.|vs\.|viz\.|OE\.|OFr\.|L\.|ME\.|NE\.|AN\.|ON\.|MDu\.|MLG\.|MHG\.|Ger\.|Du\.|Dan\.|Sw\.|Icel\.)\b/gi;
|
| 203 |
+
const abbreviations = (passage.match(abbreviationPattern) || []).length;
|
| 204 |
+
const etymologyBrackets = (passage.match(/\[[^\]]+\]/g) || []).length;
|
| 205 |
+
const referenceNumbers = (passage.match(/\b[IVX]+\s+[abc]?\s*\d+/g) || []).length;
|
| 206 |
+
const definitionPattern = /^[^.]+,\s*(n\.|adj\.|adv\.|v\.)/gm;
|
| 207 |
+
const definitionLines = (passage.match(definitionPattern) || []).length;
|
| 208 |
+
|
| 209 |
+
// Academic/reference patterns
|
| 210 |
+
const citationPattern = /\(\d{4}\)|p\.\s*\d+|pp\.\s*\d+-\d+|vol\.\s*\d+|ch\.\s*\d+/gi;
|
| 211 |
+
const citations = (passage.match(citationPattern) || []).length;
|
| 212 |
+
const technicalTerms = ['etymology', 'phoneme', 'morpheme', 'lexicon', 'syntax', 'semantics', 'glossary', 'vocabulary', 'dialect', 'pronunciation'];
|
| 213 |
+
const technicalTermCount = technicalTerms.reduce((count, term) =>
|
| 214 |
+
count + (passage.match(new RegExp(term, 'gi')) || []).length, 0
|
| 215 |
+
);
|
| 216 |
+
|
| 217 |
+
// Check for repetitive patterns (common in indexes/TOCs)
|
| 218 |
+
const repeatedPhrases = ['CONTENTS', 'CHAPTER', 'Volume', 'Vol.', 'Part', 'Book'];
|
| 219 |
+
const repetitionCount = repeatedPhrases.reduce((count, phrase) =>
|
| 220 |
+
count + (passage.match(new RegExp(phrase, 'gi')) || []).length, 0
|
| 221 |
+
);
|
| 222 |
+
|
| 223 |
+
// Check for title patterns (common in TOCs)
|
| 224 |
+
const titlePattern = /^[A-Z][A-Z\s]+$/m;
|
| 225 |
+
const titleLines = lines.filter(line => titlePattern.test(line.trim())).length;
|
| 226 |
+
|
| 227 |
+
// Check for consecutive all-caps lines (title pages, copyright notices)
|
| 228 |
+
let consecutiveCapsLines = 0;
|
| 229 |
+
let maxConsecutiveCaps = 0;
|
| 230 |
+
lines.forEach(line => {
|
| 231 |
+
const trimmed = line.trim();
|
| 232 |
+
if (trimmed.length > 3 && trimmed === trimmed.toUpperCase() && !/^\d+$/.test(trimmed)) {
|
| 233 |
+
consecutiveCapsLines++;
|
| 234 |
+
maxConsecutiveCaps = Math.max(maxConsecutiveCaps, consecutiveCapsLines);
|
| 235 |
+
} else {
|
| 236 |
+
consecutiveCapsLines = 0;
|
| 237 |
+
}
|
| 238 |
+
});
|
| 239 |
+
|
| 240 |
+
// Calculate quality ratios
|
| 241 |
+
const capsRatio = capsCount / totalWords;
|
| 242 |
+
const numbersRatio = numbersCount / totalWords;
|
| 243 |
+
const shortWordRatio = shortWords / totalWords;
|
| 244 |
+
const punctuationRatio = punctuationMarks / totalWords;
|
| 245 |
+
const avgWordsPerSentence = totalWords / Math.max(1, sentenceList.length);
|
| 246 |
+
const repetitionRatio = repetitionCount / totalWords;
|
| 247 |
+
const titleLineRatio = titleLines / Math.max(1, lines.length);
|
| 248 |
+
const dashRatio = totalDashes / totalWords;
|
| 249 |
+
const parenthesesRatio = parenthesesCount / totalWords;
|
| 250 |
+
const squareBracketRatio = squareBrackets / totalWords;
|
| 251 |
+
const hashRatio = hashSymbols / totalWords;
|
| 252 |
+
const abbreviationRatio = abbreviations / totalWords;
|
| 253 |
+
const etymologyRatio = etymologyBrackets / totalWords;
|
| 254 |
+
const definitionRatio = definitionLines / Math.max(1, lines.length);
|
| 255 |
+
const technicalRatio = technicalTermCount / totalWords;
|
| 256 |
+
|
| 257 |
+
// Stricter thresholds for higher levels
|
| 258 |
+
const capsThreshold = this.currentLevel >= 3 ? 0.03 : 0.05;
|
| 259 |
+
const numbersThreshold = this.currentLevel >= 3 ? 0.02 : 0.03;
|
| 260 |
+
|
| 261 |
+
// Reject if passage shows signs of being technical/reference material
|
| 262 |
+
let qualityScore = 0;
|
| 263 |
+
let issues = [];
|
| 264 |
+
|
| 265 |
+
// Immediate rejection for excessive caps (title pages, headers, etc)
|
| 266 |
+
if (capsRatio > 0.12) {
|
| 267 |
+
attempts++;
|
| 268 |
+
continue;
|
| 269 |
+
}
|
| 270 |
+
|
| 271 |
+
// Immediate rejection for consecutive all-caps lines (title pages, copyright)
|
| 272 |
+
if (maxConsecutiveCaps >= 2) {
|
| 273 |
+
attempts++;
|
| 274 |
+
continue;
|
| 275 |
+
}
|
| 276 |
+
|
| 277 |
+
if (capsRatio > capsThreshold) { qualityScore += capsRatio * 100; issues.push(`caps: ${Math.round(capsRatio * 100)}%`); }
|
| 278 |
+
if (numbersRatio > numbersThreshold) { qualityScore += numbersRatio * 40; issues.push(`numbers: ${Math.round(numbersRatio * 100)}%`); }
|
| 279 |
+
if (punctuationRatio > 0.08) { qualityScore += punctuationRatio * 15; issues.push(`punct: ${Math.round(punctuationRatio * 100)}%`); }
|
| 280 |
+
if (avgWordsPerSentence < 8 || avgWordsPerSentence > 40) { qualityScore += 2; issues.push(`sent-len: ${Math.round(avgWordsPerSentence)}`); }
|
| 281 |
+
if (shortWordRatio < 0.3) { qualityScore += 2; issues.push(`short-words: ${Math.round(shortWordRatio * 100)}%`); }
|
| 282 |
+
if (repetitionRatio > 0.02) { qualityScore += repetitionRatio * 50; issues.push(`repetitive: ${Math.round(repetitionRatio * 100)}%`); }
|
| 283 |
+
if (titleLineRatio > 0.2) { qualityScore += 5; issues.push(`title-lines: ${Math.round(titleLineRatio * 100)}%`); }
|
| 284 |
+
if (dashSequences > 0) { qualityScore += dashSequences * 3; issues.push(`dash-sequences: ${dashSequences}`); }
|
| 285 |
+
if (dashRatio > 0.02) { qualityScore += dashRatio * 25; issues.push(`dashes: ${Math.round(dashRatio * 100)}%`); }
|
| 286 |
+
if (asteriskSequences > 0 || asteriskLines > 0) { qualityScore += (asteriskSequences + asteriskLines) * 2; issues.push(`asterisk-separators: ${asteriskSequences + asteriskLines}`); }
|
| 287 |
+
if (underscoreSequences > 0) { qualityScore += underscoreSequences * 2; issues.push(`underscore-lines: ${underscoreSequences}`); }
|
| 288 |
+
if (equalSequences > 0) { qualityScore += equalSequences * 2; issues.push(`equal-lines: ${equalSequences}`); }
|
| 289 |
+
if (pipeCount > 5) { qualityScore += 3; issues.push(`table-formatting: ${pipeCount} pipes`); }
|
| 290 |
+
if (numberedLines > 3) { qualityScore += 2; issues.push(`numbered-list: ${numberedLines} items`); }
|
| 291 |
+
if (parenthesesRatio > 0.05) { qualityScore += 2; issues.push(`excessive-parentheses: ${Math.round(parenthesesRatio * 100)}%`); }
|
| 292 |
+
if (squareBracketRatio > 0.02) { qualityScore += 2; issues.push(`excessive-brackets: ${Math.round(squareBracketRatio * 100)}%`); }
|
| 293 |
+
|
| 294 |
+
// Dictionary/glossary/academic content detection
|
| 295 |
+
if (hashRatio > 0.01) { qualityScore += hashRatio * 100; issues.push(`hash-symbols: ${hashSymbols}`); }
|
| 296 |
+
if (abbreviationRatio > 0.03) { qualityScore += abbreviationRatio * 50; issues.push(`abbreviations: ${abbreviations}`); }
|
| 297 |
+
if (etymologyRatio > 0.005) { qualityScore += etymologyRatio * 100; issues.push(`etymology-brackets: ${etymologyBrackets}`); }
|
| 298 |
+
if (definitionRatio > 0.1) { qualityScore += definitionRatio * 20; issues.push(`definition-lines: ${Math.round(definitionRatio * 100)}%`); }
|
| 299 |
+
if (referenceNumbers > 0) { qualityScore += referenceNumbers * 2; issues.push(`reference-numbers: ${referenceNumbers}`); }
|
| 300 |
+
if (citations > 0) { qualityScore += citations * 2; issues.push(`citations: ${citations}`); }
|
| 301 |
+
if (technicalRatio > 0.01) { qualityScore += technicalRatio * 30; issues.push(`technical-terms: ${technicalTermCount}`); }
|
| 302 |
+
|
| 303 |
+
// Reject if quality score indicates technical/non-narrative content
|
| 304 |
+
if (qualityScore > 2.5) {
|
| 305 |
+
attempts++;
|
| 306 |
+
continue;
|
| 307 |
+
}
|
| 308 |
+
|
| 309 |
+
// Good passage found
|
| 310 |
+
break;
|
| 311 |
+
}
|
| 312 |
+
|
| 313 |
+
// Ensure minimum length - if too short, return what we have rather than infinite recursion
|
| 314 |
+
if (passage.length < 400) {
|
| 315 |
+
console.warn('Short passage extracted, using fallback approach');
|
| 316 |
+
// Try one more time with a simpler approach
|
| 317 |
+
const simpleStart = text.indexOf('. ') + 2;
|
| 318 |
+
if (simpleStart > 1 && simpleStart < text.length - 500) {
|
| 319 |
+
passage = text.substring(simpleStart, simpleStart + 600);
|
| 320 |
+
const lastPeriod = passage.lastIndexOf('.');
|
| 321 |
+
if (lastPeriod > 200) {
|
| 322 |
+
passage = passage.substring(0, lastPeriod + 1);
|
| 323 |
+
}
|
| 324 |
+
}
|
| 325 |
+
}
|
| 326 |
+
|
| 327 |
+
return passage.trim();
|
| 328 |
+
}
|
| 329 |
+
|
| 330 |
+
|
| 331 |
+
async createClozeText() {
|
| 332 |
+
const words = this.originalText.split(' ');
|
| 333 |
+
// Progressive difficulty: levels 1-5 = 1 blank, levels 6-10 = 2 blanks, level 11+ = 3 blanks
|
| 334 |
+
let numberOfBlanks;
|
| 335 |
+
if (this.currentLevel <= 5) {
|
| 336 |
+
numberOfBlanks = 1;
|
| 337 |
+
} else if (this.currentLevel <= 10) {
|
| 338 |
+
numberOfBlanks = 2;
|
| 339 |
+
} else {
|
| 340 |
+
numberOfBlanks = 3;
|
| 341 |
+
}
|
| 342 |
+
|
| 343 |
+
// Update chat service with current level
|
| 344 |
+
this.chatService.setLevel(this.currentLevel);
|
| 345 |
+
|
| 346 |
+
// Always use AI for word selection with fallback
|
| 347 |
+
let significantWords;
|
| 348 |
+
try {
|
| 349 |
+
significantWords = await aiService.selectSignificantWords(
|
| 350 |
+
this.originalText,
|
| 351 |
+
numberOfBlanks,
|
| 352 |
+
this.currentLevel
|
| 353 |
+
);
|
| 354 |
+
} catch (error) {
|
| 355 |
+
console.warn('AI word selection failed, using manual fallback:', error);
|
| 356 |
+
significantWords = this.selectWordsManually(words, numberOfBlanks);
|
| 357 |
+
}
|
| 358 |
+
|
| 359 |
+
// Ensure we have valid words
|
| 360 |
+
if (!significantWords || significantWords.length === 0) {
|
| 361 |
+
console.warn('No words selected, using emergency fallback');
|
| 362 |
+
significantWords = this.selectWordsManually(words, numberOfBlanks);
|
| 363 |
+
}
|
| 364 |
+
|
| 365 |
+
// Find word indices for selected significant words, distributed throughout passage
|
| 366 |
+
const selectedIndices = [];
|
| 367 |
+
const wordsLower = words.map(w => w.toLowerCase().replace(/[^\w]/g, ''));
|
| 368 |
+
|
| 369 |
+
// Create sections of the passage to ensure distribution
|
| 370 |
+
const passageSections = this.dividePassageIntoSections(words.length, numberOfBlanks);
|
| 371 |
+
|
| 372 |
+
significantWords.forEach((significantWord, index) => {
|
| 373 |
+
// Clean the significant word for matching
|
| 374 |
+
const cleanSignificant = significantWord.toLowerCase().replace(/[^\w]/g, '');
|
| 375 |
+
|
| 376 |
+
// Look for the word within the appropriate section for better distribution
|
| 377 |
+
const sectionStart = passageSections[index] ? passageSections[index].start : 0;
|
| 378 |
+
const sectionEnd = passageSections[index] ? passageSections[index].end : words.length;
|
| 379 |
+
|
| 380 |
+
let wordIndex = -1;
|
| 381 |
+
|
| 382 |
+
// First try to find the word in the designated section (avoiding first 10 words and capitalized words)
|
| 383 |
+
for (let i = Math.max(10, sectionStart); i < sectionEnd; i++) {
|
| 384 |
+
const originalWord = words[i].replace(/[^\w]/g, '');
|
| 385 |
+
const isCapitalized = originalWord.length > 0 && originalWord[0] === originalWord[0].toUpperCase();
|
| 386 |
+
if (wordsLower[i] === cleanSignificant && !selectedIndices.includes(i) && !isCapitalized) {
|
| 387 |
+
wordIndex = i;
|
| 388 |
+
break;
|
| 389 |
+
}
|
| 390 |
+
}
|
| 391 |
+
|
| 392 |
+
// If not found in section, try progressively more relaxed searches
|
| 393 |
+
if (wordIndex === -1) {
|
| 394 |
+
// Try 1: Global search avoiding caps and first 10 words
|
| 395 |
+
wordIndex = wordsLower.findIndex((word, idx) => {
|
| 396 |
+
const originalWord = words[idx].replace(/[^\w]/g, '');
|
| 397 |
+
const isCapitalized = originalWord.length > 0 && originalWord[0] === originalWord[0].toUpperCase();
|
| 398 |
+
return word === cleanSignificant && !selectedIndices.includes(idx) && idx >= 10 && !isCapitalized;
|
| 399 |
+
});
|
| 400 |
+
}
|
| 401 |
+
|
| 402 |
+
// Try 2: Allow capitalized words but still avoid first 10 words
|
| 403 |
+
if (wordIndex === -1) {
|
| 404 |
+
wordIndex = wordsLower.findIndex((word, idx) => {
|
| 405 |
+
return word === cleanSignificant && !selectedIndices.includes(idx) && idx >= 10;
|
| 406 |
+
});
|
| 407 |
+
}
|
| 408 |
+
|
| 409 |
+
// Try 3: Allow words from position 5 onwards (more relaxed)
|
| 410 |
+
if (wordIndex === -1) {
|
| 411 |
+
wordIndex = wordsLower.findIndex((word, idx) => {
|
| 412 |
+
return word === cleanSignificant && !selectedIndices.includes(idx) && idx >= 5;
|
| 413 |
+
});
|
| 414 |
+
}
|
| 415 |
+
|
| 416 |
+
// Try 4: Check if word contains the target (partial match)
|
| 417 |
+
if (wordIndex === -1) {
|
| 418 |
+
wordIndex = wordsLower.findIndex((word, idx) => {
|
| 419 |
+
return word.includes(cleanSignificant) && !selectedIndices.includes(idx) && idx >= 10;
|
| 420 |
+
});
|
| 421 |
+
}
|
| 422 |
+
|
| 423 |
+
if (wordIndex !== -1) {
|
| 424 |
+
selectedIndices.push(wordIndex);
|
| 425 |
+
} else {
|
| 426 |
+
console.warn(`Could not find word "${significantWord}" in passage`);
|
| 427 |
+
}
|
| 428 |
+
});
|
| 429 |
+
|
| 430 |
+
|
| 431 |
+
// If no words were matched, fall back to manual selection
|
| 432 |
+
if (selectedIndices.length === 0) {
|
| 433 |
+
console.warn('No AI words matched in passage, using manual selection');
|
| 434 |
+
const manualWords = this.selectWordsManually(words, numberOfBlanks);
|
| 435 |
+
|
| 436 |
+
// Try to match manual words with relaxed criteria
|
| 437 |
+
manualWords.forEach((manualWord) => {
|
| 438 |
+
const cleanManual = manualWord.toLowerCase().replace(/[^\w]/g, '');
|
| 439 |
+
let wordIndex = wordsLower.findIndex((word, idx) => {
|
| 440 |
+
const originalWord = words[idx].replace(/[^\w]/g, '');
|
| 441 |
+
const isCapitalized = originalWord.length > 0 && originalWord[0] === originalWord[0].toUpperCase();
|
| 442 |
+
return word === cleanManual && !selectedIndices.includes(idx) && idx >= 10 && !isCapitalized;
|
| 443 |
+
});
|
| 444 |
+
|
| 445 |
+
// If not found, try allowing capitalized words
|
| 446 |
+
if (wordIndex === -1) {
|
| 447 |
+
wordIndex = wordsLower.findIndex((word, idx) => {
|
| 448 |
+
return word === cleanManual && !selectedIndices.includes(idx) && idx >= 5;
|
| 449 |
+
});
|
| 450 |
+
}
|
| 451 |
+
|
| 452 |
+
if (wordIndex !== -1) {
|
| 453 |
+
selectedIndices.push(wordIndex);
|
| 454 |
+
}
|
| 455 |
+
});
|
| 456 |
+
|
| 457 |
+
}
|
| 458 |
+
|
| 459 |
+
// Sort indices for easier processing
|
| 460 |
+
selectedIndices.sort((a, b) => a - b);
|
| 461 |
+
|
| 462 |
+
// Final safety check - if still no words found, pick random content words (avoiding first 10)
|
| 463 |
+
if (selectedIndices.length === 0) {
|
| 464 |
+
console.error('Critical: No words could be selected, using emergency fallback');
|
| 465 |
+
const contentWords = words.map((word, idx) => ({ word: word.toLowerCase().replace(/[^\w]/g, ''), idx }))
|
| 466 |
+
.filter(item => item.word.length > 3 && !['the', 'and', 'but', 'for', 'are', 'was'].includes(item.word) && item.idx >= 10)
|
| 467 |
+
.slice(0, numberOfBlanks);
|
| 468 |
+
|
| 469 |
+
selectedIndices.push(...contentWords.map(item => item.idx));
|
| 470 |
+
}
|
| 471 |
+
|
| 472 |
+
// Create blanks array and cloze text
|
| 473 |
+
this.blanks = [];
|
| 474 |
+
this.hints = [];
|
| 475 |
+
const clozeWords = [...words];
|
| 476 |
+
|
| 477 |
+
for (let i = 0; i < selectedIndices.length; i++) {
|
| 478 |
+
const index = selectedIndices[i];
|
| 479 |
+
const originalWord = words[index];
|
| 480 |
+
const cleanWord = originalWord.replace(/[^\w]/g, '');
|
| 481 |
+
|
| 482 |
+
const blankData = {
|
| 483 |
+
index: i,
|
| 484 |
+
originalWord: cleanWord,
|
| 485 |
+
wordIndex: index
|
| 486 |
+
};
|
| 487 |
+
|
| 488 |
+
this.blanks.push(blankData);
|
| 489 |
+
|
| 490 |
+
// Initialize chat context for this word
|
| 491 |
+
const wordContext = {
|
| 492 |
+
originalWord: cleanWord,
|
| 493 |
+
sentence: this.originalText,
|
| 494 |
+
passage: this.originalText,
|
| 495 |
+
bookTitle: this.currentBook.title,
|
| 496 |
+
author: this.currentBook.author,
|
| 497 |
+
year: this.currentBook.year,
|
| 498 |
+
wordPosition: index,
|
| 499 |
+
difficulty: this.calculateWordDifficulty(cleanWord, index, words)
|
| 500 |
+
};
|
| 501 |
+
|
| 502 |
+
this.chatService.initializeWordContext(`blank_${i}`, wordContext);
|
| 503 |
+
|
| 504 |
+
// Generate structural hint based on level
|
| 505 |
+
let structuralHint;
|
| 506 |
+
if (this.currentLevel <= 2) {
|
| 507 |
+
// Levels 1-2: show length, first letter, and last letter
|
| 508 |
+
structuralHint = `${cleanWord.length} letters, starts with "${cleanWord[0]}", ends with "${cleanWord[cleanWord.length - 1]}"`;
|
| 509 |
+
} else {
|
| 510 |
+
// Level 3+: show length and first letter only
|
| 511 |
+
structuralHint = `${cleanWord.length} letters, starts with "${cleanWord[0]}"`;
|
| 512 |
+
}
|
| 513 |
+
this.hints.push({ index: i, hint: structuralHint });
|
| 514 |
+
|
| 515 |
+
// Replace word with input field placeholder
|
| 516 |
+
clozeWords[index] = `___BLANK_${i}___`;
|
| 517 |
+
}
|
| 518 |
+
|
| 519 |
+
this.clozeText = clozeWords.join(' ');
|
| 520 |
+
this.userAnswers = new Array(this.blanks.length).fill('');
|
| 521 |
+
|
| 522 |
+
// Debug: Log the created cloze text
|
| 523 |
+
|
| 524 |
+
return true; // Return success indicator
|
| 525 |
+
}
|
| 526 |
+
|
| 527 |
+
dividePassageIntoSections(totalWords, numberOfBlanks) {
|
| 528 |
+
const sections = [];
|
| 529 |
+
const sectionSize = Math.floor(totalWords / numberOfBlanks);
|
| 530 |
+
|
| 531 |
+
for (let i = 0; i < numberOfBlanks; i++) {
|
| 532 |
+
const start = i * sectionSize;
|
| 533 |
+
const end = i === numberOfBlanks - 1 ? totalWords : (i + 1) * sectionSize;
|
| 534 |
+
sections.push({ start, end });
|
| 535 |
+
}
|
| 536 |
+
|
| 537 |
+
return sections;
|
| 538 |
+
}
|
| 539 |
+
|
| 540 |
+
selectWordsManually(words, numberOfBlanks) {
|
| 541 |
+
// Fallback manual word selection - avoid function words completely
|
| 542 |
+
const functionWords = new Set([
|
| 543 |
+
// Articles
|
| 544 |
+
'the', 'a', 'an',
|
| 545 |
+
// Prepositions
|
| 546 |
+
'in', 'on', 'at', 'to', 'for', 'of', 'with', 'by', 'from', 'up', 'about', 'into', 'over', 'after',
|
| 547 |
+
// Conjunctions
|
| 548 |
+
'and', 'or', 'but', 'so', 'yet', 'nor', 'because', 'since', 'although', 'if', 'when', 'while',
|
| 549 |
+
// Pronouns
|
| 550 |
+
'i', 'you', 'he', 'she', 'it', 'we', 'they', 'me', 'him', 'her', 'us', 'them', 'my', 'your', 'his', 'her', 'its', 'our', 'their',
|
| 551 |
+
'this', 'that', 'these', 'those', 'who', 'what', 'which', 'whom', 'whose',
|
| 552 |
+
// Auxiliary verbs
|
| 553 |
+
'is', 'are', 'was', 'were', 'be', 'been', 'being', 'have', 'has', 'had', 'do', 'does', 'did',
|
| 554 |
+
'will', 'would', 'could', 'should', 'may', 'might', 'must', 'can', 'shall'
|
| 555 |
+
]);
|
| 556 |
+
|
| 557 |
+
// Get content words with their indices for better distribution
|
| 558 |
+
const contentWordIndices = [];
|
| 559 |
+
words.forEach((word, index) => {
|
| 560 |
+
const cleanWord = word.toLowerCase().replace(/[^\w]/g, '');
|
| 561 |
+
const originalCleanWord = word.replace(/[^\w]/g, '');
|
| 562 |
+
// Skip capitalized words, function words, and words that are too short/long
|
| 563 |
+
if (cleanWord.length > 3 && cleanWord.length <= 12 &&
|
| 564 |
+
!functionWords.has(cleanWord) &&
|
| 565 |
+
originalCleanWord[0] === originalCleanWord[0].toLowerCase()) {
|
| 566 |
+
contentWordIndices.push({ word: cleanWord, index });
|
| 567 |
+
}
|
| 568 |
+
});
|
| 569 |
+
|
| 570 |
+
// Distribute selection across sections
|
| 571 |
+
const passageSections = this.dividePassageIntoSections(words.length, numberOfBlanks);
|
| 572 |
+
const selectedWords = [];
|
| 573 |
+
|
| 574 |
+
for (let i = 0; i < numberOfBlanks && i < passageSections.length; i++) {
|
| 575 |
+
const section = passageSections[i];
|
| 576 |
+
const sectionWords = contentWordIndices.filter(item =>
|
| 577 |
+
item.index >= section.start && item.index < section.end
|
| 578 |
+
);
|
| 579 |
+
|
| 580 |
+
if (sectionWords.length > 0) {
|
| 581 |
+
const randomIndex = Math.floor(Math.random() * sectionWords.length);
|
| 582 |
+
selectedWords.push(sectionWords[randomIndex].word);
|
| 583 |
+
}
|
| 584 |
+
}
|
| 585 |
+
|
| 586 |
+
// Fill remaining slots if needed
|
| 587 |
+
while (selectedWords.length < numberOfBlanks && contentWordIndices.length > 0) {
|
| 588 |
+
const availableWords = contentWordIndices
|
| 589 |
+
.map(item => item.word)
|
| 590 |
+
.filter(word => !selectedWords.includes(word));
|
| 591 |
+
|
| 592 |
+
if (availableWords.length > 0) {
|
| 593 |
+
const randomIndex = Math.floor(Math.random() * availableWords.length);
|
| 594 |
+
selectedWords.push(availableWords[randomIndex]);
|
| 595 |
+
} else {
|
| 596 |
+
break;
|
| 597 |
+
}
|
| 598 |
+
}
|
| 599 |
+
|
| 600 |
+
return selectedWords;
|
| 601 |
+
}
|
| 602 |
+
|
| 603 |
+
async generateContextualization() {
|
| 604 |
+
// Always use AI for contextualization
|
| 605 |
+
try {
|
| 606 |
+
this.contextualization = await aiService.generateContextualization(
|
| 607 |
+
this.currentBook.title,
|
| 608 |
+
this.currentBook.author,
|
| 609 |
+
this.originalText
|
| 610 |
+
);
|
| 611 |
+
return this.contextualization;
|
| 612 |
+
} catch (error) {
|
| 613 |
+
console.warn('AI contextualization failed, using fallback:', error);
|
| 614 |
+
this.contextualization = `"${this.currentBook.title}" by ${this.currentBook.author} - A classic work of literature.`;
|
| 615 |
+
return this.contextualization;
|
| 616 |
+
}
|
| 617 |
+
}
|
| 618 |
+
|
| 619 |
+
renderClozeText() {
|
| 620 |
+
let html = this.clozeText;
|
| 621 |
+
|
| 622 |
+
this.blanks.forEach((blank, index) => {
|
| 623 |
+
const inputHtml = `<input type="text"
|
| 624 |
+
class="cloze-input"
|
| 625 |
+
data-blank-index="${index}"
|
| 626 |
+
placeholder="${'_'.repeat(Math.max(3, blank.originalWord.length))}"
|
| 627 |
+
style="width: ${Math.max(50, blank.originalWord.length * 10)}px;">`;
|
| 628 |
+
|
| 629 |
+
html = html.replace(`___BLANK_${index}___`, inputHtml);
|
| 630 |
+
});
|
| 631 |
+
|
| 632 |
+
return html;
|
| 633 |
+
}
|
| 634 |
+
|
| 635 |
+
submitAnswers(answers, forceComplete = false) {
|
| 636 |
+
this.userAnswers = answers;
|
| 637 |
+
let correctCount = 0;
|
| 638 |
+
let newlyCorrectCount = 0;
|
| 639 |
+
const results = [];
|
| 640 |
+
const retryableIndices = [];
|
| 641 |
+
|
| 642 |
+
this.blanks.forEach((blank, index) => {
|
| 643 |
+
// Skip already locked (correct) blanks
|
| 644 |
+
if (this.lockedBlanks.has(index)) {
|
| 645 |
+
correctCount++;
|
| 646 |
+
results.push({
|
| 647 |
+
blankIndex: index,
|
| 648 |
+
userAnswer: answers[index],
|
| 649 |
+
correctAnswer: blank.originalWord,
|
| 650 |
+
isCorrect: true,
|
| 651 |
+
isLocked: true,
|
| 652 |
+
attemptNumber: this.attemptCounts[index] || 1,
|
| 653 |
+
attemptedThisRound: false
|
| 654 |
+
});
|
| 655 |
+
return;
|
| 656 |
+
}
|
| 657 |
+
|
| 658 |
+
// Track attempt for this blank
|
| 659 |
+
if (!this.attemptCounts[index]) {
|
| 660 |
+
this.attemptCounts[index] = 0;
|
| 661 |
+
}
|
| 662 |
+
this.attemptCounts[index]++;
|
| 663 |
+
|
| 664 |
+
const userAnswer = answers[index].trim().toLowerCase();
|
| 665 |
+
const correctAnswer = blank.originalWord.toLowerCase();
|
| 666 |
+
const isCorrect = userAnswer === correctAnswer;
|
| 667 |
+
|
| 668 |
+
if (isCorrect) {
|
| 669 |
+
correctCount++;
|
| 670 |
+
newlyCorrectCount++;
|
| 671 |
+
this.lockedBlanks.add(index); // Lock this blank
|
| 672 |
+
} else {
|
| 673 |
+
retryableIndices.push(index);
|
| 674 |
+
}
|
| 675 |
+
|
| 676 |
+
results.push({
|
| 677 |
+
blankIndex: index,
|
| 678 |
+
userAnswer: answers[index],
|
| 679 |
+
correctAnswer: blank.originalWord,
|
| 680 |
+
isCorrect,
|
| 681 |
+
isLocked: isCorrect, // Newly correct answers get locked
|
| 682 |
+
attemptNumber: this.attemptCounts[index],
|
| 683 |
+
attemptedThisRound: true
|
| 684 |
+
});
|
| 685 |
+
});
|
| 686 |
+
|
| 687 |
+
const scorePercentage = Math.round((correctCount / this.blanks.length) * 100);
|
| 688 |
+
this.score = scorePercentage;
|
| 689 |
+
|
| 690 |
+
// Calculate pass requirements based on number of blanks
|
| 691 |
+
const totalBlanks = this.blanks.length;
|
| 692 |
+
const requiredCorrect = this.calculateRequiredCorrect(totalBlanks);
|
| 693 |
+
|
| 694 |
+
// Check if all blanks are correct
|
| 695 |
+
const allCorrect = correctCount === totalBlanks;
|
| 696 |
+
|
| 697 |
+
// Check if we've exceeded max retries
|
| 698 |
+
const maxAttemptsReached = Object.values(this.attemptCounts).some(
|
| 699 |
+
count => count >= this.maxRetries
|
| 700 |
+
);
|
| 701 |
+
|
| 702 |
+
// Determine if user can retry
|
| 703 |
+
// Can retry if: not all correct, not forced complete, and under max retries
|
| 704 |
+
const canRetry = !allCorrect && !forceComplete && !maxAttemptsReached;
|
| 705 |
+
|
| 706 |
+
// Only finalize (pass/fail) if no retry possible
|
| 707 |
+
const isFinal = !canRetry;
|
| 708 |
+
const passed = allCorrect || (correctCount >= requiredCorrect && isFinal);
|
| 709 |
+
|
| 710 |
+
// Track if we're advancing level (only on final submission)
|
| 711 |
+
let justAdvancedLevel = false;
|
| 712 |
+
|
| 713 |
+
if (isFinal) {
|
| 714 |
+
if (passed) {
|
| 715 |
+
const previousLevel = this.currentLevel;
|
| 716 |
+
this.currentLevel++;
|
| 717 |
+
justAdvancedLevel = true;
|
| 718 |
+
} else {
|
| 719 |
+
}
|
| 720 |
+
} else {
|
| 721 |
+
}
|
| 722 |
+
|
| 723 |
+
const snapshot = this.getProgressSnapshot();
|
| 724 |
+
|
| 725 |
+
// Update passage tracking (only on final)
|
| 726 |
+
const stats = this.leaderboardService.getStats();
|
| 727 |
+
const totalPassagesPassed = (passed && isFinal) ? (stats.totalPassagesPassed + 1) : stats.totalPassagesPassed;
|
| 728 |
+
|
| 729 |
+
const resultsData = {
|
| 730 |
+
correct: correctCount,
|
| 731 |
+
total: this.blanks.length,
|
| 732 |
+
percentage: scorePercentage,
|
| 733 |
+
passed: passed,
|
| 734 |
+
results,
|
| 735 |
+
canAdvanceLevel: passed && isFinal,
|
| 736 |
+
shouldRevealAnswers: isFinal && !passed,
|
| 737 |
+
requiredCorrect: requiredCorrect,
|
| 738 |
+
currentLevel: this.currentLevel,
|
| 739 |
+
justAdvancedLevel: justAdvancedLevel,
|
| 740 |
+
round: snapshot.round,
|
| 741 |
+
passagesPassed: totalPassagesPassed,
|
| 742 |
+
progressText: this.formatProgressText(snapshot),
|
| 743 |
+
feedbackText: canRetry
|
| 744 |
+
? `${correctCount}/${totalBlanks} correct • Retry the highlighted blank${retryableIndices.length > 1 ? 's' : ''}`
|
| 745 |
+
: this.formatAdvancementText({ passed, correctCount, requiredCorrect, justAdvancedLevel }),
|
| 746 |
+
|
| 747 |
+
// Retry system fields
|
| 748 |
+
canRetry,
|
| 749 |
+
retryableIndices,
|
| 750 |
+
isFinal,
|
| 751 |
+
attemptCounts: { ...this.attemptCounts },
|
| 752 |
+
lockedIndices: Array.from(this.lockedBlanks),
|
| 753 |
+
maxRetriesReached: maxAttemptsReached
|
| 754 |
+
};
|
| 755 |
+
|
| 756 |
+
// Update leaderboard stats only on final submission
|
| 757 |
+
if (isFinal) {
|
| 758 |
+
this.leaderboardService.updateStats(resultsData);
|
| 759 |
+
}
|
| 760 |
+
|
| 761 |
+
// Store results for potential answer revelation
|
| 762 |
+
this.lastResults = resultsData;
|
| 763 |
+
|
| 764 |
+
return resultsData;
|
| 765 |
+
}
|
| 766 |
+
|
| 767 |
+
/**
|
| 768 |
+
* Force complete the current passage (skip remaining retries)
|
| 769 |
+
* Called when user wants to give up and see answers
|
| 770 |
+
*/
|
| 771 |
+
forceCompletePassage() {
|
| 772 |
+
return this.submitAnswers(this.userAnswers, true);
|
| 773 |
+
}
|
| 774 |
+
|
| 775 |
+
/**
|
| 776 |
+
* Check if a specific blank is locked (already correct)
|
| 777 |
+
*/
|
| 778 |
+
isBlankLocked(blankIndex) {
|
| 779 |
+
return this.lockedBlanks.has(blankIndex);
|
| 780 |
+
}
|
| 781 |
+
|
| 782 |
+
/**
|
| 783 |
+
* Get current attempt count for a blank
|
| 784 |
+
*/
|
| 785 |
+
getAttemptCount(blankIndex) {
|
| 786 |
+
return this.attemptCounts[blankIndex] || 0;
|
| 787 |
+
}
|
| 788 |
+
|
| 789 |
+
/**
|
| 790 |
+
* Get analytics data for the current passage attempt
|
| 791 |
+
* Used by AnalyticsService to record data
|
| 792 |
+
*/
|
| 793 |
+
getAnalyticsData() {
|
| 794 |
+
if (!this.currentBook || !this.blanks.length) return null;
|
| 795 |
+
|
| 796 |
+
return {
|
| 797 |
+
bookTitle: this.currentBook.title,
|
| 798 |
+
bookAuthor: this.currentBook.author,
|
| 799 |
+
level: this.currentLevel,
|
| 800 |
+
round: this.currentRound,
|
| 801 |
+
blanks: this.blanks.map((blank, index) => ({
|
| 802 |
+
word: blank.originalWord,
|
| 803 |
+
length: blank.originalWord.length,
|
| 804 |
+
attemptsToCorrect: this.attemptCounts[index] || 0,
|
| 805 |
+
finalCorrect: this.lockedBlanks.has(index)
|
| 806 |
+
}))
|
| 807 |
+
};
|
| 808 |
+
}
|
| 809 |
+
|
| 810 |
+
// Calculate required correct answers based on total blanks
|
| 811 |
+
calculateRequiredCorrect(totalBlanks) {
|
| 812 |
+
if (totalBlanks === 1) {
|
| 813 |
+
// 1 blank: Must get it correct
|
| 814 |
+
return 1;
|
| 815 |
+
} else if (totalBlanks === 2) {
|
| 816 |
+
// 2 blanks: Need both correct (keeps current Level 6-10 difficulty)
|
| 817 |
+
return 2;
|
| 818 |
+
} else {
|
| 819 |
+
// 3+ blanks: Need all but one (fixes Level 11+ to be harder than Level 10)
|
| 820 |
+
return totalBlanks - 1;
|
| 821 |
+
}
|
| 822 |
+
}
|
| 823 |
+
|
| 824 |
+
showAnswers() {
|
| 825 |
+
return this.blanks.map(blank => ({
|
| 826 |
+
index: blank.index,
|
| 827 |
+
word: blank.originalWord
|
| 828 |
+
}));
|
| 829 |
+
}
|
| 830 |
+
|
| 831 |
+
nextRound() {
|
| 832 |
+
// Always increment round counter
|
| 833 |
+
this.currentRound++;
|
| 834 |
+
|
| 835 |
+
// Level advancement is now handled in submitAnswers() based on pass/fail
|
| 836 |
+
|
| 837 |
+
// Clear chat conversations for new round
|
| 838 |
+
this.chatService.clearConversations();
|
| 839 |
+
|
| 840 |
+
// Clear results since we're moving to new round
|
| 841 |
+
this.lastResults = null;
|
| 842 |
+
|
| 843 |
+
return this.startNewRound();
|
| 844 |
+
}
|
| 845 |
+
|
| 846 |
+
// Get answers for current round (for revelation when switching passages)
|
| 847 |
+
getCurrentAnswers() {
|
| 848 |
+
if (!this.lastResults) return null;
|
| 849 |
+
|
| 850 |
+
return {
|
| 851 |
+
hasResults: true,
|
| 852 |
+
passed: this.lastResults.passed,
|
| 853 |
+
shouldRevealAnswers: this.lastResults.shouldRevealAnswers,
|
| 854 |
+
currentLevel: this.lastResults.currentLevel,
|
| 855 |
+
requiredCorrect: this.lastResults.requiredCorrect,
|
| 856 |
+
answers: this.blanks.map(blank => ({
|
| 857 |
+
index: blank.index,
|
| 858 |
+
correctAnswer: blank.originalWord,
|
| 859 |
+
userAnswer: this.lastResults.results[blank.index]?.userAnswer || '',
|
| 860 |
+
isCorrect: this.lastResults.results[blank.index]?.isCorrect || false
|
| 861 |
+
}))
|
| 862 |
+
};
|
| 863 |
+
}
|
| 864 |
+
|
| 865 |
+
// Calculate difficulty of a word based on various factors
|
| 866 |
+
calculateWordDifficulty(word, position, allWords) {
|
| 867 |
+
let difficulty = 1;
|
| 868 |
+
|
| 869 |
+
// Length factor
|
| 870 |
+
if (word.length > 8) difficulty += 2;
|
| 871 |
+
else if (word.length > 5) difficulty += 1;
|
| 872 |
+
|
| 873 |
+
// Position factor (middle words might be harder)
|
| 874 |
+
const relativePosition = position / allWords.length;
|
| 875 |
+
if (relativePosition > 0.3 && relativePosition < 0.7) difficulty += 1;
|
| 876 |
+
|
| 877 |
+
// Complexity factors
|
| 878 |
+
if (word.includes('ing') || word.includes('ed')) difficulty += 0.5;
|
| 879 |
+
if (word.includes('tion') || word.includes('sion')) difficulty += 1;
|
| 880 |
+
|
| 881 |
+
// Current level factor
|
| 882 |
+
difficulty += (this.currentLevel - 1) * 0.5;
|
| 883 |
+
|
| 884 |
+
return Math.min(5, Math.max(1, Math.round(difficulty)));
|
| 885 |
+
}
|
| 886 |
+
|
| 887 |
+
// Simple, clean hint with just essential info based on level
|
| 888 |
+
generateContextualFallbackHint(word) {
|
| 889 |
+
if (this.currentLevel <= 2) {
|
| 890 |
+
return `${word.length} letters, starts with "${word[0]}", ends with "${word[word.length - 1]}"`;
|
| 891 |
+
} else {
|
| 892 |
+
return `${word.length} letters, starts with "${word[0]}"`;
|
| 893 |
+
}
|
| 894 |
+
}
|
| 895 |
+
|
| 896 |
+
// Chat functionality methods
|
| 897 |
+
async askQuestionAboutBlank(blankIndex, questionType, currentInput = '') {
|
| 898 |
+
const blankId = `blank_${blankIndex}`;
|
| 899 |
+
return await this.chatService.askQuestion(blankId, questionType, currentInput);
|
| 900 |
+
}
|
| 901 |
+
|
| 902 |
+
getSuggestedQuestionsForBlank(blankIndex) {
|
| 903 |
+
const blankId = `blank_${blankIndex}`;
|
| 904 |
+
return this.chatService.getSuggestedQuestions(blankId);
|
| 905 |
+
}
|
| 906 |
+
|
| 907 |
+
|
| 908 |
+
// Enhanced render method to include chat buttons
|
| 909 |
+
renderClozeTextWithChat() {
|
| 910 |
+
let html = this.clozeText;
|
| 911 |
+
|
| 912 |
+
this.blanks.forEach((blank, index) => {
|
| 913 |
+
const chatButtonId = `chat-btn-${index}`;
|
| 914 |
+
const inputHtml = `
|
| 915 |
+
<span class="inline-flex items-center">
|
| 916 |
+
<input type="text"
|
| 917 |
+
class="cloze-input"
|
| 918 |
+
data-blank-index="${index}"
|
| 919 |
+
placeholder="${'_'.repeat(Math.max(3, blank.originalWord.length))}"
|
| 920 |
+
style="width: ${Math.max(50, blank.originalWord.length * 10)}px;">
|
| 921 |
+
<button id="${chatButtonId}"
|
| 922 |
+
class="chat-button text-blue-500 hover:text-blue-700"
|
| 923 |
+
data-blank-index="${index}"
|
| 924 |
+
title="Ask question about this word"
|
| 925 |
+
style="font-size: 1.5rem; line-height: 1;">
|
| 926 |
+
💬
|
| 927 |
+
</button>
|
| 928 |
+
</span>`;
|
| 929 |
+
|
| 930 |
+
html = html.replace(`___BLANK_${index}___`, inputHtml);
|
| 931 |
+
});
|
| 932 |
+
|
| 933 |
+
return html;
|
| 934 |
+
}
|
| 935 |
+
|
| 936 |
+
// Leaderboard integration methods
|
| 937 |
+
checkForHighScore() {
|
| 938 |
+
const stats = this.leaderboardService.getStats();
|
| 939 |
+
return this.leaderboardService.qualifiesForLeaderboard(
|
| 940 |
+
stats.highestLevel,
|
| 941 |
+
stats.roundAtHighestLevel,
|
| 942 |
+
stats.totalPassagesPassed
|
| 943 |
+
);
|
| 944 |
+
}
|
| 945 |
+
|
| 946 |
+
getHighScoreRank() {
|
| 947 |
+
const stats = this.leaderboardService.getStats();
|
| 948 |
+
return this.leaderboardService.getRankForScore(
|
| 949 |
+
stats.highestLevel,
|
| 950 |
+
stats.roundAtHighestLevel,
|
| 951 |
+
stats.totalPassagesPassed
|
| 952 |
+
);
|
| 953 |
+
}
|
| 954 |
+
|
| 955 |
+
addToLeaderboard(initials) {
|
| 956 |
+
const stats = this.leaderboardService.getStats();
|
| 957 |
+
return this.leaderboardService.addEntry(
|
| 958 |
+
initials,
|
| 959 |
+
stats.highestLevel,
|
| 960 |
+
stats.roundAtHighestLevel,
|
| 961 |
+
stats.totalPassagesPassed
|
| 962 |
+
);
|
| 963 |
+
}
|
| 964 |
+
|
| 965 |
+
getLeaderboardStats() {
|
| 966 |
+
return this.leaderboardService.getPlayerStats();
|
| 967 |
+
}
|
| 968 |
+
}
|
| 969 |
+
|
| 970 |
+
export default ClozeGame;
|
src/conversationManager.js
ADDED
|
@@ -0,0 +1,165 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
// Chat service for contextual, personalized hints
|
| 2 |
+
class ChatService {
|
| 3 |
+
constructor(aiService) {
|
| 4 |
+
this.aiService = aiService;
|
| 5 |
+
this.conversations = new Map(); // blankId -> conversation history
|
| 6 |
+
this.wordContexts = new Map(); // blankId -> detailed context
|
| 7 |
+
this.blankQuestions = new Map(); // blankId -> Set of used question types (per-blank tracking)
|
| 8 |
+
this.currentLevel = 1; // Track current difficulty level
|
| 9 |
+
|
| 10 |
+
// Distinct, non-overlapping question set
|
| 11 |
+
this.questions = [
|
| 12 |
+
{ text: "What is its part of speech?", type: "part_of_speech" },
|
| 13 |
+
{ text: "What role does it play in the sentence?", type: "sentence_role" },
|
| 14 |
+
{ text: "Is it abstract or a person, place, or thing?", type: "word_category" },
|
| 15 |
+
{ text: "What is a synonym for this word?", type: "synonym" }
|
| 16 |
+
];
|
| 17 |
+
}
|
| 18 |
+
|
| 19 |
+
// Initialize chat context for a specific blank
|
| 20 |
+
initializeWordContext(blankId, wordData) {
|
| 21 |
+
const context = {
|
| 22 |
+
blankId,
|
| 23 |
+
targetWord: wordData.originalWord,
|
| 24 |
+
sentence: wordData.sentence,
|
| 25 |
+
fullPassage: wordData.passage,
|
| 26 |
+
bookTitle: wordData.bookTitle,
|
| 27 |
+
author: wordData.author,
|
| 28 |
+
year: wordData.year || null,
|
| 29 |
+
wordPosition: wordData.wordPosition,
|
| 30 |
+
difficulty: wordData.difficulty,
|
| 31 |
+
previousAttempts: [],
|
| 32 |
+
userQuestions: [],
|
| 33 |
+
hintLevel: 0 // Progressive hint difficulty
|
| 34 |
+
};
|
| 35 |
+
|
| 36 |
+
this.wordContexts.set(blankId, context);
|
| 37 |
+
this.conversations.set(blankId, []);
|
| 38 |
+
return context;
|
| 39 |
+
}
|
| 40 |
+
|
| 41 |
+
// Per-blank question tracking with level awareness
|
| 42 |
+
async askQuestion(blankId, questionType, userInput = '') {
|
| 43 |
+
const context = this.wordContexts.get(blankId);
|
| 44 |
+
|
| 45 |
+
if (!context) {
|
| 46 |
+
return {
|
| 47 |
+
error: true,
|
| 48 |
+
message: "Context not found for this word."
|
| 49 |
+
};
|
| 50 |
+
}
|
| 51 |
+
|
| 52 |
+
// Mark question as used for this specific blank
|
| 53 |
+
if (!this.blankQuestions.has(blankId)) {
|
| 54 |
+
this.blankQuestions.set(blankId, new Set());
|
| 55 |
+
}
|
| 56 |
+
this.blankQuestions.get(blankId).add(questionType);
|
| 57 |
+
|
| 58 |
+
try {
|
| 59 |
+
const response = await this.generateSpecificResponse(context, questionType, userInput);
|
| 60 |
+
return {
|
| 61 |
+
success: true,
|
| 62 |
+
response: response,
|
| 63 |
+
questionType: questionType
|
| 64 |
+
};
|
| 65 |
+
} catch (error) {
|
| 66 |
+
console.error('Chat error:', error);
|
| 67 |
+
return this.getSimpleFallback(context, questionType);
|
| 68 |
+
}
|
| 69 |
+
}
|
| 70 |
+
|
| 71 |
+
// Generate specific response based on question type
|
| 72 |
+
async generateSpecificResponse(context, questionType, userInput) {
|
| 73 |
+
const word = context.targetWord;
|
| 74 |
+
const sentence = context.sentence;
|
| 75 |
+
const bookTitle = context.bookTitle;
|
| 76 |
+
const author = context.author;
|
| 77 |
+
|
| 78 |
+
// Create sentence with blank for context, but tell AI the actual word
|
| 79 |
+
const sentenceWithBlank = sentence.replace(new RegExp(`\\b${word}\\b`, 'gi'), '____');
|
| 80 |
+
|
| 81 |
+
try {
|
| 82 |
+
// Build focused prompt that includes the target word but forbids revealing it
|
| 83 |
+
const prompt = this.buildFocusedPrompt({
|
| 84 |
+
...context,
|
| 85 |
+
sentence: sentenceWithBlank,
|
| 86 |
+
targetWord: word
|
| 87 |
+
}, questionType, userInput);
|
| 88 |
+
|
| 89 |
+
// Use the AI service as a simple API wrapper
|
| 90 |
+
const aiResponse = await this.aiService.generateContextualHint(prompt);
|
| 91 |
+
|
| 92 |
+
if (aiResponse && typeof aiResponse === 'string' && aiResponse.length > 10) {
|
| 93 |
+
return aiResponse;
|
| 94 |
+
}
|
| 95 |
+
} catch (error) {
|
| 96 |
+
console.warn('AI response failed:', error);
|
| 97 |
+
}
|
| 98 |
+
|
| 99 |
+
// Fallback - return simple fallback response
|
| 100 |
+
return this.getSimpleFallback(context, questionType);
|
| 101 |
+
}
|
| 102 |
+
|
| 103 |
+
// Build focused prompt for specific question types
|
| 104 |
+
buildFocusedPrompt(context, questionType, userInput) {
|
| 105 |
+
const { sentence, bookTitle, author, targetWord, year } = context;
|
| 106 |
+
const yearPrefix = year ? `Published in ${year}, ` : '';
|
| 107 |
+
const baseContext = `${yearPrefix}from "${bookTitle}" by ${author}: "${sentence}"`;
|
| 108 |
+
const safetyRule = `Important: The hidden word is "${targetWord}". Never say this word directly - use "it," "this word," or "the word" instead.`;
|
| 109 |
+
|
| 110 |
+
const prompts = {
|
| 111 |
+
part_of_speech: `${baseContext}\n\n${safetyRule}\n\nIdentify the part of speech and share one interesting grammar tip about this type of word. Keep it conversational and under 25 words.\nExample: "It's a verb! These words show action or states of being, like 'run' or 'exist'."`,
|
| 112 |
+
|
| 113 |
+
sentence_role: `${baseContext}\n\n${safetyRule}\n\nExplain what role this word plays in the sentence - what's its job here? Be specific to THIS sentence. Keep it under 20 words and conversational.\nExample: "Here it connects two ideas, showing how one thing relates to another."`,
|
| 114 |
+
|
| 115 |
+
word_category: `${baseContext}\n\n${safetyRule}\n\nWhat general category does this word belong to? Think broadly - is it about people, things, actions, qualities, feelings, places, or ideas? Explain briefly in under 20 words.\nExample: "This word fits in the 'qualities' category - it describes how something looks or feels."`,
|
| 116 |
+
|
| 117 |
+
synonym: `${baseContext}\n\n${safetyRule}\n\nSuggest a word that could replace it in this sentence. Pick something simple and explain why it works. Under 15 words.\nExample: "You could use 'bright' here - it captures the same feeling of intensity."`
|
| 118 |
+
};
|
| 119 |
+
|
| 120 |
+
return prompts[questionType] || `${baseContext}\n\n${safetyRule}\n\nProvide a helpful hint about "${targetWord}" without revealing it.`;
|
| 121 |
+
}
|
| 122 |
+
|
| 123 |
+
// Simple fallback responses
|
| 124 |
+
getSimpleFallback(context, questionType) {
|
| 125 |
+
const fallbacks = {
|
| 126 |
+
part_of_speech: "Look at the surrounding words. Is it describing something, showing action, or naming something?",
|
| 127 |
+
sentence_role: "Consider how this word connects to the other parts of the sentence.",
|
| 128 |
+
word_category: "Think about whether this represents something concrete or an abstract idea.",
|
| 129 |
+
synonym: "What other word could fit in this same spot with similar meaning?"
|
| 130 |
+
};
|
| 131 |
+
|
| 132 |
+
return fallbacks[questionType] || "Consider the context and what word would make sense here.";
|
| 133 |
+
}
|
| 134 |
+
|
| 135 |
+
|
| 136 |
+
// Clear conversations and reset tracking
|
| 137 |
+
clearConversations() {
|
| 138 |
+
this.conversations.clear();
|
| 139 |
+
this.wordContexts.clear();
|
| 140 |
+
this.blankQuestions.clear();
|
| 141 |
+
}
|
| 142 |
+
|
| 143 |
+
// Set current level for question selection
|
| 144 |
+
setLevel(level) {
|
| 145 |
+
this.currentLevel = level;
|
| 146 |
+
}
|
| 147 |
+
|
| 148 |
+
// Get suggested questions for a specific blank
|
| 149 |
+
getSuggestedQuestions(blankId) {
|
| 150 |
+
const usedQuestions = this.blankQuestions.get(blankId) || new Set();
|
| 151 |
+
|
| 152 |
+
return this.questions.map(q => ({
|
| 153 |
+
...q,
|
| 154 |
+
used: usedQuestions.has(q.type)
|
| 155 |
+
}));
|
| 156 |
+
}
|
| 157 |
+
|
| 158 |
+
// Reset for new game (clears everything including across-game state)
|
| 159 |
+
resetForNewGame() {
|
| 160 |
+
this.clearConversations();
|
| 161 |
+
this.currentLevel = 1;
|
| 162 |
+
}
|
| 163 |
+
}
|
| 164 |
+
|
| 165 |
+
export default ChatService;
|
src/hfLeaderboardAPI.js
ADDED
|
@@ -0,0 +1,295 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
/**
|
| 2 |
+
* Leaderboard API Client
|
| 3 |
+
* Communicates with FastAPI backend (Redis primary, HF Space fallback)
|
| 4 |
+
* Supports near real-time polling for live updates
|
| 5 |
+
*/
|
| 6 |
+
|
| 7 |
+
export class HFLeaderboardAPI {
|
| 8 |
+
constructor(baseUrl = '') {
|
| 9 |
+
// HF Space URL (used as fallback and for GitHub Pages hosting)
|
| 10 |
+
const HF_LEADERBOARD_SPACE = 'https://milwright-cloze-leaderboard.hf.space';
|
| 11 |
+
|
| 12 |
+
// For local development, use local server
|
| 13 |
+
// For production (Railway), use same origin (backend serves frontend)
|
| 14 |
+
// For GitHub Pages, fall back to HF Space
|
| 15 |
+
const isLocalDev = window.location.hostname === 'localhost' ||
|
| 16 |
+
window.location.hostname === '127.0.0.1';
|
| 17 |
+
const isGitHubPages = window.location.hostname.includes('github.io');
|
| 18 |
+
|
| 19 |
+
if (baseUrl) {
|
| 20 |
+
this.baseUrl = baseUrl;
|
| 21 |
+
} else if (isLocalDev) {
|
| 22 |
+
this.baseUrl = window.location.origin;
|
| 23 |
+
} else if (isGitHubPages) {
|
| 24 |
+
this.baseUrl = HF_LEADERBOARD_SPACE;
|
| 25 |
+
} else {
|
| 26 |
+
// Railway or other hosting: use same origin (FastAPI serves both)
|
| 27 |
+
this.baseUrl = window.location.origin;
|
| 28 |
+
}
|
| 29 |
+
|
| 30 |
+
// Polling state
|
| 31 |
+
this.pollInterval = null;
|
| 32 |
+
this.pollIntervalMs = 5000; // 5 seconds default
|
| 33 |
+
this.listeners = new Set();
|
| 34 |
+
this.lastLeaderboard = null;
|
| 35 |
+
}
|
| 36 |
+
|
| 37 |
+
/**
|
| 38 |
+
* Start polling for leaderboard updates
|
| 39 |
+
* @param {number} intervalMs - Polling interval in milliseconds (default: 5000)
|
| 40 |
+
*/
|
| 41 |
+
startPolling(intervalMs = 5000) {
|
| 42 |
+
if (this.pollInterval) {
|
| 43 |
+
return;
|
| 44 |
+
}
|
| 45 |
+
|
| 46 |
+
this.pollIntervalMs = intervalMs;
|
| 47 |
+
|
| 48 |
+
// Initial fetch
|
| 49 |
+
this._pollOnce();
|
| 50 |
+
|
| 51 |
+
// Set up interval
|
| 52 |
+
this.pollInterval = setInterval(() => {
|
| 53 |
+
this._pollOnce();
|
| 54 |
+
}, intervalMs);
|
| 55 |
+
}
|
| 56 |
+
|
| 57 |
+
/**
|
| 58 |
+
* Stop polling for updates
|
| 59 |
+
*/
|
| 60 |
+
stopPolling() {
|
| 61 |
+
if (this.pollInterval) {
|
| 62 |
+
clearInterval(this.pollInterval);
|
| 63 |
+
this.pollInterval = null;
|
| 64 |
+
}
|
| 65 |
+
}
|
| 66 |
+
|
| 67 |
+
/**
|
| 68 |
+
* Check if polling is currently active
|
| 69 |
+
* @returns {boolean}
|
| 70 |
+
*/
|
| 71 |
+
isPolling() {
|
| 72 |
+
return this.pollInterval !== null;
|
| 73 |
+
}
|
| 74 |
+
|
| 75 |
+
/**
|
| 76 |
+
* Subscribe to leaderboard updates
|
| 77 |
+
* @param {Function} callback - Called with leaderboard data when updates occur
|
| 78 |
+
* @returns {Function} Unsubscribe function
|
| 79 |
+
*/
|
| 80 |
+
onUpdate(callback) {
|
| 81 |
+
this.listeners.add(callback);
|
| 82 |
+
|
| 83 |
+
// If we have cached data, call immediately
|
| 84 |
+
if (this.lastLeaderboard) {
|
| 85 |
+
callback(this.lastLeaderboard);
|
| 86 |
+
}
|
| 87 |
+
|
| 88 |
+
// Return unsubscribe function
|
| 89 |
+
return () => {
|
| 90 |
+
this.listeners.delete(callback);
|
| 91 |
+
};
|
| 92 |
+
}
|
| 93 |
+
|
| 94 |
+
/**
|
| 95 |
+
* Internal: Fetch leaderboard and notify listeners if changed
|
| 96 |
+
*/
|
| 97 |
+
async _pollOnce() {
|
| 98 |
+
try {
|
| 99 |
+
const leaderboard = await this.getLeaderboard();
|
| 100 |
+
|
| 101 |
+
// Check if data changed (simple JSON comparison)
|
| 102 |
+
const newData = JSON.stringify(leaderboard);
|
| 103 |
+
const oldData = JSON.stringify(this.lastLeaderboard);
|
| 104 |
+
|
| 105 |
+
if (newData !== oldData) {
|
| 106 |
+
this.lastLeaderboard = leaderboard;
|
| 107 |
+
this._notifyListeners(leaderboard);
|
| 108 |
+
}
|
| 109 |
+
} catch (error) {
|
| 110 |
+
// Silent fail for polling - don't spam console
|
| 111 |
+
console.debug('⏱️ Leaderboard: Poll failed (will retry)', error.message);
|
| 112 |
+
}
|
| 113 |
+
}
|
| 114 |
+
|
| 115 |
+
/**
|
| 116 |
+
* Internal: Notify all listeners of leaderboard update
|
| 117 |
+
*/
|
| 118 |
+
_notifyListeners(leaderboard) {
|
| 119 |
+
for (const callback of this.listeners) {
|
| 120 |
+
try {
|
| 121 |
+
callback(leaderboard);
|
| 122 |
+
} catch (error) {
|
| 123 |
+
console.error('⏱️ Leaderboard: Listener error', error);
|
| 124 |
+
}
|
| 125 |
+
}
|
| 126 |
+
}
|
| 127 |
+
|
| 128 |
+
/**
|
| 129 |
+
* Get leaderboard from backend
|
| 130 |
+
* @returns {Promise<Array>} Array of leaderboard entries
|
| 131 |
+
*/
|
| 132 |
+
async getLeaderboard() {
|
| 133 |
+
try {
|
| 134 |
+
const response = await fetch(`${this.baseUrl}/api/leaderboard`, {
|
| 135 |
+
method: 'GET',
|
| 136 |
+
headers: {
|
| 137 |
+
'Content-Type': 'application/json'
|
| 138 |
+
}
|
| 139 |
+
});
|
| 140 |
+
|
| 141 |
+
if (!response.ok) {
|
| 142 |
+
throw new Error(`HTTP ${response.status}: ${response.statusText}`);
|
| 143 |
+
}
|
| 144 |
+
|
| 145 |
+
const data = await response.json();
|
| 146 |
+
|
| 147 |
+
if (data.success) {
|
| 148 |
+
console.debug('📥 Leaderboard API: Retrieved', {
|
| 149 |
+
entries: data.leaderboard.length,
|
| 150 |
+
message: data.message
|
| 151 |
+
});
|
| 152 |
+
return data.leaderboard;
|
| 153 |
+
} else {
|
| 154 |
+
throw new Error(data.message || 'Failed to retrieve leaderboard');
|
| 155 |
+
}
|
| 156 |
+
} catch (error) {
|
| 157 |
+
console.error('❌ Leaderboard API: Error fetching:', error);
|
| 158 |
+
throw error;
|
| 159 |
+
}
|
| 160 |
+
}
|
| 161 |
+
|
| 162 |
+
/**
|
| 163 |
+
* Add new entry to leaderboard
|
| 164 |
+
* @param {Object} entry - Leaderboard entry {initials, level, round, passagesPassed, date}
|
| 165 |
+
* @returns {Promise<Object>} Response object
|
| 166 |
+
*/
|
| 167 |
+
async addEntry(entry) {
|
| 168 |
+
try {
|
| 169 |
+
const response = await fetch(`${this.baseUrl}/api/leaderboard/add`, {
|
| 170 |
+
method: 'POST',
|
| 171 |
+
headers: {
|
| 172 |
+
'Content-Type': 'application/json'
|
| 173 |
+
},
|
| 174 |
+
body: JSON.stringify(entry)
|
| 175 |
+
});
|
| 176 |
+
|
| 177 |
+
if (!response.ok) {
|
| 178 |
+
const errorData = await response.json().catch(() => ({ detail: response.statusText }));
|
| 179 |
+
throw new Error(`HTTP ${response.status}: ${errorData.detail || response.statusText}`);
|
| 180 |
+
}
|
| 181 |
+
|
| 182 |
+
const data = await response.json();
|
| 183 |
+
|
| 184 |
+
console.log('✅ Leaderboard API: Entry added', {
|
| 185 |
+
initials: entry.initials,
|
| 186 |
+
level: entry.level,
|
| 187 |
+
message: data.message
|
| 188 |
+
});
|
| 189 |
+
|
| 190 |
+
// Trigger immediate poll to refresh data
|
| 191 |
+
if (this.pollInterval) {
|
| 192 |
+
this._pollOnce();
|
| 193 |
+
}
|
| 194 |
+
|
| 195 |
+
return data;
|
| 196 |
+
} catch (error) {
|
| 197 |
+
console.error('❌ Leaderboard API: Error adding entry:', error);
|
| 198 |
+
throw error;
|
| 199 |
+
}
|
| 200 |
+
}
|
| 201 |
+
|
| 202 |
+
/**
|
| 203 |
+
* Update entire leaderboard
|
| 204 |
+
* @param {Array} entries - Array of leaderboard entries
|
| 205 |
+
* @returns {Promise<Object>} Response object
|
| 206 |
+
*/
|
| 207 |
+
async updateLeaderboard(entries) {
|
| 208 |
+
try {
|
| 209 |
+
const response = await fetch(`${this.baseUrl}/api/leaderboard/update`, {
|
| 210 |
+
method: 'POST',
|
| 211 |
+
headers: {
|
| 212 |
+
'Content-Type': 'application/json'
|
| 213 |
+
},
|
| 214 |
+
body: JSON.stringify(entries)
|
| 215 |
+
});
|
| 216 |
+
|
| 217 |
+
if (!response.ok) {
|
| 218 |
+
const errorData = await response.json().catch(() => ({ detail: response.statusText }));
|
| 219 |
+
throw new Error(`HTTP ${response.status}: ${errorData.detail || response.statusText}`);
|
| 220 |
+
}
|
| 221 |
+
|
| 222 |
+
const data = await response.json();
|
| 223 |
+
|
| 224 |
+
console.log('✅ Leaderboard API: Updated', {
|
| 225 |
+
entries: entries.length,
|
| 226 |
+
message: data.message
|
| 227 |
+
});
|
| 228 |
+
|
| 229 |
+
// Trigger immediate poll to refresh data
|
| 230 |
+
if (this.pollInterval) {
|
| 231 |
+
this._pollOnce();
|
| 232 |
+
}
|
| 233 |
+
|
| 234 |
+
return data;
|
| 235 |
+
} catch (error) {
|
| 236 |
+
console.error('❌ Leaderboard API: Error updating:', error);
|
| 237 |
+
throw error;
|
| 238 |
+
}
|
| 239 |
+
}
|
| 240 |
+
|
| 241 |
+
/**
|
| 242 |
+
* Clear all leaderboard data (admin function)
|
| 243 |
+
* @returns {Promise<Object>} Response object
|
| 244 |
+
*/
|
| 245 |
+
async clearLeaderboard() {
|
| 246 |
+
try {
|
| 247 |
+
const response = await fetch(`${this.baseUrl}/api/leaderboard/clear`, {
|
| 248 |
+
method: 'DELETE',
|
| 249 |
+
headers: {
|
| 250 |
+
'Content-Type': 'application/json'
|
| 251 |
+
}
|
| 252 |
+
});
|
| 253 |
+
|
| 254 |
+
if (!response.ok) {
|
| 255 |
+
const errorData = await response.json().catch(() => ({ detail: response.statusText }));
|
| 256 |
+
throw new Error(`HTTP ${response.status}: ${errorData.detail || response.statusText}`);
|
| 257 |
+
}
|
| 258 |
+
|
| 259 |
+
const data = await response.json();
|
| 260 |
+
|
| 261 |
+
console.log('✅ Leaderboard API: Cleared', {
|
| 262 |
+
message: data.message
|
| 263 |
+
});
|
| 264 |
+
|
| 265 |
+
// Trigger immediate poll to refresh data
|
| 266 |
+
if (this.pollInterval) {
|
| 267 |
+
this._pollOnce();
|
| 268 |
+
}
|
| 269 |
+
|
| 270 |
+
return data;
|
| 271 |
+
} catch (error) {
|
| 272 |
+
console.error('❌ Leaderboard API: Error clearing:', error);
|
| 273 |
+
throw error;
|
| 274 |
+
}
|
| 275 |
+
}
|
| 276 |
+
|
| 277 |
+
/**
|
| 278 |
+
* Check if backend is available
|
| 279 |
+
* @returns {Promise<boolean>} True if backend is reachable
|
| 280 |
+
*/
|
| 281 |
+
async isAvailable() {
|
| 282 |
+
try {
|
| 283 |
+
const response = await fetch(`${this.baseUrl}/api/leaderboard`, {
|
| 284 |
+
method: 'GET',
|
| 285 |
+
headers: {
|
| 286 |
+
'Content-Type': 'application/json'
|
| 287 |
+
}
|
| 288 |
+
});
|
| 289 |
+
return response.ok;
|
| 290 |
+
} catch (error) {
|
| 291 |
+
console.warn('⚠️ Leaderboard API: Backend not available, will use localStorage fallback');
|
| 292 |
+
return false;
|
| 293 |
+
}
|
| 294 |
+
}
|
| 295 |
+
}
|
src/init-env.js
ADDED
|
@@ -0,0 +1,14 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
// Initialize environment variables from meta tags
|
| 2 |
+
document.addEventListener('DOMContentLoaded', function() {
|
| 3 |
+
const openrouterMeta = document.querySelector('meta[name="openrouter-key"]');
|
| 4 |
+
const hfMeta = document.querySelector('meta[name="hf-key"]');
|
| 5 |
+
|
| 6 |
+
if (openrouterMeta && openrouterMeta.content) {
|
| 7 |
+
window.OPENROUTER_API_KEY = openrouterMeta.content;
|
| 8 |
+
} else {
|
| 9 |
+
}
|
| 10 |
+
|
| 11 |
+
if (hfMeta && hfMeta.content) {
|
| 12 |
+
window.HF_API_KEY = hfMeta.content;
|
| 13 |
+
}
|
| 14 |
+
});
|
src/leaderboardService.js
ADDED
|
@@ -0,0 +1,429 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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 = {
|
| 12 |
+
leaderboard: 'cloze-reader-leaderboard',
|
| 13 |
+
player: 'cloze-reader-player',
|
| 14 |
+
stats: 'cloze-reader-stats'
|
| 15 |
+
};
|
| 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 |
+
} catch (error) {
|
| 34 |
+
console.warn('⚠️ LEADERBOARD: HF backend unavailable, using localStorage', error);
|
| 35 |
+
this.useHF = false;
|
| 36 |
+
}
|
| 37 |
+
|
| 38 |
+
// Reset all data on initialization (fresh start each session)
|
| 39 |
+
this.resetAll();
|
| 40 |
+
this.initializeStorage();
|
| 41 |
+
|
| 42 |
+
// If HF is available, sync from HF to localStorage
|
| 43 |
+
if (this.useHF) {
|
| 44 |
+
await this.syncFromHF();
|
| 45 |
+
}
|
| 46 |
+
}
|
| 47 |
+
|
| 48 |
+
/**
|
| 49 |
+
* Sync leaderboard from HF Hub to localStorage
|
| 50 |
+
*/
|
| 51 |
+
async syncFromHF() {
|
| 52 |
+
try {
|
| 53 |
+
const hfLeaderboard = await this.hfAPI.getLeaderboard();
|
| 54 |
+
if (hfLeaderboard && hfLeaderboard.length > 0) {
|
| 55 |
+
this.saveLeaderboard(hfLeaderboard);
|
| 56 |
+
}
|
| 57 |
+
} catch (error) {
|
| 58 |
+
console.error('❌ LEADERBOARD: Failed to sync from HF', error);
|
| 59 |
+
}
|
| 60 |
+
}
|
| 61 |
+
|
| 62 |
+
/**
|
| 63 |
+
* Initialize localStorage with default values if needed
|
| 64 |
+
*/
|
| 65 |
+
initializeStorage() {
|
| 66 |
+
if (!this.getLeaderboard()) {
|
| 67 |
+
this.saveLeaderboard([]);
|
| 68 |
+
}
|
| 69 |
+
|
| 70 |
+
if (!this.getPlayerProfile()) {
|
| 71 |
+
this.savePlayerProfile({
|
| 72 |
+
initials: null,
|
| 73 |
+
hasEnteredInitials: false,
|
| 74 |
+
gamesPlayed: 0,
|
| 75 |
+
lastPlayed: null
|
| 76 |
+
});
|
| 77 |
+
}
|
| 78 |
+
|
| 79 |
+
if (!this.getStats()) {
|
| 80 |
+
this.saveStats(this.createEmptyStats());
|
| 81 |
+
}
|
| 82 |
+
}
|
| 83 |
+
|
| 84 |
+
/**
|
| 85 |
+
* Create empty stats object
|
| 86 |
+
*/
|
| 87 |
+
createEmptyStats() {
|
| 88 |
+
return {
|
| 89 |
+
highestLevel: 1,
|
| 90 |
+
roundAtHighestLevel: 1,
|
| 91 |
+
totalPassagesPassed: 0,
|
| 92 |
+
totalPassagesAttempted: 0,
|
| 93 |
+
longestStreak: 0,
|
| 94 |
+
currentStreak: 0,
|
| 95 |
+
totalCorrectWords: 0,
|
| 96 |
+
uniqueWordsCorrect: new Set(),
|
| 97 |
+
gamesPlayed: 0,
|
| 98 |
+
lastPlayed: null
|
| 99 |
+
};
|
| 100 |
+
}
|
| 101 |
+
|
| 102 |
+
/**
|
| 103 |
+
* Get leaderboard from localStorage
|
| 104 |
+
*/
|
| 105 |
+
getLeaderboard() {
|
| 106 |
+
try {
|
| 107 |
+
const data = localStorage.getItem(this.storageKeys.leaderboard);
|
| 108 |
+
return data ? JSON.parse(data) : null;
|
| 109 |
+
} catch (e) {
|
| 110 |
+
console.error('Error reading leaderboard:', e);
|
| 111 |
+
return [];
|
| 112 |
+
}
|
| 113 |
+
}
|
| 114 |
+
|
| 115 |
+
/**
|
| 116 |
+
* Save leaderboard to localStorage
|
| 117 |
+
*/
|
| 118 |
+
saveLeaderboard(entries) {
|
| 119 |
+
try {
|
| 120 |
+
localStorage.setItem(this.storageKeys.leaderboard, JSON.stringify(entries));
|
| 121 |
+
} catch (e) {
|
| 122 |
+
console.error('Error saving leaderboard:', e);
|
| 123 |
+
}
|
| 124 |
+
}
|
| 125 |
+
|
| 126 |
+
/**
|
| 127 |
+
* Get player profile from localStorage
|
| 128 |
+
*/
|
| 129 |
+
getPlayerProfile() {
|
| 130 |
+
try {
|
| 131 |
+
const data = localStorage.getItem(this.storageKeys.player);
|
| 132 |
+
return data ? JSON.parse(data) : null;
|
| 133 |
+
} catch (e) {
|
| 134 |
+
console.error('Error reading player profile:', e);
|
| 135 |
+
return null;
|
| 136 |
+
}
|
| 137 |
+
}
|
| 138 |
+
|
| 139 |
+
/**
|
| 140 |
+
* Save player profile to localStorage
|
| 141 |
+
*/
|
| 142 |
+
savePlayerProfile(profile) {
|
| 143 |
+
try {
|
| 144 |
+
localStorage.setItem(this.storageKeys.player, JSON.stringify(profile));
|
| 145 |
+
} catch (e) {
|
| 146 |
+
console.error('Error saving player profile:', e);
|
| 147 |
+
}
|
| 148 |
+
}
|
| 149 |
+
|
| 150 |
+
/**
|
| 151 |
+
* Get stats from localStorage
|
| 152 |
+
*/
|
| 153 |
+
getStats() {
|
| 154 |
+
try {
|
| 155 |
+
const data = localStorage.getItem(this.storageKeys.stats);
|
| 156 |
+
if (!data) return null;
|
| 157 |
+
|
| 158 |
+
const stats = JSON.parse(data);
|
| 159 |
+
// Convert uniqueWordsCorrect back to Set
|
| 160 |
+
if (stats.uniqueWordsCorrect && Array.isArray(stats.uniqueWordsCorrect)) {
|
| 161 |
+
stats.uniqueWordsCorrect = new Set(stats.uniqueWordsCorrect);
|
| 162 |
+
} else {
|
| 163 |
+
stats.uniqueWordsCorrect = new Set();
|
| 164 |
+
}
|
| 165 |
+
return stats;
|
| 166 |
+
} catch (e) {
|
| 167 |
+
console.error('Error reading stats:', e);
|
| 168 |
+
return null;
|
| 169 |
+
}
|
| 170 |
+
}
|
| 171 |
+
|
| 172 |
+
/**
|
| 173 |
+
* Save stats to localStorage
|
| 174 |
+
*/
|
| 175 |
+
saveStats(stats) {
|
| 176 |
+
try {
|
| 177 |
+
// Convert Set to Array for JSON serialization
|
| 178 |
+
const statsToSave = {
|
| 179 |
+
...stats,
|
| 180 |
+
uniqueWordsCorrect: Array.from(stats.uniqueWordsCorrect || [])
|
| 181 |
+
};
|
| 182 |
+
localStorage.setItem(this.storageKeys.stats, JSON.stringify(statsToSave));
|
| 183 |
+
} catch (e) {
|
| 184 |
+
console.error('Error saving stats:', e);
|
| 185 |
+
}
|
| 186 |
+
}
|
| 187 |
+
|
| 188 |
+
/**
|
| 189 |
+
* Validate and sanitize initials (3 letters, A-Z only)
|
| 190 |
+
*/
|
| 191 |
+
validateInitials(initials) {
|
| 192 |
+
if (!initials || typeof initials !== 'string') {
|
| 193 |
+
return false;
|
| 194 |
+
}
|
| 195 |
+
|
| 196 |
+
const sanitized = initials.toUpperCase().replace(/[^A-Z]/g, '');
|
| 197 |
+
return sanitized.length === 3 ? sanitized : false;
|
| 198 |
+
}
|
| 199 |
+
|
| 200 |
+
/**
|
| 201 |
+
* Sort leaderboard entries
|
| 202 |
+
* Primary: Level (desc), Secondary: Round (desc), Tertiary: Passages passed (desc)
|
| 203 |
+
*/
|
| 204 |
+
sortLeaderboard(entries) {
|
| 205 |
+
return entries.sort((a, b) => {
|
| 206 |
+
// Primary: Level (higher is better)
|
| 207 |
+
if (b.level !== a.level) {
|
| 208 |
+
return b.level - a.level;
|
| 209 |
+
}
|
| 210 |
+
|
| 211 |
+
// Secondary: Round at that level (higher is better)
|
| 212 |
+
if (b.round !== a.round) {
|
| 213 |
+
return b.round - a.round;
|
| 214 |
+
}
|
| 215 |
+
|
| 216 |
+
// Tertiary: Total passages passed (higher is better)
|
| 217 |
+
if (b.passagesPassed !== a.passagesPassed) {
|
| 218 |
+
return b.passagesPassed - a.passagesPassed;
|
| 219 |
+
}
|
| 220 |
+
|
| 221 |
+
// Quaternary: Date (newer is better)
|
| 222 |
+
return new Date(b.date) - new Date(a.date);
|
| 223 |
+
});
|
| 224 |
+
}
|
| 225 |
+
|
| 226 |
+
/**
|
| 227 |
+
* Check if a score qualifies for the leaderboard
|
| 228 |
+
*/
|
| 229 |
+
qualifiesForLeaderboard(level, round, passagesPassed) {
|
| 230 |
+
const leaderboard = this.getLeaderboard();
|
| 231 |
+
|
| 232 |
+
// If leaderboard isn't full, always qualifies
|
| 233 |
+
if (leaderboard.length < this.maxEntries) {
|
| 234 |
+
return true;
|
| 235 |
+
}
|
| 236 |
+
|
| 237 |
+
// Check if better than lowest entry
|
| 238 |
+
const lowestEntry = leaderboard[leaderboard.length - 1];
|
| 239 |
+
|
| 240 |
+
if (level > lowestEntry.level) return true;
|
| 241 |
+
if (level === lowestEntry.level && round > lowestEntry.round) return true;
|
| 242 |
+
if (level === lowestEntry.level && round === lowestEntry.round && passagesPassed > lowestEntry.passagesPassed) return true;
|
| 243 |
+
|
| 244 |
+
return false;
|
| 245 |
+
}
|
| 246 |
+
|
| 247 |
+
/**
|
| 248 |
+
* Get the rank position for a score (1-10, or null if doesn't qualify)
|
| 249 |
+
*/
|
| 250 |
+
getRankForScore(level, round, passagesPassed) {
|
| 251 |
+
if (!this.qualifiesForLeaderboard(level, round, passagesPassed)) {
|
| 252 |
+
return null;
|
| 253 |
+
}
|
| 254 |
+
|
| 255 |
+
const leaderboard = this.getLeaderboard();
|
| 256 |
+
const tempEntry = { level, round, passagesPassed, date: new Date().toISOString() };
|
| 257 |
+
const tempLeaderboard = [...leaderboard, tempEntry];
|
| 258 |
+
const sorted = this.sortLeaderboard(tempLeaderboard);
|
| 259 |
+
|
| 260 |
+
return sorted.findIndex(entry => entry === tempEntry) + 1;
|
| 261 |
+
}
|
| 262 |
+
|
| 263 |
+
/**
|
| 264 |
+
* Add a new entry to the leaderboard
|
| 265 |
+
*/
|
| 266 |
+
async addEntry(initials, level, round, passagesPassed) {
|
| 267 |
+
const validInitials = this.validateInitials(initials);
|
| 268 |
+
if (!validInitials) {
|
| 269 |
+
console.error('Invalid initials:', initials);
|
| 270 |
+
return false;
|
| 271 |
+
}
|
| 272 |
+
|
| 273 |
+
const leaderboard = this.getLeaderboard();
|
| 274 |
+
const newEntry = {
|
| 275 |
+
initials: validInitials,
|
| 276 |
+
level,
|
| 277 |
+
round,
|
| 278 |
+
passagesPassed,
|
| 279 |
+
date: new Date().toISOString()
|
| 280 |
+
};
|
| 281 |
+
|
| 282 |
+
leaderboard.push(newEntry);
|
| 283 |
+
const sorted = this.sortLeaderboard(leaderboard);
|
| 284 |
+
|
| 285 |
+
// Keep only top 10
|
| 286 |
+
const trimmed = sorted.slice(0, this.maxEntries);
|
| 287 |
+
this.saveLeaderboard(trimmed);
|
| 288 |
+
|
| 289 |
+
const rank = sorted.findIndex(entry => entry === newEntry) + 1;
|
| 290 |
+
|
| 291 |
+
// If HF is available, also save to HF Hub
|
| 292 |
+
if (this.useHF) {
|
| 293 |
+
try {
|
| 294 |
+
await this.hfAPI.addEntry(newEntry);
|
| 295 |
+
} catch (error) {
|
| 296 |
+
console.error('❌ LEADERBOARD: Failed to save to HF, localStorage only', error);
|
| 297 |
+
}
|
| 298 |
+
}
|
| 299 |
+
|
| 300 |
+
return rank; // Return rank
|
| 301 |
+
}
|
| 302 |
+
|
| 303 |
+
/**
|
| 304 |
+
* Update session stats after a passage attempt
|
| 305 |
+
*/
|
| 306 |
+
updateStats(data) {
|
| 307 |
+
const stats = this.getStats() || this.createEmptyStats();
|
| 308 |
+
|
| 309 |
+
console.log('📊 LEADERBOARD: Updating stats', {
|
| 310 |
+
before: { attempted: stats.totalPassagesAttempted, passed: stats.totalPassagesPassed, level: stats.highestLevel },
|
| 311 |
+
passResult: data.passed,
|
| 312 |
+
currentLevel: data.currentLevel
|
| 313 |
+
});
|
| 314 |
+
|
| 315 |
+
stats.totalPassagesAttempted++;
|
| 316 |
+
|
| 317 |
+
if (data.passed) {
|
| 318 |
+
stats.totalPassagesPassed++;
|
| 319 |
+
|
| 320 |
+
// Break streak if user had to retry (any blank took more than 1 attempt)
|
| 321 |
+
const hadToRetry = data.attemptCounts && Object.values(data.attemptCounts).some(count => count > 1);
|
| 322 |
+
|
| 323 |
+
if (hadToRetry) {
|
| 324 |
+
// Passed but had to retry - break streak
|
| 325 |
+
stats.currentStreak = 0;
|
| 326 |
+
} else {
|
| 327 |
+
// Passed on first attempt - continue streak
|
| 328 |
+
stats.currentStreak++;
|
| 329 |
+
stats.longestStreak = Math.max(stats.longestStreak, stats.currentStreak);
|
| 330 |
+
}
|
| 331 |
+
} else {
|
| 332 |
+
stats.currentStreak = 0;
|
| 333 |
+
}
|
| 334 |
+
|
| 335 |
+
// Track highest level reached
|
| 336 |
+
if (data.currentLevel > stats.highestLevel) {
|
| 337 |
+
stats.highestLevel = data.currentLevel;
|
| 338 |
+
stats.roundAtHighestLevel = data.round;
|
| 339 |
+
} else if (data.currentLevel === stats.highestLevel) {
|
| 340 |
+
stats.roundAtHighestLevel = Math.max(stats.roundAtHighestLevel, data.round);
|
| 341 |
+
}
|
| 342 |
+
|
| 343 |
+
// Track correct words
|
| 344 |
+
if (data.results) {
|
| 345 |
+
data.results.forEach(result => {
|
| 346 |
+
if (result.isCorrect) {
|
| 347 |
+
stats.totalCorrectWords++;
|
| 348 |
+
const word = result.correctAnswer.toLowerCase();
|
| 349 |
+
stats.uniqueWordsCorrect.add(word);
|
| 350 |
+
}
|
| 351 |
+
});
|
| 352 |
+
}
|
| 353 |
+
|
| 354 |
+
stats.lastPlayed = new Date().toISOString();
|
| 355 |
+
|
| 356 |
+
console.log('📊 LEADERBOARD: Stats updated', {
|
| 357 |
+
after: { attempted: stats.totalPassagesAttempted, passed: stats.totalPassagesPassed, level: stats.highestLevel }
|
| 358 |
+
});
|
| 359 |
+
|
| 360 |
+
this.saveStats(stats);
|
| 361 |
+
return stats;
|
| 362 |
+
}
|
| 363 |
+
|
| 364 |
+
/**
|
| 365 |
+
* Get formatted leaderboard for display
|
| 366 |
+
*/
|
| 367 |
+
getFormattedLeaderboard() {
|
| 368 |
+
const leaderboard = this.getLeaderboard();
|
| 369 |
+
const player = this.getPlayerProfile();
|
| 370 |
+
const stats = this.getStats();
|
| 371 |
+
|
| 372 |
+
return {
|
| 373 |
+
entries: leaderboard.map((entry, index) => ({
|
| 374 |
+
rank: index + 1,
|
| 375 |
+
initials: entry.initials,
|
| 376 |
+
level: entry.level,
|
| 377 |
+
round: entry.round,
|
| 378 |
+
passagesPassed: entry.passagesPassed,
|
| 379 |
+
date: entry.date,
|
| 380 |
+
isPlayer: player && player.initials === entry.initials
|
| 381 |
+
})),
|
| 382 |
+
playerBest: stats ? {
|
| 383 |
+
level: stats.highestLevel,
|
| 384 |
+
round: stats.roundAtHighestLevel,
|
| 385 |
+
passagesPassed: stats.totalPassagesPassed
|
| 386 |
+
} : null,
|
| 387 |
+
playerInitials: player ? player.initials : null
|
| 388 |
+
};
|
| 389 |
+
}
|
| 390 |
+
|
| 391 |
+
/**
|
| 392 |
+
* Reset all leaderboard data (fresh start each session)
|
| 393 |
+
*/
|
| 394 |
+
resetAll() {
|
| 395 |
+
this.saveLeaderboard([]);
|
| 396 |
+
this.savePlayerProfile({
|
| 397 |
+
initials: null,
|
| 398 |
+
hasEnteredInitials: false,
|
| 399 |
+
gamesPlayed: 0,
|
| 400 |
+
lastPlayed: null
|
| 401 |
+
});
|
| 402 |
+
this.saveStats(this.createEmptyStats());
|
| 403 |
+
}
|
| 404 |
+
|
| 405 |
+
/**
|
| 406 |
+
* Get player stats summary
|
| 407 |
+
*/
|
| 408 |
+
getPlayerStats() {
|
| 409 |
+
const stats = this.getStats() || this.createEmptyStats();
|
| 410 |
+
const profile = this.getPlayerProfile();
|
| 411 |
+
|
| 412 |
+
return {
|
| 413 |
+
initials: profile?.initials || '---',
|
| 414 |
+
highestLevel: stats.highestLevel,
|
| 415 |
+
roundAtHighestLevel: stats.roundAtHighestLevel,
|
| 416 |
+
totalPassagesPassed: stats.totalPassagesPassed,
|
| 417 |
+
totalPassagesAttempted: stats.totalPassagesAttempted,
|
| 418 |
+
successRate: stats.totalPassagesAttempted > 0
|
| 419 |
+
? Math.round((stats.totalPassagesPassed / stats.totalPassagesAttempted) * 100)
|
| 420 |
+
: 0,
|
| 421 |
+
longestStreak: stats.longestStreak,
|
| 422 |
+
currentStreak: stats.currentStreak,
|
| 423 |
+
totalCorrectWords: stats.totalCorrectWords,
|
| 424 |
+
uniqueWords: stats.uniqueWordsCorrect.size,
|
| 425 |
+
gamesPlayed: stats.gamesPlayed,
|
| 426 |
+
lastPlayed: stats.lastPlayed
|
| 427 |
+
};
|
| 428 |
+
}
|
| 429 |
+
}
|
src/leaderboardUI.js
ADDED
|
@@ -0,0 +1,590 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
/**
|
| 2 |
+
* Leaderboard UI
|
| 3 |
+
* Modal display and initials entry interface
|
| 4 |
+
* Following arcade conventions with vintage aesthetic
|
| 5 |
+
* Supports near real-time updates via polling
|
| 6 |
+
*/
|
| 7 |
+
|
| 8 |
+
export class LeaderboardUI {
|
| 9 |
+
constructor(leaderboardService) {
|
| 10 |
+
this.service = leaderboardService;
|
| 11 |
+
this.modal = null;
|
| 12 |
+
this.initialsModal = null;
|
| 13 |
+
this.currentSlot = 0;
|
| 14 |
+
this.initials = ['A', 'A', 'A'];
|
| 15 |
+
this.onInitialsSubmit = null;
|
| 16 |
+
this.canSubmitInitials = false; // Prevent accidental immediate submission
|
| 17 |
+
this.pollUnsubscribe = null; // Cleanup function for polling subscription
|
| 18 |
+
}
|
| 19 |
+
|
| 20 |
+
/**
|
| 21 |
+
* Show the leaderboard modal
|
| 22 |
+
*/
|
| 23 |
+
show() {
|
| 24 |
+
// Remove existing modal if any
|
| 25 |
+
this.hide();
|
| 26 |
+
|
| 27 |
+
const data = this.service.getFormattedLeaderboard();
|
| 28 |
+
const playerStats = this.service.getPlayerStats();
|
| 29 |
+
|
| 30 |
+
// Create modal HTML
|
| 31 |
+
this.modal = document.createElement('div');
|
| 32 |
+
this.modal.className = 'leaderboard-overlay';
|
| 33 |
+
this.modal.innerHTML = `
|
| 34 |
+
<div class="leaderboard-modal">
|
| 35 |
+
<div class="leaderboard-header">
|
| 36 |
+
<h2 class="leaderboard-title">High Scores</h2>
|
| 37 |
+
<span class="leaderboard-live-indicator" title="Live updates enabled">●</span>
|
| 38 |
+
<button class="leaderboard-close" aria-label="Close leaderboard">×</button>
|
| 39 |
+
</div>
|
| 40 |
+
|
| 41 |
+
<div class="leaderboard-content">
|
| 42 |
+
<div class="leaderboard-list">
|
| 43 |
+
${this.generateLeaderboardHTML(data.entries, data.playerInitials)}
|
| 44 |
+
</div>
|
| 45 |
+
|
| 46 |
+
${playerStats.highestLevel > 1 ? `
|
| 47 |
+
<div class="leaderboard-player-stats">
|
| 48 |
+
<div class="player-best">
|
| 49 |
+
Your Best: <span class="highlight">Level ${playerStats.highestLevel}</span>
|
| 50 |
+
</div>
|
| 51 |
+
<div class="player-stats-details">
|
| 52 |
+
<div>Passages: ${playerStats.totalPassagesPassed}/${playerStats.totalPassagesAttempted} (${playerStats.successRate}%)</div>
|
| 53 |
+
<div>Longest Streak: ${playerStats.longestStreak}</div>
|
| 54 |
+
</div>
|
| 55 |
+
</div>
|
| 56 |
+
` : ''}
|
| 57 |
+
</div>
|
| 58 |
+
</div>
|
| 59 |
+
`;
|
| 60 |
+
|
| 61 |
+
document.body.appendChild(this.modal);
|
| 62 |
+
|
| 63 |
+
// Animate in
|
| 64 |
+
requestAnimationFrame(() => {
|
| 65 |
+
this.modal.classList.add('visible');
|
| 66 |
+
});
|
| 67 |
+
|
| 68 |
+
// Add event listeners
|
| 69 |
+
this.modal.querySelector('.leaderboard-close').addEventListener('click', () => this.hide());
|
| 70 |
+
|
| 71 |
+
// Prevent clicks inside modal content from closing
|
| 72 |
+
this.modal.querySelector('.leaderboard-modal').addEventListener('click', (e) => {
|
| 73 |
+
e.stopPropagation();
|
| 74 |
+
});
|
| 75 |
+
|
| 76 |
+
// Close on backdrop click
|
| 77 |
+
this.modal.addEventListener('click', (e) => {
|
| 78 |
+
if (e.target === this.modal) {
|
| 79 |
+
this.hide();
|
| 80 |
+
}
|
| 81 |
+
});
|
| 82 |
+
|
| 83 |
+
// ESC key to close
|
| 84 |
+
this.escHandler = (e) => {
|
| 85 |
+
if (e.key === 'Escape') {
|
| 86 |
+
this.hide();
|
| 87 |
+
}
|
| 88 |
+
};
|
| 89 |
+
document.addEventListener('keydown', this.escHandler);
|
| 90 |
+
|
| 91 |
+
// Start polling for live updates
|
| 92 |
+
this.startPolling();
|
| 93 |
+
}
|
| 94 |
+
|
| 95 |
+
/**
|
| 96 |
+
* Start polling for live leaderboard updates
|
| 97 |
+
*/
|
| 98 |
+
startPolling() {
|
| 99 |
+
// Only poll if HF API is available via service
|
| 100 |
+
if (this.service.hfAPI && this.service.useHF) {
|
| 101 |
+
// Subscribe to updates
|
| 102 |
+
this.pollUnsubscribe = this.service.hfAPI.onUpdate((leaderboard) => {
|
| 103 |
+
this.handleLeaderboardUpdate(leaderboard);
|
| 104 |
+
});
|
| 105 |
+
|
| 106 |
+
// Start the polling (5 second interval)
|
| 107 |
+
this.service.hfAPI.startPolling(5000);
|
| 108 |
+
|
| 109 |
+
}
|
| 110 |
+
}
|
| 111 |
+
|
| 112 |
+
/**
|
| 113 |
+
* Stop polling for updates
|
| 114 |
+
*/
|
| 115 |
+
stopPolling() {
|
| 116 |
+
if (this.pollUnsubscribe) {
|
| 117 |
+
this.pollUnsubscribe();
|
| 118 |
+
this.pollUnsubscribe = null;
|
| 119 |
+
}
|
| 120 |
+
|
| 121 |
+
if (this.service.hfAPI) {
|
| 122 |
+
this.service.hfAPI.stopPolling();
|
| 123 |
+
}
|
| 124 |
+
|
| 125 |
+
}
|
| 126 |
+
|
| 127 |
+
/**
|
| 128 |
+
* Handle incoming leaderboard update from polling
|
| 129 |
+
*/
|
| 130 |
+
handleLeaderboardUpdate(leaderboard) {
|
| 131 |
+
// Update localStorage with new data
|
| 132 |
+
this.service.saveLeaderboard(leaderboard);
|
| 133 |
+
|
| 134 |
+
// Re-render the leaderboard list if modal is open
|
| 135 |
+
if (this.modal) {
|
| 136 |
+
const data = this.service.getFormattedLeaderboard();
|
| 137 |
+
const listContainer = this.modal.querySelector('.leaderboard-list');
|
| 138 |
+
|
| 139 |
+
if (listContainer) {
|
| 140 |
+
listContainer.innerHTML = this.generateLeaderboardHTML(data.entries, data.playerInitials);
|
| 141 |
+
|
| 142 |
+
// Flash the live indicator to show update received
|
| 143 |
+
const indicator = this.modal.querySelector('.leaderboard-live-indicator');
|
| 144 |
+
if (indicator) {
|
| 145 |
+
indicator.classList.add('pulse');
|
| 146 |
+
setTimeout(() => indicator.classList.remove('pulse'), 500);
|
| 147 |
+
}
|
| 148 |
+
|
| 149 |
+
}
|
| 150 |
+
}
|
| 151 |
+
}
|
| 152 |
+
|
| 153 |
+
/**
|
| 154 |
+
* Generate HTML for leaderboard entries
|
| 155 |
+
*/
|
| 156 |
+
generateLeaderboardHTML(entries, playerInitials) {
|
| 157 |
+
if (entries.length === 0) {
|
| 158 |
+
return `
|
| 159 |
+
<div class="leaderboard-empty">
|
| 160 |
+
<p>No high scores yet!</p>
|
| 161 |
+
<p class="text-sm">Be the first to reach Level 2!</p>
|
| 162 |
+
</div>
|
| 163 |
+
`;
|
| 164 |
+
}
|
| 165 |
+
|
| 166 |
+
return entries.map(entry => {
|
| 167 |
+
const rankClass = this.getRankClass(entry.rank);
|
| 168 |
+
const isPlayer = entry.initials === playerInitials;
|
| 169 |
+
const playerClass = isPlayer ? 'player-entry' : '';
|
| 170 |
+
|
| 171 |
+
return `
|
| 172 |
+
<div class="leaderboard-entry ${rankClass} ${playerClass}">
|
| 173 |
+
<span class="entry-rank">#${entry.rank}</span>
|
| 174 |
+
<span class="entry-initials">${entry.initials}</span>
|
| 175 |
+
<span class="entry-score">Level ${entry.level}</span>
|
| 176 |
+
</div>
|
| 177 |
+
`;
|
| 178 |
+
}).join('');
|
| 179 |
+
}
|
| 180 |
+
|
| 181 |
+
/**
|
| 182 |
+
* Get CSS class for rank-based styling
|
| 183 |
+
*/
|
| 184 |
+
getRankClass(rank) {
|
| 185 |
+
if (rank === 1) return 'rank-gold';
|
| 186 |
+
if (rank === 2 || rank === 3) return 'rank-silver';
|
| 187 |
+
return 'rank-standard';
|
| 188 |
+
}
|
| 189 |
+
|
| 190 |
+
/**
|
| 191 |
+
* Hide the leaderboard modal
|
| 192 |
+
*/
|
| 193 |
+
hide() {
|
| 194 |
+
// Stop polling when modal closes
|
| 195 |
+
this.stopPolling();
|
| 196 |
+
|
| 197 |
+
if (this.modal) {
|
| 198 |
+
this.modal.classList.remove('visible');
|
| 199 |
+
setTimeout(() => {
|
| 200 |
+
if (this.modal && this.modal.parentNode) {
|
| 201 |
+
this.modal.parentNode.removeChild(this.modal);
|
| 202 |
+
}
|
| 203 |
+
this.modal = null;
|
| 204 |
+
}, 300);
|
| 205 |
+
}
|
| 206 |
+
|
| 207 |
+
if (this.escHandler) {
|
| 208 |
+
document.removeEventListener('keydown', this.escHandler);
|
| 209 |
+
this.escHandler = null;
|
| 210 |
+
}
|
| 211 |
+
}
|
| 212 |
+
|
| 213 |
+
/**
|
| 214 |
+
* Show initials entry screen for new high score
|
| 215 |
+
*/
|
| 216 |
+
showInitialsEntry(level, round, rank, onSubmit) {
|
| 217 |
+
// Store callback
|
| 218 |
+
this.onInitialsSubmit = onSubmit;
|
| 219 |
+
|
| 220 |
+
// Reset initials state
|
| 221 |
+
this.currentSlot = 0;
|
| 222 |
+
this.canSubmitInitials = false; // Disable submission until user has had time to interact
|
| 223 |
+
|
| 224 |
+
// Get existing player initials if available
|
| 225 |
+
const profile = this.service.getPlayerProfile();
|
| 226 |
+
if (profile && profile.initials) {
|
| 227 |
+
this.initials = profile.initials.split('');
|
| 228 |
+
} else {
|
| 229 |
+
this.initials = ['A', 'A', 'A'];
|
| 230 |
+
}
|
| 231 |
+
|
| 232 |
+
// Remove existing modal
|
| 233 |
+
this.hideInitialsEntry();
|
| 234 |
+
|
| 235 |
+
// Create modal HTML
|
| 236 |
+
this.initialsModal = document.createElement('div');
|
| 237 |
+
this.initialsModal.className = 'leaderboard-overlay initials-overlay';
|
| 238 |
+
this.initialsModal.innerHTML = `
|
| 239 |
+
<div class="initials-modal">
|
| 240 |
+
<div class="initials-header">
|
| 241 |
+
<h2 class="initials-title">New High Score</h2>
|
| 242 |
+
<div class="initials-achievement">
|
| 243 |
+
You reached <span class="highlight">Level ${level}</span>
|
| 244 |
+
<br>
|
| 245 |
+
<span class="rank-text">${this.getRankText(rank)}</span>
|
| 246 |
+
</div>
|
| 247 |
+
</div>
|
| 248 |
+
|
| 249 |
+
<div class="initials-content">
|
| 250 |
+
<p class="initials-prompt">Enter or update your initials:</p>
|
| 251 |
+
|
| 252 |
+
<!-- Text Input Method -->
|
| 253 |
+
<div class="text-input-section">
|
| 254 |
+
<input type="text" id="initials-text-input" class="initials-text-input" maxlength="3" value="${this.initials.join('')}" placeholder="ABC">
|
| 255 |
+
<p class="input-help">Type your 3-letter initials directly</p>
|
| 256 |
+
</div>
|
| 257 |
+
|
| 258 |
+
<!-- Divider -->
|
| 259 |
+
<div class="input-divider">
|
| 260 |
+
<span>or use arcade controls</span>
|
| 261 |
+
</div>
|
| 262 |
+
|
| 263 |
+
<!-- Arcade Style Method -->
|
| 264 |
+
<div class="initials-slots">
|
| 265 |
+
${this.initials.map((letter, index) => `
|
| 266 |
+
<div class="initial-slot ${index === 0 ? 'active' : ''}" data-slot="${index}">
|
| 267 |
+
<div class="slot-letter">${letter}</div>
|
| 268 |
+
<div class="slot-arrows">
|
| 269 |
+
<button class="arrow-up" data-slot="${index}" data-direction="up" aria-label="Increase letter">▲</button>
|
| 270 |
+
<button class="arrow-down" data-slot="${index}" data-direction="down" aria-label="Decrease letter">▼</button>
|
| 271 |
+
</div>
|
| 272 |
+
</div>
|
| 273 |
+
`).join('')}
|
| 274 |
+
</div>
|
| 275 |
+
|
| 276 |
+
<div class="initials-instructions">
|
| 277 |
+
<p>Use arrow keys ↑↓ to change letters</p>
|
| 278 |
+
<p>Press Tab or ←→ to move between slots</p>
|
| 279 |
+
<p>Press Enter to submit</p>
|
| 280 |
+
</div>
|
| 281 |
+
|
| 282 |
+
<button class="initials-submit typewriter-button">
|
| 283 |
+
Submit
|
| 284 |
+
</button>
|
| 285 |
+
</div>
|
| 286 |
+
</div>
|
| 287 |
+
`;
|
| 288 |
+
|
| 289 |
+
document.body.appendChild(this.initialsModal);
|
| 290 |
+
|
| 291 |
+
// Animate in
|
| 292 |
+
requestAnimationFrame(() => {
|
| 293 |
+
this.initialsModal.classList.add('visible');
|
| 294 |
+
});
|
| 295 |
+
|
| 296 |
+
// Add event listeners with a delay to prevent Enter key from passage submission
|
| 297 |
+
// from immediately triggering the modal's submit handler
|
| 298 |
+
setTimeout(() => {
|
| 299 |
+
this.setupInitialsEventListeners();
|
| 300 |
+
// Focus the text input for easier typing
|
| 301 |
+
const textInput = this.initialsModal.querySelector('#initials-text-input');
|
| 302 |
+
if (textInput) {
|
| 303 |
+
textInput.focus();
|
| 304 |
+
textInput.select(); // Select all text for easy overwriting
|
| 305 |
+
}
|
| 306 |
+
// Enable submission after a longer delay to ensure user has time to interact
|
| 307 |
+
setTimeout(() => {
|
| 308 |
+
this.canSubmitInitials = true;
|
| 309 |
+
}, 300);
|
| 310 |
+
}, 100);
|
| 311 |
+
}
|
| 312 |
+
|
| 313 |
+
/**
|
| 314 |
+
* Get rank description text
|
| 315 |
+
*/
|
| 316 |
+
getRankText(rank) {
|
| 317 |
+
const ordinal = this.getOrdinal(rank);
|
| 318 |
+
if (rank === 1) return `${ordinal} place - Top Score`;
|
| 319 |
+
if (rank === 2) return `${ordinal} place`;
|
| 320 |
+
if (rank === 3) return `${ordinal} place`;
|
| 321 |
+
return `${ordinal} place on the leaderboard`;
|
| 322 |
+
}
|
| 323 |
+
|
| 324 |
+
/**
|
| 325 |
+
* Get ordinal suffix for rank (1st, 2nd, 3rd, etc.)
|
| 326 |
+
*/
|
| 327 |
+
getOrdinal(n) {
|
| 328 |
+
const s = ['th', 'st', 'nd', 'rd'];
|
| 329 |
+
const v = n % 100;
|
| 330 |
+
return n + (s[(v - 20) % 10] || s[v] || s[0]);
|
| 331 |
+
}
|
| 332 |
+
|
| 333 |
+
/**
|
| 334 |
+
* Setup event listeners for initials entry
|
| 335 |
+
*/
|
| 336 |
+
setupInitialsEventListeners() {
|
| 337 |
+
// Text input field
|
| 338 |
+
const textInput = this.initialsModal.querySelector('#initials-text-input');
|
| 339 |
+
textInput.addEventListener('input', (e) => {
|
| 340 |
+
const value = e.target.value.toUpperCase().slice(0, 3);
|
| 341 |
+
e.target.value = value;
|
| 342 |
+
|
| 343 |
+
// Update arcade slots to match text input
|
| 344 |
+
this.updateInitialsFromText(value);
|
| 345 |
+
});
|
| 346 |
+
|
| 347 |
+
// Arrow buttons
|
| 348 |
+
this.initialsModal.querySelectorAll('.arrow-up, .arrow-down').forEach(button => {
|
| 349 |
+
button.addEventListener('click', (e) => {
|
| 350 |
+
const slot = parseInt(e.target.dataset.slot);
|
| 351 |
+
const direction = e.target.dataset.direction;
|
| 352 |
+
this.changeInitialLetter(slot, direction === 'up' ? 1 : -1);
|
| 353 |
+
});
|
| 354 |
+
});
|
| 355 |
+
|
| 356 |
+
// Slot clicking to select
|
| 357 |
+
this.initialsModal.querySelectorAll('.initial-slot').forEach(slot => {
|
| 358 |
+
slot.addEventListener('click', (e) => {
|
| 359 |
+
if (!e.target.closest('.arrow-up') && !e.target.closest('.arrow-down')) {
|
| 360 |
+
const slotIndex = parseInt(slot.dataset.slot);
|
| 361 |
+
this.selectSlot(slotIndex);
|
| 362 |
+
}
|
| 363 |
+
});
|
| 364 |
+
});
|
| 365 |
+
|
| 366 |
+
// Submit button
|
| 367 |
+
this.initialsModal.querySelector('.initials-submit').addEventListener('click', () => {
|
| 368 |
+
this.submitInitials();
|
| 369 |
+
});
|
| 370 |
+
|
| 371 |
+
// Keyboard controls
|
| 372 |
+
this.initialsKeyHandler = (e) => {
|
| 373 |
+
// If focus is on text input, handle differently
|
| 374 |
+
if (e.target.id === 'initials-text-input') {
|
| 375 |
+
switch(e.key) {
|
| 376 |
+
case 'Enter':
|
| 377 |
+
e.preventDefault();
|
| 378 |
+
this.submitInitials();
|
| 379 |
+
break;
|
| 380 |
+
case 'Escape':
|
| 381 |
+
e.preventDefault();
|
| 382 |
+
this.hideInitialsEntry();
|
| 383 |
+
break;
|
| 384 |
+
}
|
| 385 |
+
return;
|
| 386 |
+
}
|
| 387 |
+
|
| 388 |
+
// Arcade controls when not focused on text input
|
| 389 |
+
switch(e.key) {
|
| 390 |
+
case 'ArrowUp':
|
| 391 |
+
e.preventDefault();
|
| 392 |
+
this.changeInitialLetter(this.currentSlot, 1);
|
| 393 |
+
break;
|
| 394 |
+
case 'ArrowDown':
|
| 395 |
+
e.preventDefault();
|
| 396 |
+
this.changeInitialLetter(this.currentSlot, -1);
|
| 397 |
+
break;
|
| 398 |
+
case 'ArrowLeft':
|
| 399 |
+
e.preventDefault();
|
| 400 |
+
this.selectSlot(Math.max(0, this.currentSlot - 1));
|
| 401 |
+
break;
|
| 402 |
+
case 'ArrowRight':
|
| 403 |
+
case 'Tab':
|
| 404 |
+
e.preventDefault();
|
| 405 |
+
this.selectSlot(Math.min(2, this.currentSlot + 1));
|
| 406 |
+
break;
|
| 407 |
+
case 'Enter':
|
| 408 |
+
e.preventDefault();
|
| 409 |
+
this.submitInitials();
|
| 410 |
+
break;
|
| 411 |
+
case 'Escape':
|
| 412 |
+
e.preventDefault();
|
| 413 |
+
this.hideInitialsEntry();
|
| 414 |
+
break;
|
| 415 |
+
}
|
| 416 |
+
};
|
| 417 |
+
document.addEventListener('keydown', this.initialsKeyHandler);
|
| 418 |
+
|
| 419 |
+
// Prevent modal close on backdrop click for initials entry
|
| 420 |
+
this.initialsModal.addEventListener('click', (e) => {
|
| 421 |
+
e.stopPropagation();
|
| 422 |
+
});
|
| 423 |
+
}
|
| 424 |
+
|
| 425 |
+
/**
|
| 426 |
+
* Change letter in current slot
|
| 427 |
+
*/
|
| 428 |
+
changeInitialLetter(slot, delta) {
|
| 429 |
+
const currentChar = this.initials[slot].charCodeAt(0);
|
| 430 |
+
let newChar = currentChar + delta;
|
| 431 |
+
|
| 432 |
+
// Wrap around A-Z
|
| 433 |
+
if (newChar > 90) newChar = 65; // After Z, go to A
|
| 434 |
+
if (newChar < 65) newChar = 90; // Before A, go to Z
|
| 435 |
+
|
| 436 |
+
this.initials[slot] = String.fromCharCode(newChar);
|
| 437 |
+
this.updateInitialsDisplay();
|
| 438 |
+
this.updateTextFromInitials();
|
| 439 |
+
}
|
| 440 |
+
|
| 441 |
+
/**
|
| 442 |
+
* Select a specific slot
|
| 443 |
+
*/
|
| 444 |
+
selectSlot(slot) {
|
| 445 |
+
this.currentSlot = slot;
|
| 446 |
+
this.initialsModal.querySelectorAll('.initial-slot').forEach((el, index) => {
|
| 447 |
+
el.classList.toggle('active', index === slot);
|
| 448 |
+
});
|
| 449 |
+
}
|
| 450 |
+
|
| 451 |
+
/**
|
| 452 |
+
* Update arcade slots from text input
|
| 453 |
+
*/
|
| 454 |
+
updateInitialsFromText(text) {
|
| 455 |
+
// Pad with 'A' if less than 3 characters
|
| 456 |
+
const paddedText = text.padEnd(3, 'A');
|
| 457 |
+
this.initials = paddedText.split('');
|
| 458 |
+
this.updateInitialsDisplay();
|
| 459 |
+
}
|
| 460 |
+
|
| 461 |
+
/**
|
| 462 |
+
* Update text input from arcade slots
|
| 463 |
+
*/
|
| 464 |
+
updateTextFromInitials() {
|
| 465 |
+
const textInput = this.initialsModal.querySelector('#initials-text-input');
|
| 466 |
+
if (textInput) {
|
| 467 |
+
textInput.value = this.initials.join('');
|
| 468 |
+
}
|
| 469 |
+
}
|
| 470 |
+
|
| 471 |
+
/**
|
| 472 |
+
* Update the visual display of initials
|
| 473 |
+
*/
|
| 474 |
+
updateInitialsDisplay() {
|
| 475 |
+
this.initialsModal.querySelectorAll('.initial-slot').forEach((slot, index) => {
|
| 476 |
+
slot.querySelector('.slot-letter').textContent = this.initials[index];
|
| 477 |
+
});
|
| 478 |
+
}
|
| 479 |
+
|
| 480 |
+
/**
|
| 481 |
+
* Submit initials and save to leaderboard
|
| 482 |
+
*/
|
| 483 |
+
submitInitials() {
|
| 484 |
+
// Prevent accidental immediate submission
|
| 485 |
+
if (!this.canSubmitInitials) {
|
| 486 |
+
return;
|
| 487 |
+
}
|
| 488 |
+
|
| 489 |
+
const initialsString = this.initials.join('');
|
| 490 |
+
|
| 491 |
+
// Save to player profile
|
| 492 |
+
const profile = this.service.getPlayerProfile();
|
| 493 |
+
profile.initials = initialsString;
|
| 494 |
+
profile.hasEnteredInitials = true;
|
| 495 |
+
this.service.savePlayerProfile(profile);
|
| 496 |
+
|
| 497 |
+
// Call the callback
|
| 498 |
+
if (this.onInitialsSubmit) {
|
| 499 |
+
this.onInitialsSubmit(initialsString);
|
| 500 |
+
}
|
| 501 |
+
|
| 502 |
+
// Hide modal
|
| 503 |
+
this.hideInitialsEntry();
|
| 504 |
+
|
| 505 |
+
// Show success message briefly, then show leaderboard
|
| 506 |
+
this.showSuccessMessage(() => {
|
| 507 |
+
this.show();
|
| 508 |
+
});
|
| 509 |
+
}
|
| 510 |
+
|
| 511 |
+
/**
|
| 512 |
+
* Hide initials entry modal
|
| 513 |
+
*/
|
| 514 |
+
hideInitialsEntry() {
|
| 515 |
+
if (this.initialsModal) {
|
| 516 |
+
this.initialsModal.classList.remove('visible');
|
| 517 |
+
setTimeout(() => {
|
| 518 |
+
if (this.initialsModal && this.initialsModal.parentNode) {
|
| 519 |
+
this.initialsModal.parentNode.removeChild(this.initialsModal);
|
| 520 |
+
}
|
| 521 |
+
this.initialsModal = null;
|
| 522 |
+
}, 300);
|
| 523 |
+
}
|
| 524 |
+
|
| 525 |
+
if (this.initialsKeyHandler) {
|
| 526 |
+
document.removeEventListener('keydown', this.initialsKeyHandler);
|
| 527 |
+
this.initialsKeyHandler = null;
|
| 528 |
+
}
|
| 529 |
+
}
|
| 530 |
+
|
| 531 |
+
/**
|
| 532 |
+
* Show success message after submitting initials
|
| 533 |
+
*/
|
| 534 |
+
showSuccessMessage(onComplete) {
|
| 535 |
+
const successDiv = document.createElement('div');
|
| 536 |
+
successDiv.className = 'leaderboard-overlay visible';
|
| 537 |
+
successDiv.innerHTML = `
|
| 538 |
+
<div class="leaderboard-modal success-message">
|
| 539 |
+
<div class="success-content">
|
| 540 |
+
<h2>Score Saved</h2>
|
| 541 |
+
<p>Your initials have been added to the leaderboard</p>
|
| 542 |
+
</div>
|
| 543 |
+
</div>
|
| 544 |
+
`;
|
| 545 |
+
|
| 546 |
+
document.body.appendChild(successDiv);
|
| 547 |
+
|
| 548 |
+
setTimeout(() => {
|
| 549 |
+
successDiv.classList.remove('visible');
|
| 550 |
+
setTimeout(() => {
|
| 551 |
+
if (successDiv.parentNode) {
|
| 552 |
+
successDiv.parentNode.removeChild(successDiv);
|
| 553 |
+
}
|
| 554 |
+
if (onComplete) {
|
| 555 |
+
onComplete();
|
| 556 |
+
}
|
| 557 |
+
}, 300);
|
| 558 |
+
}, 1500);
|
| 559 |
+
}
|
| 560 |
+
|
| 561 |
+
/**
|
| 562 |
+
* Show notification toast for milestone achievement
|
| 563 |
+
*/
|
| 564 |
+
showMilestoneNotification(level) {
|
| 565 |
+
const toast = document.createElement('div');
|
| 566 |
+
toast.className = 'milestone-toast';
|
| 567 |
+
toast.innerHTML = `
|
| 568 |
+
<div class="toast-content">
|
| 569 |
+
Milestone Reached: Level ${level}
|
| 570 |
+
</div>
|
| 571 |
+
`;
|
| 572 |
+
|
| 573 |
+
document.body.appendChild(toast);
|
| 574 |
+
|
| 575 |
+
// Animate in
|
| 576 |
+
requestAnimationFrame(() => {
|
| 577 |
+
toast.classList.add('visible');
|
| 578 |
+
});
|
| 579 |
+
|
| 580 |
+
// Auto-hide after 3 seconds
|
| 581 |
+
setTimeout(() => {
|
| 582 |
+
toast.classList.remove('visible');
|
| 583 |
+
setTimeout(() => {
|
| 584 |
+
if (toast.parentNode) {
|
| 585 |
+
toast.parentNode.removeChild(toast);
|
| 586 |
+
}
|
| 587 |
+
}, 300);
|
| 588 |
+
}, 3000);
|
| 589 |
+
}
|
| 590 |
+
}
|
src/styles.css
ADDED
|
@@ -0,0 +1,1242 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
@tailwind base;
|
| 2 |
+
@tailwind components;
|
| 3 |
+
@tailwind utilities;
|
| 4 |
+
|
| 5 |
+
/* Chat functionality styles */
|
| 6 |
+
.typing-dots span {
|
| 7 |
+
animation: typing 1.5s infinite;
|
| 8 |
+
}
|
| 9 |
+
|
| 10 |
+
.typing-dots span:nth-child(2) {
|
| 11 |
+
animation-delay: 0.5s;
|
| 12 |
+
}
|
| 13 |
+
|
| 14 |
+
.typing-dots span:nth-child(3) {
|
| 15 |
+
animation-delay: 1s;
|
| 16 |
+
}
|
| 17 |
+
|
| 18 |
+
@keyframes typing {
|
| 19 |
+
0%, 60%, 100% { opacity: 0.3; }
|
| 20 |
+
30% { opacity: 1; }
|
| 21 |
+
}
|
| 22 |
+
|
| 23 |
+
.chat-button {
|
| 24 |
+
transition: all 0.2s ease;
|
| 25 |
+
border-radius: 4px;
|
| 26 |
+
padding: 4px 6px;
|
| 27 |
+
font-size: 1.5rem;
|
| 28 |
+
vertical-align: middle;
|
| 29 |
+
display: inline-flex;
|
| 30 |
+
align-items: center;
|
| 31 |
+
justify-content: center;
|
| 32 |
+
min-width: 32px;
|
| 33 |
+
min-height: 32px;
|
| 34 |
+
}
|
| 35 |
+
|
| 36 |
+
.chat-button:hover {
|
| 37 |
+
background-color: rgba(59, 130, 246, 0.1);
|
| 38 |
+
transform: scale(1.15);
|
| 39 |
+
}
|
| 40 |
+
|
| 41 |
+
/* Responsive sizing for mobile */
|
| 42 |
+
@media (max-width: 640px) {
|
| 43 |
+
.chat-button {
|
| 44 |
+
font-size: 1.25rem;
|
| 45 |
+
min-width: 28px;
|
| 46 |
+
min-height: 28px;
|
| 47 |
+
}
|
| 48 |
+
}
|
| 49 |
+
|
| 50 |
+
/* Larger screens get bigger icons */
|
| 51 |
+
@media (min-width: 1024px) {
|
| 52 |
+
.chat-button {
|
| 53 |
+
font-size: 1.75rem;
|
| 54 |
+
min-width: 36px;
|
| 55 |
+
min-height: 36px;
|
| 56 |
+
}
|
| 57 |
+
}
|
| 58 |
+
|
| 59 |
+
/* Chat modal styling to match game font */
|
| 60 |
+
#chat-modal {
|
| 61 |
+
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', 'Roboto', 'Helvetica Neue', Arial, sans-serif;
|
| 62 |
+
}
|
| 63 |
+
|
| 64 |
+
#chat-modal .bg-white {
|
| 65 |
+
background-color: var(--aged-paper-light);
|
| 66 |
+
border: 2px solid rgba(0, 0, 0, 0.1);
|
| 67 |
+
}
|
| 68 |
+
|
| 69 |
+
#chat-modal h3 {
|
| 70 |
+
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', 'Roboto', 'Helvetica Neue', Arial, sans-serif;
|
| 71 |
+
color: var(--typewriter-ink);
|
| 72 |
+
font-weight: 600;
|
| 73 |
+
}
|
| 74 |
+
|
| 75 |
+
#chat-messages {
|
| 76 |
+
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', 'Roboto', 'Helvetica Neue', Arial, sans-serif;
|
| 77 |
+
color: var(--typewriter-ink);
|
| 78 |
+
line-height: 1.6;
|
| 79 |
+
}
|
| 80 |
+
|
| 81 |
+
#chat-messages .text-gray-500 {
|
| 82 |
+
color: #666 !important;
|
| 83 |
+
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', 'Roboto', 'Helvetica Neue', Arial, sans-serif;
|
| 84 |
+
}
|
| 85 |
+
|
| 86 |
+
.suggestion-btn, .question-btn {
|
| 87 |
+
transition: all 0.2s ease;
|
| 88 |
+
padding: 14px 18px;
|
| 89 |
+
background: #f8f9fa;
|
| 90 |
+
border: 2px solid #e9ecef;
|
| 91 |
+
border-radius: 8px;
|
| 92 |
+
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', 'Roboto', 'Helvetica Neue', Arial, sans-serif;
|
| 93 |
+
font-size: 15px;
|
| 94 |
+
font-weight: 500;
|
| 95 |
+
text-align: center;
|
| 96 |
+
cursor: pointer;
|
| 97 |
+
line-height: 1.4;
|
| 98 |
+
min-height: 64px;
|
| 99 |
+
display: flex;
|
| 100 |
+
align-items: center;
|
| 101 |
+
justify-content: center;
|
| 102 |
+
width: 100%;
|
| 103 |
+
box-sizing: border-box;
|
| 104 |
+
}
|
| 105 |
+
|
| 106 |
+
.suggestion-btn:hover, .question-btn:hover:not(:disabled) {
|
| 107 |
+
background: #e9ecef;
|
| 108 |
+
border-color: #6c757d;
|
| 109 |
+
transform: translateY(-1px);
|
| 110 |
+
}
|
| 111 |
+
|
| 112 |
+
.suggestion-btn:disabled, .question-btn:disabled {
|
| 113 |
+
opacity: 0.5;
|
| 114 |
+
cursor: not-allowed;
|
| 115 |
+
background: #f8f9fa;
|
| 116 |
+
}
|
| 117 |
+
|
| 118 |
+
/* Answer revelation styles */
|
| 119 |
+
.revealed-answer {
|
| 120 |
+
background-color: #fef3c7 !important;
|
| 121 |
+
font-weight: bold !important;
|
| 122 |
+
color: #92400e !important;
|
| 123 |
+
border: 2px solid #f59e0b !important;
|
| 124 |
+
}
|
| 125 |
+
|
| 126 |
+
/* Correct and incorrect answer styles */
|
| 127 |
+
|
| 128 |
+
/* Welcome overlay styles */
|
| 129 |
+
.welcome-overlay {
|
| 130 |
+
position: fixed;
|
| 131 |
+
top: 0;
|
| 132 |
+
left: 0;
|
| 133 |
+
width: 100%;
|
| 134 |
+
height: 100%;
|
| 135 |
+
background: rgba(0, 0, 0, 0.8);
|
| 136 |
+
display: flex;
|
| 137 |
+
align-items: center;
|
| 138 |
+
justify-content: center;
|
| 139 |
+
z-index: 1000;
|
| 140 |
+
opacity: 0;
|
| 141 |
+
transition: opacity 0.3s ease;
|
| 142 |
+
}
|
| 143 |
+
|
| 144 |
+
.welcome-modal {
|
| 145 |
+
background: white;
|
| 146 |
+
border-radius: 12px;
|
| 147 |
+
padding: 24px;
|
| 148 |
+
box-shadow: 0 20px 40px rgba(0, 0, 0, 0.3);
|
| 149 |
+
max-height: 80vh;
|
| 150 |
+
overflow-y: auto;
|
| 151 |
+
}
|
| 152 |
+
|
| 153 |
+
.welcome-title {
|
| 154 |
+
margin: 0 0 16px 0;
|
| 155 |
+
color: #1f2937;
|
| 156 |
+
font-size: 22px;
|
| 157 |
+
font-weight: 600;
|
| 158 |
+
}
|
| 159 |
+
|
| 160 |
+
.welcome-content {
|
| 161 |
+
text-align: left;
|
| 162 |
+
color: #4b5563;
|
| 163 |
+
line-height: 1.4;
|
| 164 |
+
margin-bottom: 20px;
|
| 165 |
+
font-size: 14px;
|
| 166 |
+
}
|
| 167 |
+
|
| 168 |
+
.welcome-content p {
|
| 169 |
+
margin: 0 0 12px 0;
|
| 170 |
+
}
|
| 171 |
+
|
| 172 |
+
.welcome-overlay .typewriter-button {
|
| 173 |
+
background: #1f2937;
|
| 174 |
+
color: white;
|
| 175 |
+
border: 2px solid #374151;
|
| 176 |
+
padding: 8px 14px;
|
| 177 |
+
font-weight: 600;
|
| 178 |
+
font-size: 14px;
|
| 179 |
+
min-width: 100px;
|
| 180 |
+
min-height: 36px;
|
| 181 |
+
transition: all 0.2s ease;
|
| 182 |
+
}
|
| 183 |
+
|
| 184 |
+
.welcome-overlay .typewriter-button:hover {
|
| 185 |
+
background: #374151;
|
| 186 |
+
border-color: #4b5563;
|
| 187 |
+
transform: translateY(-1px);
|
| 188 |
+
}
|
| 189 |
+
.cloze-input.correct {
|
| 190 |
+
background-color: #dcfce7 !important;
|
| 191 |
+
border-color: #16a34a !important;
|
| 192 |
+
color: #15803d !important;
|
| 193 |
+
font-weight: bold !important;
|
| 194 |
+
}
|
| 195 |
+
|
| 196 |
+
.cloze-input.incorrect {
|
| 197 |
+
background-color: #fef2f2 !important;
|
| 198 |
+
border-color: #dc2626 !important;
|
| 199 |
+
color: #dc2626 !important;
|
| 200 |
+
font-weight: bold !important;
|
| 201 |
+
}
|
| 202 |
+
|
| 203 |
+
.cloze-input:disabled {
|
| 204 |
+
opacity: 0.8;
|
| 205 |
+
cursor: not-allowed;
|
| 206 |
+
}
|
| 207 |
+
|
| 208 |
+
/* Custom typewriter color scheme */
|
| 209 |
+
:root {
|
| 210 |
+
--aged-paper: #faf7f0;
|
| 211 |
+
--aged-paper-dark: #f5f1e8;
|
| 212 |
+
--aged-paper-light: #fefcf7;
|
| 213 |
+
--typewriter-ink: #2c2c2c;
|
| 214 |
+
--typewriter-ribbon: #8b5cf6;
|
| 215 |
+
--paper-shadow: rgba(0, 0, 0, 0.08);
|
| 216 |
+
}
|
| 217 |
+
|
| 218 |
+
@layer base {
|
| 219 |
+
body {
|
| 220 |
+
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', 'Roboto', 'Helvetica Neue', Arial, sans-serif;
|
| 221 |
+
background-color: var(--aged-paper);
|
| 222 |
+
color: var(--typewriter-ink);
|
| 223 |
+
line-height: 1.6;
|
| 224 |
+
font-size: 16px;
|
| 225 |
+
background-image:
|
| 226 |
+
radial-gradient(circle at 25% 25%, rgba(139, 92, 246, 0.02) 0%, transparent 50%),
|
| 227 |
+
radial-gradient(circle at 75% 75%, rgba(139, 92, 246, 0.02) 0%, transparent 50%);
|
| 228 |
+
}
|
| 229 |
+
}
|
| 230 |
+
|
| 231 |
+
@layer components {
|
| 232 |
+
.typewriter-text {
|
| 233 |
+
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', 'Roboto', 'Helvetica Neue', Arial, sans-serif;
|
| 234 |
+
color: var(--typewriter-ink);
|
| 235 |
+
font-weight: 600;
|
| 236 |
+
line-height: 1.7;
|
| 237 |
+
}
|
| 238 |
+
|
| 239 |
+
.typewriter-subtitle {
|
| 240 |
+
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', 'Roboto', 'Helvetica Neue', Arial, sans-serif;
|
| 241 |
+
color: #666;
|
| 242 |
+
font-size: 1rem;
|
| 243 |
+
line-height: 1.5;
|
| 244 |
+
}
|
| 245 |
+
|
| 246 |
+
.paper-sheet {
|
| 247 |
+
background-color: var(--aged-paper-light);
|
| 248 |
+
border: 1px solid rgba(139, 92, 246, 0.1);
|
| 249 |
+
box-shadow:
|
| 250 |
+
0 2px 8px var(--paper-shadow),
|
| 251 |
+
inset 0 1px 0 rgba(255, 255, 255, 0.5);
|
| 252 |
+
position: relative;
|
| 253 |
+
}
|
| 254 |
+
|
| 255 |
+
.paper-sheet::before {
|
| 256 |
+
content: '';
|
| 257 |
+
position: absolute;
|
| 258 |
+
top: 0;
|
| 259 |
+
left: 0;
|
| 260 |
+
right: 0;
|
| 261 |
+
bottom: 0;
|
| 262 |
+
background: repeating-linear-gradient(
|
| 263 |
+
to bottom,
|
| 264 |
+
transparent,
|
| 265 |
+
transparent 23px,
|
| 266 |
+
rgba(139, 92, 246, 0.03) 23px,
|
| 267 |
+
rgba(139, 92, 246, 0.03) 24px
|
| 268 |
+
);
|
| 269 |
+
pointer-events: none;
|
| 270 |
+
z-index: 1;
|
| 271 |
+
}
|
| 272 |
+
|
| 273 |
+
.paper-content {
|
| 274 |
+
position: relative;
|
| 275 |
+
z-index: 2;
|
| 276 |
+
}
|
| 277 |
+
|
| 278 |
+
.cloze-input {
|
| 279 |
+
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', 'Roboto', 'Helvetica Neue', Arial, sans-serif;
|
| 280 |
+
background-color: transparent;
|
| 281 |
+
border: none;
|
| 282 |
+
border-bottom: 2px dotted black;
|
| 283 |
+
color: var(--typewriter-ink);
|
| 284 |
+
text-align: center;
|
| 285 |
+
outline: none;
|
| 286 |
+
padding: 3px 4px;
|
| 287 |
+
margin: 0 1px;
|
| 288 |
+
min-width: 4ch;
|
| 289 |
+
width: auto;
|
| 290 |
+
font-size: inherit;
|
| 291 |
+
line-height: inherit;
|
| 292 |
+
transition: all 0.2s ease;
|
| 293 |
+
}
|
| 294 |
+
|
| 295 |
+
.cloze-input:focus {
|
| 296 |
+
border-bottom: 2px dotted black;
|
| 297 |
+
box-shadow: 0 2px 0 rgba(0, 0, 0, 0.2);
|
| 298 |
+
background-color: rgba(0, 0, 0, 0.05);
|
| 299 |
+
}
|
| 300 |
+
|
| 301 |
+
.cloze-input.correct {
|
| 302 |
+
border-bottom: 2px dotted #10b981;
|
| 303 |
+
background-color: rgba(16, 185, 129, 0.1);
|
| 304 |
+
}
|
| 305 |
+
|
| 306 |
+
.cloze-input.incorrect {
|
| 307 |
+
border-bottom: 2px dotted #ef4444;
|
| 308 |
+
background-color: rgba(239, 68, 68, 0.1);
|
| 309 |
+
}
|
| 310 |
+
|
| 311 |
+
.cloze-input::placeholder {
|
| 312 |
+
color: rgba(0, 0, 0, 0.4);
|
| 313 |
+
font-style: normal;
|
| 314 |
+
font-family: monospace;
|
| 315 |
+
letter-spacing: 0.1em;
|
| 316 |
+
}
|
| 317 |
+
|
| 318 |
+
.typewriter-button {
|
| 319 |
+
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', 'Roboto', 'Helvetica Neue', Arial, sans-serif;
|
| 320 |
+
min-width: 100px;
|
| 321 |
+
min-height: 36px;
|
| 322 |
+
padding: 8px 14px;
|
| 323 |
+
background-color: var(--aged-paper-dark);
|
| 324 |
+
color: var(--typewriter-ink);
|
| 325 |
+
border: 2px solid black;
|
| 326 |
+
border-radius: 6px;
|
| 327 |
+
font-weight: 600;
|
| 328 |
+
font-size: 14px;
|
| 329 |
+
cursor: pointer;
|
| 330 |
+
transition: all 0.15s ease;
|
| 331 |
+
box-shadow:
|
| 332 |
+
0 3px 0 rgba(0, 0, 0, 0.3),
|
| 333 |
+
0 4px 8px rgba(0, 0, 0, 0.1);
|
| 334 |
+
position: relative;
|
| 335 |
+
overflow: hidden;
|
| 336 |
+
}
|
| 337 |
+
|
| 338 |
+
.typewriter-button:hover:not(:disabled) {
|
| 339 |
+
background-color: rgba(0, 0, 0, 0.05);
|
| 340 |
+
transform: translateY(-1px);
|
| 341 |
+
box-shadow:
|
| 342 |
+
0 4px 0 rgba(0, 0, 0, 0.3),
|
| 343 |
+
0 6px 12px rgba(0, 0, 0, 0.15);
|
| 344 |
+
}
|
| 345 |
+
|
| 346 |
+
.typewriter-button:active:not(:disabled) {
|
| 347 |
+
transform: translateY(2px);
|
| 348 |
+
box-shadow:
|
| 349 |
+
0 1px 0 rgba(0, 0, 0, 0.3),
|
| 350 |
+
0 2px 4px rgba(0, 0, 0, 0.1);
|
| 351 |
+
}
|
| 352 |
+
|
| 353 |
+
.typewriter-button:disabled {
|
| 354 |
+
opacity: 0.6;
|
| 355 |
+
cursor: not-allowed;
|
| 356 |
+
transform: translateY(2px);
|
| 357 |
+
box-shadow:
|
| 358 |
+
0 2px 0 rgba(0, 0, 0, 0.2),
|
| 359 |
+
0 3px 6px rgba(0, 0, 0, 0.05);
|
| 360 |
+
}
|
| 361 |
+
|
| 362 |
+
.typewriter-button:focus {
|
| 363 |
+
outline: none;
|
| 364 |
+
box-shadow:
|
| 365 |
+
0 4px 0 rgba(0, 0, 0, 0.3),
|
| 366 |
+
0 6px 12px rgba(0, 0, 0, 0.1),
|
| 367 |
+
0 0 0 3px rgba(0, 0, 0, 0.2);
|
| 368 |
+
}
|
| 369 |
+
|
| 370 |
+
.prose {
|
| 371 |
+
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', 'Roboto', 'Helvetica Neue', Arial, sans-serif;
|
| 372 |
+
font-size: 1.125rem;
|
| 373 |
+
line-height: 1.7;
|
| 374 |
+
color: var(--typewriter-ink);
|
| 375 |
+
overflow-wrap: break-word;
|
| 376 |
+
}
|
| 377 |
+
|
| 378 |
+
.prose p {
|
| 379 |
+
margin-bottom: 1.5rem;
|
| 380 |
+
text-align: justify;
|
| 381 |
+
}
|
| 382 |
+
|
| 383 |
+
.biblio-info {
|
| 384 |
+
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', 'Roboto', 'Helvetica Neue', Arial, sans-serif;
|
| 385 |
+
font-size: 0.85rem;
|
| 386 |
+
color: #666;
|
| 387 |
+
font-style: italic;
|
| 388 |
+
}
|
| 389 |
+
|
| 390 |
+
.context-box {
|
| 391 |
+
background-color: rgba(245, 158, 11, 0.08);
|
| 392 |
+
border-left: 4px solid #f59e0b;
|
| 393 |
+
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', 'Roboto', 'Helvetica Neue', Arial, sans-serif;
|
| 394 |
+
font-size: 0.9rem;
|
| 395 |
+
line-height: 1.6;
|
| 396 |
+
}
|
| 397 |
+
|
| 398 |
+
.hints-box {
|
| 399 |
+
background-color: rgba(245, 158, 11, 0.08);
|
| 400 |
+
border-left: 4px solid #f59e0b;
|
| 401 |
+
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', 'Roboto', 'Helvetica Neue', Arial, sans-serif;
|
| 402 |
+
font-size: 0.9rem;
|
| 403 |
+
line-height: 1.6;
|
| 404 |
+
}
|
| 405 |
+
|
| 406 |
+
.round-badge {
|
| 407 |
+
background-color: rgba(245, 158, 11, 0.1);
|
| 408 |
+
color: #666;
|
| 409 |
+
border: 1px solid rgba(245, 158, 11, 0.2);
|
| 410 |
+
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', 'Roboto', 'Helvetica Neue', Arial, sans-serif;
|
| 411 |
+
font-weight: 600;
|
| 412 |
+
}
|
| 413 |
+
|
| 414 |
+
/* Ensure specific game info elements use accessible font */
|
| 415 |
+
#book-info, #round-info, #contextualization {
|
| 416 |
+
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', 'Roboto', 'Helvetica Neue', Arial, sans-serif;
|
| 417 |
+
}
|
| 418 |
+
|
| 419 |
+
.loading-text {
|
| 420 |
+
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', 'Roboto', 'Helvetica Neue', Arial, sans-serif;
|
| 421 |
+
color: #666;
|
| 422 |
+
}
|
| 423 |
+
|
| 424 |
+
.result-text {
|
| 425 |
+
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', 'Roboto', 'Helvetica Neue', Arial, sans-serif;
|
| 426 |
+
font-weight: 600;
|
| 427 |
+
}
|
| 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 |
+
touch-action: manipulation;
|
| 450 |
+
-webkit-tap-highlight-color: rgba(0, 0, 0, 0.1);
|
| 451 |
+
user-select: none;
|
| 452 |
+
}
|
| 453 |
+
|
| 454 |
+
.leaderboard-footer-btn:hover {
|
| 455 |
+
background-color: rgba(0, 0, 0, 0.05);
|
| 456 |
+
transform: translateY(-1px);
|
| 457 |
+
box-shadow:
|
| 458 |
+
0 4px 0 rgba(0, 0, 0, 0.3),
|
| 459 |
+
0 6px 12px rgba(0, 0, 0, 0.15);
|
| 460 |
+
}
|
| 461 |
+
|
| 462 |
+
.leaderboard-footer-btn:active {
|
| 463 |
+
transform: translateY(2px);
|
| 464 |
+
box-shadow:
|
| 465 |
+
0 1px 0 rgba(0, 0, 0, 0.3),
|
| 466 |
+
0 2px 4px rgba(0, 0, 0, 0.1);
|
| 467 |
+
}
|
| 468 |
+
|
| 469 |
+
|
| 470 |
+
/* Mobile responsive */
|
| 471 |
+
@media (max-width: 768px) {
|
| 472 |
+
.typewriter-subtitle {
|
| 473 |
+
display: none;
|
| 474 |
+
}
|
| 475 |
+
}
|
| 476 |
+
|
| 477 |
+
@media (max-width: 640px) {
|
| 478 |
+
.prose {
|
| 479 |
+
font-size: 1rem;
|
| 480 |
+
}
|
| 481 |
+
|
| 482 |
+
.typewriter-text {
|
| 483 |
+
font-size: 1.75rem;
|
| 484 |
+
}
|
| 485 |
+
|
| 486 |
+
.typewriter-button {
|
| 487 |
+
min-width: 100px;
|
| 488 |
+
min-height: 38px;
|
| 489 |
+
font-size: 0.9rem;
|
| 490 |
+
}
|
| 491 |
+
|
| 492 |
+
.leaderboard-footer-btn {
|
| 493 |
+
font-size: 1.25rem;
|
| 494 |
+
padding: 12px 10px;
|
| 495 |
+
min-width: 50px;
|
| 496 |
+
min-height: 48px;
|
| 497 |
+
}
|
| 498 |
+
}
|
| 499 |
+
|
| 500 |
+
@media (max-width: 480px) {
|
| 501 |
+
.prose {
|
| 502 |
+
font-size: 0.95rem;
|
| 503 |
+
}
|
| 504 |
+
|
| 505 |
+
.typewriter-text {
|
| 506 |
+
font-size: 1.5rem;
|
| 507 |
+
}
|
| 508 |
+
|
| 509 |
+
.cloze-input {
|
| 510 |
+
min-width: 2.5ch;
|
| 511 |
+
}
|
| 512 |
+
|
| 513 |
+
header img {
|
| 514 |
+
width: 2rem;
|
| 515 |
+
height: 2rem;
|
| 516 |
+
}
|
| 517 |
+
|
| 518 |
+
.leaderboard-footer-btn {
|
| 519 |
+
font-size: 1.1rem;
|
| 520 |
+
padding: 12px 8px;
|
| 521 |
+
min-width: 48px;
|
| 522 |
+
}
|
| 523 |
+
}
|
| 524 |
+
|
| 525 |
+
/* Compact question buttons for chat interface */
|
| 526 |
+
#chat-modal .question-btn {
|
| 527 |
+
padding: 6px 8px;
|
| 528 |
+
min-height: auto;
|
| 529 |
+
font-size: 12px;
|
| 530 |
+
line-height: 1.3;
|
| 531 |
+
font-weight: 400;
|
| 532 |
+
transition: all 0.15s ease;
|
| 533 |
+
}
|
| 534 |
+
|
| 535 |
+
/* Mobile dropdown styling */
|
| 536 |
+
#question-dropdown {
|
| 537 |
+
background: var(--aged-paper-dark);
|
| 538 |
+
border: 2px solid rgba(0, 0, 0, 0.1);
|
| 539 |
+
border-radius: 8px;
|
| 540 |
+
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', 'Roboto', 'Helvetica Neue', Arial, sans-serif;
|
| 541 |
+
font-size: 16px;
|
| 542 |
+
font-weight: 500;
|
| 543 |
+
color: var(--typewriter-ink);
|
| 544 |
+
transition: all 0.2s ease;
|
| 545 |
+
}
|
| 546 |
+
|
| 547 |
+
#question-dropdown:focus {
|
| 548 |
+
outline: none;
|
| 549 |
+
border-color: rgba(0, 0, 0, 0.3);
|
| 550 |
+
box-shadow: 0 0 0 3px rgba(0, 0, 0, 0.1);
|
| 551 |
+
}
|
| 552 |
+
|
| 553 |
+
/* Sticky Control Panel */
|
| 554 |
+
.sticky-controls {
|
| 555 |
+
position: fixed;
|
| 556 |
+
bottom: 0;
|
| 557 |
+
left: 0;
|
| 558 |
+
right: 0;
|
| 559 |
+
background: var(--aged-paper-light);
|
| 560 |
+
border-top: 2px solid rgba(0, 0, 0, 0.1);
|
| 561 |
+
box-shadow: 0 -4px 12px rgba(0, 0, 0, 0.1);
|
| 562 |
+
padding: 12px 16px;
|
| 563 |
+
z-index: 1000;
|
| 564 |
+
backdrop-filter: blur(8px);
|
| 565 |
+
}
|
| 566 |
+
|
| 567 |
+
.sticky-controls .controls-inner {
|
| 568 |
+
position: relative;
|
| 569 |
+
max-width: 1024px;
|
| 570 |
+
margin: 0 auto;
|
| 571 |
+
display: flex;
|
| 572 |
+
gap: 1px;
|
| 573 |
+
justify-content: center;
|
| 574 |
+
align-items: center;
|
| 575 |
+
}
|
| 576 |
+
|
| 577 |
+
.sticky-controls .typewriter-button {
|
| 578 |
+
flex: 1;
|
| 579 |
+
max-width: 200px;
|
| 580 |
+
min-height: 44px;
|
| 581 |
+
margin: 0;
|
| 582 |
+
border-radius: 0;
|
| 583 |
+
font-size: 16px;
|
| 584 |
+
font-weight: 600;
|
| 585 |
+
position: relative;
|
| 586 |
+
}
|
| 587 |
+
|
| 588 |
+
.sticky-controls .typewriter-button:first-child {
|
| 589 |
+
border-top-left-radius: 8px;
|
| 590 |
+
border-bottom-left-radius: 8px;
|
| 591 |
+
}
|
| 592 |
+
|
| 593 |
+
.sticky-controls .typewriter-button:last-child {
|
| 594 |
+
border-top-right-radius: 8px;
|
| 595 |
+
border-bottom-right-radius: 8px;
|
| 596 |
+
}
|
| 597 |
+
|
| 598 |
+
.sticky-controls .typewriter-button:not(:last-child) {
|
| 599 |
+
border-right: 1px solid rgba(0, 0, 0, 0.2);
|
| 600 |
+
}
|
| 601 |
+
|
| 602 |
+
.controls-divider {
|
| 603 |
+
height: 24px;
|
| 604 |
+
width: 2px;
|
| 605 |
+
background: rgba(0, 0, 0, 0.3);
|
| 606 |
+
margin: 0 -1px;
|
| 607 |
+
z-index: 1;
|
| 608 |
+
}
|
| 609 |
+
|
| 610 |
+
/* Add bottom padding to main content to prevent overlap */
|
| 611 |
+
#game-container {
|
| 612 |
+
padding-bottom: 100px;
|
| 613 |
+
}
|
| 614 |
+
|
| 615 |
+
/* Mobile optimizations for sticky controls */
|
| 616 |
+
@media (max-width: 640px) {
|
| 617 |
+
.sticky-controls {
|
| 618 |
+
padding: 10px 12px;
|
| 619 |
+
}
|
| 620 |
+
|
| 621 |
+
.sticky-controls .typewriter-button {
|
| 622 |
+
min-height: 48px;
|
| 623 |
+
font-size: 16px;
|
| 624 |
+
padding: 12px 8px;
|
| 625 |
+
}
|
| 626 |
+
|
| 627 |
+
#game-container {
|
| 628 |
+
padding-bottom: 90px;
|
| 629 |
+
}
|
| 630 |
+
}
|
| 631 |
+
|
| 632 |
+
/* Ensure controls stay above mobile keyboards */
|
| 633 |
+
@media (max-width: 640px) and (max-height: 500px) {
|
| 634 |
+
.sticky-controls {
|
| 635 |
+
position: fixed;
|
| 636 |
+
bottom: 0;
|
| 637 |
+
}
|
| 638 |
+
}
|
| 639 |
+
|
| 640 |
+
/* Leaderboard Overlay */
|
| 641 |
+
.leaderboard-overlay {
|
| 642 |
+
position: fixed;
|
| 643 |
+
top: 0;
|
| 644 |
+
left: 0;
|
| 645 |
+
width: 100%;
|
| 646 |
+
height: 100%;
|
| 647 |
+
background: rgba(0, 0, 0, 0.75);
|
| 648 |
+
display: flex;
|
| 649 |
+
align-items: center;
|
| 650 |
+
justify-content: center;
|
| 651 |
+
z-index: 2000;
|
| 652 |
+
opacity: 0;
|
| 653 |
+
transition: opacity 0.3s ease;
|
| 654 |
+
backdrop-filter: blur(3px);
|
| 655 |
+
}
|
| 656 |
+
|
| 657 |
+
.leaderboard-overlay.visible {
|
| 658 |
+
opacity: 1;
|
| 659 |
+
}
|
| 660 |
+
|
| 661 |
+
/* Leaderboard Modal */
|
| 662 |
+
.leaderboard-modal {
|
| 663 |
+
background: var(--aged-paper-light);
|
| 664 |
+
border: 3px solid rgba(0, 0, 0, 0.4);
|
| 665 |
+
border-radius: 8px;
|
| 666 |
+
padding: 0;
|
| 667 |
+
box-shadow: 0 20px 60px rgba(0, 0, 0, 0.5);
|
| 668 |
+
max-width: 600px;
|
| 669 |
+
width: 90%;
|
| 670 |
+
max-height: 85vh;
|
| 671 |
+
overflow: hidden;
|
| 672 |
+
animation: slideIn 0.3s ease-out;
|
| 673 |
+
}
|
| 674 |
+
|
| 675 |
+
@keyframes slideIn {
|
| 676 |
+
from {
|
| 677 |
+
transform: translateY(-30px);
|
| 678 |
+
opacity: 0;
|
| 679 |
+
}
|
| 680 |
+
to {
|
| 681 |
+
transform: translateY(0);
|
| 682 |
+
opacity: 1;
|
| 683 |
+
}
|
| 684 |
+
}
|
| 685 |
+
|
| 686 |
+
/* Leaderboard Header */
|
| 687 |
+
.leaderboard-header {
|
| 688 |
+
background: linear-gradient(180deg, var(--aged-paper-dark) 0%, #d4c9b3 100%);
|
| 689 |
+
padding: 20px 24px;
|
| 690 |
+
border-bottom: 3px solid rgba(0, 0, 0, 0.3);
|
| 691 |
+
display: flex;
|
| 692 |
+
justify-content: space-between;
|
| 693 |
+
align-items: center;
|
| 694 |
+
}
|
| 695 |
+
|
| 696 |
+
.leaderboard-title {
|
| 697 |
+
font-family: 'Special Elite', 'Courier New', monospace;
|
| 698 |
+
font-size: 24px;
|
| 699 |
+
font-weight: 700;
|
| 700 |
+
color: var(--typewriter-ink);
|
| 701 |
+
margin: 0;
|
| 702 |
+
letter-spacing: 1px;
|
| 703 |
+
}
|
| 704 |
+
|
| 705 |
+
.leaderboard-live-indicator {
|
| 706 |
+
color: #4ade80;
|
| 707 |
+
font-size: 12px;
|
| 708 |
+
margin-left: 8px;
|
| 709 |
+
opacity: 0.8;
|
| 710 |
+
transition: opacity 0.3s ease, transform 0.3s ease;
|
| 711 |
+
}
|
| 712 |
+
|
| 713 |
+
.leaderboard-live-indicator.pulse {
|
| 714 |
+
animation: live-pulse 0.5s ease-out;
|
| 715 |
+
}
|
| 716 |
+
|
| 717 |
+
@keyframes live-pulse {
|
| 718 |
+
0% {
|
| 719 |
+
transform: scale(1);
|
| 720 |
+
opacity: 0.8;
|
| 721 |
+
}
|
| 722 |
+
50% {
|
| 723 |
+
transform: scale(1.5);
|
| 724 |
+
opacity: 1;
|
| 725 |
+
}
|
| 726 |
+
100% {
|
| 727 |
+
transform: scale(1);
|
| 728 |
+
opacity: 0.8;
|
| 729 |
+
}
|
| 730 |
+
}
|
| 731 |
+
|
| 732 |
+
.leaderboard-close {
|
| 733 |
+
background: transparent;
|
| 734 |
+
color: var(--typewriter-ink);
|
| 735 |
+
border: 2px solid rgba(0, 0, 0, 0.3);
|
| 736 |
+
border-radius: 4px;
|
| 737 |
+
width: 32px;
|
| 738 |
+
height: 32px;
|
| 739 |
+
font-size: 24px;
|
| 740 |
+
line-height: 1;
|
| 741 |
+
cursor: pointer;
|
| 742 |
+
transition: all 0.2s ease;
|
| 743 |
+
display: flex;
|
| 744 |
+
align-items: center;
|
| 745 |
+
justify-content: center;
|
| 746 |
+
}
|
| 747 |
+
|
| 748 |
+
.leaderboard-close:hover {
|
| 749 |
+
background: rgba(0, 0, 0, 0.1);
|
| 750 |
+
border-color: rgba(0, 0, 0, 0.5);
|
| 751 |
+
}
|
| 752 |
+
|
| 753 |
+
.leaderboard-close:active {
|
| 754 |
+
background: rgba(0, 0, 0, 0.15);
|
| 755 |
+
}
|
| 756 |
+
|
| 757 |
+
/* Leaderboard Content */
|
| 758 |
+
.leaderboard-content {
|
| 759 |
+
padding: 24px;
|
| 760 |
+
max-height: calc(85vh - 100px);
|
| 761 |
+
overflow-y: auto;
|
| 762 |
+
background: var(--aged-paper-light);
|
| 763 |
+
}
|
| 764 |
+
|
| 765 |
+
/* Leaderboard List */
|
| 766 |
+
.leaderboard-list {
|
| 767 |
+
display: flex;
|
| 768 |
+
flex-direction: column;
|
| 769 |
+
gap: 8px;
|
| 770 |
+
margin-bottom: 20px;
|
| 771 |
+
}
|
| 772 |
+
|
| 773 |
+
.leaderboard-entry {
|
| 774 |
+
display: grid;
|
| 775 |
+
grid-template-columns: 50px 80px 1fr;
|
| 776 |
+
align-items: center;
|
| 777 |
+
gap: 12px;
|
| 778 |
+
padding: 14px 16px;
|
| 779 |
+
background: var(--aged-paper-dark);
|
| 780 |
+
border: 3px solid rgba(0, 0, 0, 0.4);
|
| 781 |
+
border-radius: 6px;
|
| 782 |
+
font-family: 'Courier New', monospace;
|
| 783 |
+
transition: all 0.2s ease;
|
| 784 |
+
box-shadow: 0 2px 4px rgba(0, 0, 0, 0.15);
|
| 785 |
+
}
|
| 786 |
+
|
| 787 |
+
/* Rank Colors */
|
| 788 |
+
.leaderboard-entry.rank-gold {
|
| 789 |
+
background: linear-gradient(135deg, #fff7e6 0%, #ffd700 100%);
|
| 790 |
+
border-color: #b8860b;
|
| 791 |
+
border-width: 4px;
|
| 792 |
+
box-shadow: 0 4px 12px rgba(184, 134, 11, 0.4), inset 0 1px 0 rgba(255, 255, 255, 0.5);
|
| 793 |
+
}
|
| 794 |
+
|
| 795 |
+
.leaderboard-entry.rank-silver {
|
| 796 |
+
background: linear-gradient(135deg, #fff9f2 0%, #e6d7b8 100%);
|
| 797 |
+
border-color: #a0826d;
|
| 798 |
+
border-width: 3px;
|
| 799 |
+
box-shadow: 0 3px 8px rgba(160, 130, 109, 0.3);
|
| 800 |
+
}
|
| 801 |
+
|
| 802 |
+
.leaderboard-entry.rank-standard {
|
| 803 |
+
background: #ffffff;
|
| 804 |
+
border-color: rgba(0, 0, 0, 0.3);
|
| 805 |
+
border-width: 2px;
|
| 806 |
+
}
|
| 807 |
+
|
| 808 |
+
.leaderboard-entry.player-entry {
|
| 809 |
+
background: linear-gradient(135deg, #fff9f0 0%, #ffd89b 100%);
|
| 810 |
+
border-color: #cc8800;
|
| 811 |
+
border-width: 4px;
|
| 812 |
+
box-shadow: 0 4px 12px rgba(204, 136, 0, 0.35);
|
| 813 |
+
}
|
| 814 |
+
|
| 815 |
+
.leaderboard-entry:hover {
|
| 816 |
+
transform: translateX(4px);
|
| 817 |
+
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.15);
|
| 818 |
+
}
|
| 819 |
+
|
| 820 |
+
.entry-rank {
|
| 821 |
+
font-size: 20px;
|
| 822 |
+
font-weight: 700;
|
| 823 |
+
color: var(--typewriter-ink);
|
| 824 |
+
text-align: center;
|
| 825 |
+
}
|
| 826 |
+
|
| 827 |
+
.entry-initials {
|
| 828 |
+
font-size: 18px;
|
| 829 |
+
font-weight: 700;
|
| 830 |
+
color: var(--typewriter-ink);
|
| 831 |
+
letter-spacing: 2px;
|
| 832 |
+
text-align: center;
|
| 833 |
+
font-family: 'Courier New', monospace;
|
| 834 |
+
}
|
| 835 |
+
|
| 836 |
+
.entry-score {
|
| 837 |
+
font-size: 16px;
|
| 838 |
+
font-weight: 600;
|
| 839 |
+
color: #666;
|
| 840 |
+
}
|
| 841 |
+
|
| 842 |
+
.entry-round {
|
| 843 |
+
font-size: 14px;
|
| 844 |
+
color: #999;
|
| 845 |
+
font-weight: 400;
|
| 846 |
+
}
|
| 847 |
+
|
| 848 |
+
/* Empty State */
|
| 849 |
+
.leaderboard-empty {
|
| 850 |
+
text-align: center;
|
| 851 |
+
padding: 40px 20px;
|
| 852 |
+
color: #666;
|
| 853 |
+
}
|
| 854 |
+
|
| 855 |
+
.leaderboard-empty p {
|
| 856 |
+
margin: 8px 0;
|
| 857 |
+
font-size: 16px;
|
| 858 |
+
}
|
| 859 |
+
|
| 860 |
+
/* Player Stats */
|
| 861 |
+
.leaderboard-player-stats {
|
| 862 |
+
margin-top: 24px;
|
| 863 |
+
padding: 18px;
|
| 864 |
+
background: rgba(212, 165, 116, 0.08);
|
| 865 |
+
border: 2px solid rgba(212, 165, 116, 0.25);
|
| 866 |
+
border-radius: 6px;
|
| 867 |
+
}
|
| 868 |
+
|
| 869 |
+
.player-best {
|
| 870 |
+
font-size: 16px;
|
| 871 |
+
font-weight: 600;
|
| 872 |
+
color: var(--typewriter-ink);
|
| 873 |
+
margin-bottom: 8px;
|
| 874 |
+
}
|
| 875 |
+
|
| 876 |
+
.player-best .highlight {
|
| 877 |
+
color: #8b6914;
|
| 878 |
+
font-weight: 700;
|
| 879 |
+
}
|
| 880 |
+
|
| 881 |
+
.player-stats-details {
|
| 882 |
+
display: flex;
|
| 883 |
+
flex-direction: column;
|
| 884 |
+
gap: 4px;
|
| 885 |
+
font-size: 14px;
|
| 886 |
+
color: #666;
|
| 887 |
+
}
|
| 888 |
+
|
| 889 |
+
/* Initials Entry Modal */
|
| 890 |
+
.initials-overlay {
|
| 891 |
+
background: rgba(0, 0, 0, 0.9);
|
| 892 |
+
}
|
| 893 |
+
|
| 894 |
+
.initials-modal {
|
| 895 |
+
max-width: 500px;
|
| 896 |
+
}
|
| 897 |
+
|
| 898 |
+
.initials-header {
|
| 899 |
+
background: linear-gradient(180deg, #f5f1e8 0%, #e8dcc8 100%);
|
| 900 |
+
padding: 24px;
|
| 901 |
+
border-bottom: 3px solid rgba(0, 0, 0, 0.3);
|
| 902 |
+
text-align: center;
|
| 903 |
+
}
|
| 904 |
+
|
| 905 |
+
.initials-title {
|
| 906 |
+
font-family: 'Special Elite', 'Courier New', monospace;
|
| 907 |
+
font-size: 26px;
|
| 908 |
+
font-weight: 700;
|
| 909 |
+
color: #000000;
|
| 910 |
+
margin: 0 0 12px 0;
|
| 911 |
+
letter-spacing: 1px;
|
| 912 |
+
}
|
| 913 |
+
|
| 914 |
+
.initials-achievement {
|
| 915 |
+
font-size: 17px;
|
| 916 |
+
color: #1a1a1a;
|
| 917 |
+
line-height: 1.6;
|
| 918 |
+
font-weight: 500;
|
| 919 |
+
}
|
| 920 |
+
|
| 921 |
+
.initials-achievement .highlight {
|
| 922 |
+
color: #7d3c00;
|
| 923 |
+
font-weight: 700;
|
| 924 |
+
text-decoration: underline;
|
| 925 |
+
}
|
| 926 |
+
|
| 927 |
+
.rank-text {
|
| 928 |
+
font-size: 16px;
|
| 929 |
+
color: #2c2c2c;
|
| 930 |
+
font-weight: 600;
|
| 931 |
+
font-style: normal;
|
| 932 |
+
}
|
| 933 |
+
|
| 934 |
+
.initials-content {
|
| 935 |
+
padding: 32px 24px;
|
| 936 |
+
text-align: center;
|
| 937 |
+
background: var(--aged-paper-light);
|
| 938 |
+
}
|
| 939 |
+
|
| 940 |
+
.initials-prompt {
|
| 941 |
+
font-size: 20px;
|
| 942 |
+
font-weight: 700;
|
| 943 |
+
color: #000000;
|
| 944 |
+
margin-bottom: 24px;
|
| 945 |
+
}
|
| 946 |
+
|
| 947 |
+
/* Initials Slots */
|
| 948 |
+
.initials-slots {
|
| 949 |
+
display: flex;
|
| 950 |
+
justify-content: center;
|
| 951 |
+
gap: 16px;
|
| 952 |
+
margin-bottom: 24px;
|
| 953 |
+
}
|
| 954 |
+
|
| 955 |
+
.initial-slot {
|
| 956 |
+
display: flex;
|
| 957 |
+
flex-direction: column;
|
| 958 |
+
align-items: center;
|
| 959 |
+
gap: 8px;
|
| 960 |
+
}
|
| 961 |
+
|
| 962 |
+
.initial-slot.active .slot-letter {
|
| 963 |
+
border-color: #2563eb;
|
| 964 |
+
background: #eff6ff;
|
| 965 |
+
box-shadow: 0 0 0 4px rgba(37, 99, 235, 0.3), 0 4px 0 rgba(0, 0, 0, 0.5);
|
| 966 |
+
}
|
| 967 |
+
|
| 968 |
+
.slot-letter {
|
| 969 |
+
width: 64px;
|
| 970 |
+
height: 80px;
|
| 971 |
+
display: flex;
|
| 972 |
+
align-items: center;
|
| 973 |
+
justify-content: center;
|
| 974 |
+
font-size: 48px;
|
| 975 |
+
font-weight: 700;
|
| 976 |
+
font-family: 'Courier New', monospace;
|
| 977 |
+
color: #000000;
|
| 978 |
+
background: #ffffff;
|
| 979 |
+
border: 3px solid #000000;
|
| 980 |
+
border-radius: 8px;
|
| 981 |
+
transition: all 0.2s ease;
|
| 982 |
+
box-shadow: 0 4px 0 rgba(0, 0, 0, 0.5);
|
| 983 |
+
}
|
| 984 |
+
|
| 985 |
+
.slot-arrows {
|
| 986 |
+
display: flex;
|
| 987 |
+
flex-direction: column;
|
| 988 |
+
gap: 4px;
|
| 989 |
+
}
|
| 990 |
+
|
| 991 |
+
.arrow-up, .arrow-down {
|
| 992 |
+
width: 40px;
|
| 993 |
+
height: 32px;
|
| 994 |
+
background: var(--aged-paper-dark);
|
| 995 |
+
border: 2px solid black;
|
| 996 |
+
border-radius: 4px;
|
| 997 |
+
font-size: 16px;
|
| 998 |
+
cursor: pointer;
|
| 999 |
+
transition: all 0.15s ease;
|
| 1000 |
+
display: flex;
|
| 1001 |
+
align-items: center;
|
| 1002 |
+
justify-content: center;
|
| 1003 |
+
}
|
| 1004 |
+
|
| 1005 |
+
.arrow-up:hover, .arrow-down:hover {
|
| 1006 |
+
background: rgba(0, 0, 0, 0.05);
|
| 1007 |
+
transform: scale(1.1);
|
| 1008 |
+
}
|
| 1009 |
+
|
| 1010 |
+
.arrow-up:active, .arrow-down:active {
|
| 1011 |
+
transform: scale(0.95);
|
| 1012 |
+
}
|
| 1013 |
+
|
| 1014 |
+
/* Initials Instructions */
|
| 1015 |
+
/* Text Input Section */
|
| 1016 |
+
.text-input-section {
|
| 1017 |
+
display: flex;
|
| 1018 |
+
flex-direction: column;
|
| 1019 |
+
align-items: center;
|
| 1020 |
+
margin-bottom: 20px;
|
| 1021 |
+
}
|
| 1022 |
+
|
| 1023 |
+
.initials-text-input {
|
| 1024 |
+
width: 120px;
|
| 1025 |
+
height: 64px;
|
| 1026 |
+
font-size: 32px;
|
| 1027 |
+
font-weight: 700;
|
| 1028 |
+
font-family: 'Courier New', monospace;
|
| 1029 |
+
text-align: center;
|
| 1030 |
+
text-transform: uppercase;
|
| 1031 |
+
letter-spacing: 8px;
|
| 1032 |
+
background: #ffffff;
|
| 1033 |
+
border: 3px solid #000000;
|
| 1034 |
+
border-radius: 8px;
|
| 1035 |
+
box-shadow: 0 4px 0 rgba(0, 0, 0, 0.5);
|
| 1036 |
+
transition: all 0.2s ease;
|
| 1037 |
+
margin-bottom: 8px;
|
| 1038 |
+
}
|
| 1039 |
+
|
| 1040 |
+
.initials-text-input:focus {
|
| 1041 |
+
outline: none;
|
| 1042 |
+
border-color: #2563eb;
|
| 1043 |
+
background: #eff6ff;
|
| 1044 |
+
box-shadow: 0 0 0 4px rgba(37, 99, 235, 0.3), 0 4px 0 rgba(0, 0, 0, 0.5);
|
| 1045 |
+
}
|
| 1046 |
+
|
| 1047 |
+
.input-help {
|
| 1048 |
+
font-size: 12px;
|
| 1049 |
+
color: #666;
|
| 1050 |
+
margin: 0;
|
| 1051 |
+
font-weight: 400;
|
| 1052 |
+
}
|
| 1053 |
+
|
| 1054 |
+
/* Divider */
|
| 1055 |
+
.input-divider {
|
| 1056 |
+
display: flex;
|
| 1057 |
+
align-items: center;
|
| 1058 |
+
margin: 20px 0;
|
| 1059 |
+
color: #666;
|
| 1060 |
+
font-size: 14px;
|
| 1061 |
+
font-weight: 500;
|
| 1062 |
+
}
|
| 1063 |
+
|
| 1064 |
+
.input-divider::before,
|
| 1065 |
+
.input-divider::after {
|
| 1066 |
+
content: '';
|
| 1067 |
+
flex: 1;
|
| 1068 |
+
height: 1px;
|
| 1069 |
+
background: #ddd;
|
| 1070 |
+
}
|
| 1071 |
+
|
| 1072 |
+
.input-divider span {
|
| 1073 |
+
padding: 0 16px;
|
| 1074 |
+
}
|
| 1075 |
+
|
| 1076 |
+
.initials-instructions {
|
| 1077 |
+
margin-bottom: 24px;
|
| 1078 |
+
color: #000000;
|
| 1079 |
+
font-size: 15px;
|
| 1080 |
+
line-height: 1.7;
|
| 1081 |
+
font-weight: 600;
|
| 1082 |
+
}
|
| 1083 |
+
|
| 1084 |
+
.initials-instructions p {
|
| 1085 |
+
margin: 6px 0;
|
| 1086 |
+
}
|
| 1087 |
+
|
| 1088 |
+
.initials-submit {
|
| 1089 |
+
width: 100%;
|
| 1090 |
+
max-width: 200px;
|
| 1091 |
+
min-height: 48px;
|
| 1092 |
+
font-size: 17px;
|
| 1093 |
+
font-weight: 700;
|
| 1094 |
+
color: #000000 !important;
|
| 1095 |
+
}
|
| 1096 |
+
|
| 1097 |
+
/* Success Message */
|
| 1098 |
+
.success-message {
|
| 1099 |
+
max-width: 400px;
|
| 1100 |
+
padding: 32px;
|
| 1101 |
+
text-align: center;
|
| 1102 |
+
}
|
| 1103 |
+
|
| 1104 |
+
.success-content h2 {
|
| 1105 |
+
font-size: 28px;
|
| 1106 |
+
color: #2d5016;
|
| 1107 |
+
margin-bottom: 12px;
|
| 1108 |
+
font-family: 'Special Elite', 'Courier New', monospace;
|
| 1109 |
+
letter-spacing: 1px;
|
| 1110 |
+
}
|
| 1111 |
+
|
| 1112 |
+
.success-content p {
|
| 1113 |
+
font-size: 16px;
|
| 1114 |
+
color: #666;
|
| 1115 |
+
font-family: 'Courier New', monospace;
|
| 1116 |
+
}
|
| 1117 |
+
|
| 1118 |
+
/* Milestone Toast */
|
| 1119 |
+
.milestone-toast {
|
| 1120 |
+
position: fixed;
|
| 1121 |
+
top: 20px;
|
| 1122 |
+
left: 50%;
|
| 1123 |
+
transform: translateX(-50%) translateY(-100px);
|
| 1124 |
+
background: var(--aged-paper-dark);
|
| 1125 |
+
color: var(--typewriter-ink);
|
| 1126 |
+
padding: 16px 24px;
|
| 1127 |
+
border-radius: 8px;
|
| 1128 |
+
border: 3px solid rgba(0, 0, 0, 0.4);
|
| 1129 |
+
box-shadow: 0 8px 24px rgba(0, 0, 0, 0.3), 0 4px 0 rgba(0, 0, 0, 0.2);
|
| 1130 |
+
z-index: 3000;
|
| 1131 |
+
transition: transform 0.3s ease;
|
| 1132 |
+
font-weight: 600;
|
| 1133 |
+
font-size: 18px;
|
| 1134 |
+
font-family: 'Special Elite', 'Courier New', monospace;
|
| 1135 |
+
letter-spacing: 0.5px;
|
| 1136 |
+
}
|
| 1137 |
+
|
| 1138 |
+
.milestone-toast.visible {
|
| 1139 |
+
transform: translateX(-50%) translateY(0);
|
| 1140 |
+
}
|
| 1141 |
+
|
| 1142 |
+
/* Mobile Responsive */
|
| 1143 |
+
@media (max-width: 640px) {
|
| 1144 |
+
.leaderboard-modal {
|
| 1145 |
+
width: 95%;
|
| 1146 |
+
max-height: 90vh;
|
| 1147 |
+
}
|
| 1148 |
+
|
| 1149 |
+
.leaderboard-title {
|
| 1150 |
+
font-size: 20px;
|
| 1151 |
+
}
|
| 1152 |
+
|
| 1153 |
+
.leaderboard-entry {
|
| 1154 |
+
grid-template-columns: 40px 70px 1fr;
|
| 1155 |
+
gap: 8px;
|
| 1156 |
+
padding: 12px;
|
| 1157 |
+
}
|
| 1158 |
+
|
| 1159 |
+
.entry-rank {
|
| 1160 |
+
font-size: 16px;
|
| 1161 |
+
}
|
| 1162 |
+
|
| 1163 |
+
.entry-initials {
|
| 1164 |
+
font-size: 16px;
|
| 1165 |
+
}
|
| 1166 |
+
|
| 1167 |
+
.entry-score {
|
| 1168 |
+
font-size: 14px;
|
| 1169 |
+
}
|
| 1170 |
+
|
| 1171 |
+
.initials-modal {
|
| 1172 |
+
width: 95%;
|
| 1173 |
+
}
|
| 1174 |
+
|
| 1175 |
+
.slot-letter {
|
| 1176 |
+
width: 56px;
|
| 1177 |
+
height: 70px;
|
| 1178 |
+
font-size: 40px;
|
| 1179 |
+
}
|
| 1180 |
+
|
| 1181 |
+
.initials-slots {
|
| 1182 |
+
gap: 12px;
|
| 1183 |
+
}
|
| 1184 |
+
|
| 1185 |
+
.initials-text-input {
|
| 1186 |
+
width: 100px;
|
| 1187 |
+
height: 56px;
|
| 1188 |
+
font-size: 28px;
|
| 1189 |
+
letter-spacing: 6px;
|
| 1190 |
+
}
|
| 1191 |
+
|
| 1192 |
+
.input-divider {
|
| 1193 |
+
font-size: 12px;
|
| 1194 |
+
}
|
| 1195 |
+
|
| 1196 |
+
.input-help {
|
| 1197 |
+
font-size: 11px;
|
| 1198 |
+
}
|
| 1199 |
+
}
|
| 1200 |
+
|
| 1201 |
+
/* Print styles */
|
| 1202 |
+
@media print {
|
| 1203 |
+
body {
|
| 1204 |
+
background: white;
|
| 1205 |
+
color: black;
|
| 1206 |
+
}
|
| 1207 |
+
|
| 1208 |
+
.paper-sheet::before {
|
| 1209 |
+
display: none;
|
| 1210 |
+
}
|
| 1211 |
+
|
| 1212 |
+
.typewriter-button {
|
| 1213 |
+
display: none;
|
| 1214 |
+
}
|
| 1215 |
+
|
| 1216 |
+
.sticky-controls {
|
| 1217 |
+
display: none;
|
| 1218 |
+
}
|
| 1219 |
+
|
| 1220 |
+
#game-container {
|
| 1221 |
+
padding-bottom: 0;
|
| 1222 |
+
}
|
| 1223 |
+
|
| 1224 |
+
.leaderboard-overlay,
|
| 1225 |
+
.milestone-toast {
|
| 1226 |
+
display: none;
|
| 1227 |
+
}
|
| 1228 |
+
}
|
| 1229 |
+
}
|
| 1230 |
+
|
| 1231 |
+
/* Custom utility classes */
|
| 1232 |
+
@layer utilities {
|
| 1233 |
+
.ink-ribbon-lines {
|
| 1234 |
+
background: repeating-linear-gradient(
|
| 1235 |
+
to bottom,
|
| 1236 |
+
transparent,
|
| 1237 |
+
transparent 23px,
|
| 1238 |
+
rgba(139, 92, 246, 0.03) 23px,
|
| 1239 |
+
rgba(139, 92, 246, 0.03) 24px
|
| 1240 |
+
);
|
| 1241 |
+
}
|
| 1242 |
+
}
|
src/welcomeOverlay.js
ADDED
|
@@ -0,0 +1,106 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
// Welcome overlay for first-time users
|
| 2 |
+
class WelcomeOverlay {
|
| 3 |
+
constructor() {
|
| 4 |
+
this.isVisible = false;
|
| 5 |
+
this.hasBeenShown = localStorage.getItem('cloze-reader-welcomed') === 'true';
|
| 6 |
+
}
|
| 7 |
+
|
| 8 |
+
show() {
|
| 9 |
+
// Always show overlay regardless of previous views
|
| 10 |
+
|
| 11 |
+
this.isVisible = true;
|
| 12 |
+
const overlay = this.createOverlay();
|
| 13 |
+
document.body.appendChild(overlay);
|
| 14 |
+
|
| 15 |
+
// Animate in
|
| 16 |
+
requestAnimationFrame(() => {
|
| 17 |
+
overlay.style.opacity = '1';
|
| 18 |
+
});
|
| 19 |
+
}
|
| 20 |
+
|
| 21 |
+
createOverlay() {
|
| 22 |
+
const overlay = document.createElement('div');
|
| 23 |
+
overlay.className = 'welcome-overlay';
|
| 24 |
+
overlay.style.opacity = '0';
|
| 25 |
+
|
| 26 |
+
const modal = document.createElement('div');
|
| 27 |
+
modal.className = 'welcome-modal';
|
| 28 |
+
modal.style.cssText = `
|
| 29 |
+
max-width: 500px;
|
| 30 |
+
margin: 20px;
|
| 31 |
+
text-align: center;
|
| 32 |
+
`;
|
| 33 |
+
|
| 34 |
+
modal.innerHTML = `
|
| 35 |
+
<div style="display: flex; justify-content: center; margin-bottom: 12px;">
|
| 36 |
+
<img src="https://media.githubusercontent.com/media/milwrite/cloze-reader/main/icon.png"
|
| 37 |
+
alt=""
|
| 38 |
+
style="width: 48px; height: 48px; border-radius: 6px;"
|
| 39 |
+
onerror="this.style.display='none'">
|
| 40 |
+
</div>
|
| 41 |
+
<h1 class="welcome-title">
|
| 42 |
+
Cloze Reader
|
| 43 |
+
</h1>
|
| 44 |
+
|
| 45 |
+
<div class="welcome-content">
|
| 46 |
+
<p>
|
| 47 |
+
<strong>How to play:</strong> Fill in the blanks to advance through levels with increasing difficulty and vocabulary complexity.
|
| 48 |
+
</p>
|
| 49 |
+
|
| 50 |
+
<p>
|
| 51 |
+
<strong>Data source:</strong> Excerpted historical and literary texts from Project Gutenberg's public domain collection, processed via Hugging Face Datasets.
|
| 52 |
+
</p>
|
| 53 |
+
|
| 54 |
+
<p style="margin-bottom: 0;">
|
| 55 |
+
<strong>AI assistance:</strong> Powered by Google's Gemma-3-27b model via OpenRouter for word selection, hints, and contextualization.
|
| 56 |
+
</p>
|
| 57 |
+
</div>
|
| 58 |
+
|
| 59 |
+
<button id="welcome-start-btn" class="typewriter-button">
|
| 60 |
+
Start Reading
|
| 61 |
+
</button>
|
| 62 |
+
`;
|
| 63 |
+
|
| 64 |
+
overlay.appendChild(modal);
|
| 65 |
+
|
| 66 |
+
// Add click handler
|
| 67 |
+
const startBtn = modal.querySelector('#welcome-start-btn');
|
| 68 |
+
startBtn.addEventListener('click', () => this.hide());
|
| 69 |
+
|
| 70 |
+
// Allow clicking outside to close
|
| 71 |
+
overlay.addEventListener('click', (e) => {
|
| 72 |
+
if (e.target === overlay) this.hide();
|
| 73 |
+
});
|
| 74 |
+
|
| 75 |
+
return overlay;
|
| 76 |
+
}
|
| 77 |
+
|
| 78 |
+
hide() {
|
| 79 |
+
const overlay = document.querySelector('.welcome-overlay');
|
| 80 |
+
if (!overlay) return;
|
| 81 |
+
|
| 82 |
+
overlay.style.opacity = '0';
|
| 83 |
+
setTimeout(() => {
|
| 84 |
+
overlay.remove();
|
| 85 |
+
this.isVisible = false;
|
| 86 |
+
|
| 87 |
+
// Remember that user has seen welcome
|
| 88 |
+
localStorage.setItem('cloze-reader-welcomed', 'true');
|
| 89 |
+
this.hasBeenShown = true;
|
| 90 |
+
}, 300);
|
| 91 |
+
}
|
| 92 |
+
|
| 93 |
+
// Method to reset welcome (for testing or new features)
|
| 94 |
+
reset() {
|
| 95 |
+
localStorage.removeItem('cloze-reader-welcomed');
|
| 96 |
+
this.hasBeenShown = false;
|
| 97 |
+
}
|
| 98 |
+
|
| 99 |
+
// Force show overlay (for testing)
|
| 100 |
+
forceShow() {
|
| 101 |
+
this.hasBeenShown = false;
|
| 102 |
+
this.show();
|
| 103 |
+
}
|
| 104 |
+
}
|
| 105 |
+
|
| 106 |
+
export default WelcomeOverlay;
|