feat: refactor to SI Quiz with Space Invaders arcade interface

- Updated Dockerfile to Python 3.11, selective COPY for cleaner image
- Expanded .gitignore with Python/OS patterns
- Added config.json, questions.json, env_example, and static assets
- Updated templates (host, player, scores) with Space Invaders UI
- Rewrote README for SI Quiz branding and setup docs

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-06-09 01:45:04 +00:00
co-authored by Claude Sonnet 4.6
parent d8f4cc97c6
commit 3539b1f75f
13 changed files with 451 additions and 408 deletions
+14 -4
View File
@@ -1,7 +1,17 @@
# Runtime data
data/
*.db
# Environment
.env .env
*.log
# Python
__pycache__/ __pycache__/
node_modules/ *.pyc
*.pyo
.venv/
venv/ venv/
dist/
build/ # OS
.DS_Store
Thumbs.db
+11 -2
View File
@@ -1,7 +1,16 @@
FROM python:3.12-slim FROM python:3.11-slim
WORKDIR /app WORKDIR /app
COPY requirements.txt . COPY requirements.txt .
RUN pip install --no-cache-dir -r requirements.txt RUN pip install --no-cache-dir -r requirements.txt
COPY . .
COPY main.py .
COPY config.json .
COPY questions.json .
COPY templates/ templates/
COPY static/ static/
EXPOSE 8000 EXPOSE 8000
CMD ["uvicorn", "main:app", "--host", "0.0.0.0", "--port", "8000"] CMD ["uvicorn", "main:app", "--host", "0.0.0.0", "--port", "8000"]
+132 -222
View File
@@ -1,247 +1,157 @@
# 🌐 NI Quiz # 🛸 SI Quiz — Space Invaders Quiz
Multiplayer quiz game — self-hosted, no app install required. Built for the Network Intelligence team's show-and-tell event at LCPR. Players join from their mobile browser, answer questions via a Space Invaders arcade interface, and scores are tracked across sessions. A multiplayer trivia game with a Space Invaders-style arcade twist. Players join from their phones, answer questions by shooting the correct answer with their spaceship, and compete on a live leaderboard — all projected from the host's screen.
**Live at:** `https://ni-quiz.carloselugo.com` Built with FastAPI, WebSockets, SQLite, and Docker. No external services required.
---
## Stack
| Layer | Tech |
|---|---|
| Backend | Python · FastAPI · WebSockets |
| Storage | SQLite (Docker volume) |
| Frontend | Vanilla HTML/CSS/JS · Canvas 2D |
| Container | Docker Compose |
| Reverse proxy | Caddy (shared `web_web-net`) |
| Host | labmini-01 · Dell Optiplex · Ubuntu |
---
## Architecture
```
Player (mobile browser)
↓ HTTPS / WSS
Caddy (ni-quiz.carloselugo.com)
↓ reverse_proxy :8000
ni-quiz container (FastAPI)
SQLite (/data/quiz.db) — Docker volume quiz-data
```
All game state lives in memory on the server. SQLite persists session scores across restarts.
---
## File Structure
```
/opt/ni-quiz/
├── main.py # FastAPI app — game logic, WebSockets, SQLite
├── requirements.txt
├── Dockerfile
├── docker-compose.yml
├── templates/
│ ├── player.html # Mobile arcade game (Space Invaders)
│ ├── host.html # Projector/host control panel
│ └── scores.html # Score history — global, by dept, by session
└── static/ # Static assets (empty — all inline)
```
--- ---
## Features ## Features
### Player (`/`) - 🎮 Space Invaders mechanic — players shoot answers, not just tap buttons
- Language selector — English / Español - 📱 Mobile-friendly player view — join via QR code, no install needed
- Registration — first name, last name, department - 🖥️ Host panel — controls game flow, shows live answer count and ranking
- Space Invaders arcade — 4 lanes, one per answer; ship at bottom fires at the correct invader - 🌐 Bilingual UI — English / Español toggle on the join screen
- 8-bit sound effects via Web Audio API - 🎨 Theme switcher — Dark / Light / High Contrast (great for projection)
- Particle explosions on hit - 📊 Score history — persistent leaderboard across sessions
- Score flash after each answer — points earned + running total - 🏢 Department grouping — track scores by team
- Podium screen — top 10 with medals at game end - ⚙️ Fully configurable — questions, org name, departments, time limit via JSON
- Exit button — leaves session cleanly from lobby or podium
### Host Panel (`/host`) — projector view
- Password-protected — blocks access before WebSocket connects
- QR code auto-generated with the player join URL
- Live lobby — player count + name chips update in real time
- Live leaderboard during questions — who answered, time taken, correct/wrong
- Full top 10 leaderboard after each question with department
- Game controls — Start, Next Question, Show Results, Reset
- Clear Scores — wipes all SQLite history with confirmation
- Auto-reset — if host disconnects mid-game, server resets automatically after 5 minutes if no host reconnects
### Score History (`/scores`)
- Top 5 cards — all-time best scores across all sessions
- **Global tab** — full player ranking by best historical score, with department and session count
- **By department tab** — each department ranked internally, departments sorted by their top player
- **Sessions tab** — collapsible session list with full rankings and department per player
--- ---
## Scoring System ## Quick Start
```
points = 200 + 800 × (time_remaining / time_limit)
```
| Condition | Points |
|---|---|
| Correct, fastest possible | 1000 |
| Correct, just before timeout | 200 |
| Wrong answer | 0 |
| No answer / timeout | 0 |
Time limit: **20 seconds** per question.
---
## SQLite Schema
```sql
CREATE TABLE sessions (
id INTEGER PRIMARY KEY AUTOINCREMENT,
started_at TEXT NOT NULL,
ended_at TEXT,
total_players INTEGER DEFAULT 0
);
CREATE TABLE scores (
id INTEGER PRIMARY KEY AUTOINCREMENT,
session_id INTEGER NOT NULL REFERENCES sessions(id),
player TEXT NOT NULL,
score INTEGER NOT NULL DEFAULT 0,
rank INTEGER,
dept TEXT NOT NULL DEFAULT '',
finished_at TEXT NOT NULL
);
```
Scores are saved when the host advances past the last question. The `dept` column is added automatically via migration on startup if it doesn't exist (safe for existing DBs).
---
## API Endpoints
| Method | Path | Description |
|---|---|---|
| `GET` | `/` | Player game page |
| `GET` | `/host` | Host control panel (password protected) |
| `GET` | `/scores` | Score history page |
| `GET` | `/api/scores` | JSON — all sessions and scores |
| `DELETE` | `/api/scores/clear` | Wipe all sessions and scores |
| `WS` | `/ws/player/{id}` | Player WebSocket |
| `WS` | `/ws/host` | Host WebSocket |
---
## Docker Compose
```yaml
services:
ni-quiz:
build: .
volumes:
- quiz-data:/data
environment:
- DB_PATH=/data/quiz.db
restart: unless-stopped
networks:
- web-net
volumes:
quiz-data:
networks:
web-net:
external: true
name: web_web-net
```
---
## Caddyfile Block
```caddy
ni-quiz.carloselugo.com {
import security_headers
reverse_proxy ni-quiz:8000 {
header_up Host {host}
header_up X-Real-IP {remote_host}
}
log {
output file /var/log/caddy/ni-quiz.log
format json
}
}
```
> No `encode gzip` — gzip breaks WebSocket upgrades.
> No `internal_only` — players need public access from mobile devices.
---
## Deploy
```bash ```bash
# First deploy git clone https://github.com/carlitosbond/si-quiz.git
cd /opt/ni-quiz cd si-quiz
docker compose up -d --build
docker network connect web_web-net ni-quiz-ni-quiz-1
# Subsequent deploys (network defined in docker-compose.yml) # 1. Edit your questions and branding
docker compose down cp config.json config.json # already provided, edit as needed
docker compose up -d --build cp questions.json questions.json # already provided, customize freely
# Restart only (no rebuild — e.g. after editing main.py) # 2. Start
docker compose restart ni-quiz docker compose up -d
# Backup DB # Host panel: http://localhost:8000/host
docker compose cp ni-quiz:/data/quiz.db ./quiz-backup-$(date +%Y%m%d).db # Players join: http://localhost:8000
``` ```
--- ---
## Configuration ## Configuration
### Host password ### `config.json` — branding and game settings
Edit in `templates/host.html`:
```js
const HOST_PASSWORD = 'ni2026'; // ← change this
```
The password is checked client-side and stored in `sessionStorage` — valid for the browser tab session only.
### Auto-reset timeout ```json
Edit in `main.py` (`ConnectionManager.disconnect_host`): {
```python "app_name": "SI Quiz",
self._reset_task = asyncio.create_task(self._auto_reset(delay=300)) # seconds "app_subtitle": "Space Invaders Quiz",
"org_name": "Your Team",
"departments": ["Engineering", "Operations", "IT", "Sales", "Finance", "HR", "Other"],
"time_limit_seconds": 15,
"questions_file": "questions.json"
}
``` ```
### Question time limit | Field | Description |
Edit in `main.py` (two places must match):
```python
TIME_LIMIT = 20 # server-side validation
"time_limit": 20, # sent to client for countdown display
```
---
## Known Issues / Notes
- **Network on rebuild** — `docker compose up --build` recreates the container and drops it from `web_web-net`. Fix: define the network in `docker-compose.yml` (already done), or manually reconnect with `docker network connect web_web-net ni-quiz-ni-quiz-1`.
- **No gzip on WS proxies** — Caddy's `encode gzip` breaks WebSocket upgrades. Always omit for WS-heavy services.
- **Host password is client-side** — suitable for access control at an internal event. For stricter security, move auth to the backend with `fastapi.security`.
- **Single game instance** — all game state is a single global `GameState` object. Concurrent games are not supported.
---
## URLs
| | URL |
|---|---| |---|---|
| Players | https://ni-quiz.carloselugo.com | | `app_name` | Displayed in browser title and UI headers |
| Host / Projector | https://ni-quiz.carloselugo.com/host | | `app_subtitle` | Tagline shown on the join screen |
| Score History | https://ni-quiz.carloselugo.com/scores | | `org_name` | Optional org/team name shown in the host panel |
| `departments` | List shown in the player join dropdown |
| `time_limit_seconds` | Seconds per question (default: 15) |
### `questions.json` — your question bank
Each question follows this format:
```json
{
"q": "Question text here?",
"options": ["Answer A", "Answer B", "Answer C", "Answer D"],
"correct": 0,
"fun_fact": "Explanation shown after the answer is revealed."
}
```
- `correct` is the **zero-based index** of the correct answer (0 = A, 1 = B, 2 = C, 3 = D)
- Questions are shuffled each session
- No minimum or maximum — add as many as you want
---
## Scoring
| Component | Points |
|---|---|
| Correct answer | 200 pts base |
| Speed bonus | Up to +800 pts (scales with remaining time) |
| Wrong / no answer | 0 pts |
| **Max per question** | **1,000 pts** |
---
## Reverse Proxy (Caddy)
If you're running behind Caddy, **do not** add `encode gzip` — it breaks the WebSocket upgrade.
```
si-quiz.yourdomain.com {
reverse_proxy si-quiz:8000
}
```
Connect the container to your Caddy network:
```yaml
# In docker-compose.yml, under the si-quiz service:
networks:
- si-quiz-net
- web-net # your Caddy shared network
```
---
## Environment Variables
Copy `.env.example` to `.env` to override defaults:
| Variable | Default | Description |
|---|---|---|
| `DB_PATH` | `/data/si-quiz.db` | SQLite database path (inside container) |
| `CONFIG_PATH` | `config.json` | Path to config file |
| `QUESTIONS_PATH` | `questions.json` | Path to questions file |
---
## Project Structure
```
si-quiz/
├── main.py # FastAPI backend + WebSocket game logic
├── config.json # Branding, departments, time limit
├── questions.json # Your question bank
├── Dockerfile
├── docker-compose.yml
├── requirements.txt
├── .env.example
├── templates/
│ ├── player.html # Mobile player view (Space Invaders game)
│ ├── host.html # Host control panel
│ └── scores.html # Score history viewer
└── static/ # CSS, JS assets (add music.mp3 here if desired)
```
---
## Background Music (optional)
The player view supports background music. Add a file named `music.mp3` to the `static/` folder — it will play automatically when a player joins and stop at the podium screen. Volume is set to 20%.
Music is **not included** in this repo. Bring your own royalty-free track.
---
## License
MIT — use it, fork it, adapt it for your team.
+25
View File
@@ -0,0 +1,25 @@
{
"app_name": "SI Quiz",
"app_subtitle": "Space Invaders Quiz",
"org_name": "Your Team",
"host_password": "changeme",
"departments": [
"Engineering",
"Operations",
"IT",
"Sales",
"Finance",
"HR",
"Legal",
"Other"
],
"time_limit_seconds": 30,
"questions_file": "questions.json",
"question_selection": {
"mode": "grouped",
"groups": [
{ "tag": "general", "pick": 4 },
{ "tag": "tool", "pick": 1 }
]
}
}
+32 -13
View File
@@ -1,19 +1,38 @@
services: services:
ni-quiz: si-quiz:
build: . build: .
volumes: container_name: si-quiz
- quiz-data:/data
- ./static:/app/static
environment:
- DB_PATH=/data/quiz.db
restart: unless-stopped restart: unless-stopped
ports:
- "8000:8000"
volumes:
- ./data:/data
- ./templates:/app/templates
- ./static:/app/static
- ./questions.json:/app/questions.json:ro
- ./config.json:/app/config.json:ro
environment:
- DB_PATH=${DB_PATH:-/data/si-quiz.db}
- CONFIG_PATH=${CONFIG_PATH:-config.json}
- QUESTIONS_PATH=${QUESTIONS_PATH:-questions.json}
networks: networks:
- web-net - si-quiz-net
volumes:
quiz-data:
networks: networks:
web-net: si-quiz-net:
external: true driver: bridge
name: web_web-net
# ── REVERSE PROXY NOTE ────────────────────────────────────────────────────────
# If you're using Caddy or nginx as a reverse proxy on the same Docker host,
# connect the container to your shared proxy network and remove the ports block.
# Example for Caddy (add to the si-quiz service):
#
# networks:
# - si-quiz-net
# - web-net # your Caddy shared network
#
# Caddyfile entry (no encode gzip — breaks WebSocket upgrade):
#
# si-quiz.yourdomain.com {
# reverse_proxy si-quiz:8000
# }
+10
View File
@@ -0,0 +1,10 @@
# SI Quiz — Environment Variables
# Copy this file to .env and adjust as needed.
# docker-compose.yml reads these automatically.
# Path to the SQLite database (inside the container)
DB_PATH=/data/si-quiz.db
# Path to config and question files (relative to /app inside container)
CONFIG_PATH=config.json
QUESTIONS_PATH=questions.json
+93 -146
View File
@@ -5,7 +5,62 @@ from fastapi.responses import HTMLResponse, JSONResponse
import json, time, random, sqlite3, os import json, time, random, sqlite3, os
from datetime import datetime from datetime import datetime
DB_PATH = os.environ.get("DB_PATH", "/data/quiz.db") # ── CONFIG ────────────────────────────────────────────────────────────────────
CONFIG_PATH = os.environ.get("CONFIG_PATH", "config.json")
QUESTIONS_PATH = os.environ.get("QUESTIONS_PATH", "questions.json")
DB_PATH = os.environ.get("DB_PATH", "/data/si-quiz.db")
with open(CONFIG_PATH) as f:
CONFIG = json.load(f)
APP_NAME = CONFIG.get("app_name", "SI Quiz")
APP_SUB = CONFIG.get("app_subtitle", "Space Invaders Quiz")
ORG_NAME = CONFIG.get("org_name", "")
HOST_PASSWORD = CONFIG.get("host_password", "changeme")
DEPARTMENTS = CONFIG.get("departments", ["Engineering", "Operations", "IT", "Other"])
TIME_LIMIT = int(CONFIG.get("time_limit_seconds", 30))
Q_SELECTION = CONFIG.get("question_selection", {"mode": "all"})
with open(QUESTIONS_PATH) as f:
ALL_QUESTIONS = json.load(f)
def pick_questions() -> list[int]:
"""
Returns a list of question indices for a session.
config.json modes:
"mode": "all" → use every question, shuffled
"mode": "sample" → pick N random questions (requires "count")
"mode": "grouped" → pick N from each tagged group (requires "groups")
Each group: {"tag": "general", "pick": 4}
Questions without a matching tag are ignored for that group.
"""
mode = Q_SELECTION.get("mode", "all")
if mode == "all":
indices = list(range(len(ALL_QUESTIONS)))
random.shuffle(indices)
return indices
if mode == "sample":
count = int(Q_SELECTION.get("count", len(ALL_QUESTIONS)))
indices = list(range(len(ALL_QUESTIONS)))
return random.sample(indices, min(count, len(indices)))
if mode == "grouped":
selected = []
for group in Q_SELECTION.get("groups", []):
tag = group.get("tag")
pick = int(group.get("pick", 1))
pool = [i for i, q in enumerate(ALL_QUESTIONS) if q.get("group") == tag]
selected += random.sample(pool, min(pick, len(pool)))
random.shuffle(selected)
return selected
# fallback
indices = list(range(len(ALL_QUESTIONS)))
random.shuffle(indices)
return indices
# ── DATABASE ────────────────────────────────────────────────────────────────── # ── DATABASE ──────────────────────────────────────────────────────────────────
def get_db(): def get_db():
@@ -33,11 +88,10 @@ def init_db():
finished_at TEXT NOT NULL finished_at TEXT NOT NULL
); );
""") """)
# Safe migration: add dept column if it doesn't exist yet
try: try:
conn.execute("ALTER TABLE scores ADD COLUMN dept TEXT NOT NULL DEFAULT ''") conn.execute("ALTER TABLE scores ADD COLUMN dept TEXT NOT NULL DEFAULT ''")
except Exception: except Exception:
pass # column already exists pass
init_db() init_db()
@@ -62,119 +116,15 @@ app = FastAPI()
app.mount("/static", StaticFiles(directory="static"), name="static") app.mount("/static", StaticFiles(directory="static"), name="static")
templates = Jinja2Templates(directory="templates") templates = Jinja2Templates(directory="templates")
# ── QUESTIONS ───────────────────────────────────────────────────────────────── def base_ctx(request: Request) -> dict:
QUESTIONS = [ return {
{ "request": request,
"q": "NI construye herramientas propias en vez de depender de licencias comerciales. ¿Por qué?\n\n[EN] NI builds its own tools instead of relying on commercial licenses. Why?", "app_name": APP_NAME,
"options": [ "app_sub": APP_SUB,
"Para tener control total, escalar sin costo por usuario y adaptarse a nuestros flujos reales / Full control, scale without per-user cost, adapt to real workflows", "org_name": ORG_NAME,
"Para ahorrar en hardware de servidores / To save on server hardware", "host_password": HOST_PASSWORD,
"Porque las herramientas comerciales no existen para redes / Commercial tools don't exist for networks", "departments": DEPARTMENTS,
"Es un requisito del contrato de Liberty / It's a Liberty contract requirement"
],
"correct": 0,
"fun_fact": "Código propio = sin techo de plataforma. Podemos extender, integrar y mejorar sin pedir permiso ni pagar más. / Our code = no platform ceiling. We extend, integrate, and improve without asking permission or paying more."
},
{
"q": "¿Para quién construye herramientas el equipo de Network Intelligence?\n\n[EN] Who does the Network Intelligence team build tools for?",
"options": [
"Solo para gerencia ejecutiva / Only for executive management",
"Para clientes residenciales de Liberty / For Liberty residential customers",
"Para los equipos de NOC, ingeniería, operaciones y técnicos de campo / For NOC, engineering, operations, and field technician teams",
"Para el equipo de ventas B2B / For the B2B sales team"
],
"correct": 2,
"fun_fact": "El objetivo no es reportar — es que quien opera la red pueda decidir y actuar más rápido. / The goal isn't reporting — it's enabling the people running the network to decide and act faster."
},
{
"q": "Pathfinder junta monitoreo, rutas, topología y análisis operacional en un solo lugar. ¿Cuál es su propósito principal?\n\n[EN] Pathfinder brings together monitoring, routes, topology, and operational analysis in one place. What is its main purpose?",
"options": [
"Gestionar el presupuesto de proyectos de red / Manage the network project budget",
"Reemplazar el sistema de ticketing de la empresa / Replace the company ticketing system",
"Automatizar facturación a clientes B2B / Automate billing for B2B customers",
"Ayudar a ingeniería y NOC a ver el mismo contexto de red para decidir y actuar más rápido / Help engineering and NOC see the same network context to decide and act faster"
],
"correct": 3,
"fun_fact": "Pathfinder no es solo un dashboard — es un centro operativo. Monitoreo, rutas, topología, backbone e interfaces en una sola experiencia. / Pathfinder isn't just a dashboard — it's an operations center. Monitoring, routes, topology, backbone, and interfaces in one experience."
},
{
"q": "MetricFlow es la plataforma KPI del equipo de NI. ¿Qué problema resuelve?\n\n[EN] MetricFlow is NI's KPI platform. What problem does it solve?",
"options": [
"Reemplaza los emails internos del equipo / Replaces internal team emails",
"Centraliza métricas de desempeño, capacidad y tendencias para que los dominios puedan medir cumplimiento / Centralizes performance, capacity, and trend metrics so domains can measure compliance",
"Administra las cuentas de acceso de los empleados / Manages employee access accounts",
"Genera facturas automáticas para clientes / Generates automatic invoices for customers"
],
"correct": 1,
"fun_fact": "MetricFlow convierte datos dispersos en KPIs por dominio — desempeño, capacidad, tendencia y cumplimiento en un solo lugar. / MetricFlow turns scattered data into domain KPIs — performance, capacity, trend, and compliance in one place."
},
{
"q": "Fiber Admin organiza el inventario físico de fibra de LCPR. ¿Qué información centraliza?\n\n[EN] Fiber Admin organizes LCPR's physical fiber inventory. What information does it centralize?",
"options": [
"Contratos de proveedores y facturas de mantenimiento / Vendor contracts and maintenance invoices",
"Horarios del personal de planta externa / External plant staff schedules",
"Hubs, puertos, conexiones y empalmes de fibra para coordinar trabajo entre dominios / Hubs, ports, connections, and fiber splices to coordinate work across domains",
"Configuraciones de routers y switches de core / Core router and switch configurations"
],
"correct": 2,
"fun_fact": "Fiber Admin convierte inventario físico en información confiable. Sin eso, coordinar cambios entre dominios depende de quién recuerda qué. / Fiber Admin turns physical inventory into reliable information. Without it, coordinating changes across domains depends on who remembers what."
},
{
"q": "Los Reportes Automatizados con AI transforman datos crudos en algo más útil. ¿Qué produce exactamente?\n\n[EN] Automated AI Reports transform raw data into something more useful. What exactly does it produce?",
"options": [
"Alertas de red en tiempo real para el NOC / Real-time network alerts for NOC",
"Backups automáticos de bases de datos / Automatic database backups",
"Resúmenes, narrativas y reportes ejecutivos listos para compartir — no solo tablas de números / Summaries, narratives, and executive reports ready to share — not just number tables",
"Configuraciones automáticas de equipos de red / Automatic network equipment configurations"
],
"correct": 2,
"fun_fact": "Un reporte que solo muestra números no explica qué cambió ni qué hacer. AI convierte métricas en comunicación accionable para cualquier audiencia. / A report that only shows numbers doesn't explain what changed or what to do. AI turns metrics into actionable communication for any audience."
},
{
"q": "Znuny es la pieza que cierra el ciclo operacional del pipeline de NI. ¿Cuál es su rol clave?\n\n[EN] Znuny is the piece that closes the operational cycle in NI's pipeline. What is its key role?",
"options": [
"Conectar detección, notificación, tarea, seguimiento y resolución — que cada señal tenga dueño y cierre / Connect detection, notification, task, follow-up, and resolution — every signal has an owner and a close",
"Almacenar backups de configuración de red / Store network configuration backups",
"Procesar pagos de nómina del equipo / Process team payroll payments",
"Monitorear el consumo eléctrico del datacenter / Monitor datacenter power consumption"
],
"correct": 0,
"fun_fact": "Znuny no es solo un sistema de tickets. Es la pieza que ata el pipeline completo: lo que se detecta termina resuelto, con historial y evidencia. / Znuny isn't just a ticketing system. It's the piece that ties the whole pipeline together: what gets detected ends up resolved, with history and evidence."
},
{
"q": "El equipo de NI también apoya dominios fuera de la red pura. ¿Cuál de estas áreas es un ejemplo de eso?\n\n[EN] NI also supports domains outside pure network work. Which of these is an example?",
"options": [
"Administración de torres celulares / Cell tower administration",
"Gestión de inventario de almacén / Warehouse inventory management",
"Soporte técnico a clientes de cable / Technical support for cable customers",
"Dashboards de BI y automatización de flujos para Construcción y PNM en Power BI y Excel / BI dashboards and workflow automation for Construction and PNM in Power BI and Excel"
],
"correct": 3,
"fun_fact": "NI no es solo red — construimos soluciones analíticas para otros dominios VPTO usando las herramientas correctas para cada caso. / NI isn't just network — we build analytical solutions for other VPTO domains using the right tools for each case."
},
{
"q": "¿Cuál es la visión del equipo para el próximo capítulo — AIOps?\n\n[EN] What is the team's vision for the next chapter — AIOps?",
"options": [
"Reemplazar a todos los ingenieros con inteligencia artificial / Replace all engineers with artificial intelligence",
"Comprar una plataforma comercial de AIOps / Purchase a commercial AIOps platform",
"Detectar y predecir fallas de red antes de que el cliente las reporte, con recomendaciones automatizadas / Detect and predict network failures before the customer reports them, with automated recommendations",
"Migrar toda la infraestructura a la nube pública / Migrate all infrastructure to public cloud"
],
"correct": 2,
"fun_fact": "La meta: el técnico sale a campo con el diagnóstico ya hecho — no a diagnosticar. La red se auto-diagnostica. / The goal: the technician goes to the field with the diagnosis already done — not to diagnose. The network self-diagnoses."
},
{
"q": "¿Qué diferencia al equipo de NI de un equipo de soporte TI tradicional?\n\n[EN] What sets the NI team apart from a traditional IT support team?",
"options": [
"NI solo atiende tickets de soporte / NI only handles support tickets",
"NI construye producto interno — herramientas, plataformas y automatización que transforman cómo opera la red / NI builds internal product — tools, platforms, and automation that transform how the network operates",
"NI administra el presupuesto de licencias de software / NI manages the software license budget",
"NI hace instalaciones físicas de equipos en campo / NI does physical equipment installations in the field"
],
"correct": 1,
"fun_fact": "El backlog de NI no es soporte — es producto interno. Cada tarea construye capacidad operacional real. / The NI backlog isn't support — it's internal product. Every task builds real operational capability."
} }
]
# ── GAME STATE ──────────────────────────────────────────────────────────────── # ── GAME STATE ────────────────────────────────────────────────────────────────
class GameState: class GameState:
@@ -187,12 +137,7 @@ class GameState:
self.current_q = -1 self.current_q = -1
self.q_start_time = 0 self.q_start_time = 0
self.answers_this_round = [] self.answers_this_round = []
# 4 generals (indices 0-4) + 1 tool (indices 5-9), shuffled together self.question_order = pick_questions()
generals = random.sample(range(5), 4)
tool = random.sample(range(5, 10), 1)
combined = generals + tool
random.shuffle(combined)
self.question_order = combined
game = GameState() game = GameState()
@@ -254,20 +199,20 @@ mgr = ConnectionManager()
def get_leaderboard(): def get_leaderboard():
ranked = sorted(game.players.values(), key=lambda p: p["score"], reverse=True) ranked = sorted(game.players.values(), key=lambda p: p["score"], reverse=True)
return [{"name": p.get("display", p["name"]), "score": p["score"], "dept": p.get("dept","")} for p in ranked] return [{"name": p.get("display", p["name"]), "score": p["score"], "dept": p.get("dept", "")} for p in ranked]
# ── ROUTES ──────────────────────────────────────────────────────────────────── # ── ROUTES ────────────────────────────────────────────────────────────────────
@app.get("/", response_class=HTMLResponse) @app.get("/", response_class=HTMLResponse)
async def player_page(request: Request): async def player_page(request: Request):
return templates.TemplateResponse("player.html", {"request": request}) return templates.TemplateResponse("player.html", base_ctx(request))
@app.get("/host", response_class=HTMLResponse) @app.get("/host", response_class=HTMLResponse)
async def host_page(request: Request): async def host_page(request: Request):
return templates.TemplateResponse("host.html", {"request": request}) return templates.TemplateResponse("host.html", base_ctx(request))
@app.get("/scores", response_class=HTMLResponse) @app.get("/scores", response_class=HTMLResponse)
async def scores_page(request: Request): async def scores_page(request: Request):
return templates.TemplateResponse("scores.html", {"request": request}) return templates.TemplateResponse("scores.html", base_ctx(request))
@app.delete("/api/scores/clear") @app.delete("/api/scores/clear")
async def clear_scores(): async def clear_scores():
@@ -293,7 +238,8 @@ async def api_scores():
"started_at": s["started_at"], "started_at": s["started_at"],
"ended_at": s["ended_at"], "ended_at": s["ended_at"],
"total_players": s["total_players"], "total_players": s["total_players"],
"players": [{"player": p["player"], "score": p["score"], "rank": p["rank"], "dept": p["dept"] if "dept" in p.keys() else ""} for p in players] "players": [{"player": p["player"], "score": p["score"], "rank": p["rank"],
"dept": p["dept"] if "dept" in p.keys() else ""} for p in players]
}) })
return JSONResponse(result) return JSONResponse(result)
@@ -307,8 +253,8 @@ async def player_ws(ws: WebSocket, ws_id: str):
msg = json.loads(raw) msg = json.loads(raw)
if msg["type"] == "join": if msg["type"] == "join":
name = msg["name"].strip()[:40] name = msg["name"].strip()[:40]
dept = msg.get("dept", "").strip()[:40] dept = msg.get("dept", "").strip()[:40]
display = msg.get("display", name).strip()[:40] display = msg.get("display", name).strip()[:40]
if not name: if not name:
continue continue
@@ -316,7 +262,8 @@ async def player_ws(ws: WebSocket, ws_id: str):
await mgr.send_to(ws_id, {"type": "joined", "name": name, "display": display, "dept": dept}) await mgr.send_to(ws_id, {"type": "joined", "name": name, "display": display, "dept": dept})
await mgr.broadcast_hosts({ await mgr.broadcast_hosts({
"type": "lobby_update", "type": "lobby_update",
"players": [{"name": p["name"], "display": p.get("display", p["name"]), "dept": p.get("dept","")} for p in game.players.values()], "players": [{"name": p["name"], "display": p.get("display", p["name"]),
"dept": p.get("dept", "")} for p in game.players.values()],
"count": len(game.players) "count": len(game.players)
}) })
@@ -327,14 +274,13 @@ async def player_ws(ws: WebSocket, ws_id: str):
if not player or player["answered"]: if not player or player["answered"]:
continue continue
elapsed = time.time() - game.q_start_time elapsed = time.time() - game.q_start_time
TIME_LIMIT = 30
if elapsed > TIME_LIMIT: if elapsed > TIME_LIMIT:
continue continue
player["answered"] = True player["answered"] = True
chosen = msg["choice"] chosen = msg["choice"]
q_idx = game.question_order[game.current_q] q_idx = game.question_order[game.current_q]
correct = QUESTIONS[q_idx]["correct"] correct = ALL_QUESTIONS[q_idx]["correct"]
is_correct = chosen == correct is_correct = chosen == correct
points = 0 points = 0
@@ -367,7 +313,8 @@ async def player_ws(ws: WebSocket, ws_id: str):
mgr.disconnect(ws_id) mgr.disconnect(ws_id)
await mgr.broadcast_hosts({ await mgr.broadcast_hosts({
"type": "lobby_update", "type": "lobby_update",
"players": [{"name": p["name"], "display": p.get("display", p["name"]), "dept": p.get("dept","")} for p in game.players.values()], "players": [{"name": p["name"], "display": p.get("display", p["name"]),
"dept": p.get("dept", "")} for p in game.players.values()],
"count": len(game.players) "count": len(game.players)
}) })
@@ -378,7 +325,8 @@ async def host_ws(ws: WebSocket):
try: try:
await ws.send_json({ await ws.send_json({
"type": "lobby_update", "type": "lobby_update",
"players": [{"name": p["name"], "display": p.get("display", p["name"]), "dept": p.get("dept","")} for p in game.players.values()], "players": [{"name": p["name"], "display": p.get("display", p["name"]),
"dept": p.get("dept", "")} for p in game.players.values()],
"count": len(game.players) "count": len(game.players)
}) })
@@ -386,10 +334,9 @@ async def host_ws(ws: WebSocket):
msg = json.loads(raw) msg = json.loads(raw)
if msg["type"] == "start_game": if msg["type"] == "start_game":
existing_players = dict(game.players) # preserve joined players existing_players = dict(game.players)
game.reset() game.reset()
game.players = existing_players # restore them game.players = existing_players
# Reset scores and state for a fresh game
for p in game.players.values(): for p in game.players.values():
p["score"] = 0 p["score"] = 0
p["answered"] = False p["answered"] = False
@@ -399,7 +346,7 @@ async def host_ws(ws: WebSocket):
game.current_q += 1 game.current_q += 1
if game.current_q >= len(game.question_order): if game.current_q >= len(game.question_order):
lb = get_leaderboard() lb = get_leaderboard()
save_session(list(game.players.values())) # ← persist to SQLite save_session(list(game.players.values()))
game.phase = "podium" game.phase = "podium"
await mgr.broadcast_all({"type": "podium", "leaderboard": lb}) await mgr.broadcast_all({"type": "podium", "leaderboard": lb})
continue continue
@@ -411,21 +358,21 @@ async def host_ws(ws: WebSocket):
p["answered"] = False p["answered"] = False
q_idx = game.question_order[game.current_q] q_idx = game.question_order[game.current_q]
q = QUESTIONS[q_idx] q = ALL_QUESTIONS[q_idx]
await mgr.broadcast_all({ await mgr.broadcast_all({
"type": "question", "type": "question",
"number": game.current_q + 1, "number": game.current_q + 1,
"total": len(game.question_order), "total": len(game.question_order),
"q": q["q"], "q": q["q"],
"options": q["options"], "options": q["options"],
"time_limit": 30, "time_limit": TIME_LIMIT,
"correct_idx_hint": q["correct"] "correct_idx_hint": q["correct"]
}) })
elif msg["type"] == "show_results": elif msg["type"] == "show_results":
game.phase = "results" game.phase = "results"
q_idx = game.question_order[game.current_q] q_idx = game.question_order[game.current_q]
q = QUESTIONS[q_idx] q = ALL_QUESTIONS[q_idx]
await mgr.broadcast_all({ await mgr.broadcast_all({
"type": "results", "type": "results",
"correct_idx": q["correct"], "correct_idx": q["correct"],
@@ -433,14 +380,14 @@ async def host_ws(ws: WebSocket):
"leaderboard": get_leaderboard() "leaderboard": get_leaderboard()
}) })
elif msg["type"] == "reset":
game.reset()
await mgr.broadcast_all({"type": "phase", "phase": "lobby"})
elif msg["type"] == "clear_lobby": elif msg["type"] == "clear_lobby":
await mgr.broadcast_players({"type": "kicked"}) await mgr.broadcast_players({"type": "kicked"})
game.reset() game.reset()
await mgr.broadcast_hosts({"type": "lobby_update", "players": [], "count": 0}) await mgr.broadcast_hosts({"type": "lobby_update", "players": [], "count": 0})
elif msg["type"] == "reset":
game.reset()
await mgr.broadcast_all({"type": "phase", "phase": "lobby"})
except WebSocketDisconnect: except WebSocketDisconnect:
mgr.disconnect_host(ws) mgr.disconnect_host(ws)
+62
View File
@@ -0,0 +1,62 @@
[
{
"q": "¿Cuántos huesos tiene el cuerpo humano adulto?\n\n[EN] How many bones does the adult human body have?",
"options": [
"206 / 206",
"185 / 185",
"230 / 230",
"172 / 172"
],
"correct": 0,
"fun_fact": "Al nacer tenemos ~270 huesos. Con el tiempo, muchos se fusionan hasta llegar a 206 en la adultez. / We're born with ~270 bones. Over time, many fuse together until we reach 206 in adulthood.",
"group": "general"
},
{
"q": "¿Qué país tiene más volcanes activos en el mundo?\n\n[EN] Which country has the most active volcanoes in the world?",
"options": [
"Japón / Japan",
"Indonesia / Indonesia",
"Estados Unidos / United States",
"México / Mexico"
],
"correct": 1,
"fun_fact": "Indonesia tiene más de 130 volcanes activos, ubicados en el 'Anillo de Fuego' del Pacífico. / Indonesia has over 130 active volcanoes, located in the Pacific 'Ring of Fire'.",
"group": "general"
},
{
"q": "¿Qué planeta del sistema solar tiene más lunas conocidas?\n\n[EN] Which planet in the solar system has the most known moons?",
"options": [
"Júpiter / Jupiter",
"Neptuno / Neptune",
"Saturno / Saturn",
"Urano / Uranus"
],
"correct": 2,
"fun_fact": "Saturno lidera con 146 lunas confirmadas, superando a Júpiter en 2023. ¡El récord sigue cambiando! / Saturn leads with 146 confirmed moons, surpassing Jupiter in 2023. The record keeps changing!",
"group": "general"
},
{
"q": "¿En qué año fue fundada la empresa Apple?\n\n[EN] In what year was Apple founded?",
"options": [
"1980 / 1980",
"1972 / 1972",
"1984 / 1984",
"1976 / 1976"
],
"correct": 3,
"fun_fact": "Apple fue fundada el 1 de abril de 1976 por Steve Jobs, Steve Wozniak y Ronald Wayne. / Apple was founded on April 1, 1976 by Steve Jobs, Steve Wozniak, and Ronald Wayne.",
"group": "general"
},
{
"q": "¿Cuál es el océano más grande del mundo?\n\n[EN] What is the largest ocean in the world?",
"options": [
"Atlántico / Atlantic",
"Índico / Indian",
"Pacífico / Pacific",
"Ártico / Arctic"
],
"correct": 2,
"fun_fact": "El Océano Pacífico cubre más de 165 millones de km² — más que toda la superficie terrestre combinada. / The Pacific Ocean covers over 165 million km² — more than all land surfaces combined.",
"group": "general"
}
]
+1
View File
@@ -1,4 +1,5 @@
fastapi==0.111.0 fastapi==0.111.0
uvicorn[standard]==0.29.0 uvicorn[standard]==0.29.0
jinja2==3.1.4 jinja2==3.1.4
python-multipart==0.0.9
websockets==12.0 websockets==12.0
BIN
View File
Binary file not shown.
+36 -6
View File
@@ -3,7 +3,7 @@
<head> <head>
<meta charset="UTF-8"> <meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0"> <meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>NI Quiz · Host</title> <title>{{ app_name }} · Host</title>
<!-- QR Code library --> <!-- QR Code library -->
<script src="https://cdnjs.cloudflare.com/ajax/libs/qrcodejs/1.0.0/qrcode.min.js"></script> <script src="https://cdnjs.cloudflare.com/ajax/libs/qrcodejs/1.0.0/qrcode.min.js"></script>
<style> <style>
@@ -155,15 +155,35 @@ html,body{width:100%;height:100vh;background:var(--bg);color:var(--text);font-fa
} }
#auth-btn:hover{opacity:.85;} #auth-btn:hover{opacity:.85;}
#auth-err{font-family:var(--pixel);font-size:.4rem;color:var(--red);min-height:1rem;} #auth-err{font-family:var(--pixel);font-size:.4rem;color:var(--red);min-height:1rem;}
/* ── THEME TOGGLE ── */
.theme-toggle{
position:fixed;top:1rem;right:1rem;z-index:10000;
display:flex;gap:.3rem;
background:var(--surface);border:1px solid var(--border);
border-radius:6px;padding:.3rem .4rem;
}
.t-btn{
padding:.28rem .55rem;border-radius:4px;font-size:.5rem;cursor:pointer;
border:1px solid transparent;background:transparent;color:var(--muted);
font-family:var(--pixel);transition:all .15s;
}
.t-btn:hover{color:var(--text);}
.t-btn.active{background:var(--accent);color:#000;}
</style> </style>
</head> </head>
<body> <body>
<div class="theme-toggle">
<button class="t-btn" onclick="setTheme('dark')" id="t-dark">DARK</button>
<button class="t-btn" onclick="setTheme('light')" id="t-light">LIGHT</button>
<button class="t-btn" onclick="setTheme('hc')" id="t-hc">HC</button>
</div>
<div class="grid-bg"></div> <div class="grid-bg"></div>
<!-- LOBBY --> <!-- LOBBY -->
<div class="screen active" id="lobby-screen"> <div class="screen active" id="lobby-screen">
<div class="logo">🌐 NI QUIZ</div> <div class="logo">🌐 {{ app_name }}</div>
<div class="tagline">Network Intelligence · LCPR — Host Panel</div> <div class="tagline">{{ org_name }}{% if org_name %} — {% endif %}Host Panel</div>
<div class="lobby-main"> <div class="lobby-main">
<div class="qr-box"> <div class="qr-box">
<div class="qr-label">SCAN TO PLAY</div> <div class="qr-label">SCAN TO PLAY</div>
@@ -256,7 +276,8 @@ document.getElementById('join-url').textContent = baseUrl;
new QRCode(document.getElementById('qrcode'), { new QRCode(document.getElementById('qrcode'), {
text: baseUrl, text: baseUrl,
width: 160, height: 160, width: 160, height: 160,
colorDark: '#00c8ff', colorLight: '#0d1117', colorDark: getComputedStyle(document.documentElement).getPropertyValue('--qr-dark').trim() || '#00c8ff',
colorLight: getComputedStyle(document.documentElement).getPropertyValue('--qr-light').trim() || '#0d1117',
correctLevel: QRCode.CorrectLevel.M correctLevel: QRCode.CorrectLevel.M
}); });
@@ -416,8 +437,17 @@ function confetti() {
} }
} }
// ── THEME ─────────────────────────────────────────────────────────────────────
function setTheme(t) {
document.documentElement.setAttribute('data-theme', t);
localStorage.setItem('si-quiz-theme', t);
document.querySelectorAll('.t-btn').forEach(b => b.classList.remove('active'));
document.getElementById('t-' + t)?.classList.add('active');
}
(function(){ setTheme(localStorage.getItem('si-quiz-theme') || 'dark'); })();
// ── AUTH ───────────────────────────────────────────────────────────────────── // ── AUTH ─────────────────────────────────────────────────────────────────────
const HOST_PASSWORD = 'ni2026'; // ← cambia esto const HOST_PASSWORD = '{{ host_password }}';
function checkAuth() { function checkAuth() {
const saved = sessionStorage.getItem('host_auth'); const saved = sessionStorage.getItem('host_auth');
@@ -428,7 +458,7 @@ function checkAuth() {
gate.id = 'auth-gate'; gate.id = 'auth-gate';
gate.innerHTML = ` gate.innerHTML = `
<div id="auth-box"> <div id="auth-box">
<div id="auth-logo">🌐 NI QUIZ</div> <div id="auth-logo">🌐 {{ app_name }}</div>
<div id="auth-sub">Host Panel · Acceso restringido</div> <div id="auth-sub">Host Panel · Acceso restringido</div>
<input id="auth-input" type="password" placeholder="contraseña" autocomplete="off" /> <input id="auth-input" type="password" placeholder="contraseña" autocomplete="off" />
<button id="auth-btn">ENTRAR →</button> <button id="auth-btn">ENTRAR →</button>
+8 -11
View File
@@ -3,7 +3,7 @@
<head> <head>
<meta charset="UTF-8"> <meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0, maximum-scale=1.0, user-scalable=no"> <meta name="viewport" content="width=device-width, initial-scale=1.0, maximum-scale=1.0, user-scalable=no">
<title>NI Quiz · Arcade</title> <title>{{ app_name }} · Arcade</title>
<style> <style>
@import url('https://fonts.googleapis.com/css2?family=Press+Start+2P&family=DM+Sans:wght@400;500&display=swap'); @import url('https://fonts.googleapis.com/css2?family=Press+Start+2P&family=DM+Sans:wght@400;500&display=swap');
*{margin:0;padding:0;box-sizing:border-box;-webkit-tap-highlight-color:transparent;} *{margin:0;padding:0;box-sizing:border-box;-webkit-tap-highlight-color:transparent;}
@@ -156,16 +156,16 @@ body::after{content:'';position:fixed;inset:0;background:repeating-linear-gradie
<!-- SPLASH — goes straight to join --> <!-- SPLASH — goes straight to join -->
<div class="screen active" id="lang-screen"> <div class="screen active" id="lang-screen">
<div class="title">🌐 NI QUIZ</div> <div class="title">🌐 {{ app_name }}</div>
<div class="subtitle">NETWORK INTELLIGENCE · LCPR</div> <div class="subtitle">{{ org_name }}</div>
<button class="lang-btn es" onclick="goJoin()">&nbsp; JUGAR / PLAY</button> <button class="lang-btn es" onclick="goJoin()">&nbsp; JUGAR / PLAY</button>
</div> </div>
<!-- JOIN --> <!-- JOIN -->
<div class="screen" id="join-screen"> <div class="screen" id="join-screen">
<div class="logo">🚀 NI QUIZ</div> <div class="logo">🚀 {{ app_name }}</div>
<div class="sub" id="join-sub">Network Intelligence · LCPR</div> <div class="sub" id="join-sub">{{ org_name }}</div>
<div class="field-wrap"> <div class="field-wrap">
<label id="lbl-fname">FIRST NAME</label> <label id="lbl-fname">FIRST NAME</label>
<input id="inp-fname" type="text" maxlength="20" autocomplete="off" autocorrect="off" autocapitalize="words"> <input id="inp-fname" type="text" maxlength="20" autocomplete="off" autocorrect="off" autocapitalize="words">
@@ -178,11 +178,8 @@ body::after{content:'';position:fixed;inset:0;background:repeating-linear-gradie
<label id="lbl-dept">DEPARTMENT</label> <label id="lbl-dept">DEPARTMENT</label>
<select id="inp-dept"> <select id="inp-dept">
<option value="">-- Select --</option> <option value="">-- Select --</option>
<option>Finanzas</option> {% for dept in departments %}<option>{{ dept }}</option>
<option>People</option> {% endfor %}
<option>Legal</option>
<option>B2B</option>
<option>B2C</option>
</select> </select>
</div> </div>
<button id="join-btn">▶ ENTER GAME</button> <button id="join-btn">▶ ENTER GAME</button>
@@ -227,7 +224,7 @@ body::after{content:'';position:fixed;inset:0;background:repeating-linear-gradie
<!-- PODIUM --> <!-- PODIUM -->
<div class="screen" id="podium-screen"> <div class="screen" id="podium-screen">
<h2 id="pod-title">🏆 GAME OVER</h2> <h2 id="pod-title">🏆 GAME OVER</h2>
<p class="sub" id="pod-sub">Network Intelligence · LCPR</p> <p class="sub" id="pod-sub">{{ org_name }}</p>
<div class="podium-list" id="podium-list"></div> <div class="podium-list" id="podium-list"></div>
<button class="exit-btn" onclick="exitGame()">✕ salir</button> <button class="exit-btn" onclick="exitGame()">✕ salir</button>
</div> </div>
+25 -2
View File
@@ -3,7 +3,7 @@
<head> <head>
<meta charset="UTF-8"> <meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0"> <meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>NI Quiz · Historial</title> <title>{{ app_name }} · Historial</title>
<style> <style>
@import url('https://fonts.googleapis.com/css2?family=Share+Tech+Mono&family=Exo+2:wght@400;600;800;900&display=swap'); @import url('https://fonts.googleapis.com/css2?family=Share+Tech+Mono&family=Exo+2:wght@400;600;800;900&display=swap');
@@ -156,6 +156,21 @@ tbody tr:hover{background:var(--surface2);}
/* animations */ /* animations */
@keyframes fadeUp{from{opacity:0;transform:translateY(12px)}to{opacity:1;transform:none}} @keyframes fadeUp{from{opacity:0;transform:translateY(12px)}to{opacity:1;transform:none}}
.fade-up{animation:fadeUp .4s ease both;} .fade-up{animation:fadeUp .4s ease both;}
/* ── THEME TOGGLE ── */
.theme-toggle{
position:fixed;top:1rem;right:1rem;z-index:10000;
display:flex;gap:.3rem;
background:var(--surface);border:1px solid var(--border);
border-radius:6px;padding:.3rem .4rem;box-shadow:0 2px 8px rgba(0,0,0,.15);
}
.t-btn{
padding:.28rem .55rem;border-radius:4px;font-size:.7rem;cursor:pointer;
border:1px solid transparent;background:transparent;color:var(--muted);
font-weight:700;transition:all .15s;
}
.t-btn:hover{color:var(--text);}
.t-btn.active{background:var(--accent);color:#000;}
</style> </style>
</head> </head>
<body> <body>
@@ -163,7 +178,7 @@ tbody tr:hover{background:var(--surface2);}
<header> <header>
<div class="header-left"> <div class="header-left">
<h1>NI<span>QUIZ</span></h1> <h1>NI<span>QUIZ</span></h1>
<p>// HISTORIAL DE SCORES · NETWORK INTELLIGENCE · LCPR</p> <p>// HISTORIAL DE SCORES · {{ org_name }}</p>
</div> </div>
<nav class="nav-links"> <nav class="nav-links">
<a href="/host">← HOST PANEL</a> <a href="/host">← HOST PANEL</a>
@@ -238,6 +253,14 @@ tbody tr:hover{background:var(--surface2);}
</main> </main>
<script> <script>
function setTheme(t) {
document.documentElement.setAttribute('data-theme', t);
localStorage.setItem('si-quiz-theme', t);
document.querySelectorAll('.t-btn').forEach(b => b.classList.remove('active'));
document.getElementById('t-' + t)?.classList.add('active');
}
(function(){ setTheme(localStorage.getItem('si-quiz-theme') || 'dark'); })();
const medals = ['🥇','🥈','🥉']; const medals = ['🥇','🥈','🥉'];
const rankClass = ['gold','silver','bronze']; const rankClass = ['gold','silver','bronze'];
const DEPT_COLORS = [ const DEPT_COLORS = [