This commit is contained in:
Isaac Johnson 2026-11-11 16:06:26 -06:00
parent 035ae48e54
commit 76facfed58
84 changed files with 13799 additions and 0 deletions

20
.dockerignore Normal file
View File

@ -0,0 +1,20 @@
venv/
.venv/
__pycache__/
*.py[cod]
*$py.class
.pytest_cache/
.coverage
htmlcov/
*.db
*.db-journal
*.db-wal
*.db-shm
*.sqlite3
.git/
.gitignore
frontend/node_modules/
frontend/dist/
.hallmark/renders/
.hallmark/shots/
.env

View File

@ -0,0 +1,89 @@
name: Build and Publish Docker Image
on:
push:
branches:
- main
jobs:
build-and-push:
name: Build and Push Docker Image
runs-on: my_custom_label
container: node:22
steps:
- name: Checkout Code
uses: actions/checkout@v3
- name: Prepare Env for Docker
run: |
whoami
which docker || true
apt update
cat /etc/os-release
apt install -y ca-certificates curl gnupg
mkdir -p /etc/apt/keyrings
curl -fsSL https://download.docker.com/linux/ubuntu/gpg | gpg --dearmor -o /etc/apt/keyrings/docker.gpg
echo \
"deb [arch=$(dpkg --print-architecture) signed-by=/etc/apt/keyrings/docker.gpg] https://download.docker.com/linux/ubuntu \
focal stable" | tee /etc/apt/sources.list.d/docker.list > /dev/null
apt update
DEBIAN_FRONTEND=noninteractive apt-get install -y docker-ce docker-ce-cli containerd.io docker-buildx-plugin docker-compose-plugin
- name: Get Version from version.ini
id: get_version
run: |
# Extract version value from version.ini
VERSION=$(awk -F'=[ \t]*' '/^version[ \t]*=/ {gsub(/[ \t"'\''\r]/, "", $2); print $2}' version.ini)
echo "VERSION=$VERSION" >> $GITHUB_ENV
echo "version=$VERSION" >> $GITHUB_OUTPUT
- name: Build Dockerfile
run: |
export BUILDIMGTAG="`cat Dockerfile | tail -n1 | sed 's/^.*\///g'`"
docker build -t $BUILDIMGTAG:${{ env.VERSION }} .
docker images
- name: Tag and Push (Harbor)
run: |
export BUILDIMGTAG="`cat Dockerfile | tail -n1 | sed 's/^.*\///g'`"
export FINALBUILDTAG="`cat Dockerfile | tail -n1 | sed 's/^#//g'`"
docker tag $BUILDIMGTAG:${{ env.VERSION }} $FINALBUILDTAG:${{ env.VERSION }}
docker images
echo $CR_PAT | docker login harbor.freshbrewed.science -u $CR_USER --password-stdin
docker push $FINALBUILDTAG:${{ env.VERSION }}
env: # Or as an environment variable
CR_PAT: ${{ secrets.CR_PAT }}
CR_USER: ${{ secrets.CR_USER }}
- name: Tag and Push (Dockerhub)
run: |
export BUILDIMGTAG="`cat Dockerfile | tail -n1 | sed 's/^.*\///g'`"
docker tag $BUILDIMGTAG:${{ env.VERSION }} $DHUSER/$BUILDIMGTAG:${{ env.VERSION }}
docker images
echo $DHPAT | docker login -u $DHUSER --password-stdin
docker push $DHUSER/$BUILDIMGTAG:${{ env.VERSION }}
env: # Or as an environment variable
DHPAT: ${{ secrets.DHPAT }}
DHUSER: ${{ secrets.DHUSER }}
- name: Prepare Env for Helm OCI Push (Helm v3 reference only)
run: |
if [ ! -f /tmp/linux-amd64/helm ]; then
apt update
DEBIAN_FRONTEND=noninteractive apt-get install -y unzip curl || true
wget https://get.helm.sh/helm-v3.14.0-linux-amd64.tar.gz -O /tmp/helm.tar.gz || true
cd /tmp
tar xzvf helm.tar.gz
chmod +x linux-amd64/helm
fi
- name: Package Helm Chart
run: |
set -x
/tmp/linux-amd64/helm package ./helm-chart
export HLMPKG=`ls -tr *.tgz | tail -n1 | tr -d '\n'`
/tmp/linux-amd64/helm registry login harbor.freshbrewed.science -u $CR_USER -p $CR_PAT
/tmp/linux-amd64/helm push ./$HLMPKG oci://harbor.freshbrewed.science/chartrepo/trymeapp-helm
env: # Or as an environment variable
CR_PAT: ${{ secrets.CR_PAT }}
CR_USER: ${{ secrets.CR_USER }}

67
.github/workflows/ci.yml vendored Normal file
View File

@ -0,0 +1,67 @@
name: Continuous Integration and Container Build
on:
push:
branches: [main]
pull_request:
branches: [main]
jobs:
test-and-package:
name: Test Backend, Build Frontend, Lint Helm & Build Container
runs-on: ubuntu-latest
services:
postgres:
image: postgres:16-alpine
env:
POSTGRES_USER: tryme
POSTGRES_PASSWORD: tryme_ci_password
POSTGRES_DB: trymedb_test
ports:
- 5432:5432
options: >-
--health-cmd pg_isready
--health-interval 5s
--health-timeout 5s
--health-retries 10
steps:
- name: Checkout repository
uses: actions/checkout@v4
- name: Set up Python 3.11
uses: actions/setup-python@v5
with:
python-version: "3.11"
- name: Install Python dependencies and run pytest
run: |
python -m venv venv
venv/bin/pip install --upgrade pip
venv/bin/pip install -e ".[dev]"
DATABASE_URL="postgresql+psycopg://tryme:tryme_ci_password@localhost:5432/trymedb_test" venv/bin/alembic upgrade head
venv/bin/pytest -v
- name: Set up Node.js 22 and build SPA
uses: actions/setup-node@v4
with:
node-version: "22"
cache: "npm"
cache-dependency-path: frontend/package-lock.json
- name: Build frontend bundle
working-directory: frontend
run: |
npm ci
npm run build
- name: Extract version from version.ini and build Docker image
run: |
VERSION=$(awk -F'=[ \t]*' '/^version[ \t]*=/ {gsub(/[ \t"'\''\r]/, "", $2); print $2}' version.ini)
echo "Building trymeapp:${VERSION}"
docker build -t "trymeapp:${VERSION}" .
- name: Lint and template Helm chart (PostgreSQL and SQLite PVC modes)
run: |
helm lint ./helm-chart
helm template tryme ./helm-chart > /dev/null
helm template tryme-sqlite ./helm-chart --set database.mode=sqlite | grep -q "PersistentVolumeClaim"

9
.gitignore vendored
View File

@ -189,3 +189,12 @@ poetry.toml
pyrightconfig.json pyrightconfig.json
# End of https://www.toptal.com/developers/gitignore/api/python,linux # End of https://www.toptal.com/developers/gitignore/api/python,linux
### TryMe App Additions ###
*.db
*.db-wal
*.db-shm
frontend/node_modules/
frontend/dist/
.hallmark/renders/
.hallmark/shots/

5
.hallmark/.gitignore vendored Normal file
View File

@ -0,0 +1,5 @@
shots/
before/
before.json
before.txt
compare.html

13
.hallmark/log.json Normal file
View File

@ -0,0 +1,13 @@
[
{
"scope": "app",
"theme": "Hum",
"genre": "playful",
"axes": "light/rounded-sans/chromatic-ember",
"signature": "draggable card-stock quiz workspace with pop-up recycle drop zone",
"waived": [],
"prompt": "TryMe student quiz creation and sharing game app light fun festive warm",
"date": "2026-11-11",
"mode": "build"
}
]

BIN
2dlogo_large.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 348 KiB

BIN
2dlogo_medium_256x256.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 38 KiB

BIN
2dlogo_small_64x64.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.3 KiB

BIN
3dlogo_large.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.7 MiB

47
Dockerfile Normal file
View File

@ -0,0 +1,47 @@
# Stage 1: Build React + TypeScript SPA
FROM node:22-slim AS frontend-builder
WORKDIR /build/frontend
COPY frontend/package.json frontend/package-lock.json ./
RUN npm ci
COPY frontend/ ./
RUN npm run build
# Stage 2: Build Python virtual environment
FROM python:3.11-slim AS python-builder
WORKDIR /build
RUN python -m venv /opt/venv
ENV PATH="/opt/venv/bin:$PATH"
COPY pyproject.toml README.md version.ini ./
COPY app/ ./app/
RUN pip install --no-cache-dir --upgrade pip && pip install --no-cache-dir .
# Stage 3: Production Runtime
FROM python:3.11-slim AS runtime
WORKDIR /app
ENV PYTHONDONTWRITEBYTECODE=1 \
PYTHONUNBUFFERED=1 \
PATH="/opt/venv/bin:$PATH" \
PORT=8000
RUN groupadd --gid 1001 appgroup && \
useradd --uid 1001 --gid appgroup --create-home appuser && \
mkdir -p /data && chown -R appuser:appgroup /app /data
COPY --from=python-builder /opt/venv /opt/venv
COPY --chown=appuser:appgroup version.ini openapi.yaml alembic.ini ./
COPY --chown=appuser:appgroup 2dlogo_large.png 2dlogo_medium_256x256.png 2dlogo_small_64x64.png 3dlogo_large.png ./
COPY --chown=appuser:appgroup alembic/ ./alembic/
COPY --chown=appuser:appgroup app/ ./app/
COPY --from=frontend-builder --chown=appuser:appgroup /build/frontend/dist ./frontend/dist
COPY --chown=appuser:appgroup docker-entrypoint.sh /usr/local/bin/docker-entrypoint.sh
RUN chmod +x /usr/local/bin/docker-entrypoint.sh
USER appuser
EXPOSE 8000
HEALTHCHECK --interval=30s --timeout=5s --start-period=15s --retries=3 \
CMD python -c "import urllib.request; urllib.request.urlopen('http://127.0.0.1:8000/health', timeout=4)" || exit 1
ENTRYPOINT ["/usr/local/bin/docker-entrypoint.sh"]
#harbor.freshbrewed.science/library/trymeapp

98
INSTALL.md Normal file
View File

@ -0,0 +1,98 @@
# TryMe Gaming App — Installation & Operations (`INSTALL.md`)
## Prerequisites
- **Python** `3.11+`
- **Node.js** `22+` and `npm` (for building the React + TypeScript SPA locally)
- **Docker** & **Docker Compose** (for containerized deployment)
- **Helm** `3.x` (for Kubernetes deployment)
---
## 1. Local Development Setup
```bash
# 1. Create Python virtual environment and install backend + test dependencies
python3 -m venv venv
venv/bin/pip install --upgrade pip
venv/bin/pip install -e ".[dev]"
# 2. Apply Alembic database migrations (defaults to local SQLite ./tryme.db)
venv/bin/alembic upgrade head
# 3. Install and build the frontend SPA
cd frontend
npm ci
npm run build
cd ..
# 4. Start the FastAPI server (serves API, /docs, /openapi.yaml, and SPA on :8000)
SEED_STARTER_QUIZZES=true venv/bin/uvicorn app.main:app --reload --port 8000
```
Run the automated test suite:
```bash
venv/bin/pytest -v
```
---
## 2. Docker & Docker Compose
### Launch App + PostgreSQL Together
```bash
docker compose up --build
```
The application will run Alembic migrations automatically and listen at `http://localhost:8000` (Swagger UI at `http://localhost:8000/docs`, OpenAPI YAML at `http://localhost:8000/openapi.yaml`).
### Standalone Container Build
The container tag is tracked in [version.ini](file:///home/isaac/Workspaces/trymeapp/version.ini) (`0.1`):
```bash
VERSION=$(awk -F'=[ \t]*' '/^version[ \t]*=/ {gsub(/[ \t"'\''\r]/, "", $2); print $2}' version.ini)
docker build -t trymeapp:${VERSION} .
docker run -p 8000:8000 -e DEV_AUTH_ENABLED=true -e SEED_STARTER_QUIZZES=true trymeapp:${VERSION}
```
---
## 3. Kubernetes Deployment (Helm Chart)
### Option A: With External / Cluster PostgreSQL
```bash
helm upgrade --install tryme ./helm-chart \
--set database.mode=postgres \
--set database.url="postgresql+psycopg://tryme:secret@postgres-svc:5432/trymedb" \
--set config.adminUser="moderator@school.edu" \
--set config.auth0Domain="your-tenant.us.auth0.com" \
--set config.auth0ClientId="your-client-id" \
--set secrets.auth0ClientSecret="your-client-secret" \
--set secrets.sessionSecret="$(openssl rand -hex 32)"
```
### Option B: With SQLite + Automatic PersistentVolumeClaim (PVC)
When `database.mode=sqlite` is set, the Helm chart automatically creates a `PersistentVolumeClaim`, switches the deployment strategy to `Recreate`, mounts the volume at `/data`, and points `DATABASE_URL` to `/data/tryme.db`:
```bash
helm upgrade --install tryme ./helm-chart \
--set database.mode=sqlite \
--set persistence.size=5Gi \
--set config.adminUser="moderator@school.edu"
```
---
## 4. CI/CD Workflows & Required Repository Secrets
Two CI/CD workflows are included:
1. **Gitea Actions (`.gitea/workflows/cicd.yaml`)**:
- Reads the version tag from `version.ini` and registry path from the final line of `Dockerfile` (`#harbor.freshbrewed.science/library/trymeapp`).
- Builds and pushes the image to Harbor and Docker Hub, then packages `./helm-chart` and pushes it to `oci://harbor.freshbrewed.science/chartrepo/trymeapp-helm`.
- **Required Gitea Secrets**:
- `CR_USER` / `CR_PAT`: Harbor container registry & Helm OCI chart repository credentials.
- `DHUSER` / `DHPAT`: Docker Hub username & personal access token.
2. **GitHub Actions (`.github/workflows/ci.yml`)**:
- Runs `alembic upgrade head` against a PostgreSQL 16 service container, executes `pytest`, compiles the TypeScript/Vite SPA, builds the Docker image tagged from `version.ini`, and validates `./helm-chart` in both PostgreSQL and SQLite PVC modes.

View File

@ -1 +1,72 @@
# TryMe Gaming App # TryMe Gaming App
![TryMe Logo](./2dlogo_medium_256x256.png)
## Purpose
**TryMe** is a student quiz creation and sharing web application where students build multiple-choice and write-in quizzes (0 to 40 questions on the public free tier), share direct quiz URLs with classmates, vote with thumbs up/down, pin and reorder favourite quizzes in a drag-and-drop right-hand rail, and compete on per-quiz leaderboards.
---
## Key Features
- **Federated Auth0 Login & Local Dev Mode**: Sign in with federated IdPs via Auth0 OIDC/JWKS (`AUTH0_DOMAIN`, `AUTH0_CLIENT_ID`, `AUTH0_CLIENT_SECRET`), plus an optional `DEV_AUTH_ENABLED` local mode for instant development and automated testing.
- **Student-Friendly Player Profiles**:
- Pick from 8 stylized geometric animal emblems (`Fox`, `Owl`, `Bear`, `Otter`, `Koala`, `Tiger`, `Frog`, `Penguin`) across 8 festive colorways.
- Set a unique `nickname`, `first_name`, `last_name`, optional `age` (**never** asks for a birthday), inclusive `gender` options (including Non-binary, Two-Spirit, Agender, Genderqueer, Other / self-described, and Prefer not to say), and explicit confirmation of permission to use public websites.
- **Multiple-Choice & Write-In Quizzes (0–40 Questions)**:
- Combine multiple-choice and case-insensitive write-in questions (with optional accepted synonyms) up to 40 questions on the free tier.
- Share any public quiz via `/quiz/<share-slug>`, vote thumbs up or down, and view **your latest score**, **your all-time best**, and **the best score by anyone (with their nickname)**.
- **Drag-and-Drop Favourites Rail + Pop-Up Recycle Bin**:
- Drag quiz cards from the main page into the right-hand **Favourites** pane, drag to reorder, or drag out of the pane onto the pop-up **Recycle Bin** (with one-click Undo and full keyboard accessibility).
- **Personal API Keys**: Generate `tm_live_...` API keys to access `/api/v1/*` programmatically and expire old keys immediately.
- **Moderator Controls**: Configured at launch (initial moderator claim) or via `ADMIN_USER` container variable to ban/restore users and remove inappropriate quizzes.
---
## Quick Start
### Run with Docker Compose (App + PostgreSQL 16)
```bash
docker compose up --build
```
Open **`http://localhost:8000`** for the web app, **`http://localhost:8000/docs`** for Swagger UI, or **`http://localhost:8000/openapi.yaml`** for the OpenAPI 3.1 specification.
### Run Locally (SQLite + Python Virtual Environment)
```bash
python3 -m venv venv
venv/bin/pip install -e ".[dev]"
venv/bin/alembic upgrade head
cd frontend && npm ci && npm run build && cd ..
SEED_STARTER_QUIZZES=true venv/bin/uvicorn app.main:app --port 8000
```
### Run Automated Tests
```bash
venv/bin/pytest -v
```
---
## Project Layout
```text
trymeapp/
├── .gitea/workflows/cicd.yaml # Harbor + DockerHub + Helm OCI CI/CD pipeline
├── .github/workflows/ci.yml # GitHub Actions pytest, SPA, Docker & Helm CI
├── Dockerfile # Multi-stage Node 22 + Python 3.11 build
├── docker-compose.yaml # App + PostgreSQL 16 stack (symlinked at docker-compose.yml)
├── version.ini # Root container & API version (0.1)
├── openapi.yaml # OpenAPI 3.1 specification
├── alembic.ini / alembic/ # Database schema migrations
├── app/ # FastAPI application, Auth0/API key auth, routers & models
├── frontend/ # React 19 + TypeScript + Vite SPA (Hallmark Hum theme)
├── helm-chart/ # Kubernetes Deployment, Service, Ingress & SQLite PVC
├── tests/ # Pytest test suite
├── SETUP.md # Auth0, Moderator, Database & API Key setup guide
├── INSTALL.md # Operator installation, Helm & CI secrets reference
└── SYSTEM.md # Architecture & data flow design document
```
See [SETUP.md](./SETUP.md) for Auth0 and moderator configuration, [INSTALL.md](./INSTALL.md) for Helm and Docker operations, and [SYSTEM.md](./SYSTEM.md) for system architecture details.

103
SETUP.md Normal file
View File

@ -0,0 +1,103 @@
# TryMe Gaming App — Setup Guide (`SETUP.md`)
This guide walks through configuring **Auth0 federated Single Sign-On**, the **Moderator account**, **PostgreSQL vs. SQLite** storage, and **User API Keys**.
---
## 1. Auth0 Federated IdP Configuration
TryMe authenticates students and moderators via Auth0 OIDC Authorization Code flow and RS256 JWKS verification.
### Step 1: Create an Auth0 Application
1. Sign in to the [Auth0 Dashboard](https://manage.auth0.com/) and navigate to **Applications -> Create Application**.
2. Name the application **`TryMe Quiz App`**, select **Regular Web Applications**, and click **Create**.
3. Open the **Settings** tab and note your:
- **Domain** (e.g., `your-tenant.us.auth0.com`)
- **Client ID**
- **Client Secret**
### Step 2: Configure Allowed URLs in Auth0
Set the following URLs in your Auth0 Application settings (replace `http://localhost:8000` with your production domain, e.g., `https://tryme.example.edu`):
- **Allowed Callback URLs**:
```text
http://localhost:8000/api/v1/auth/callback
```
- **Allowed Logout URLs**:
```text
http://localhost:8000/
```
- **Allowed Web Origins**:
```text
http://localhost:8000
```
### Step 3: Enable Federated Connections
Under **Authentication -> Social** or **Enterprise** in Auth0, enable the federated identity providers your students use (Google Workspace for Education, Microsoft Azure AD / Entra ID, GitHub, Apple, etc.) and toggle them on for the **TryMe Quiz App** application.
### Step 4: Pass Auth0 Variables to TryMe
Set these environment variables in `.env`, `docker-compose.yaml`, or `helm-chart/values.yaml`:
```bash
AUTH0_DOMAIN=your-tenant.us.auth0.com
AUTH0_CLIENT_ID=your_auth0_client_id
AUTH0_CLIENT_SECRET=your_auth0_client_secret
AUTH0_AUDIENCE=https://tryme-api.example.edu # Optional API audience
AUTH0_CALLBACK_URL=http://localhost:8000/api/v1/auth/callback
FRONTEND_BASE_URL=http://localhost:8000
DEV_AUTH_ENABLED=false # Disable local dev login in production
SESSION_SECRET=generate-a-long-random-secret
```
> **Local Development Without Auth0**: When `DEV_AUTH_ENABLED=true` (the default in `docker-compose.yaml`), TryMe enables a local player sign-in form so you can run, demo, and test all features immediately before connecting an Auth0 tenant.
---
## 2. Moderator Account Setup
The Moderator account can ban disruptive users and remove public quizzes. You can configure it in either of two ways:
1. **Container Environment Variable (`ADMIN_USER`)**:
Pass `ADMIN_USER` to the container matching the moderator's Auth0 `sub`, email address, or TryMe `nickname`:
```bash
ADMIN_USER=moderator@school.edu
```
When that user signs in, TryMe automatically grants moderator privileges and locks initial moderator claiming.
2. **Initial Launch Claim (When `ADMIN_USER` is Unset)**:
If `ADMIN_USER` is left empty and zero moderators exist in the database, the **Moderation** tab appears for the first signed-in operator so they can click **Claim moderator account** once at initial launch. Once claimed, subsequent users receive `409 Conflict` if they attempt to claim it.
---
## 3. Database Configuration (PostgreSQL vs. SQLite)
TryMe supports both **PostgreSQL** and **SQLite** via the `DATABASE_URL` environment variable:
- **PostgreSQL (Recommended for Docker Compose & Multi-Replica Kubernetes)**:
```bash
DATABASE_URL=postgresql+psycopg://tryme:tryme_secret@db:5432/trymedb
```
*(Plain `postgresql://...` and `postgres://...` URLs are also normalized automatically.)*
- **SQLite (Local Development or Single-Pod Helm with PVC)**:
```bash
DATABASE_URL=sqlite:///./tryme.db
```
When deploying via Helm with `--set database.mode=sqlite`, the chart automatically provisions and mounts a `PersistentVolumeClaim` (`trymeapp-sqlite-pvc`) at `/data` and configures `DATABASE_URL=sqlite:////data/tryme.db`.
---
## 4. User API Keys & Expiring Old Keys
Every onboarded user can generate personal API keys (`tm_live_...`) from **Profile and API keys** in the UI (or via `POST /api/v1/users/me/api-keys`).
```bash
# Authenticate any API call using X-API-Key
curl -H "X-API-Key: tm_live_your_generated_token" \
http://localhost:8000/api/v1/quizzes
# Expire an old key immediately via REST API
curl -X POST \
-H "X-API-Key: tm_live_your_generated_token" \
http://localhost:8000/api/v1/users/me/api-keys/1/expire
```

82
SYSTEM.md Normal file
View File

@ -0,0 +1,82 @@
# TryMe Gaming App — System Architecture (`SYSTEM.md`)
## 1. High-Level Overview
**TryMe** is a web platform designed for students to author, share, vote on, favourite, and play multiple-choice and write-in quizzes (0–40 questions on the public free tier). It packages a **React 19 + TypeScript** single-page frontend and a **FastAPI + SQLAlchemy 2.0** backend into a single multi-stage container image, backed by either **PostgreSQL** or **SQLite**.
```mermaid
graph TB
Student["Student / Moderator Browser (SPA)"]
CLI["Automation / Study Script (X-API-Key)"]
Auth0["Auth0 Federated IdP (OIDC / JWKS)"]
subgraph Container["TryMe Container (:8000)"]
FastAPI["FastAPI HTTP & Static Layer (app/main.py)"]
AuthLayer["Session, Auth0 JWKS & API Key Guard (app/auth.py)"]
QuizSvc["Quiz Grading & Leaderboard Service (app/services/quiz_service.py)"]
SPA["Compiled React + Vite Assets (frontend/dist)"]
end
DB[("PostgreSQL 16 or SQLite + PVC")]
Student -->|"HTTPS / Cookie or Bearer JWT"| FastAPI
CLI -->|"HTTPS + X-API-Key"| FastAPI
FastAPI --> SPA
FastAPI --> AuthLayer
AuthLayer -->|"Verify OIDC / JWKS"| Auth0
AuthLayer --> QuizSvc
QuizSvc --> DB
```
---
## 2. CoreSubsystems
### 2.1 Authentication, Consent & API Keys (`app/auth.py`, `app/routers/auth_routes.py`, `app/routers/users.py`)
- **Auth0 Federated Login**: `/api/v1/auth/login` initiates an OAuth2/OIDC Authorization Code flow with signed anti-CSRF state (`itsdangerous`). `/api/v1/auth/callback` exchanges the code, fetches `/userinfo`, provisions or updates the `User` row, and issues an `HttpOnly` session JWT cookie (`tryme_session`). Direct Auth0 RS256 Bearer tokens are also verified via cached `/.well-known/jwks.json`.
- **Player Onboarding & Privacy**: Users select a stylized animal icon (`fox`, `owl`, `bear`, `otter`, `koala`, `tiger`, `frog`, `penguin`) and colorway (`avatar-1..8`), set a unique `nickname`, `first_name`, `last_name`, optional integer `age` (**never** storing a birthday), inclusive `gender` (`woman`, `man`, `non_binary`, `agender`, `genderqueer`, `two_spirit`, `other`, `prefer_not_to_say`), and explicitly confirm permission to use public websites (`public_website_consent`).
- **Personal API Keys**: Users generate `tm_live_<token>` keys stored strictly as SHA-256 hashes (`key_hash`) with configurable expiration and one-click revocation (`POST /api/v1/users/me/api-keys/{id}/expire`).
### 2.2 Quiz Engine, Leaderboards & Voting (`app/routers/quizzes.py`, `app/services/quiz_service.py`)
- **Free Tier Guardrails**: Validated via Pydantic (`max_length=40`) and route checks (`0 <= len(questions) <= 40`).
- **Question Formats**: Each question supports either `multiple_choice` (2–6 options) or `write_in` (whitespace-normalized, case-insensitive matching against `correct_answer` plus optional `accepted_answers` synonyms).
- **Score Aggregation**: Every quiz card and detail response computes three distinct metrics in bulk SQL queries:
1. **Global Best Score**: Highest `score_percent` across all attempts, paired with the top holder's `nickname` and animal icon (ties broken by earliest completion).
2. **User Latest Score**: The signed-in player's most chronological recent `QuizAttempt`.
3. **User All-Time Best Score**: The signed-in player's highest `QuizAttempt` on that quiz.
- **Thumbs Up / Down Voting**: Stored in `quiz_votes` with a `UNIQUE(quiz_id, user_id)` constraint (`+1` or `-1`, toggleable back to `0`).
- **Shareable URLs**: Every quiz receives a unique `share_slug` (e.g., `/quiz/solar-system-quick-ch-a1b2c3`) routable directly in the SPA and backend.
### 2.3 Right-Hand Favourites Pane & Recycle Bin (`app/routers/favourites.py`, `FavouritesPane.tsx`)
```mermaid
sequenceDiagram
participant Main as Explore Feed Card
participant Rail as Right Favourites Pane
participant Bin as Pop-up Recycle Bin
participant API as FastAPI /api/v1/favourites
Main->>Rail: Drag quiz card (application/x-tryme-quiz-id)
Rail->>API: POST /api/v1/favourites {quiz_id, position}
API-->>Rail: Ordered favourites list
Rail->>Rail: Drag existing favourite (summons Recycle Bin)
alt Reorder inside rail
Rail->>API: PUT /api/v1/favourites/reorder {quiz_ids}
else Drop onto Recycle Bin
Rail->>Bin: Drop favourite item
Bin->>API: DELETE /api/v1/favourites/{quiz_id}
Bin-->>Rail: Show 7-second Undo bar
end
```
### 2.4 Moderation (`app/routers/admin.py`, `ModeratorView.tsx`)
- Moderators can be designated via `ADMIN_USER` container env var (matched against `auth_sub`, `email`, or `nickname`) or claimed once at initial launch (`POST /api/v1/admin/claim`) when no moderator exists.
- Moderators can suspend/restore accounts (`is_banned = True` blocks quiz creation, submissions, and voting with `403 Forbidden`) and remove quizzes (`is_removed = True` hides the quiz from public feeds and cleans up dangling favourites).
---
## 3. UI Design System
The frontend uses the Hallmark **`Hum`** (`playful` genre, `--scope app`) design system in `frontend/src/styles/tokens.css` and `frontend/src/styles/app.css`:
- **Primary Brand Colors**: Warm festive orange `#fb723a` (`oklch(70.6% 0.178 41)`) paired with deep slate-navy `#253450` (`oklch(32.5% 0.058 258)`) over warm cream card stock (`oklch(97.2% 0.012 72)`).
- **Typography**: `Chillax` (`500`, `600`) for headings, body, and controls; `Geist Mono` exclusively for API key tokens; `tabular-nums` on all scores, counts, and votes.
- **Accessibility & Motion**: Instant `:focus-visible` rings, 44px touch targets, native `<dialog>` modals, keyboard reorder/delete fallbacks alongside HTML5 drag-and-drop, and `prefers-reduced-motion: reduce` support.

39
alembic.ini Normal file
View File

@ -0,0 +1,39 @@
[alembic]
script_location = alembic
prepend_sys_path = .
version_path_separator = os
sqlalchemy.url = sqlite:///./tryme.db
[loggers]
keys = root,sqlalchemy,alembic
[handlers]
keys = console
[formatters]
keys = generic
[logger_root]
level = WARN
handlers = console
qualname =
[logger_sqlalchemy]
level = WARN
handlers =
qualname = sqlalchemy.engine
[logger_alembic]
level = INFO
handlers =
qualname = alembic
[handler_console]
class = StreamHandler
args = (sys.stderr,)
level = NOTSET
formatter = generic
[formatter_generic]
format = %(levelname)-5.5s [%(name)s] %(message)s
datefmt = %H:%M:%S

61
alembic/env.py Normal file
View File

@ -0,0 +1,61 @@
from __future__ import annotations
from logging.config import fileConfig
from alembic import context
from sqlalchemy import engine_from_config, pool
import app.models # noqa: F401
from app.config import Settings
from app.database import Base
config = context.config
if config.config_file_name is not None:
fileConfig(config.config_file_name)
# Always resolve runtime Settings() so DATABASE_URL env overrides take precedence
runtime_settings = Settings()
config.set_main_option("sqlalchemy.url", runtime_settings.database_url)
target_metadata = Base.metadata
def run_migrations_offline() -> None:
url = config.get_main_option("sqlalchemy.url")
context.configure(
url=url,
target_metadata=target_metadata,
literal_binds=True,
dialect_opts={"paramstyle": "named"},
render_as_batch=url.startswith("sqlite") if url else False,
)
with context.begin_transaction():
context.run_migrations()
def run_migrations_online() -> None:
section = config.get_section(config.config_ini_section, {})
url = section.get("sqlalchemy.url", "")
connectable = engine_from_config(
section,
prefix="sqlalchemy.",
poolclass=pool.NullPool,
)
with connectable.connect() as connection:
context.configure(
connection=connection,
target_metadata=target_metadata,
render_as_batch=url.startswith("sqlite"),
)
with context.begin_transaction():
context.run_migrations()
if context.is_offline_mode():
run_migrations_offline()
else:
run_migrations_online()

25
alembic/script.py.mako Normal file
View File

@ -0,0 +1,25 @@
"""${message}
Revision ID: ${up_revision}
Revises: ${down_revision | comma,n}
Create Date: ${create_date}
"""
from typing import Sequence, Union
from alembic import op
import sqlalchemy as sa
${imports if imports else ""}
revision: str = ${repr(up_revision)}
down_revision: Union[str, None] = ${repr(down_revision)}
branch_labels: Union[str, Sequence[str], None] = ${repr(branch_labels)}
depends_on: Union[str, Sequence[str], None] = ${repr(depends_on)}
def upgrade() -> None:
${upgrades if upgrades else "pass"}
def downgrade() -> None:
${downgrades if downgrades else "pass"}

View File

@ -0,0 +1,186 @@
"""Initial TryMe schema
Revision ID: 0001_initial_schema
Revises:
Create Date: 2026-11-11 13:45:00
"""
from collections.abc import Sequence
import sqlalchemy as sa
from alembic import op
revision: str = "0001_initial_schema"
down_revision: str | None = None
branch_labels: str | Sequence[str] | None = None
depends_on: str | Sequence[str] | None = None
def upgrade() -> None:
op.create_table(
"users",
sa.Column("id", sa.Integer(), autoincrement=True, nullable=False),
sa.Column("auth_sub", sa.String(length=190), nullable=False),
sa.Column("email", sa.String(length=255), nullable=True),
sa.Column("nickname", sa.String(length=48), nullable=False),
sa.Column("first_name", sa.String(length=80), nullable=False, server_default=""),
sa.Column("last_name", sa.String(length=80), nullable=False, server_default=""),
sa.Column("age", sa.Integer(), nullable=True),
sa.Column(
"gender", sa.String(length=40), nullable=False, server_default="prefer_not_to_say"
),
sa.Column("gender_custom", sa.String(length=80), nullable=True),
sa.Column("avatar_animal", sa.String(length=32), nullable=False, server_default="fox"),
sa.Column("avatar_color", sa.String(length=32), nullable=False, server_default="avatar-1"),
sa.Column(
"public_website_consent", sa.Boolean(), nullable=False, server_default=sa.text("false")
),
sa.Column("consent_at", sa.DateTime(timezone=True), nullable=True),
sa.Column("onboarded", sa.Boolean(), nullable=False, server_default=sa.text("false")),
sa.Column("is_admin", sa.Boolean(), nullable=False, server_default=sa.text("false")),
sa.Column("is_banned", sa.Boolean(), nullable=False, server_default=sa.text("false")),
sa.Column("ban_reason", sa.String(length=255), nullable=True),
sa.Column("created_at", sa.DateTime(timezone=True), nullable=False),
sa.PrimaryKeyConstraint("id"),
sa.UniqueConstraint("auth_sub"),
sa.UniqueConstraint("nickname"),
)
op.create_index(op.f("ix_users_auth_sub"), "users", ["auth_sub"], unique=True)
op.create_index(op.f("ix_users_email"), "users", ["email"], unique=False)
op.create_index(op.f("ix_users_nickname"), "users", ["nickname"], unique=True)
op.create_table(
"api_keys",
sa.Column("id", sa.Integer(), autoincrement=True, nullable=False),
sa.Column("user_id", sa.Integer(), nullable=False),
sa.Column("name", sa.String(length=80), nullable=False),
sa.Column("key_prefix", sa.String(length=24), nullable=False),
sa.Column("key_hash", sa.String(length=64), nullable=False),
sa.Column("expires_at", sa.DateTime(timezone=True), nullable=True),
sa.Column("revoked_at", sa.DateTime(timezone=True), nullable=True),
sa.Column("last_used_at", sa.DateTime(timezone=True), nullable=True),
sa.Column("created_at", sa.DateTime(timezone=True), nullable=False),
sa.ForeignKeyConstraint(["user_id"], ["users.id"], ondelete="CASCADE"),
sa.PrimaryKeyConstraint("id"),
sa.UniqueConstraint("key_hash"),
)
op.create_index(op.f("ix_api_keys_key_hash"), "api_keys", ["key_hash"], unique=True)
op.create_index(op.f("ix_api_keys_user_id"), "api_keys", ["user_id"], unique=False)
op.create_table(
"quizzes",
sa.Column("id", sa.Integer(), autoincrement=True, nullable=False),
sa.Column("share_slug", sa.String(length=40), nullable=False),
sa.Column("author_id", sa.Integer(), nullable=False),
sa.Column("title", sa.String(length=140), nullable=False),
sa.Column("description", sa.Text(), nullable=False, server_default=""),
sa.Column("is_public", sa.Boolean(), nullable=False, server_default=sa.text("true")),
sa.Column("is_removed", sa.Boolean(), nullable=False, server_default=sa.text("false")),
sa.Column("created_at", sa.DateTime(timezone=True), nullable=False),
sa.Column("updated_at", sa.DateTime(timezone=True), nullable=False),
sa.ForeignKeyConstraint(["author_id"], ["users.id"], ondelete="CASCADE"),
sa.PrimaryKeyConstraint("id"),
sa.UniqueConstraint("share_slug"),
)
op.create_index(op.f("ix_quizzes_author_id"), "quizzes", ["author_id"], unique=False)
op.create_index(op.f("ix_quizzes_is_public"), "quizzes", ["is_public"], unique=False)
op.create_index(op.f("ix_quizzes_is_removed"), "quizzes", ["is_removed"], unique=False)
op.create_index(op.f("ix_quizzes_share_slug"), "quizzes", ["share_slug"], unique=True)
op.create_table(
"questions",
sa.Column("id", sa.Integer(), autoincrement=True, nullable=False),
sa.Column("quiz_id", sa.Integer(), nullable=False),
sa.Column("position", sa.Integer(), nullable=False, server_default="0"),
sa.Column("question_type", sa.String(length=24), nullable=False),
sa.Column("prompt", sa.Text(), nullable=False),
sa.Column("options", sa.JSON(), nullable=False),
sa.Column("correct_answer", sa.String(length=500), nullable=False),
sa.Column("accepted_answers", sa.JSON(), nullable=False),
sa.ForeignKeyConstraint(["quiz_id"], ["quizzes.id"], ondelete="CASCADE"),
sa.PrimaryKeyConstraint("id"),
)
op.create_index(op.f("ix_questions_quiz_id"), "questions", ["quiz_id"], unique=False)
op.create_table(
"quiz_attempts",
sa.Column("id", sa.Integer(), autoincrement=True, nullable=False),
sa.Column("quiz_id", sa.Integer(), nullable=False),
sa.Column("user_id", sa.Integer(), nullable=False),
sa.Column("score_count", sa.Integer(), nullable=False),
sa.Column("total_questions", sa.Integer(), nullable=False),
sa.Column("score_percent", sa.Float(), nullable=False),
sa.Column("answers_payload", sa.JSON(), nullable=False),
sa.Column("completed_at", sa.DateTime(timezone=True), nullable=False),
sa.ForeignKeyConstraint(["quiz_id"], ["quizzes.id"], ondelete="CASCADE"),
sa.ForeignKeyConstraint(["user_id"], ["users.id"], ondelete="CASCADE"),
sa.PrimaryKeyConstraint("id"),
)
op.create_index(
"ix_quiz_attempts_quiz_score",
"quiz_attempts",
["quiz_id", "score_percent", "completed_at"],
unique=False,
)
op.create_index(
"ix_quiz_attempts_user_quiz",
"quiz_attempts",
["user_id", "quiz_id", "completed_at"],
unique=False,
)
op.create_table(
"quiz_votes",
sa.Column("id", sa.Integer(), autoincrement=True, nullable=False),
sa.Column("quiz_id", sa.Integer(), nullable=False),
sa.Column("user_id", sa.Integer(), nullable=False),
sa.Column("value", sa.Integer(), nullable=False),
sa.Column("updated_at", sa.DateTime(timezone=True), nullable=False),
sa.ForeignKeyConstraint(["quiz_id"], ["quizzes.id"], ondelete="CASCADE"),
sa.ForeignKeyConstraint(["user_id"], ["users.id"], ondelete="CASCADE"),
sa.PrimaryKeyConstraint("id"),
sa.UniqueConstraint("quiz_id", "user_id", name="uq_quiz_vote_user"),
)
op.create_index(op.f("ix_quiz_votes_quiz_id"), "quiz_votes", ["quiz_id"], unique=False)
op.create_index(op.f("ix_quiz_votes_user_id"), "quiz_votes", ["user_id"], unique=False)
op.create_table(
"favourites",
sa.Column("id", sa.Integer(), autoincrement=True, nullable=False),
sa.Column("user_id", sa.Integer(), nullable=False),
sa.Column("quiz_id", sa.Integer(), nullable=False),
sa.Column("position", sa.Integer(), nullable=False, server_default="0"),
sa.Column("created_at", sa.DateTime(timezone=True), nullable=False),
sa.ForeignKeyConstraint(["quiz_id"], ["quizzes.id"], ondelete="CASCADE"),
sa.ForeignKeyConstraint(["user_id"], ["users.id"], ondelete="CASCADE"),
sa.PrimaryKeyConstraint("id"),
sa.UniqueConstraint("user_id", "quiz_id", name="uq_favourite_user_quiz"),
)
op.create_index(op.f("ix_favourites_quiz_id"), "favourites", ["quiz_id"], unique=False)
op.create_index(op.f("ix_favourites_user_id"), "favourites", ["user_id"], unique=False)
def downgrade() -> None:
op.drop_index(op.f("ix_favourites_user_id"), table_name="favourites")
op.drop_index(op.f("ix_favourites_quiz_id"), table_name="favourites")
op.drop_table("favourites")
op.drop_index(op.f("ix_quiz_votes_user_id"), table_name="quiz_votes")
op.drop_index(op.f("ix_quiz_votes_quiz_id"), table_name="quiz_votes")
op.drop_table("quiz_votes")
op.drop_index("ix_quiz_attempts_user_quiz", table_name="quiz_attempts")
op.drop_index("ix_quiz_attempts_quiz_score", table_name="quiz_attempts")
op.drop_table("quiz_attempts")
op.drop_index(op.f("ix_questions_quiz_id"), table_name="questions")
op.drop_table("questions")
op.drop_index(op.f("ix_quizzes_share_slug"), table_name="quizzes")
op.drop_index(op.f("ix_quizzes_is_removed"), table_name="quizzes")
op.drop_index(op.f("ix_quizzes_is_public"), table_name="quizzes")
op.drop_index(op.f("ix_quizzes_author_id"), table_name="quizzes")
op.drop_table("quizzes")
op.drop_index(op.f("ix_api_keys_user_id"), table_name="api_keys")
op.drop_index(op.f("ix_api_keys_key_hash"), table_name="api_keys")
op.drop_table("api_keys")
op.drop_index(op.f("ix_users_nickname"), table_name="users")
op.drop_index(op.f("ix_users_email"), table_name="users")
op.drop_index(op.f("ix_users_auth_sub"), table_name="users")
op.drop_table("users")

1
app/__init__.py Normal file
View File

@ -0,0 +1 @@
"""TryMe Gaming App backend package."""

329
app/auth.py Normal file
View File

@ -0,0 +1,329 @@
from __future__ import annotations
import hashlib
import re
import secrets
from datetime import datetime, timedelta, timezone
from typing import Any
import httpx
from fastapi import Cookie, Depends, Header, HTTPException, status
from jose import JWTError, jwt
from sqlalchemy import func, select
from sqlalchemy.orm import Session
from app.config import Settings, get_settings
from app.database import get_db
from app.models import AVATAR_ANIMALS, AVATAR_COLORS, ApiKey, User, utcnow
SESSION_COOKIE_NAME = "tryme_session"
SESSION_ALGORITHM = "HS256"
_jwks_cache: dict[str, tuple[float, dict[str, Any]]] = {}
def hash_api_key(raw_key: str) -> str:
return hashlib.sha256(raw_key.encode("utf-8")).hexdigest()
def generate_api_key_pair() -> tuple[str, str, str]:
token = secrets.token_urlsafe(28)
raw_key = f"tm_live_{token}"
prefix = raw_key[:16]
return raw_key, prefix, hash_api_key(raw_key)
def create_session_token(user_id: int, auth_sub: str, settings: Settings) -> str:
now = utcnow()
exp = now + timedelta(hours=settings.session_ttl_hours)
payload = {
"sub": auth_sub,
"uid": user_id,
"iat": int(now.timestamp()),
"exp": int(exp.timestamp()),
"iss": "trymeapp",
}
return jwt.encode(payload, settings.session_secret, algorithm=SESSION_ALGORITHM)
def _slugify_nickname(seed: str) -> str:
cleaned = re.sub(r"[^A-Za-z0-9_\-]", "", seed.strip())
if len(cleaned) < 2:
cleaned = "player"
return cleaned[:36]
def ensure_unique_nickname(db: Session, desired: str) -> str:
base = _slugify_nickname(desired)
candidate = base
suffix = 1
while db.scalar(select(User.id).where(func.lower(User.nickname) == candidate.lower())) is not None:
suffix += 1
candidate = f"{base[:30]}_{suffix}"
return candidate
def matches_configured_admin(
settings: Settings,
*,
auth_sub: str,
email: str | None,
nickname: str,
) -> bool:
target = settings.admin_user.strip().lower()
if not target:
return False
candidates = {auth_sub.lower(), nickname.lower()}
if email:
candidates.add(email.lower())
return target in candidates
def get_or_create_user_from_identity(
db: Session,
settings: Settings,
*,
auth_sub: str,
email: str | None = None,
preferred_nickname: str | None = None,
first_name: str = "",
last_name: str = "",
) -> User:
user = db.scalar(select(User).where(User.auth_sub == auth_sub))
if user is None:
seed = preferred_nickname or (email.split("@")[0] if email else auth_sub.split("|")[-1])
nickname = ensure_unique_nickname(db, seed)
idx = abs(hash(auth_sub))
user = User(
auth_sub=auth_sub,
email=email,
nickname=nickname,
first_name=first_name,
last_name=last_name,
avatar_animal=AVATAR_ANIMALS[idx % len(AVATAR_ANIMALS)],
avatar_color=AVATAR_COLORS[idx % len(AVATAR_COLORS)],
is_admin=matches_configured_admin(
settings, auth_sub=auth_sub, email=email, nickname=nickname
),
)
db.add(user)
db.commit()
db.refresh(user)
else:
promoted = matches_configured_admin(
settings, auth_sub=user.auth_sub, email=user.email or email, nickname=user.nickname
)
changed = False
if email and not user.email:
user.email = email
changed = True
if promoted and not user.is_admin:
user.is_admin = True
changed = True
if changed:
db.commit()
db.refresh(user)
return user
async def verify_auth0_jwt(token: str, settings: Settings) -> dict[str, Any]:
domain = settings.auth0_domain.strip().rstrip("/")
if not domain.startswith("http"):
issuer_base = f"https://{domain}"
else:
issuer_base = domain
jwks_url = f"{issuer_base}/.well-known/jwks.json"
now_ts = datetime.now(timezone.utc).timestamp()
cached = _jwks_cache.get(jwks_url)
if cached and (now_ts - cached[0]) < 3600:
jwks = cached[1]
else:
async with httpx.AsyncClient(timeout=10.0) as client:
resp = await client.get(jwks_url)
resp.raise_for_status()
jwks = resp.json()
_jwks_cache[jwks_url] = (now_ts, jwks)
unverified_header = jwt.get_unverified_header(token)
rsa_key: dict[str, Any] = {}
for key in jwks.get("keys", []):
if key.get("kid") == unverified_header.get("kid"):
rsa_key = {
"kty": key["kty"],
"kid": key["kid"],
"use": key.get("use", "sig"),
"n": key["n"],
"e": key["e"],
}
break
if not rsa_key:
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail="Unable to match Auth0 signing key.",
)
audience = settings.auth0_audience or settings.auth0_client_id
options = {"verify_aud": bool(audience)}
try:
payload = jwt.decode(
token,
rsa_key,
algorithms=["RS256"],
audience=audience or None,
issuer=f"{issuer_base}/",
options=options,
)
except JWTError as exc:
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail=f"Invalid Auth0 token: {exc}",
) from exc
return payload
def _authenticate_api_key(raw_key: str, db: Session) -> User:
digest = hash_api_key(raw_key.strip())
api_key = db.scalar(select(ApiKey).where(ApiKey.key_hash == digest))
if api_key is None:
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail="Invalid API key.",
)
now = utcnow()
if api_key.revoked_at is not None:
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail="This API key has been expired.",
)
if api_key.expires_at is not None:
exp = api_key.expires_at
if exp.tzinfo is None:
exp = exp.replace(tzinfo=timezone.utc)
if exp <= now:
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail="This API key has reached its expiration date.",
)
api_key.last_used_at = now
db.commit()
user = db.get(User, api_key.user_id)
if user is None:
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="User not found.")
return user
async def get_optional_user(
db: Session = Depends(get_db),
settings: Settings = Depends(get_settings),
authorization: str | None = Header(default=None),
x_api_key: str | None = Header(default=None, alias="X-API-Key"),
session_cookie: str | None = Cookie(default=None, alias=SESSION_COOKIE_NAME),
) -> User | None:
if x_api_key:
return _authenticate_api_key(x_api_key, db)
token: str | None = None
if authorization and authorization.lower().startswith("bearer "):
candidate = authorization[7:].strip()
if candidate.startswith("tm_live_"):
return _authenticate_api_key(candidate, db)
token = candidate
elif session_cookie:
token = session_cookie
if not token:
return None
# First check app-issued HS256 session JWT
try:
payload = jwt.decode(
token,
settings.session_secret,
algorithms=[SESSION_ALGORITHM],
options={"verify_aud": False},
)
if payload.get("iss") == "trymeapp" and payload.get("uid"):
user = db.get(User, int(payload["uid"]))
if user is not None:
if matches_configured_admin(
settings,
auth_sub=user.auth_sub,
email=user.email,
nickname=user.nickname,
) and not user.is_admin:
user.is_admin = True
db.commit()
db.refresh(user)
return user
except JWTError:
pass
# Fallback: federated Auth0 RS256 JWT
if settings.auth0_configured:
claims = await verify_auth0_jwt(token, settings)
sub = str(claims.get("sub", ""))
if not sub:
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail="Auth0 token missing subject claim.",
)
return get_or_create_user_from_identity(
db,
settings,
auth_sub=sub,
email=claims.get("email"),
preferred_nickname=claims.get("nickname"),
first_name=claims.get("given_name", "") or "",
last_name=claims.get("family_name", "") or "",
)
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail="Invalid or expired session token.",
)
async def get_current_user(
user: User | None = Depends(get_optional_user),
) -> User:
if user is None:
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail="Sign in or provide a valid X-API-Key header to continue.",
)
return user
async def get_active_user(
user: User = Depends(get_current_user),
) -> User:
if user.is_banned:
reason_suffix = f" Reason: {user.ban_reason}" if user.ban_reason else ""
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail=f"Your account has been suspended by a moderator.{reason_suffix}",
)
return user
async def get_consented_user(
user: User = Depends(get_active_user),
) -> User:
if not user.public_website_consent:
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail="Please complete profile setup and confirm permission to use public websites.",
)
return user
async def get_admin_user(
user: User = Depends(get_active_user),
) -> User:
if not user.is_admin:
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail="Moderator privileges are required for this action.",
)
return user

70
app/config.py Normal file
View File

@ -0,0 +1,70 @@
from __future__ import annotations
import configparser
from functools import lru_cache
from pathlib import Path
from pydantic import field_validator
from pydantic_settings import BaseSettings, SettingsConfigDict
ROOT_DIR = Path(__file__).resolve().parent.parent
def read_app_version() -> str:
parser = configparser.ConfigParser()
version_file = ROOT_DIR / "version.ini"
if version_file.exists():
parser.read(version_file, encoding="utf-8")
return parser.get("version", "version", fallback="0.1").strip()
return "0.1"
class Settings(BaseSettings):
model_config = SettingsConfigDict(
env_file=".env",
env_file_encoding="utf-8",
extra="ignore",
)
app_name: str = "TryMe Gaming App"
database_url: str = f"sqlite:///{(ROOT_DIR / 'tryme.db').as_posix()}"
# Auth0 Federated OIDC / JWT settings
auth0_domain: str = ""
auth0_client_id: str = ""
auth0_client_secret: str = ""
auth0_audience: str = ""
auth0_callback_url: str = "http://localhost:8000/api/v1/auth/callback"
frontend_base_url: str = "http://localhost:8000"
# Local dev auth fallback & session signing
dev_auth_enabled: bool = True
session_secret: str = "tryme-dev-secret-change-in-production"
session_ttl_hours: int = 168
# Initial moderator setup or container variable (matches sub, email, or nickname)
admin_user: str = ""
# Optional starter quizzes for local demo / compose
seed_starter_quizzes: bool = False
@field_validator("database_url", mode="before")
@classmethod
def normalize_database_url(cls, value: str) -> str:
if isinstance(value, str):
val = value.strip()
if val.startswith("postgres://"):
return "postgresql+psycopg://" + val[len("postgres://") :]
if val.startswith("postgresql://"):
return "postgresql+psycopg://" + val[len("postgresql://") :]
return val
return value
@property
def auth0_configured(self) -> bool:
return bool(self.auth0_domain and self.auth0_client_id)
@lru_cache
def get_settings() -> Settings:
return Settings()

45
app/database.py Normal file
View File

@ -0,0 +1,45 @@
from __future__ import annotations
from collections.abc import Generator
from typing import Any
from sqlalchemy import create_engine, event
from sqlalchemy.engine import Engine
from sqlalchemy.orm import DeclarativeBase, Session, sessionmaker
from app.config import get_settings
class Base(DeclarativeBase):
pass
def build_engine(database_url: str) -> Engine:
is_sqlite = database_url.startswith("sqlite")
connect_args: dict[str, Any] = {"check_same_thread": False} if is_sqlite else {}
engine = create_engine(
database_url,
connect_args=connect_args,
pool_pre_ping=not is_sqlite,
)
if is_sqlite:
@event.listens_for(engine, "connect")
def _set_sqlite_pragma(dbapi_connection: Any, _connection_record: Any) -> None:
cursor = dbapi_connection.cursor()
cursor.execute("PRAGMA foreign_keys=ON")
cursor.close()
return engine
engine = build_engine(get_settings().database_url)
SessionLocal = sessionmaker(bind=engine, autoflush=False, autocommit=False, expire_on_commit=False)
def get_db() -> Generator[Session, None, None]:
db = SessionLocal()
try:
yield db
finally:
db.close()

117
app/main.py Normal file
View File

@ -0,0 +1,117 @@
from __future__ import annotations
from collections.abc import AsyncIterator
from contextlib import asynccontextmanager
import yaml
from fastapi import FastAPI, HTTPException
from fastapi.middleware.cors import CORSMiddleware
from fastapi.responses import FileResponse, Response
from fastapi.staticfiles import StaticFiles
from app.config import ROOT_DIR, get_settings, read_app_version
from app.database import SessionLocal
from app.routers import admin, auth_routes, favourites, quizzes, users
from app.seed import seed_starter_data_if_empty
BRAND_FILES = {
"2dlogo_large.png": ROOT_DIR / "2dlogo_large.png",
"2dlogo_medium_256x256.png": ROOT_DIR / "2dlogo_medium_256x256.png",
"2dlogo_small_64x64.png": ROOT_DIR / "2dlogo_small_64x64.png",
"3dlogo_large.png": ROOT_DIR / "3dlogo_large.png",
}
FRONTEND_DIST = ROOT_DIR / "frontend" / "dist"
@asynccontextmanager
async def lifespan(_app: FastAPI) -> AsyncIterator[None]:
settings = get_settings()
if settings.seed_starter_quizzes:
with SessionLocal() as db:
seed_starter_data_if_empty(db)
yield
app = FastAPI(
title="TryMe Gaming App API",
description=(
"Student quiz creation, sharing, voting, favourites, leaderboards, "
"Auth0 federated login, API keys, and moderation."
),
version=read_app_version(),
docs_url="/docs",
redoc_url="/redoc",
lifespan=lifespan,
)
app.add_middleware(
CORSMiddleware,
allow_origins=["http://localhost:5173", "http://localhost:8000"],
allow_credentials=True,
allow_methods=["*"],
allow_headers=["*"],
)
app.include_router(auth_routes.router)
app.include_router(users.router)
app.include_router(quizzes.router)
app.include_router(favourites.router)
app.include_router(admin.router)
@app.get("/health", tags=["System"])
def healthcheck() -> dict[str, str]:
return {
"status": "ok",
"service": "trymeapp",
"version": read_app_version(),
}
@app.get("/openapi.yaml", include_in_schema=False)
def get_openapi_yaml() -> Response:
spec_yaml = yaml.safe_dump(app.openapi(), sort_keys=False, allow_unicode=True)
return Response(content=spec_yaml, media_type="application/yaml")
@app.get("/brand/{filename}", include_in_schema=False)
def serve_brand_asset(filename: str) -> FileResponse:
target = BRAND_FILES.get(filename)
if target is None or not target.exists():
raise HTTPException(status_code=404, detail="Brand asset not found.")
return FileResponse(target, media_type="image/png")
if (FRONTEND_DIST / "assets").exists():
app.mount(
"/assets",
StaticFiles(directory=str(FRONTEND_DIST / "assets")),
name="spa-assets",
)
@app.get("/{full_path:path}", include_in_schema=False)
def serve_spa_fallback(full_path: str) -> Response:
if full_path.startswith("api/"):
raise HTTPException(status_code=404, detail="API endpoint not found.")
if FRONTEND_DIST.exists():
candidate = (FRONTEND_DIST / full_path).resolve()
if (
full_path
and candidate.is_file()
and str(candidate).startswith(str(FRONTEND_DIST.resolve()))
):
return FileResponse(candidate)
index_html = FRONTEND_DIST / "index.html"
if index_html.is_file():
return FileResponse(index_html)
return Response(
content=(
"TryMe API is running. Build the frontend (`cd frontend && npm run build`) "
"or visit /docs for interactive API documentation."
),
media_type="text/plain",
)

236
app/models.py Normal file
View File

@ -0,0 +1,236 @@
from __future__ import annotations
from datetime import datetime, timezone
from typing import Any
from sqlalchemy import (
JSON,
Boolean,
DateTime,
Float,
ForeignKey,
Index,
Integer,
String,
Text,
UniqueConstraint,
)
from sqlalchemy.orm import Mapped, mapped_column, relationship
from app.database import Base
def utcnow() -> datetime:
return datetime.now(timezone.utc)
AVATAR_ANIMALS = (
"fox",
"owl",
"bear",
"otter",
"koala",
"tiger",
"frog",
"penguin",
)
AVATAR_COLORS = (
"avatar-1",
"avatar-2",
"avatar-3",
"avatar-4",
"avatar-5",
"avatar-6",
"avatar-7",
"avatar-8",
)
GENDER_OPTIONS = (
"woman",
"man",
"non_binary",
"agender",
"genderqueer",
"two_spirit",
"other",
"prefer_not_to_say",
)
class User(Base):
__tablename__ = "users"
id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True)
auth_sub: Mapped[str] = mapped_column(String(190), unique=True, nullable=False, index=True)
email: Mapped[str | None] = mapped_column(String(255), nullable=True, index=True)
nickname: Mapped[str] = mapped_column(String(48), unique=True, nullable=False, index=True)
first_name: Mapped[str] = mapped_column(String(80), nullable=False, default="")
last_name: Mapped[str] = mapped_column(String(80), nullable=False, default="")
age: Mapped[int | None] = mapped_column(Integer, nullable=True)
gender: Mapped[str] = mapped_column(String(40), nullable=False, default="prefer_not_to_say")
gender_custom: Mapped[str | None] = mapped_column(String(80), nullable=True)
avatar_animal: Mapped[str] = mapped_column(String(32), nullable=False, default="fox")
avatar_color: Mapped[str] = mapped_column(String(32), nullable=False, default="avatar-1")
public_website_consent: Mapped[bool] = mapped_column(Boolean, nullable=False, default=False)
consent_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True)
onboarded: Mapped[bool] = mapped_column(Boolean, nullable=False, default=False)
is_admin: Mapped[bool] = mapped_column(Boolean, nullable=False, default=False)
is_banned: Mapped[bool] = mapped_column(Boolean, nullable=False, default=False)
ban_reason: Mapped[str | None] = mapped_column(String(255), nullable=True)
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), nullable=False, default=utcnow)
api_keys: Mapped[list[ApiKey]] = relationship(
"ApiKey", back_populates="user", cascade="all, delete-orphan"
)
quizzes: Mapped[list[Quiz]] = relationship(
"Quiz", back_populates="author", cascade="all, delete-orphan"
)
attempts: Mapped[list[QuizAttempt]] = relationship(
"QuizAttempt", back_populates="user", cascade="all, delete-orphan"
)
votes: Mapped[list[QuizVote]] = relationship(
"QuizVote", back_populates="user", cascade="all, delete-orphan"
)
favourites: Mapped[list[Favourite]] = relationship(
"Favourite",
back_populates="user",
cascade="all, delete-orphan",
order_by="Favourite.position",
)
class ApiKey(Base):
__tablename__ = "api_keys"
id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True)
user_id: Mapped[int] = mapped_column(
Integer, ForeignKey("users.id", ondelete="CASCADE"), nullable=False, index=True
)
name: Mapped[str] = mapped_column(String(80), nullable=False)
key_prefix: Mapped[str] = mapped_column(String(24), nullable=False)
key_hash: Mapped[str] = mapped_column(String(64), unique=True, nullable=False, index=True)
expires_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True)
revoked_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True)
last_used_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True)
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), nullable=False, default=utcnow)
user: Mapped[User] = relationship("User", back_populates="api_keys")
class Quiz(Base):
__tablename__ = "quizzes"
id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True)
share_slug: Mapped[str] = mapped_column(String(40), unique=True, nullable=False, index=True)
author_id: Mapped[int] = mapped_column(
Integer, ForeignKey("users.id", ondelete="CASCADE"), nullable=False, index=True
)
title: Mapped[str] = mapped_column(String(140), nullable=False)
description: Mapped[str] = mapped_column(Text, nullable=False, default="")
is_public: Mapped[bool] = mapped_column(Boolean, nullable=False, default=True, index=True)
is_removed: Mapped[bool] = mapped_column(Boolean, nullable=False, default=False, index=True)
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), nullable=False, default=utcnow)
updated_at: Mapped[datetime] = mapped_column(
DateTime(timezone=True), nullable=False, default=utcnow, onupdate=utcnow
)
author: Mapped[User] = relationship("User", back_populates="quizzes")
questions: Mapped[list[Question]] = relationship(
"Question",
back_populates="quiz",
cascade="all, delete-orphan",
order_by="Question.position",
)
attempts: Mapped[list[QuizAttempt]] = relationship(
"QuizAttempt", back_populates="quiz", cascade="all, delete-orphan"
)
votes: Mapped[list[QuizVote]] = relationship(
"QuizVote", back_populates="quiz", cascade="all, delete-orphan"
)
favourited_by: Mapped[list[Favourite]] = relationship(
"Favourite", back_populates="quiz", cascade="all, delete-orphan"
)
class Question(Base):
__tablename__ = "questions"
id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True)
quiz_id: Mapped[int] = mapped_column(
Integer, ForeignKey("quizzes.id", ondelete="CASCADE"), nullable=False, index=True
)
position: Mapped[int] = mapped_column(Integer, nullable=False, default=0)
question_type: Mapped[str] = mapped_column(String(24), nullable=False)
prompt: Mapped[str] = mapped_column(Text, nullable=False)
options: Mapped[list[dict[str, Any]]] = mapped_column(JSON, nullable=False, default=list)
correct_answer: Mapped[str] = mapped_column(String(500), nullable=False)
accepted_answers: Mapped[list[str]] = mapped_column(JSON, nullable=False, default=list)
quiz: Mapped[Quiz] = relationship("Quiz", back_populates="questions")
class QuizAttempt(Base):
__tablename__ = "quiz_attempts"
__table_args__ = (
Index("ix_quiz_attempts_quiz_score", "quiz_id", "score_percent", "completed_at"),
Index("ix_quiz_attempts_user_quiz", "user_id", "quiz_id", "completed_at"),
)
id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True)
quiz_id: Mapped[int] = mapped_column(
Integer, ForeignKey("quizzes.id", ondelete="CASCADE"), nullable=False
)
user_id: Mapped[int] = mapped_column(
Integer, ForeignKey("users.id", ondelete="CASCADE"), nullable=False
)
score_count: Mapped[int] = mapped_column(Integer, nullable=False)
total_questions: Mapped[int] = mapped_column(Integer, nullable=False)
score_percent: Mapped[float] = mapped_column(Float, nullable=False)
answers_payload: Mapped[dict[str, Any]] = mapped_column(JSON, nullable=False, default=dict)
completed_at: Mapped[datetime] = mapped_column(
DateTime(timezone=True), nullable=False, default=utcnow
)
quiz: Mapped[Quiz] = relationship("Quiz", back_populates="attempts")
user: Mapped[User] = relationship("User", back_populates="attempts")
class QuizVote(Base):
__tablename__ = "quiz_votes"
__table_args__ = (UniqueConstraint("quiz_id", "user_id", name="uq_quiz_vote_user"),)
id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True)
quiz_id: Mapped[int] = mapped_column(
Integer, ForeignKey("quizzes.id", ondelete="CASCADE"), nullable=False, index=True
)
user_id: Mapped[int] = mapped_column(
Integer, ForeignKey("users.id", ondelete="CASCADE"), nullable=False, index=True
)
value: Mapped[int] = mapped_column(Integer, nullable=False)
updated_at: Mapped[datetime] = mapped_column(
DateTime(timezone=True), nullable=False, default=utcnow, onupdate=utcnow
)
quiz: Mapped[Quiz] = relationship("Quiz", back_populates="votes")
user: Mapped[User] = relationship("User", back_populates="votes")
class Favourite(Base):
__tablename__ = "favourites"
__table_args__ = (UniqueConstraint("user_id", "quiz_id", name="uq_favourite_user_quiz"),)
id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True)
user_id: Mapped[int] = mapped_column(
Integer, ForeignKey("users.id", ondelete="CASCADE"), nullable=False, index=True
)
quiz_id: Mapped[int] = mapped_column(
Integer, ForeignKey("quizzes.id", ondelete="CASCADE"), nullable=False, index=True
)
position: Mapped[int] = mapped_column(Integer, nullable=False, default=0)
created_at: Mapped[datetime] = mapped_column(
DateTime(timezone=True), nullable=False, default=utcnow
)
user: Mapped[User] = relationship("User", back_populates="favourites")
quiz: Mapped[Quiz] = relationship("Quiz", back_populates="favourited_by")

1
app/routers/__init__.py Normal file
View File

@ -0,0 +1 @@
"""FastAPI API v1 routers."""

113
app/routers/admin.py Normal file
View File

@ -0,0 +1,113 @@
from __future__ import annotations
from fastapi import APIRouter, Depends, HTTPException, status
from sqlalchemy import func, select
from sqlalchemy.orm import Session, selectinload
from app.auth import get_active_user, get_admin_user
from app.config import Settings, get_settings
from app.database import get_db
from app.models import Quiz, User
from app.schemas import BanUserIn, QuizSummaryOut, UserOut
from app.services.quiz_service import enrich_quizzes
router = APIRouter(prefix="/api/v1/admin", tags=["Moderation"])
@router.post("/claim", response_model=UserOut)
def claim_initial_moderator(
db: Session = Depends(get_db),
user: User = Depends(get_active_user),
settings: Settings = Depends(get_settings),
) -> UserOut:
if settings.admin_user.strip():
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail="Moderator account is locked to the configured ADMIN_USER container setting.",
)
existing_admins = db.scalar(select(func.count(User.id)).where(User.is_admin.is_(True))) or 0
if existing_admins > 0:
raise HTTPException(
status_code=status.HTTP_409_CONFLICT,
detail="An initial moderator account has already been configured.",
)
user.is_admin = True
db.commit()
db.refresh(user)
return UserOut.model_validate(user)
@router.get("/users", response_model=list[UserOut])
def list_users_for_moderation(
db: Session = Depends(get_db),
_admin: User = Depends(get_admin_user),
) -> list[UserOut]:
users = db.scalars(select(User).order_by(User.created_at.desc(), User.id.desc())).all()
return [UserOut.model_validate(u) for u in users]
@router.post("/users/{user_id}/ban", response_model=UserOut)
def ban_user(
user_id: int,
payload: BanUserIn,
db: Session = Depends(get_db),
admin: User = Depends(get_admin_user),
) -> UserOut:
if user_id == admin.id:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail="Moderators cannot ban their own account.",
)
target = db.get(User, user_id)
if target is None:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="User not found.")
target.is_banned = True
target.ban_reason = payload.reason.strip() or "Suspended by moderator"
db.commit()
db.refresh(target)
return UserOut.model_validate(target)
@router.post("/users/{user_id}/unban", response_model=UserOut)
def unban_user(
user_id: int,
db: Session = Depends(get_db),
_admin: User = Depends(get_admin_user),
) -> UserOut:
target = db.get(User, user_id)
if target is None:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="User not found.")
target.is_banned = False
target.ban_reason = None
db.commit()
db.refresh(target)
return UserOut.model_validate(target)
@router.get("/quizzes", response_model=list[QuizSummaryOut])
def list_quizzes_for_moderation(
db: Session = Depends(get_db),
admin: User = Depends(get_admin_user),
) -> list[QuizSummaryOut]:
quizzes = list(
db.scalars(
select(Quiz)
.options(selectinload(Quiz.author), selectinload(Quiz.questions))
.where(Quiz.is_removed.is_(False))
.order_by(Quiz.created_at.desc(), Quiz.id.desc())
).all()
)
return enrich_quizzes(db, quizzes, admin)
@router.delete("/quizzes/{quiz_id}", status_code=status.HTTP_204_NO_CONTENT)
def moderator_remove_quiz(
quiz_id: int,
db: Session = Depends(get_db),
_admin: User = Depends(get_admin_user),
) -> None:
quiz = db.get(Quiz, quiz_id)
if quiz is None or quiz.is_removed:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Quiz not found.")
quiz.is_removed = True
db.commit()

205
app/routers/auth_routes.py Normal file
View File

@ -0,0 +1,205 @@
from __future__ import annotations
import secrets
from urllib.parse import urlencode
import httpx
from fastapi import APIRouter, Depends, HTTPException, Query, Response, status
from fastapi.responses import RedirectResponse
from itsdangerous import BadSignature, URLSafeTimedSerializer
from sqlalchemy import func, select
from sqlalchemy.orm import Session
from app.auth import (
SESSION_COOKIE_NAME,
create_session_token,
get_or_create_user_from_identity,
)
from app.config import Settings, get_settings
from app.database import get_db
from app.models import User
from app.schemas import AuthConfigOut, DevLoginIn, TokenSessionOut, UserOut
router = APIRouter(prefix="/api/v1/auth", tags=["Authentication"])
def _state_serializer(settings: Settings) -> URLSafeTimedSerializer:
return URLSafeTimedSerializer(settings.session_secret, salt="auth0-oauth-state")
@router.get("/config", response_model=AuthConfigOut)
def get_auth_config(
db: Session = Depends(get_db),
settings: Settings = Depends(get_settings),
) -> AuthConfigOut:
admin_count = db.scalar(select(func.count(User.id)).where(User.is_admin.is_(True))) or 0
admin_unclaimed = (admin_count == 0) and not bool(settings.admin_user.strip())
return AuthConfigOut(
auth0_enabled=settings.auth0_configured,
auth0_domain=settings.auth0_domain,
auth0_client_id=settings.auth0_client_id,
auth0_audience=settings.auth0_audience,
dev_auth_enabled=settings.dev_auth_enabled,
admin_unclaimed=admin_unclaimed,
)
@router.get("/login")
def auth0_login(
return_to: str = Query(default="/"),
settings: Settings = Depends(get_settings),
) -> RedirectResponse:
if not settings.auth0_configured:
raise HTTPException(
status_code=status.HTTP_503_SERVICE_UNAVAILABLE,
detail="Auth0 federated login is not configured on this server. Set AUTH0_DOMAIN and AUTH0_CLIENT_ID.",
)
domain = settings.auth0_domain.strip().rstrip("/")
base = domain if domain.startswith("http") else f"https://{domain}"
state = _state_serializer(settings).dumps(
{"nonce": secrets.token_hex(8), "return_to": return_to}
)
params = {
"response_type": "code",
"client_id": settings.auth0_client_id,
"redirect_uri": settings.auth0_callback_url,
"scope": "openid profile email",
"state": state,
}
if settings.auth0_audience:
params["audience"] = settings.auth0_audience
return RedirectResponse(url=f"{base}/authorize?{urlencode(params)}")
@router.get("/callback")
async def auth0_callback(
code: str = Query(...),
state: str = Query(...),
db: Session = Depends(get_db),
settings: Settings = Depends(get_settings),
) -> RedirectResponse:
if not settings.auth0_configured or not settings.auth0_client_secret:
raise HTTPException(
status_code=status.HTTP_503_SERVICE_UNAVAILABLE,
detail="Auth0 callback requires AUTH0_DOMAIN, AUTH0_CLIENT_ID, and AUTH0_CLIENT_SECRET.",
)
try:
state_data = _state_serializer(settings).loads(state, max_age=600)
except BadSignature as exc:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail="Invalid or expired OAuth state parameter.",
) from exc
domain = settings.auth0_domain.strip().rstrip("/")
base = domain if domain.startswith("http") else f"https://{domain}"
async with httpx.AsyncClient(timeout=10.0) as client:
token_resp = await client.post(
f"{base}/oauth/token",
json={
"grant_type": "authorization_code",
"client_id": settings.auth0_client_id,
"client_secret": settings.auth0_client_secret,
"code": code,
"redirect_uri": settings.auth0_callback_url,
},
)
if token_resp.status_code >= 400:
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail="Failed to exchange Auth0 authorization code.",
)
tokens = token_resp.json()
userinfo_resp = await client.get(
f"{base}/userinfo",
headers={"Authorization": f"Bearer {tokens['access_token']}"},
)
if userinfo_resp.status_code >= 400:
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail="Failed to retrieve user profile from Auth0.",
)
profile = userinfo_resp.json()
user = get_or_create_user_from_identity(
db,
settings,
auth_sub=str(profile["sub"]),
email=profile.get("email"),
preferred_nickname=profile.get("nickname"),
first_name=profile.get("given_name", "") or "",
last_name=profile.get("family_name", "") or "",
)
session_jwt = create_session_token(user.id, user.auth_sub, settings)
return_to = str(state_data.get("return_to") or "/")
if not return_to.startswith("/"):
return_to = "/"
target = f"{settings.frontend_base_url.rstrip('/')}{return_to}"
redirect = RedirectResponse(url=target, status_code=status.HTTP_302_FOUND)
redirect.set_cookie(
key=SESSION_COOKIE_NAME,
value=session_jwt,
httponly=True,
samesite="lax",
max_age=settings.session_ttl_hours * 3600,
)
return redirect
@router.post("/dev-login", response_model=TokenSessionOut)
def dev_login(
payload: DevLoginIn,
response: Response,
db: Session = Depends(get_db),
settings: Settings = Depends(get_settings),
) -> TokenSessionOut:
if not settings.dev_auth_enabled:
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail="Local development login is disabled. Please sign in with Auth0.",
)
handle = payload.nickname.strip().lower().replace(" ", "_")
auth_sub = f"dev|{handle}"
user = get_or_create_user_from_identity(
db,
settings,
auth_sub=auth_sub,
email=payload.email or f"{handle}@example.com",
preferred_nickname=payload.nickname.strip(),
)
if payload.as_moderator and not user.is_admin:
# In dev mode, allow moderator login when either ADMIN_USER is unset or matches
configured_admin = settings.admin_user.strip().lower()
if not configured_admin or configured_admin in {
user.auth_sub.lower(),
user.nickname.lower(),
(user.email or "").lower(),
}:
user.is_admin = True
db.commit()
db.refresh(user)
if user.is_banned:
reason_suffix = f" Reason: {user.ban_reason}" if user.ban_reason else ""
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail=f"Your account has been suspended by a moderator.{reason_suffix}",
)
token = create_session_token(user.id, user.auth_sub, settings)
response.set_cookie(
key=SESSION_COOKIE_NAME,
value=token,
httponly=True,
samesite="lax",
max_age=settings.session_ttl_hours * 3600,
)
return TokenSessionOut(access_token=token, user=UserOut.model_validate(user))
@router.post("/logout", status_code=status.HTTP_204_NO_CONTENT)
def logout(response: Response) -> Response:
response.delete_cookie(key=SESSION_COOKIE_NAME)
response.status_code = status.HTTP_204_NO_CONTENT
return response

151
app/routers/favourites.py Normal file
View File

@ -0,0 +1,151 @@
from __future__ import annotations
from fastapi import APIRouter, Depends, HTTPException, status
from sqlalchemy import select
from sqlalchemy.orm import Session, selectinload
from app.auth import get_active_user
from app.database import get_db
from app.models import Favourite, Quiz, User
from app.schemas import FavouriteAddIn, FavouriteItemOut, FavouriteReorderIn
from app.services.quiz_service import enrich_quizzes
router = APIRouter(prefix="/api/v1/favourites", tags=["Favourites"])
def _load_ordered_favourites(db: Session, user_id: int) -> list[Favourite]:
rows = list(
db.scalars(
select(Favourite)
.options(
selectinload(Favourite.quiz).selectinload(Quiz.author),
selectinload(Favourite.quiz).selectinload(Quiz.questions),
)
.where(Favourite.user_id == user_id)
.order_by(Favourite.position.asc(), Favourite.id.asc())
).all()
)
# Filter out any removed quizzes cleanly
active: list[Favourite] = []
dirty = False
for row in rows:
if row.quiz is None or row.quiz.is_removed:
db.delete(row)
dirty = True
else:
active.append(row)
for idx, row in enumerate(active):
if row.position != idx:
row.position = idx
dirty = True
if dirty:
db.commit()
return active
def _serialize_favourites(
db: Session, favs: list[Favourite], user: User
) -> list[FavouriteItemOut]:
if not favs:
return []
quizzes = [f.quiz for f in favs]
summaries = {s.id: s for s in enrich_quizzes(db, quizzes, user)}
return [
FavouriteItemOut(
favourite_id=f.id,
position=f.position,
quiz=summaries[f.quiz_id],
)
for f in favs
if f.quiz_id in summaries
]
@router.get("", response_model=list[FavouriteItemOut])
def list_favourites(
db: Session = Depends(get_db),
user: User = Depends(get_active_user),
) -> list[FavouriteItemOut]:
favs = _load_ordered_favourites(db, user.id)
return _serialize_favourites(db, favs, user)
@router.post("", response_model=list[FavouriteItemOut], status_code=status.HTTP_201_CREATED)
def add_favourite(
payload: FavouriteAddIn,
db: Session = Depends(get_db),
user: User = Depends(get_active_user),
) -> list[FavouriteItemOut]:
quiz = db.get(Quiz, payload.quiz_id)
if quiz is None or quiz.is_removed:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Quiz not found.")
if not quiz.is_public and quiz.author_id != user.id and not user.is_admin:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Quiz not found.")
favs = _load_ordered_favourites(db, user.id)
existing = next((f for f in favs if f.quiz_id == payload.quiz_id), None)
if existing is not None:
favs.remove(existing)
target = existing
else:
target = Favourite(user_id=user.id, quiz_id=payload.quiz_id, position=len(favs))
db.add(target)
insert_at = (
min(max(payload.position, 0), len(favs))
if payload.position is not None
else len(favs)
)
favs.insert(insert_at, target)
for idx, item in enumerate(favs):
item.position = idx
db.commit()
refreshed = _load_ordered_favourites(db, user.id)
return _serialize_favourites(db, refreshed, user)
@router.put("/reorder", response_model=list[FavouriteItemOut])
def reorder_favourites(
payload: FavouriteReorderIn,
db: Session = Depends(get_db),
user: User = Depends(get_active_user),
) -> list[FavouriteItemOut]:
favs = _load_ordered_favourites(db, user.id)
by_quiz_id = {f.quiz_id: f for f in favs}
ordered: list[Favourite] = []
seen: set[int] = set()
for qid in payload.quiz_ids:
if qid in by_quiz_id and qid not in seen:
ordered.append(by_quiz_id[qid])
seen.add(qid)
for f in favs:
if f.quiz_id not in seen:
ordered.append(f)
for idx, f in enumerate(ordered):
f.position = idx
db.commit()
return _serialize_favourites(db, ordered, user)
@router.delete("/{quiz_id}", response_model=list[FavouriteItemOut])
def remove_favourite(
quiz_id: int,
db: Session = Depends(get_db),
user: User = Depends(get_active_user),
) -> list[FavouriteItemOut]:
existing = db.scalar(
select(Favourite).where(Favourite.user_id == user.id, Favourite.quiz_id == quiz_id)
)
if existing is None:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail="Quiz is not in your favourites.",
)
db.delete(existing)
db.commit()
favs = _load_ordered_favourites(db, user.id)
return _serialize_favourites(db, favs, user)

320
app/routers/quizzes.py Normal file
View File

@ -0,0 +1,320 @@
from __future__ import annotations
from fastapi import APIRouter, Depends, HTTPException, Query, status
from sqlalchemy import or_, select
from sqlalchemy.orm import Session, selectinload
from app.auth import get_active_user, get_consented_user, get_optional_user
from app.database import get_db
from app.models import Question, Quiz, QuizAttempt, QuizVote, User
from app.schemas import (
QuestionAuthorOut,
QuestionPlayOut,
QuestionResultOut,
QuizCreateIn,
QuizDetailOut,
QuizSubmitIn,
QuizSubmitOut,
QuizSummaryOut,
QuizUpdateIn,
VoteIn,
VoteOut,
)
from app.services.quiz_service import (
compute_quiz_mode,
enrich_quizzes,
generate_share_slug,
grade_question,
serialize_play_options,
)
router = APIRouter(prefix="/api/v1/quizzes", tags=["Quizzes"])
def _load_quiz_by_slug_or_id(db: Session, slug_or_id: str) -> Quiz | None:
stmt = select(Quiz).options(selectinload(Quiz.author), selectinload(Quiz.questions))
if slug_or_id.isdigit():
quiz = db.scalar(stmt.where(Quiz.id == int(slug_or_id)))
if quiz is not None:
return quiz
return db.scalar(stmt.where(Quiz.share_slug == slug_or_id))
@router.get("", response_model=list[QuizSummaryOut])
def list_quizzes(
q: str | None = Query(default=None, max_length=120),
mode: str | None = Query(default=None),
mine_only: bool = Query(default=False),
db: Session = Depends(get_db),
current_user: User | None = Depends(get_optional_user),
) -> list[QuizSummaryOut]:
stmt = (
select(Quiz)
.options(selectinload(Quiz.author), selectinload(Quiz.questions))
.where(Quiz.is_removed.is_(False))
)
if mine_only:
if current_user is None:
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail="Sign in to view your own quizzes.",
)
stmt = stmt.where(Quiz.author_id == current_user.id)
elif current_user is not None:
stmt = stmt.where(or_(Quiz.is_public.is_(True), Quiz.author_id == current_user.id))
else:
stmt = stmt.where(Quiz.is_public.is_(True))
if q and q.strip():
needle = f"%{q.strip()}%"
stmt = stmt.where(or_(Quiz.title.ilike(needle), Quiz.description.ilike(needle)))
stmt = stmt.order_by(Quiz.created_at.desc(), Quiz.id.desc())
quizzes = list(db.scalars(stmt).all())
if mode and mode not in ("all", ""):
quizzes = [quiz for quiz in quizzes if compute_quiz_mode(quiz.questions) == mode]
return enrich_quizzes(db, quizzes, current_user)
@router.post("", response_model=QuizDetailOut, status_code=status.HTTP_201_CREATED)
def create_quiz(
payload: QuizCreateIn,
db: Session = Depends(get_db),
user: User = Depends(get_consented_user),
) -> QuizDetailOut:
if len(payload.questions) > 40:
raise HTTPException(
status_code=status.HTTP_422_UNPROCESSABLE_ENTITY,
detail="Public free tier quizzes may contain at most 40 questions.",
)
slug = generate_share_slug(db, payload.title)
quiz = Quiz(
share_slug=slug,
author_id=user.id,
title=payload.title,
description=payload.description.strip(),
is_public=payload.is_public,
)
for idx, q_in in enumerate(payload.questions):
quiz.questions.append(
Question(
position=idx,
question_type=q_in.question_type,
prompt=q_in.prompt,
options=[opt.model_dump() for opt in q_in.options],
correct_answer=q_in.correct_answer,
accepted_answers=q_in.accepted_answers,
)
)
db.add(quiz)
db.commit()
loaded = _load_quiz_by_slug_or_id(db, str(quiz.id))
assert loaded is not None
return _build_quiz_detail(db, loaded, user)
def _build_quiz_detail(db: Session, quiz: Quiz, current_user: User | None) -> QuizDetailOut:
summary = enrich_quizzes(db, [quiz], current_user)[0]
play_questions = [
QuestionPlayOut(
id=q.id,
position=q.position,
question_type=q.question_type, # type: ignore[arg-type]
prompt=q.prompt,
options=serialize_play_options(q.options),
)
for q in quiz.questions
]
editable: list[QuestionAuthorOut] | None = None
if current_user and (current_user.id == quiz.author_id or current_user.is_admin):
editable = [
QuestionAuthorOut(
id=q.id,
position=q.position,
question_type=q.question_type, # type: ignore[arg-type]
prompt=q.prompt,
options=serialize_play_options(q.options),
correct_answer=q.correct_answer,
accepted_answers=list(q.accepted_answers or []),
)
for q in quiz.questions
]
return QuizDetailOut(
**summary.model_dump(),
questions=play_questions,
editable_questions=editable,
)
@router.get("/{slug_or_id}", response_model=QuizDetailOut)
def get_quiz(
slug_or_id: str,
db: Session = Depends(get_db),
current_user: User | None = Depends(get_optional_user),
) -> QuizDetailOut:
quiz = _load_quiz_by_slug_or_id(db, slug_or_id)
if quiz is None or quiz.is_removed:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Quiz not found.")
if not quiz.is_public:
if current_user is None or (
current_user.id != quiz.author_id and not current_user.is_admin
):
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Quiz not found.")
return _build_quiz_detail(db, quiz, current_user)
@router.put("/{quiz_id}", response_model=QuizDetailOut)
def update_quiz(
quiz_id: int,
payload: QuizUpdateIn,
db: Session = Depends(get_db),
user: User = Depends(get_consented_user),
) -> QuizDetailOut:
quiz = _load_quiz_by_slug_or_id(db, str(quiz_id))
if quiz is None or quiz.is_removed:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Quiz not found.")
if quiz.author_id != user.id and not user.is_admin:
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail="Only the quiz author or a moderator can edit this quiz.",
)
quiz.title = payload.title
quiz.description = payload.description.strip()
quiz.is_public = payload.is_public
quiz.questions.clear()
db.flush()
for idx, q_in in enumerate(payload.questions):
quiz.questions.append(
Question(
position=idx,
question_type=q_in.question_type,
prompt=q_in.prompt,
options=[opt.model_dump() for opt in q_in.options],
correct_answer=q_in.correct_answer,
accepted_answers=q_in.accepted_answers,
)
)
db.commit()
loaded = _load_quiz_by_slug_or_id(db, str(quiz.id))
assert loaded is not None
return _build_quiz_detail(db, loaded, user)
@router.delete("/{quiz_id}", status_code=status.HTTP_204_NO_CONTENT)
def delete_quiz(
quiz_id: int,
db: Session = Depends(get_db),
user: User = Depends(get_active_user),
) -> None:
quiz = db.get(Quiz, quiz_id)
if quiz is None or quiz.is_removed:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Quiz not found.")
if quiz.author_id != user.id and not user.is_admin:
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail="Only the quiz author or a moderator can delete this quiz.",
)
quiz.is_removed = True
db.commit()
@router.post("/{quiz_id}/submit", response_model=QuizSubmitOut)
def submit_quiz(
quiz_id: int,
payload: QuizSubmitIn,
db: Session = Depends(get_db),
user: User = Depends(get_active_user),
) -> QuizSubmitOut:
quiz = _load_quiz_by_slug_or_id(db, str(quiz_id))
if quiz is None or quiz.is_removed:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Quiz not found.")
if not quiz.is_public and quiz.author_id != user.id and not user.is_admin:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Quiz not found.")
total = len(quiz.questions)
correct_count = 0
results: list[QuestionResultOut] = []
for q in quiz.questions:
submitted = payload.answers.get(str(q.id), "")
is_correct, correct_display = grade_question(q, submitted)
if is_correct:
correct_count += 1
results.append(
QuestionResultOut(
question_id=q.id,
prompt=q.prompt,
question_type=q.question_type, # type: ignore[arg-type]
submitted_answer=submitted,
is_correct=is_correct,
correct_display=correct_display,
)
)
score_percent = round((correct_count / total) * 100.0, 1) if total > 0 else 100.0
attempt = QuizAttempt(
quiz_id=quiz.id,
user_id=user.id,
score_count=correct_count,
total_questions=total,
score_percent=score_percent,
answers_payload=payload.answers,
)
db.add(attempt)
db.commit()
db.refresh(attempt)
summary = enrich_quizzes(db, [quiz], user)[0]
assert summary.my_latest_score is not None
assert summary.my_best_score is not None
assert summary.global_best_score is not None
return QuizSubmitOut(
attempt_id=attempt.id,
score_count=correct_count,
total_questions=total,
score_percent=score_percent,
results=results,
my_latest_score=summary.my_latest_score,
my_best_score=summary.my_best_score,
global_best_score=summary.global_best_score,
)
@router.post("/{quiz_id}/vote", response_model=VoteOut)
def vote_quiz(
quiz_id: int,
payload: VoteIn,
db: Session = Depends(get_db),
user: User = Depends(get_consented_user),
) -> VoteOut:
quiz = db.get(Quiz, quiz_id)
if quiz is None or quiz.is_removed or not quiz.is_public:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Public quiz not found.")
existing = db.scalar(
select(QuizVote).where(QuizVote.quiz_id == quiz_id, QuizVote.user_id == user.id)
)
if payload.value == 0:
if existing is not None:
db.delete(existing)
else:
if existing is None:
db.add(QuizVote(quiz_id=quiz_id, user_id=user.id, value=payload.value))
else:
existing.value = payload.value
db.commit()
votes = db.scalars(select(QuizVote).where(QuizVote.quiz_id == quiz_id)).all()
upvotes = sum(1 for v in votes if v.value > 0)
downvotes = sum(1 for v in votes if v.value < 0)
return VoteOut(
quiz_id=quiz_id,
upvotes=upvotes,
downvotes=downvotes,
user_vote=payload.value,
)

212
app/routers/users.py Normal file
View File

@ -0,0 +1,212 @@
from __future__ import annotations
from datetime import timedelta, timezone
from fastapi import APIRouter, Depends, HTTPException, status
from sqlalchemy import func, select
from sqlalchemy.orm import Session, selectinload
from app.auth import generate_api_key_pair, get_active_user, get_current_user
from app.database import get_db
from app.models import ApiKey, Quiz, QuizAttempt, User, utcnow
from app.schemas import (
ApiKeyCreatedOut,
ApiKeyCreateIn,
ApiKeyOut,
ScoreSummaryOut,
UserOut,
UserScoreRowOut,
UserUpdateIn,
)
from app.services.quiz_service import enrich_quizzes
router = APIRouter(prefix="/api/v1/users", tags=["Users & API Keys"])
def _api_key_is_active(key: ApiKey) -> bool:
if key.revoked_at is not None:
return False
if key.expires_at is not None:
exp = key.expires_at
if exp.tzinfo is None:
exp = exp.replace(tzinfo=timezone.utc)
if exp <= utcnow():
return False
return True
def _serialize_api_key(key: ApiKey) -> ApiKeyOut:
return ApiKeyOut(
id=key.id,
name=key.name,
key_prefix=key.key_prefix,
expires_at=key.expires_at,
revoked_at=key.revoked_at,
last_used_at=key.last_used_at,
created_at=key.created_at,
is_active=_api_key_is_active(key),
)
@router.get("/me", response_model=UserOut)
def get_me(user: User = Depends(get_current_user)) -> UserOut:
return UserOut.model_validate(user)
@router.patch("/me", response_model=UserOut)
def update_me(
payload: UserUpdateIn,
db: Session = Depends(get_db),
user: User = Depends(get_active_user),
) -> UserOut:
if payload.nickname is not None and payload.nickname.lower() != user.nickname.lower():
conflict = db.scalar(
select(User.id).where(
func.lower(User.nickname) == payload.nickname.lower(),
User.id != user.id,
)
)
if conflict is not None:
raise HTTPException(
status_code=status.HTTP_409_CONFLICT,
detail="That nickname is already taken by another player.",
)
user.nickname = payload.nickname
elif payload.nickname is not None:
user.nickname = payload.nickname
if payload.first_name is not None:
user.first_name = payload.first_name.strip()
if payload.last_name is not None:
user.last_name = payload.last_name.strip()
if payload.clear_age:
user.age = None
elif payload.age is not None:
user.age = payload.age
if payload.gender is not None:
user.gender = payload.gender
if payload.gender != "other":
user.gender_custom = None
if payload.gender_custom is not None:
user.gender_custom = payload.gender_custom.strip() or None
if payload.avatar_animal is not None:
user.avatar_animal = payload.avatar_animal
if payload.avatar_color is not None:
user.avatar_color = payload.avatar_color
if payload.public_website_consent is not None:
user.public_website_consent = payload.public_website_consent
user.consent_at = utcnow() if payload.public_website_consent else None
if user.public_website_consent and user.nickname:
user.onboarded = True
db.commit()
db.refresh(user)
return UserOut.model_validate(user)
@router.get("/me/scores", response_model=list[UserScoreRowOut])
def get_my_scores(
db: Session = Depends(get_db),
user: User = Depends(get_active_user),
) -> list[UserScoreRowOut]:
attempts = db.scalars(
select(QuizAttempt)
.where(QuizAttempt.user_id == user.id)
.order_by(QuizAttempt.completed_at.asc(), QuizAttempt.id.asc())
).all()
if not attempts:
return []
by_quiz: dict[int, list[QuizAttempt]] = {}
for att in attempts:
by_quiz.setdefault(att.quiz_id, []).append(att)
quizzes = db.scalars(
select(Quiz)
.options(selectinload(Quiz.author), selectinload(Quiz.questions))
.where(Quiz.id.in_(list(by_quiz.keys())), Quiz.is_removed.is_(False))
).all()
summaries = {s.id: s for s in enrich_quizzes(db, list(quizzes), user)}
rows: list[UserScoreRowOut] = []
for q in quizzes:
summary = summaries.get(q.id)
if not summary or not summary.my_latest_score or not summary.my_best_score:
continue
rows.append(
UserScoreRowOut(
quiz_id=q.id,
share_slug=q.share_slug,
quiz_title=q.title,
question_count=summary.question_count,
latest_score=ScoreSummaryOut(**summary.my_latest_score.model_dump()),
best_score=ScoreSummaryOut(**summary.my_best_score.model_dump()),
global_best_score=summary.global_best_score,
attempts_count=len(by_quiz.get(q.id, [])),
)
)
rows.sort(key=lambda r: r.latest_score.completed_at, reverse=True)
return rows
@router.get("/me/api-keys", response_model=list[ApiKeyOut])
def list_api_keys(
db: Session = Depends(get_db),
user: User = Depends(get_active_user),
) -> list[ApiKeyOut]:
keys = db.scalars(
select(ApiKey)
.where(ApiKey.user_id == user.id)
.order_by(ApiKey.created_at.desc(), ApiKey.id.desc())
).all()
return [_serialize_api_key(k) for k in keys]
@router.post("/me/api-keys", response_model=ApiKeyCreatedOut, status_code=status.HTTP_201_CREATED)
def create_api_key(
payload: ApiKeyCreateIn,
db: Session = Depends(get_db),
user: User = Depends(get_active_user),
) -> ApiKeyCreatedOut:
raw_key, prefix, digest = generate_api_key_pair()
expires_at = (
utcnow() + timedelta(days=payload.expires_in_days)
if payload.expires_in_days is not None
else None
)
api_key = ApiKey(
user_id=user.id,
name=payload.name.strip(),
key_prefix=prefix,
key_hash=digest,
expires_at=expires_at,
)
db.add(api_key)
db.commit()
db.refresh(api_key)
base = _serialize_api_key(api_key)
return ApiKeyCreatedOut(**base.model_dump(), raw_key=raw_key)
@router.post("/me/api-keys/{key_id}/expire", response_model=ApiKeyOut)
def expire_api_key(
key_id: int,
db: Session = Depends(get_db),
user: User = Depends(get_active_user),
) -> ApiKeyOut:
api_key = db.scalar(select(ApiKey).where(ApiKey.id == key_id, ApiKey.user_id == user.id))
if api_key is None:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail="API key not found.",
)
if api_key.revoked_at is None:
api_key.revoked_at = utcnow()
db.commit()
db.refresh(api_key)
return _serialize_api_key(api_key)

304
app/schemas.py Normal file
View File

@ -0,0 +1,304 @@
from __future__ import annotations
import re
from datetime import datetime
from typing import Literal
from pydantic import BaseModel, ConfigDict, Field, field_validator, model_validator
from app.models import AVATAR_ANIMALS, AVATAR_COLORS, GENDER_OPTIONS
NICKNAME_RE = re.compile(r"^[A-Za-z0-9][A-Za-z0-9_\-. ]{1,46}[A-Za-z0-9]$|^[A-Za-z0-9]{2,48}$")
class AuthConfigOut(BaseModel):
auth0_enabled: bool
auth0_domain: str
auth0_client_id: str
auth0_audience: str
dev_auth_enabled: bool
admin_unclaimed: bool
class DevLoginIn(BaseModel):
nickname: str = Field(..., min_length=2, max_length=48)
email: str | None = Field(default=None, max_length=255)
as_moderator: bool = False
class TokenSessionOut(BaseModel):
access_token: str
token_type: str = "bearer"
user: UserOut
class UserOut(BaseModel):
model_config = ConfigDict(from_attributes=True)
id: int
auth_sub: str
email: str | None
nickname: str
first_name: str
last_name: str
age: int | None
gender: str
gender_custom: str | None
avatar_animal: str
avatar_color: str
public_website_consent: bool
consent_at: datetime | None
onboarded: bool
is_admin: bool
is_banned: bool
ban_reason: str | None
created_at: datetime
class UserUpdateIn(BaseModel):
nickname: str | None = Field(default=None, min_length=2, max_length=48)
first_name: str | None = Field(default=None, max_length=80)
last_name: str | None = Field(default=None, max_length=80)
age: int | None = Field(default=None, ge=4, le=120)
clear_age: bool = False
gender: str | None = None
gender_custom: str | None = Field(default=None, max_length=80)
avatar_animal: str | None = None
avatar_color: str | None = None
public_website_consent: bool | None = None
@field_validator("nickname")
@classmethod
def validate_nickname(cls, v: str | None) -> str | None:
if v is None:
return None
cleaned = v.strip()
if not NICKNAME_RE.match(cleaned):
raise ValueError(
"Nickname must be 2 to 48 characters using letters, numbers, spaces, hyphens, or underscores."
)
return cleaned
@field_validator("gender")
@classmethod
def validate_gender(cls, v: str | None) -> str | None:
if v is not None and v not in GENDER_OPTIONS:
raise ValueError(f"Gender must be one of: {', '.join(GENDER_OPTIONS)}")
return v
@field_validator("avatar_animal")
@classmethod
def validate_animal(cls, v: str | None) -> str | None:
if v is not None and v not in AVATAR_ANIMALS:
raise ValueError(f"Avatar animal must be one of: {', '.join(AVATAR_ANIMALS)}")
return v
@field_validator("avatar_color")
@classmethod
def validate_color(cls, v: str | None) -> str | None:
if v is not None and v not in AVATAR_COLORS:
raise ValueError(f"Avatar color must be one of: {', '.join(AVATAR_COLORS)}")
return v
class ApiKeyCreateIn(BaseModel):
name: str = Field(..., min_length=1, max_length=80)
expires_in_days: int | None = Field(default=90, ge=1, le=3650)
class ApiKeyOut(BaseModel):
model_config = ConfigDict(from_attributes=True)
id: int
name: str
key_prefix: str
expires_at: datetime | None
revoked_at: datetime | None
last_used_at: datetime | None
created_at: datetime
is_active: bool
class ApiKeyCreatedOut(ApiKeyOut):
raw_key: str
class QuestionOptionSchema(BaseModel):
id: str = Field(..., min_length=1, max_length=32)
text: str = Field(..., min_length=1, max_length=300)
class QuestionIn(BaseModel):
question_type: Literal["multiple_choice", "write_in"]
prompt: str = Field(..., min_length=1, max_length=1000)
options: list[QuestionOptionSchema] = Field(default_factory=list)
correct_answer: str = Field(..., min_length=1, max_length=500)
accepted_answers: list[str] = Field(default_factory=list)
@model_validator(mode="after")
def check_question_structure(self) -> QuestionIn:
self.prompt = self.prompt.strip()
self.correct_answer = self.correct_answer.strip()
if not self.prompt:
raise ValueError("Question prompt cannot be blank.")
if not self.correct_answer:
raise ValueError("Correct answer cannot be blank.")
if self.question_type == "multiple_choice":
if len(self.options) < 2 or len(self.options) > 6:
raise ValueError("Multiple-choice questions require between 2 and 6 options.")
option_ids = [opt.id for opt in self.options]
if len(set(option_ids)) != len(option_ids):
raise ValueError("Multiple-choice option IDs must be unique.")
if self.correct_answer not in option_ids:
raise ValueError("Multiple-choice correct_answer must match one of the option IDs.")
else:
self.options = []
self.accepted_answers = [
ans.strip() for ans in self.accepted_answers if ans and ans.strip()
][:10]
return self
class QuizCreateIn(BaseModel):
title: str = Field(..., min_length=1, max_length=140)
description: str = Field(default="", max_length=1000)
is_public: bool = True
questions: list[QuestionIn] = Field(default_factory=list, max_length=40)
@field_validator("title")
@classmethod
def clean_title(cls, v: str) -> str:
cleaned = v.strip()
if not cleaned:
raise ValueError("Quiz title cannot be empty.")
return cleaned
class QuizUpdateIn(QuizCreateIn):
pass
class ScoreSummaryOut(BaseModel):
score_count: int
total_questions: int
score_percent: float
completed_at: datetime
class GlobalBestScoreOut(BaseModel):
score_count: int
total_questions: int
score_percent: float
holder_nickname: str
holder_avatar_animal: str
holder_avatar_color: str
class QuizSummaryOut(BaseModel):
id: int
share_slug: str
title: str
description: str
is_public: bool
author_id: int
author_nickname: str
author_avatar_animal: str
author_avatar_color: str
question_count: int
quiz_mode: Literal["multiple_choice", "write_in", "mixed", "empty"]
upvotes: int
downvotes: int
user_vote: int
my_latest_score: ScoreSummaryOut | None = None
my_best_score: ScoreSummaryOut | None = None
global_best_score: GlobalBestScoreOut | None = None
is_favourited: bool = False
created_at: datetime
updated_at: datetime
class QuestionPlayOut(BaseModel):
id: int
position: int
question_type: Literal["multiple_choice", "write_in"]
prompt: str
options: list[QuestionOptionSchema]
class QuestionAuthorOut(QuestionPlayOut):
correct_answer: str
accepted_answers: list[str]
class QuizDetailOut(QuizSummaryOut):
questions: list[QuestionPlayOut]
editable_questions: list[QuestionAuthorOut] | None = None
class QuizSubmitIn(BaseModel):
answers: dict[str, str] = Field(default_factory=dict)
class QuestionResultOut(BaseModel):
question_id: int
prompt: str
question_type: Literal["multiple_choice", "write_in"]
submitted_answer: str
is_correct: bool
correct_display: str
class QuizSubmitOut(BaseModel):
attempt_id: int
score_count: int
total_questions: int
score_percent: float
results: list[QuestionResultOut]
my_latest_score: ScoreSummaryOut
my_best_score: ScoreSummaryOut
global_best_score: GlobalBestScoreOut
class VoteIn(BaseModel):
value: Literal[-1, 0, 1]
class VoteOut(BaseModel):
quiz_id: int
upvotes: int
downvotes: int
user_vote: int
class FavouriteAddIn(BaseModel):
quiz_id: int
position: int | None = Field(default=None, ge=0)
class FavouriteReorderIn(BaseModel):
quiz_ids: list[int]
class FavouriteItemOut(BaseModel):
favourite_id: int
position: int
quiz: QuizSummaryOut
class UserScoreRowOut(BaseModel):
quiz_id: int
share_slug: str
quiz_title: str
question_count: int
latest_score: ScoreSummaryOut
best_score: ScoreSummaryOut
global_best_score: GlobalBestScoreOut | None = None
attempts_count: int
class BanUserIn(BaseModel):
reason: str = Field(default="Community guidelines violation", max_length=255)
TokenSessionOut.model_rebuild()

172
app/seed.py Normal file
View File

@ -0,0 +1,172 @@
from __future__ import annotations
from sqlalchemy import func, select
from sqlalchemy.orm import Session
from app.models import Question, Quiz, User, utcnow
from app.services.quiz_service import generate_share_slug
def seed_starter_data_if_empty(db: Session) -> None:
existing_count = db.scalar(select(func.count(Quiz.id))) or 0
if existing_count > 0:
return
author = db.scalar(select(User).where(User.auth_sub == "system|quiz_club"))
if author is None:
author = User(
auth_sub="system|quiz_club",
email="club@example.com",
nickname="quiz_club",
first_name="TryMe",
last_name="Club",
age=15,
gender="prefer_not_to_say",
avatar_animal="owl",
avatar_color="avatar-2",
public_website_consent=True,
consent_at=utcnow(),
onboarded=True,
is_admin=False,
)
db.add(author)
db.flush()
starter_specs = [
{
"title": "Solar System Quick Check",
"description": "Four multiple-choice questions on planets, moons, and orbits.",
"questions": [
{
"type": "multiple_choice",
"prompt": "Which planet in our solar system has the largest ring system?",
"options": [
{"id": "a", "text": "Mars"},
{"id": "b", "text": "Saturn"},
{"id": "c", "text": "Venus"},
{"id": "d", "text": "Mercury"},
],
"correct": "b",
"aliases": [],
},
{
"type": "multiple_choice",
"prompt": "Which planet is known as the Red Planet due to iron oxide on its surface?",
"options": [
{"id": "a", "text": "Mars"},
{"id": "b", "text": "Jupiter"},
{"id": "c", "text": "Neptune"},
{"id": "d", "text": "Uranus"},
],
"correct": "a",
"aliases": [],
},
{
"type": "multiple_choice",
"prompt": "Roughly how long does light from the Sun take to reach Earth?",
"options": [
{"id": "a", "text": "8 seconds"},
{"id": "b", "text": "8 minutes"},
{"id": "c", "text": "8 hours"},
{"id": "d", "text": "8 days"},
],
"correct": "b",
"aliases": [],
},
{
"type": "multiple_choice",
"prompt": "Which moon of Jupiter is the largest moon in our solar system?",
"options": [
{"id": "a", "text": "Europa"},
{"id": "b", "text": "Io"},
{"id": "c", "text": "Ganymede"},
{"id": "d", "text": "Callisto"},
],
"correct": "c",
"aliases": [],
},
],
},
{
"title": "World Capitals Write-In",
"description": "Type the capital city for each country. Spelling counts, capitalization does not.",
"questions": [
{
"type": "write_in",
"prompt": "What is the capital city of Japan?",
"options": [],
"correct": "Tokyo",
"aliases": ["tokyo city"],
},
{
"type": "write_in",
"prompt": "What is the capital city of Canada?",
"options": [],
"correct": "Ottawa",
"aliases": [],
},
{
"type": "write_in",
"prompt": "What is the capital city of Egypt?",
"options": [],
"correct": "Cairo",
"aliases": [],
},
],
},
{
"title": "Biology & Chemistry Mix",
"description": "A short warm-up combining multiple-choice and write-in lab questions.",
"questions": [
{
"type": "multiple_choice",
"prompt": "Which organelle produces most of the chemical energy (ATP) in a eukaryotic cell?",
"options": [
{"id": "a", "text": "Ribosome"},
{"id": "b", "text": "Mitochondrion"},
{"id": "c", "text": "Golgi apparatus"},
{"id": "d", "text": "Lysosome"},
],
"correct": "b",
"aliases": [],
},
{
"type": "write_in",
"prompt": "What is the chemical symbol for gold on the periodic table?",
"options": [],
"correct": "Au",
"aliases": ["au"],
},
{
"type": "write_in",
"prompt": "How many chromosomes are found in a typical human somatic cell?",
"options": [],
"correct": "46",
"aliases": ["forty-six", "forty six"],
},
],
},
]
for spec in starter_specs:
quiz = Quiz(
share_slug=generate_share_slug(db, spec["title"]),
author_id=author.id,
title=spec["title"],
description=spec["description"],
is_public=True,
)
for pos, q in enumerate(spec["questions"]):
quiz.questions.append(
Question(
position=pos,
question_type=q["type"],
prompt=q["prompt"],
options=q["options"],
correct_answer=q["correct"],
accepted_answers=q["aliases"],
)
)
db.add(quiz)
db.commit()

1
app/services/__init__.py Normal file
View File

@ -0,0 +1 @@
"""Domain service helpers."""

View File

@ -0,0 +1,192 @@
from __future__ import annotations
import re
import secrets
from collections import defaultdict
from sqlalchemy import select
from sqlalchemy.orm import Session, selectinload
from app.models import Favourite, Question, Quiz, QuizAttempt, QuizVote, User
from app.schemas import (
GlobalBestScoreOut,
QuestionOptionSchema,
QuizSummaryOut,
ScoreSummaryOut,
)
def normalize_write_in(text: str) -> str:
return " ".join(text.strip().lower().split())
def generate_share_slug(db: Session, title: str) -> str:
stem = re.sub(r"[^a-z0-9]+", "-", title.lower()).strip("-")[:22] or "quiz"
while True:
token = secrets.token_hex(3)
slug = f"{stem}-{token}"
exists = db.scalar(select(Quiz.id).where(Quiz.share_slug == slug))
if exists is None:
return slug
def compute_quiz_mode(questions: list[Question]) -> str:
if not questions:
return "empty"
kinds = {q.question_type for q in questions}
if kinds == {"multiple_choice"}:
return "multiple_choice"
if kinds == {"write_in"}:
return "write_in"
return "mixed"
def grade_question(question: Question, submitted_raw: str) -> tuple[bool, str]:
submitted = (submitted_raw or "").strip()
if question.question_type == "multiple_choice":
option_lookup = {
str(opt.get("id", "")): str(opt.get("text", ""))
for opt in (question.options or [])
}
correct_display = option_lookup.get(question.correct_answer, question.correct_answer)
is_correct = submitted == question.correct_answer
return is_correct, correct_display
accepted = {normalize_write_in(question.correct_answer)}
for alias in question.accepted_answers or []:
if alias and alias.strip():
accepted.add(normalize_write_in(alias))
is_correct = bool(submitted) and (normalize_write_in(submitted) in accepted)
return is_correct, question.correct_answer
def enrich_quizzes(
db: Session,
quizzes: list[Quiz],
current_user: User | None,
) -> list[QuizSummaryOut]:
if not quizzes:
return []
quiz_ids = [q.id for q in quizzes]
# Votes aggregation
votes = db.scalars(select(QuizVote).where(QuizVote.quiz_id.in_(quiz_ids))).all()
upvotes_map: dict[int, int] = defaultdict(int)
downvotes_map: dict[int, int] = defaultdict(int)
user_vote_map: dict[int, int] = defaultdict(int)
for vote in votes:
if vote.value > 0:
upvotes_map[vote.quiz_id] += 1
elif vote.value < 0:
downvotes_map[vote.quiz_id] += 1
if current_user and vote.user_id == current_user.id:
user_vote_map[vote.quiz_id] = vote.value
# Attempts aggregation (global best + current user latest & best)
attempts = db.scalars(
select(QuizAttempt)
.options(selectinload(QuizAttempt.user))
.where(QuizAttempt.quiz_id.in_(quiz_ids))
.order_by(QuizAttempt.completed_at.asc(), QuizAttempt.id.asc())
).all()
global_best_map: dict[int, QuizAttempt] = {}
my_latest_map: dict[int, QuizAttempt] = {}
my_best_map: dict[int, QuizAttempt] = {}
for att in attempts:
# Global best: strictly greater score_percent (ties go to earliest achiever)
prev_global = global_best_map.get(att.quiz_id)
if prev_global is None or att.score_percent > prev_global.score_percent:
global_best_map[att.quiz_id] = att
if current_user and att.user_id == current_user.id:
# Ordered ascending by completed_at, id -> last seen is latest
my_latest_map[att.quiz_id] = att
prev_best = my_best_map.get(att.quiz_id)
if prev_best is None or att.score_percent > prev_best.score_percent:
my_best_map[att.quiz_id] = att
# Favourites lookup
fav_quiz_ids: set[int] = set()
if current_user:
fav_rows = db.scalars(
select(Favourite.quiz_id).where(
Favourite.user_id == current_user.id,
Favourite.quiz_id.in_(quiz_ids),
)
).all()
fav_quiz_ids = set(fav_rows)
summaries: list[QuizSummaryOut] = []
for q in quizzes:
gb = global_best_map.get(q.id)
ml = my_latest_map.get(q.id)
mb = my_best_map.get(q.id)
global_best_out = (
GlobalBestScoreOut(
score_count=gb.score_count,
total_questions=gb.total_questions,
score_percent=round(gb.score_percent, 1),
holder_nickname=gb.user.nickname if gb.user else "student",
holder_avatar_animal=gb.user.avatar_animal if gb.user else "fox",
holder_avatar_color=gb.user.avatar_color if gb.user else "avatar-1",
)
if gb is not None
else None
)
my_latest_out = (
ScoreSummaryOut(
score_count=ml.score_count,
total_questions=ml.total_questions,
score_percent=round(ml.score_percent, 1),
completed_at=ml.completed_at,
)
if ml is not None
else None
)
my_best_out = (
ScoreSummaryOut(
score_count=mb.score_count,
total_questions=mb.total_questions,
score_percent=round(mb.score_percent, 1),
completed_at=mb.completed_at,
)
if mb is not None
else None
)
summaries.append(
QuizSummaryOut(
id=q.id,
share_slug=q.share_slug,
title=q.title,
description=q.description,
is_public=q.is_public,
author_id=q.author_id,
author_nickname=q.author.nickname if q.author else "student",
author_avatar_animal=q.author.avatar_animal if q.author else "fox",
author_avatar_color=q.author.avatar_color if q.author else "avatar-1",
question_count=len(q.questions),
quiz_mode=compute_quiz_mode(q.questions), # type: ignore[arg-type]
upvotes=upvotes_map[q.id],
downvotes=downvotes_map[q.id],
user_vote=user_vote_map[q.id],
my_latest_score=my_latest_out,
my_best_score=my_best_out,
global_best_score=global_best_out,
is_favourited=(q.id in fav_quiz_ids),
created_at=q.created_at,
updated_at=q.updated_at,
)
)
return summaries
def serialize_play_options(options_raw: list[dict]) -> list[QuestionOptionSchema]:
return [
QuestionOptionSchema(id=str(o.get("id", "")), text=str(o.get("text", "")))
for o in (options_raw or [])
]

41
docker-compose.yaml Normal file
View File

@ -0,0 +1,41 @@
services:
db:
image: postgres:16-alpine
restart: unless-stopped
environment:
POSTGRES_USER: ${POSTGRES_USER:-tryme}
POSTGRES_PASSWORD: ${POSTGRES_PASSWORD:-tryme_secret}
POSTGRES_DB: ${POSTGRES_DB:-trymedb}
volumes:
- tryme_pgdata:/var/lib/postgresql/data
healthcheck:
test: ["CMD-SHELL", "pg_isready -U $${POSTGRES_USER:-tryme} -d $${POSTGRES_DB:-trymedb}"]
interval: 5s
timeout: 5s
retries: 10
app:
build:
context: .
dockerfile: Dockerfile
restart: unless-stopped
depends_on:
db:
condition: service_healthy
ports:
- "${APP_PORT:-8000}:8000"
environment:
DATABASE_URL: ${DATABASE_URL:-postgresql+psycopg://tryme:tryme_secret@db:5432/trymedb}
ADMIN_USER: ${ADMIN_USER:-moderator}
DEV_AUTH_ENABLED: ${DEV_AUTH_ENABLED:-true}
SEED_STARTER_QUIZZES: ${SEED_STARTER_QUIZZES:-true}
SESSION_SECRET: ${SESSION_SECRET:-change-me-in-production}
AUTH0_DOMAIN: ${AUTH0_DOMAIN:-}
AUTH0_CLIENT_ID: ${AUTH0_CLIENT_ID:-}
AUTH0_CLIENT_SECRET: ${AUTH0_CLIENT_SECRET:-}
AUTH0_AUDIENCE: ${AUTH0_AUDIENCE:-}
AUTH0_CALLBACK_URL: ${AUTH0_CALLBACK_URL:-http://localhost:8000/api/v1/auth/callback}
FRONTEND_BASE_URL: ${FRONTEND_BASE_URL:-http://localhost:8000}
volumes:
tryme_pgdata:

1
docker-compose.yml Symbolic link
View File

@ -0,0 +1 @@
docker-compose.yaml

8
docker-entrypoint.sh Executable file
View File

@ -0,0 +1,8 @@
#!/bin/sh
set -e
echo "Running Alembic database migrations..."
alembic upgrade head
echo "Starting TryMe FastAPI server on port ${PORT:-8000}..."
exec uvicorn app.main:app --host 0.0.0.0 --port "${PORT:-8000}"

25
frontend/index.html Normal file
View File

@ -0,0 +1,25 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<meta
name="description"
content="TryMe lets students build multiple-choice and write-in quizzes, challenge friends by link, pin favourites, and climb quiz leaderboards."
/>
<link rel="icon" type="image/png" href="/brand/2dlogo_small_64x64.png" />
<link
rel="stylesheet"
href="https://api.fontshare.com/v2/css?f[]=chillax@500,600&display=swap"
/>
<link
rel="stylesheet"
href="https://fonts.googleapis.com/css2?family=Geist+Mono:wght@500&display=swap"
/>
<title>TryMe Quiz Studio</title>
</head>
<body>
<div id="root"></div>
<script type="module" src="/src/main.tsx"></script>
</body>
</html>

1848
frontend/package-lock.json generated Normal file

File diff suppressed because it is too large Load Diff

23
frontend/package.json Normal file
View File

@ -0,0 +1,23 @@
{
"name": "trymeapp-frontend",
"private": true,
"version": "0.1.0",
"type": "module",
"scripts": {
"dev": "vite",
"build": "tsc -b && vite build",
"preview": "vite preview"
},
"dependencies": {
"lucide-react": "^0.468.0",
"react": "^19.0.0",
"react-dom": "^19.0.0"
},
"devDependencies": {
"@types/react": "^19.0.2",
"@types/react-dom": "^19.0.2",
"@vitejs/plugin-react": "^4.3.4",
"typescript": "^5.7.2",
"vite": "^6.0.5"
}
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 348 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 38 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.3 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.7 MiB

418
frontend/src/App.tsx Normal file
View File

@ -0,0 +1,418 @@
import { useCallback, useEffect, useState } from "react";
import { api } from "./api/client";
import type {
AuthConfig,
FavouriteItem,
QuizSummary,
User,
} from "./types";
import { TopNav, type AppRouteTab } from "./components/TopNav";
import { FavouritesPane } from "./components/FavouritesPane";
import { AuthDialog } from "./components/AuthDialog";
import { OnboardingDialog } from "./components/OnboardingDialog";
import { ExploreView } from "./views/ExploreView";
import { QuizBuilderView } from "./views/QuizBuilderView";
import { QuizPlayerView } from "./views/QuizPlayerView";
import { MyScoresView } from "./views/MyScoresView";
import { AccountApiKeysView } from "./views/AccountApiKeysView";
import { ModeratorView } from "./views/ModeratorView";
interface RouteState {
tab: AppRouteTab;
quizSlug: string | null;
editSlug: string | null;
q: string;
mode: string;
mine: boolean;
}
function readRouteFromWindow(): RouteState {
const path = window.location.pathname;
const sp = new URLSearchParams(window.location.search);
const quizMatch = /^\/quiz\/([^/]+)\/?$/.exec(path);
if (quizMatch) {
return {
tab: "play",
quizSlug: decodeURIComponent(quizMatch[1]),
editSlug: null,
q: sp.get("q") || "",
mode: sp.get("mode") || "all",
mine: sp.get("mine") === "1",
};
}
const rawView = sp.get("view") || "explore";
const validTabs: AppRouteTab[] = [
"explore",
"create",
"scores",
"account",
"moderator",
];
const tab: AppRouteTab = validTabs.includes(rawView as AppRouteTab)
? (rawView as AppRouteTab)
: "explore";
return {
tab,
quizSlug: null,
editSlug: sp.get("edit"),
q: sp.get("q") || "",
mode: sp.get("mode") || "all",
mine: sp.get("mine") === "1",
};
}
function writeRouteToWindow(next: RouteState, replace = false): void {
let url = "/";
if (next.tab === "play" && next.quizSlug) {
url = `/quiz/${encodeURIComponent(next.quizSlug)}`;
} else {
const sp = new URLSearchParams();
if (next.tab !== "explore") sp.set("view", next.tab);
if (next.editSlug) sp.set("edit", next.editSlug);
if (next.q) sp.set("q", next.q);
if (next.mode && next.mode !== "all") sp.set("mode", next.mode);
if (next.mine) sp.set("mine", "1");
const qs = sp.toString();
url = qs ? `/?${qs}` : "/";
}
if (replace) {
window.history.replaceState(null, "", url);
} else {
window.history.pushState(null, "", url);
}
}
export function App() {
const [route, setRoute] = useState<RouteState>(() => readRouteFromWindow());
const [authConfig, setAuthConfig] = useState<AuthConfig | null>(null);
const [user, setUser] = useState<User | null>(null);
const [authOpen, setAuthOpen] = useState(false);
const [quizzes, setQuizzes] = useState<QuizSummary[]>([]);
const [quizzesLoading, setQuizzesLoading] = useState(true);
const [quizzesError, setQuizzesError] = useState("");
const [favourites, setFavourites] = useState<FavouriteItem[]>([]);
const [favLoading, setFavLoading] = useState(false);
const [favError, setFavError] = useState("");
const [externalCardDragging, setExternalCardDragging] = useState(false);
useEffect(() => {
const onPop = () => setRoute(readRouteFromWindow());
window.addEventListener("popstate", onPop);
return () => window.removeEventListener("popstate", onPop);
}, []);
const navigate = useCallback(
(updater: Partial<RouteState>, replace = false) => {
setRoute((prev) => {
const merged: RouteState = { ...prev, ...updater };
writeRouteToWindow(merged, replace);
return merged;
});
},
[]
);
const refreshAuthAndUser = useCallback(async () => {
try {
const cfg = await api.getAuthConfig();
setAuthConfig(cfg);
} catch {
// ignore initial config error; individual views handle API errors
}
try {
const me = await api.getMe();
setUser(me);
} catch {
setUser(null);
}
}, []);
useEffect(() => {
void refreshAuthAndUser();
}, [refreshAuthAndUser]);
const loadQuizzes = useCallback(async () => {
setQuizzesLoading(true);
setQuizzesError("");
try {
const list = await api.listQuizzes({
q: route.q || undefined,
mode: route.mode,
mineOnly: route.mine,
});
setQuizzes(list);
} catch (err) {
setQuizzesError(
err instanceof Error ? err.message : "Failed to load quizzes."
);
} finally {
setQuizzesLoading(false);
}
}, [route.q, route.mode, route.mine]);
useEffect(() => {
void loadQuizzes();
}, [loadQuizzes, user?.id]);
const loadFavourites = useCallback(async () => {
if (!user) {
setFavourites([]);
return;
}
setFavLoading(true);
setFavError("");
try {
const favs = await api.listFavourites();
setFavourites(favs);
} catch (err) {
setFavError(
err instanceof Error ? err.message : "Failed to load favourites."
);
} finally {
setFavLoading(false);
}
}, [user]);
useEffect(() => {
void loadFavourites();
}, [loadFavourites]);
const syncAllQuizData = useCallback(() => {
void loadQuizzes();
void loadFavourites();
}, [loadQuizzes, loadFavourites]);
const handleVote = async (quizId: number, nextVote: -1 | 0 | 1) => {
if (!user) {
setAuthOpen(true);
return;
}
const updated = await api.voteQuiz(quizId, nextVote);
setQuizzes((prev) =>
prev.map((q) =>
q.id === quizId
? {
...q,
upvotes: updated.upvotes,
downvotes: updated.downvotes,
user_vote: updated.user_vote,
}
: q
)
);
void loadFavourites();
};
const handleAddFavouriteAt = async (quizId: number, position?: number) => {
if (!user) {
setAuthOpen(true);
return;
}
const nextFavs = await api.addFavourite(quizId, position);
setFavourites(nextFavs);
setQuizzes((prev) =>
prev.map((q) => (q.id === quizId ? { ...q, is_favourited: true } : q))
);
};
const handleReorderFavourites = async (quizIds: number[]) => {
const nextFavs = await api.reorderFavourites(quizIds);
setFavourites(nextFavs);
};
const handleRemoveFavourite = async (quizId: number) => {
const nextFavs = await api.removeFavourite(quizId);
setFavourites(nextFavs);
setQuizzes((prev) =>
prev.map((q) => (q.id === quizId ? { ...q, is_favourited: false } : q))
);
};
const handleToggleFavourite = async (quiz: QuizSummary) => {
if (!user) {
setAuthOpen(true);
return;
}
if (quiz.is_favourited) {
await handleRemoveFavourite(quiz.id);
} else {
await handleAddFavouriteAt(quiz.id);
}
};
const showModeratorTab = Boolean(
user && (user.is_admin || authConfig?.admin_unclaimed)
);
return (
<div className="app-shell">
<TopNav
activeTab={route.tab}
user={user}
showModeratorTab={showModeratorTab}
onSelectTab={(tab) =>
navigate({ tab, quizSlug: null, editSlug: null })
}
onOpenAuth={() => setAuthOpen(true)}
onLogout={async () => {
await api.logout();
setUser(null);
setFavourites([]);
navigate({ tab: "explore", quizSlug: null, editSlug: null, mine: false });
}}
/>
<div className="workspace-grid">
<main className="main-column">
{route.tab === "explore" && (
<ExploreView
user={user}
quizzes={quizzes}
loading={quizzesLoading}
error={quizzesError}
searchQuery={route.q}
modeFilter={route.mode}
mineOnly={route.mine}
onSearchChange={(q) => navigate({ q }, true)}
onModeChange={(mode) => navigate({ mode })}
onMineOnlyChange={(mine) => navigate({ mine })}
onRetry={() => void loadQuizzes()}
onCreateQuiz={() => {
if (!user) {
setAuthOpen(true);
return;
}
navigate({ tab: "create", editSlug: null, quizSlug: null });
}}
onPlayQuiz={(slug) =>
navigate({ tab: "play", quizSlug: slug, editSlug: null })
}
onEditQuiz={(slug) =>
navigate({ tab: "create", editSlug: slug, quizSlug: null })
}
onVote={handleVote}
onToggleFavourite={handleToggleFavourite}
onCardDragStateChange={setExternalCardDragging}
/>
)}
{route.tab === "create" && (
<QuizBuilderView
user={user}
editSlug={route.editSlug}
onRequireAuth={() => setAuthOpen(true)}
onSaved={(saved) => {
syncAllQuizData();
navigate({
tab: "play",
quizSlug: saved.share_slug,
editSlug: null,
});
}}
onCancel={() =>
navigate({ tab: "explore", editSlug: null, quizSlug: null })
}
/>
)}
{route.tab === "play" && route.quizSlug && (
<QuizPlayerView
slugOrId={route.quizSlug}
user={user}
onBack={() =>
navigate({ tab: "explore", quizSlug: null, editSlug: null })
}
onRequireAuth={() => setAuthOpen(true)}
onQuizStatsChanged={syncAllQuizData}
/>
)}
{route.tab === "scores" && (
<MyScoresView
onPlayQuiz={(slug) =>
navigate({ tab: "play", quizSlug: slug, editSlug: null })
}
onExplore={() =>
navigate({ tab: "explore", quizSlug: null, editSlug: null })
}
/>
)}
{route.tab === "account" &&
(user ? (
<AccountApiKeysView
user={user}
onUserUpdated={(u) => {
setUser(u);
syncAllQuizData();
}}
/>
) : (
<section className="state-panel">
<h1>Sign in to view your account</h1>
<p className="screen-subtitle">
Sign in to update your player profile or manage API keys.
</p>
<button
type="button"
className="btn btn-primary"
onClick={() => setAuthOpen(true)}
>
Sign in
</button>
</section>
))}
{route.tab === "moderator" && (
<ModeratorView
user={user}
adminUnclaimed={Boolean(authConfig?.admin_unclaimed)}
onUserPromoted={(promoted) => {
setUser(promoted);
void refreshAuthAndUser();
}}
onQuizzesChanged={syncAllQuizData}
/>
)}
</main>
<FavouritesPane
user={user}
items={favourites}
loading={favLoading}
error={favError}
externalCardDragging={externalCardDragging}
onRetry={() => void loadFavourites()}
onOpenQuiz={(slug) =>
navigate({ tab: "play", quizSlug: slug, editSlug: null })
}
onAddQuizAt={handleAddFavouriteAt}
onReorder={handleReorderFavourites}
onRemove={handleRemoveFavourite}
onRequireAuth={() => setAuthOpen(true)}
/>
</div>
<AuthDialog
open={authOpen}
authConfig={authConfig}
onClose={() => setAuthOpen(false)}
onSignedIn={(signedUser) => {
setUser(signedUser);
void refreshAuthAndUser();
}}
/>
<OnboardingDialog
user={user}
onCompleted={(updated) => {
setUser(updated);
syncAllQuizData();
}}
/>
</div>
);
}

201
frontend/src/api/client.ts Normal file
View File

@ -0,0 +1,201 @@
import type {
ApiKeyCreated,
ApiKeyItem,
AuthConfig,
FavouriteItem,
QuestionInput,
QuizDetail,
QuizSubmitResponse,
QuizSummary,
User,
UserScoreRow,
} from "../types";
const TOKEN_STORAGE_KEY = "tryme_auth_token";
export function getStoredToken(): string | null {
return window.localStorage.getItem(TOKEN_STORAGE_KEY);
}
export function setStoredToken(token: string | null): void {
if (token) {
window.localStorage.setItem(TOKEN_STORAGE_KEY, token);
} else {
window.localStorage.removeItem(TOKEN_STORAGE_KEY);
}
}
async function request<T>(path: string, init: RequestInit = {}): Promise<T> {
const headers = new Headers(init.headers);
if (!headers.has("Content-Type") && init.body) {
headers.set("Content-Type", "application/json");
}
const token = getStoredToken();
if (token && !headers.has("Authorization")) {
headers.set("Authorization", `Bearer ${token}`);
}
const res = await fetch(path, {
...init,
headers,
credentials: "include",
});
if (res.status === 204) {
return undefined as T;
}
const contentType = res.headers.get("content-type") || "";
const payload = contentType.includes("application/json")
? await res.json()
: await res.text();
if (!res.ok) {
const detail =
typeof payload === "object" && payload !== null && "detail" in payload
? typeof payload.detail === "string"
? payload.detail
: JSON.stringify(payload.detail)
: `Request failed with status ${res.status}`;
throw new Error(detail);
}
return payload as T;
}
export const api = {
getAuthConfig: () => request<AuthConfig>("/api/v1/auth/config"),
devLogin: async (nickname: string, email?: string, asModerator = false) => {
const out = await request<{ access_token: string; user: User }>(
"/api/v1/auth/dev-login",
{
method: "POST",
body: JSON.stringify({ nickname, email, as_moderator: asModerator }),
}
);
setStoredToken(out.access_token);
return out.user;
},
logout: async () => {
setStoredToken(null);
await request<void>("/api/v1/auth/logout", { method: "POST" });
},
getMe: () => request<User>("/api/v1/users/me"),
updateMe: (data: Record<string, unknown>) =>
request<User>("/api/v1/users/me", {
method: "PATCH",
body: JSON.stringify(data),
}),
getMyScores: () => request<UserScoreRow[]>("/api/v1/users/me/scores"),
listApiKeys: () => request<ApiKeyItem[]>("/api/v1/users/me/api-keys"),
createApiKey: (name: string, expiresInDays: number | null) =>
request<ApiKeyCreated>("/api/v1/users/me/api-keys", {
method: "POST",
body: JSON.stringify({ name, expires_in_days: expiresInDays }),
}),
expireApiKey: (keyId: number) =>
request<ApiKeyItem>(`/api/v1/users/me/api-keys/${keyId}/expire`, {
method: "POST",
}),
listQuizzes: (params: { q?: string; mode?: string; mineOnly?: boolean } = {}) => {
const sp = new URLSearchParams();
if (params.q) sp.set("q", params.q);
if (params.mode && params.mode !== "all") sp.set("mode", params.mode);
if (params.mineOnly) sp.set("mine_only", "true");
const qs = sp.toString();
return request<QuizSummary[]>(`/api/v1/quizzes${qs ? `?${qs}` : ""}`);
},
getQuiz: (slugOrId: string | number) =>
request<QuizDetail>(`/api/v1/quizzes/${encodeURIComponent(String(slugOrId))}`),
createQuiz: (data: {
title: string;
description: string;
is_public: boolean;
questions: QuestionInput[];
}) =>
request<QuizDetail>("/api/v1/quizzes", {
method: "POST",
body: JSON.stringify(data),
}),
updateQuiz: (
quizId: number,
data: {
title: string;
description: string;
is_public: boolean;
questions: QuestionInput[];
}
) =>
request<QuizDetail>(`/api/v1/quizzes/${quizId}`, {
method: "PUT",
body: JSON.stringify(data),
}),
deleteQuiz: (quizId: number) =>
request<void>(`/api/v1/quizzes/${quizId}`, { method: "DELETE" }),
submitQuiz: (quizId: number, answers: Record<string, string>) =>
request<QuizSubmitResponse>(`/api/v1/quizzes/${quizId}/submit`, {
method: "POST",
body: JSON.stringify({ answers }),
}),
voteQuiz: (quizId: number, value: -1 | 0 | 1) =>
request<{ quiz_id: number; upvotes: number; downvotes: number; user_vote: number }>(
`/api/v1/quizzes/${quizId}/vote`,
{
method: "POST",
body: JSON.stringify({ value }),
}
),
listFavourites: () => request<FavouriteItem[]>("/api/v1/favourites"),
addFavourite: (quizId: number, position?: number) =>
request<FavouriteItem[]>("/api/v1/favourites", {
method: "POST",
body: JSON.stringify({ quiz_id: quizId, position }),
}),
reorderFavourites: (quizIds: number[]) =>
request<FavouriteItem[]>("/api/v1/favourites/reorder", {
method: "PUT",
body: JSON.stringify({ quiz_ids: quizIds }),
}),
removeFavourite: (quizId: number) =>
request<FavouriteItem[]>(`/api/v1/favourites/${quizId}`, {
method: "DELETE",
}),
claimInitialAdmin: () =>
request<User>("/api/v1/admin/claim", { method: "POST" }),
adminListUsers: () => request<User[]>("/api/v1/admin/users"),
adminBanUser: (userId: number, reason: string) =>
request<User>(`/api/v1/admin/users/${userId}/ban`, {
method: "POST",
body: JSON.stringify({ reason }),
}),
adminUnbanUser: (userId: number) =>
request<User>(`/api/v1/admin/users/${userId}/unban`, { method: "POST" }),
adminListQuizzes: () => request<QuizSummary[]>("/api/v1/admin/quizzes"),
adminRemoveQuiz: (quizId: number) =>
request<void>(`/api/v1/admin/quizzes/${quizId}`, { method: "DELETE" }),
};

View File

@ -0,0 +1,126 @@
import type { AvatarAnimal, AvatarColor } from "../types";
export const ANIMAL_META: { id: AvatarAnimal; label: string }[] = [
{ id: "fox", label: "Fox" },
{ id: "owl", label: "Owl" },
{ id: "bear", label: "Bear" },
{ id: "otter", label: "Otter" },
{ id: "koala", label: "Koala" },
{ id: "tiger", label: "Tiger" },
{ id: "frog", label: "Frog" },
{ id: "penguin", label: "Penguin" },
];
export const AVATAR_COLOR_LIST: { id: AvatarColor; label: string }[] = [
{ id: "avatar-1", label: "Tangerine" },
{ id: "avatar-2", label: "Midnight" },
{ id: "avatar-3", label: "Sunbeam" },
{ id: "avatar-4", label: "Meadow" },
{ id: "avatar-5", label: "Cobalt" },
{ id: "avatar-6", label: "Mulberry" },
{ id: "avatar-7", label: "Coral" },
{ id: "avatar-8", label: "Lagoon" },
];
interface AnimalAvatarProps {
animal: AvatarAnimal;
color: AvatarColor;
size?: number;
label?: string;
}
export function AnimalAvatar({
animal,
color,
size = 34,
label,
}: AnimalAvatarProps) {
const safeSize = Math.min(size, 56);
const bgVar = `var(--color-${color})`;
const fgVar = color === "avatar-3" ? "var(--color-ink)" : "var(--color-on-dark)";
return (
<svg
width={safeSize}
height={safeSize}
viewBox="0 0 40 40"
role="img"
aria-label={label || `${animal} player icon`}
style={{ flexShrink: 0, borderRadius: "var(--radius-pill)" }}
>
<circle cx="20" cy="20" r="20" fill={bgVar} />
{animal === "fox" && (
<g fill={fgVar}>
<polygon points="10,10 16,18 9,21" />
<polygon points="30,10 24,18 31,21" />
<polygon points="9,18 31,18 20,31" />
<circle cx="16" cy="21" r="1.6" fill={bgVar} />
<circle cx="24" cy="21" r="1.6" fill={bgVar} />
</g>
)}
{animal === "owl" && (
<g fill={fgVar}>
<circle cx="15" cy="19" r="5.5" />
<circle cx="25" cy="19" r="5.5" />
<circle cx="15" cy="19" r="2.2" fill={bgVar} />
<circle cx="25" cy="19" r="2.2" fill={bgVar} />
<polygon points="18,23 22,23 20,27" />
</g>
)}
{animal === "bear" && (
<g fill={fgVar}>
<circle cx="12" cy="13" r="4" />
<circle cx="28" cy="13" r="4" />
<circle cx="20" cy="22" r="9" />
<circle cx="17" cy="20" r="1.5" fill={bgVar} />
<circle cx="23" cy="20" r="1.5" fill={bgVar} />
<ellipse cx="20" cy="25" rx="3.2" ry="2.2" fill={bgVar} />
</g>
)}
{animal === "otter" && (
<g fill={fgVar}>
<circle cx="11" cy="16" r="3" />
<circle cx="29" cy="16" r="3" />
<ellipse cx="20" cy="21" rx="10" ry="8.5" />
<circle cx="16.5" cy="19" r="1.5" fill={bgVar} />
<circle cx="23.5" cy="19" r="1.5" fill={bgVar} />
<polygon points="18,22 22,22 20,25" fill={bgVar} />
</g>
)}
{animal === "koala" && (
<g fill={fgVar}>
<circle cx="10" cy="17" r="5" />
<circle cx="30" cy="17" r="5" />
<circle cx="20" cy="21" r="8.5" />
<ellipse cx="20" cy="22" rx="2.4" ry="3.6" fill={bgVar} />
</g>
)}
{animal === "tiger" && (
<g fill={fgVar}>
<polygon points="11,11 16,15 11,18" />
<polygon points="29,11 24,15 29,18" />
<circle cx="20" cy="21" r="9" />
<rect x="18.8" y="12.5" width="2.4" height="4" fill={bgVar} />
<circle cx="16.5" cy="20.5" r="1.5" fill={bgVar} />
<circle cx="23.5" cy="20.5" r="1.5" fill={bgVar} />
</g>
)}
{animal === "frog" && (
<g fill={fgVar}>
<circle cx="14" cy="14" r="4.2" />
<circle cx="26" cy="14" r="4.2" />
<ellipse cx="20" cy="23" rx="10" ry="7.2" />
<circle cx="14" cy="14" r="1.7" fill={bgVar} />
<circle cx="26" cy="14" r="1.7" fill={bgVar} />
</g>
)}
{animal === "penguin" && (
<g fill={fgVar}>
<ellipse cx="20" cy="21" rx="8.5" ry="10.5" />
<ellipse cx="20" cy="23" rx="5.2" ry="6.8" fill={bgVar} />
<polygon points="17.5,18 22.5,18 20,21.5" />
</g>
)}
</svg>
);
}

View File

@ -0,0 +1,215 @@
import { useEffect, useRef, useState } from "react";
import { Shield, X } from "lucide-react";
import { api } from "../api/client";
import type { AuthConfig, User } from "../types";
interface AuthDialogProps {
open: boolean;
authConfig: AuthConfig | null;
onClose: () => void;
onSignedIn: (user: User) => void;
}
export function AuthDialog({
open,
authConfig,
onClose,
onSignedIn,
}: AuthDialogProps) {
const dialogRef = useRef<HTMLDialogElement | null>(null);
const [nickname, setNickname] = useState("");
const [email, setEmail] = useState("");
const [asModerator, setAsModerator] = useState(false);
const [loading, setLoading] = useState(false);
const [error, setError] = useState("");
useEffect(() => {
const dialog = dialogRef.current;
if (!dialog) return;
if (open && !dialog.open) {
dialog.showModal();
} else if (!open && dialog.open) {
dialog.close();
}
}, [open]);
if (!open) return null;
const handleDevSubmit = async (e: React.FormEvent) => {
e.preventDefault();
setError("");
const trimmed = nickname.trim();
if (trimmed.length < 2) {
setError("Enter a nickname of at least 2 characters to sign in.");
return;
}
setLoading(true);
try {
const user = await api.devLogin(
trimmed,
email.trim() || undefined,
asModerator
);
onSignedIn(user);
onClose();
} catch (err) {
setError(err instanceof Error ? err.message : "Unable to sign in.");
} finally {
setLoading(false);
}
};
return (
<dialog
ref={dialogRef}
className="app-dialog"
aria-labelledby="auth-dialog-title"
onClose={onClose}
onClick={(e) => {
if (e.target === dialogRef.current) onClose();
}}
>
<div className="dialog-head">
<div className="inline-row">
<img
src="/brand/3dlogo_large.png"
alt="TryMe marquee emblem"
width={56}
height={56}
style={{ borderRadius: "var(--radius-input)" }}
/>
<div>
<h2 id="auth-dialog-title">Sign in to TryMe</h2>
<p className="screen-subtitle">
Save scores, build quizzes, vote, and pin favourites.
</p>
</div>
</div>
<button
type="button"
className="icon-btn"
onClick={onClose}
aria-label="Close sign in dialog"
>
<X size={18} aria-hidden="true" />
</button>
</div>
<div className="main-column">
{authConfig?.auth0_enabled && (
<div className="panel-surface">
<h3>Federated Single Sign-On</h3>
<p className="screen-subtitle">
Continue with your school or personal identity provider via Auth0.
</p>
<div>
<a
className="btn btn-primary"
href={`/api/v1/auth/login?return_to=${encodeURIComponent(
window.location.pathname + window.location.search
)}`}
>
Continue with Auth0
</a>
</div>
</div>
)}
{authConfig?.dev_auth_enabled && (
<form className="panel-surface" onSubmit={handleDevSubmit} noValidate>
<h3>Quick local player sign-in</h3>
<p className="screen-subtitle">
Local development mode is enabled on this server for testing without external IdP keys.
</p>
<div className="field-group">
<label className="field-label" htmlFor="dev-login-nickname">
Player nickname (required)
</label>
<input
id="dev-login-nickname"
name="nickname"
type="text"
autoComplete="username"
required
aria-required="true"
aria-invalid={error ? "true" : "false"}
aria-describedby="dev-login-helper"
className="field-input"
placeholder="astro_fox"
value={nickname}
onChange={(e) => setNickname(e.target.value)}
/>
<span
id="dev-login-helper"
className={`field-helper ${error ? "field-helper-error" : ""}`}
>
{error || "2 to 48 letters, numbers, underscores, or hyphens."}
</span>
</div>
<div className="field-group">
<label className="field-label" htmlFor="dev-login-email">
Email address (optional)
</label>
<input
id="dev-login-email"
name="email"
type="email"
autoComplete="email"
className="field-input"
placeholder="player@example.com"
value={email}
onChange={(e) => setEmail(e.target.value)}
/>
<span className="field-helper">
Used only for matching a configured moderator email.
</span>
</div>
<label className="consent-row" htmlFor="dev-login-mod">
<input
id="dev-login-mod"
type="checkbox"
checked={asModerator}
onChange={(e) => setAsModerator(e.target.checked)}
/>
<span>
<Shield
size={15}
aria-hidden="true"
style={{ display: "inline", verticalAlign: "text-bottom" }}
/>{" "}
Sign in with moderator privileges (local dev / unclaimed admin)
</span>
</label>
<div className="inline-row">
<button
type="submit"
className={
authConfig?.auth0_enabled ? "btn" : "btn btn-primary"
}
disabled={loading}
>
{loading ? "Signing in…" : "Enter studio"}
</button>
<button type="button" className="btn btn-ghost" onClick={onClose}>
Cancel
</button>
</div>
</form>
)}
{!authConfig?.auth0_enabled && !authConfig?.dev_auth_enabled && (
<div className="state-panel state-panel-error" role="alert">
<h3>Sign-in provider not configured</h3>
<p>
Set AUTH0_DOMAIN and AUTH0_CLIENT_ID on the container or enable DEV_AUTH_ENABLED for local testing.
</p>
</div>
)}
</div>
</dialog>
);
}

View File

@ -0,0 +1,334 @@
import { useEffect, useState } from "react";
import { ArrowDown, ArrowUp, GripVertical, Star, Trash2 } from "lucide-react";
import type { FavouriteItem, User } from "../types";
export const DRAG_MIME_QUIZ_CARD = "application/x-tryme-quiz-id";
export const DRAG_MIME_FAV_ITEM = "application/x-tryme-fav-quiz-id";
interface FavouritesPaneProps {
user: User | null;
items: FavouriteItem[];
loading: boolean;
error: string;
externalCardDragging: boolean;
onRetry: () => void;
onOpenQuiz: (slug: string) => void;
onAddQuizAt: (quizId: number, position?: number) => Promise<void>;
onReorder: (quizIds: number[]) => Promise<void>;
onRemove: (quizId: number) => Promise<void>;
onRequireAuth: () => void;
}
export function FavouritesPane({
user,
items,
loading,
error,
externalCardDragging,
onRetry,
onOpenQuiz,
onAddQuizAt,
onReorder,
onRemove,
onRequireAuth,
}: FavouritesPaneProps) {
const [draggingFavQuizId, setDraggingFavQuizId] = useState<number | null>(
null
);
const [overQuizId, setOverQuizId] = useState<number | null>(null);
const [railDropActive, setRailDropActive] = useState(false);
const [overRecycleBin, setOverRecycleBin] = useState(false);
const [undoItem, setUndoItem] = useState<{
quizId: number;
title: string;
position: number;
} | null>(null);
useEffect(() => {
if (!undoItem) return;
const timer = window.setTimeout(() => setUndoItem(null), 7000);
return () => window.clearTimeout(timer);
}, [undoItem]);
const moveByOffset = async (index: number, delta: -1 | 1) => {
const target = index + delta;
if (target < 0 || target >= items.length) return;
const ids = items.map((i) => i.quiz.id);
const [moved] = ids.splice(index, 1);
ids.splice(target, 0, moved);
await onReorder(ids);
};
const handleDeleteWithUndo = async (quizId: number) => {
const foundIndex = items.findIndex((i) => i.quiz.id === quizId);
const found = foundIndex >= 0 ? items[foundIndex] : null;
await onRemove(quizId);
if (found) {
setUndoItem({
quizId: found.quiz.id,
title: found.quiz.title,
position: foundIndex,
});
}
};
const handleRailDragOver = (e: React.DragEvent<HTMLElement>) => {
const types = Array.from(e.dataTransfer.types);
if (
types.includes(DRAG_MIME_QUIZ_CARD) ||
types.includes(DRAG_MIME_FAV_ITEM)
) {
e.preventDefault();
e.dataTransfer.dropEffect = types.includes(DRAG_MIME_QUIZ_CARD)
? "copy"
: "move";
setRailDropActive(true);
}
};
const handleRailDrop = async (e: React.DragEvent<HTMLElement>) => {
e.preventDefault();
setRailDropActive(false);
setOverQuizId(null);
if (!user) {
onRequireAuth();
return;
}
const fromMain = e.dataTransfer.getData(DRAG_MIME_QUIZ_CARD);
if (fromMain) {
const qid = Number.parseInt(fromMain, 10);
if (!Number.isNaN(qid)) {
await onAddQuizAt(qid);
}
}
};
const handleItemDrop = async (
e: React.DragEvent<HTMLLIElement>,
targetIndex: number
) => {
e.preventDefault();
e.stopPropagation();
setOverQuizId(null);
setRailDropActive(false);
if (!user) {
onRequireAuth();
return;
}
const fromFav = e.dataTransfer.getData(DRAG_MIME_FAV_ITEM);
if (fromFav) {
const draggedId = Number.parseInt(fromFav, 10);
if (!Number.isNaN(draggedId)) {
const currentIds = items.map((i) => i.quiz.id);
const fromIdx = currentIds.indexOf(draggedId);
if (fromIdx >= 0 && fromIdx !== targetIndex) {
currentIds.splice(fromIdx, 1);
currentIds.splice(targetIndex, 0, draggedId);
await onReorder(currentIds);
}
}
setDraggingFavQuizId(null);
return;
}
const fromMain = e.dataTransfer.getData(DRAG_MIME_QUIZ_CARD);
if (fromMain) {
const qid = Number.parseInt(fromMain, 10);
if (!Number.isNaN(qid)) {
await onAddQuizAt(qid, targetIndex);
}
}
};
return (
<aside
className={`favourites-rail ${
railDropActive || externalCardDragging ? "is-drop-active" : ""
}`}
aria-label="Favourite quizzes"
onDragOver={handleRailDragOver}
onDragLeave={(e) => {
if (!e.currentTarget.contains(e.relatedTarget as Node)) {
setRailDropActive(false);
}
}}
onDrop={handleRailDrop}
>
<div className="favourites-header">
<div className="inline-row">
<Star size={18} aria-hidden="true" />
<h2>Favourites</h2>
</div>
<span className="favourites-count">{items.length}</span>
</div>
{!user && (
<div className="field-group">
<p className="screen-subtitle">
Sign in to drag quizzes here and keep your personal study list in order.
</p>
<div>
<button type="button" className="btn" onClick={onRequireAuth}>
Sign in to pin quizzes
</button>
</div>
</div>
)}
{user && loading && (
<div className="skeleton-stack" aria-busy="true" aria-label="Loading favourites">
<div className="skeleton-row" style={{ height: "56px" }} />
<div className="skeleton-row" style={{ height: "56px" }} />
</div>
)}
{user && !loading && error && (
<div className="state-panel state-panel-error" role="alert">
<p>{error}</p>
<button type="button" className="btn" onClick={onRetry}>
Retry loading
</button>
</div>
)}
{user && !loading && !error && items.length === 0 && (
<p className="screen-subtitle">
Drag any quiz card from the main workspace into this pane to pin it for quick replay.
</p>
)}
{user && !loading && !error && items.length > 0 && (
<ul className="favourites-list">
{items.map((fav, idx) => {
const q = fav.quiz;
const isBeingDragged = draggingFavQuizId === q.id;
const isOver = overQuizId === q.id && !isBeingDragged;
return (
<li
key={q.id}
draggable
onDragStart={(e) => {
e.dataTransfer.effectAllowed = "move";
e.dataTransfer.setData(DRAG_MIME_FAV_ITEM, String(q.id));
setDraggingFavQuizId(q.id);
}}
onDragEnd={() => {
setDraggingFavQuizId(null);
setOverQuizId(null);
setOverRecycleBin(false);
}}
onDragOver={(e) => {
e.preventDefault();
setOverQuizId(q.id);
}}
onDrop={(e) => void handleItemDrop(e, idx)}
className={`favourite-row ${
isBeingDragged ? "is-dragging" : ""
} ${isOver ? "is-drag-over" : ""}`}
>
<GripVertical
size={16}
aria-hidden="true"
style={{ color: "var(--color-muted)", flexShrink: 0 }}
/>
<div className="favourite-row-main">
<button
type="button"
className="favourite-title-btn"
onClick={() => onOpenQuiz(q.share_slug)}
>
{q.title}
</button>
<span className="favourite-meta">
{q.question_count} q
{q.my_best_score
? `, best ${q.my_best_score.score_percent}%`
: ""}
</span>
</div>
<div className="favourite-controls">
<button
type="button"
className="icon-btn"
disabled={idx === 0}
aria-label={`Move ${q.title} up`}
onClick={() => void moveByOffset(idx, -1)}
>
<ArrowUp size={14} aria-hidden="true" />
</button>
<button
type="button"
className="icon-btn"
disabled={idx === items.length - 1}
aria-label={`Move ${q.title} down`}
onClick={() => void moveByOffset(idx, 1)}
>
<ArrowDown size={14} aria-hidden="true" />
</button>
<button
type="button"
className="icon-btn"
aria-label={`Remove ${q.title} from favourites`}
onClick={() => void handleDeleteWithUndo(q.id)}
>
<Trash2 size={14} aria-hidden="true" />
</button>
</div>
</li>
);
})}
</ul>
)}
{draggingFavQuizId !== null && (
<div
className={`recycle-bin-zone ${overRecycleBin ? "is-over" : ""}`}
role="region"
aria-label="Recycle bin drop target"
onDragOver={(e) => {
e.preventDefault();
e.stopPropagation();
e.dataTransfer.dropEffect = "move";
setOverRecycleBin(true);
}}
onDragLeave={() => setOverRecycleBin(false)}
onDrop={(e) => {
e.preventDefault();
e.stopPropagation();
const raw = e.dataTransfer.getData(DRAG_MIME_FAV_ITEM);
const qid = Number.parseInt(raw, 10);
setOverRecycleBin(false);
setDraggingFavQuizId(null);
if (!Number.isNaN(qid)) {
void handleDeleteWithUndo(qid);
}
}}
>
<Trash2 size={18} aria-hidden="true" />
<span>Drop here to remove from favourites</span>
</div>
)}
{undoItem && (
<div className="undo-bar" role="status">
<span>Removed {undoItem.title}</span>
<button
type="button"
className="nav-tab"
onClick={() => {
const snapshot = undoItem;
setUndoItem(null);
void onAddQuizAt(snapshot.quizId, snapshot.position);
}}
>
Undo
</button>
</div>
)}
</aside>
);
}

View File

@ -0,0 +1,324 @@
import { useEffect, useRef, useState } from "react";
import { api } from "../api/client";
import type { AvatarAnimal, AvatarColor, GenderOption, User } from "../types";
import {
ANIMAL_META,
AVATAR_COLOR_LIST,
AnimalAvatar,
} from "./AnimalAvatar";
export const GENDER_LABELS: { value: GenderOption; label: string }[] = [
{ value: "woman", label: "Woman" },
{ value: "man", label: "Man" },
{ value: "non_binary", label: "Non-binary" },
{ value: "agender", label: "Agender" },
{ value: "genderqueer", label: "Genderqueer" },
{ value: "two_spirit", label: "Two-Spirit" },
{ value: "other", label: "Other (specify below)" },
{ value: "prefer_not_to_say", label: "Prefer not to say" },
];
interface OnboardingDialogProps {
user: User | null;
onCompleted: (updated: User) => void;
}
export function OnboardingDialog({ user, onCompleted }: OnboardingDialogProps) {
const dialogRef = useRef<HTMLDialogElement | null>(null);
const needsOnboarding = Boolean(
user && (!user.onboarded || !user.public_website_consent)
);
const [nickname, setNickname] = useState(user?.nickname || "");
const [firstName, setFirstName] = useState(user?.first_name || "");
const [lastName, setLastName] = useState(user?.last_name || "");
const [age, setAge] = useState<string>(
user?.age != null ? String(user.age) : ""
);
const [gender, setGender] = useState<GenderOption>(
user?.gender || "prefer_not_to_say"
);
const [genderCustom, setGenderCustom] = useState(user?.gender_custom || "");
const [animal, setAnimal] = useState<AvatarAnimal>(
user?.avatar_animal || "fox"
);
const [color, setColor] = useState<AvatarColor>(
user?.avatar_color || "avatar-1"
);
const [consent, setConsent] = useState<boolean>(
Boolean(user?.public_website_consent)
);
const [saving, setSaving] = useState(false);
const [error, setError] = useState("");
useEffect(() => {
if (user) {
setNickname(user.nickname);
setFirstName(user.first_name);
setLastName(user.last_name);
setAge(user.age != null ? String(user.age) : "");
setGender(user.gender);
setGenderCustom(user.gender_custom || "");
setAnimal(user.avatar_animal);
setColor(user.avatar_color);
setConsent(user.public_website_consent);
}
}, [user]);
useEffect(() => {
const dialog = dialogRef.current;
if (!dialog) return;
if (needsOnboarding && !dialog.open) {
dialog.showModal();
} else if (!needsOnboarding && dialog.open) {
dialog.close();
}
}, [needsOnboarding]);
if (!needsOnboarding || !user) return null;
const handleSave = async (e: React.FormEvent) => {
e.preventDefault();
setError("");
if (!consent) {
setError(
"Please confirm that you are allowed to use public websites before continuing."
);
return;
}
const parsedAge = age.trim() === "" ? null : Number.parseInt(age, 10);
if (
parsedAge !== null &&
(Number.isNaN(parsedAge) || parsedAge < 4 || parsedAge > 120)
) {
setError("Enter a valid age between 4 and 120, or leave age blank.");
return;
}
setSaving(true);
try {
const updated = await api.updateMe({
nickname: nickname.trim(),
first_name: firstName.trim(),
last_name: lastName.trim(),
age: parsedAge,
clear_age: parsedAge === null,
gender,
gender_custom: gender === "other" ? genderCustom.trim() : null,
avatar_animal: animal,
avatar_color: color,
public_website_consent: consent,
});
onCompleted(updated);
} catch (err) {
setError(
err instanceof Error ? err.message : "Could not save profile settings."
);
} finally {
setSaving(false);
}
};
return (
<dialog
ref={dialogRef}
className="app-dialog"
aria-labelledby="onboarding-title"
onCancel={(e) => e.preventDefault()}
>
<div className="dialog-head">
<div className="inline-row">
<AnimalAvatar animal={animal} color={color} size={48} />
<div>
<h2 id="onboarding-title">Set up your player card</h2>
<p className="screen-subtitle">
Choose an animal icon, nickname, and confirm public web access.
</p>
</div>
</div>
</div>
<form className="main-column" onSubmit={handleSave} noValidate>
<div className="field-group">
<span className="field-label">Pick your animal emblem</span>
<div className="avatar-picker-grid">
{ANIMAL_META.map((item) => (
<button
key={item.id}
type="button"
className="avatar-choice-btn"
aria-pressed={animal === item.id}
onClick={() => setAnimal(item.id)}
>
<AnimalAvatar
animal={item.id}
color={color}
size={32}
label={item.label}
/>
<span>{item.label}</span>
</button>
))}
</div>
</div>
<div className="field-group">
<span className="field-label">Pick an emblem color</span>
<div className="color-swatch-row">
{AVATAR_COLOR_LIST.map((sw) => (
<button
key={sw.id}
type="button"
className={`color-swatch-btn swatch-${sw.id}`}
aria-label={sw.label}
aria-pressed={color === sw.id}
onClick={() => setColor(sw.id)}
/>
))}
</div>
</div>
<div className="field-group">
<label className="field-label" htmlFor="onboard-nickname">
Nickname (required)
</label>
<input
id="onboard-nickname"
name="nickname"
type="text"
required
aria-required="true"
className="field-input"
value={nickname}
onChange={(e) => setNickname(e.target.value)}
/>
<span className="field-helper">
Shown next to quizzes you publish and top leaderboard scores.
</span>
</div>
<div className="form-grid-two">
<div className="field-group">
<label className="field-label" htmlFor="onboard-first">
First name (required)
</label>
<input
id="onboard-first"
name="first_name"
type="text"
autoComplete="given-name"
required
aria-required="true"
className="field-input"
value={firstName}
onChange={(e) => setFirstName(e.target.value)}
/>
<span className="field-helper">Kept private to your account.</span>
</div>
<div className="field-group">
<label className="field-label" htmlFor="onboard-last">
Last name (required)
</label>
<input
id="onboard-last"
name="last_name"
type="text"
autoComplete="family-name"
required
aria-required="true"
className="field-input"
value={lastName}
onChange={(e) => setLastName(e.target.value)}
/>
<span className="field-helper">Kept private to your account.</span>
</div>
</div>
<div className="form-grid-two">
<div className="field-group">
<label className="field-label" htmlFor="onboard-age">
Age (optional)
</label>
<input
id="onboard-age"
name="age"
type="number"
inputMode="numeric"
min={4}
max={120}
className="field-input"
placeholder="15"
value={age}
onChange={(e) => setAge(e.target.value)}
/>
<span className="field-helper">
Age in years only. We never ask for your birthday.
</span>
</div>
<div className="field-group">
<label className="field-label" htmlFor="onboard-gender">
Gender
</label>
<select
id="onboard-gender"
name="gender"
className="field-select"
value={gender}
onChange={(e) => setGender(e.target.value as GenderOption)}
>
{GENDER_LABELS.map((opt) => (
<option key={opt.value} value={opt.value}>
{opt.label}
</option>
))}
</select>
<span className="field-helper">Choose whichever fits best.</span>
</div>
</div>
{gender === "other" && (
<div className="field-group">
<label className="field-label" htmlFor="onboard-gender-custom">
Describe your gender (optional)
</label>
<input
id="onboard-gender-custom"
name="gender_custom"
type="text"
className="field-input"
value={genderCustom}
onChange={(e) => setGenderCustom(e.target.value)}
/>
<span className="field-helper">Up to 80 characters.</span>
</div>
)}
<label className="consent-row" htmlFor="onboard-consent">
<input
id="onboard-consent"
type="checkbox"
checked={consent}
onChange={(e) => setConsent(e.target.checked)}
/>
<span>
I confirm that I am allowed by my school, parent, or guardian to use public websites and share quizzes on TryMe.
</span>
</label>
{error && (
<div className="state-panel state-panel-error" role="alert">
<p>{error}</p>
</div>
)}
<div className="inline-row">
<button type="submit" className="btn btn-primary" disabled={saving}>
{saving ? "Saving profile…" : "Save player card"}
</button>
</div>
</form>
</dialog>
);
}

View File

@ -0,0 +1,160 @@
import {
Award,
Compass,
KeyRound,
LogIn,
LogOut,
PlusCircle,
ShieldCheck,
} from "lucide-react";
import type { User } from "../types";
import { AnimalAvatar } from "./AnimalAvatar";
export type AppRouteTab =
| "explore"
| "create"
| "scores"
| "account"
| "moderator"
| "play";
interface TopNavProps {
activeTab: AppRouteTab;
user: User | null;
showModeratorTab: boolean;
onSelectTab: (tab: AppRouteTab) => void;
onOpenAuth: () => void;
onLogout: () => void;
}
export function TopNav({
activeTab,
user,
showModeratorTab,
onSelectTab,
onOpenAuth,
onLogout,
}: TopNavProps) {
return (
<header className="top-bar">
<div className="top-bar-inner">
<a
href="/?view=explore"
className="brand-link"
aria-label="TryMe home"
onClick={(e) => {
e.preventDefault();
onSelectTab("explore");
}}
>
<img
src="/brand/2dlogo_medium_256x256.png"
alt=""
width={42}
height={42}
className="brand-logo-img"
fetchPriority="high"
/>
<span>TryMe</span>
</a>
<nav className="top-nav-tabs" aria-label="Primary workspace navigation">
<button
type="button"
className="nav-tab"
aria-current={activeTab === "explore" ? "page" : undefined}
onClick={() => onSelectTab("explore")}
>
<Compass size={16} aria-hidden="true" />
<span>Explore quizzes</span>
</button>
<button
type="button"
className="nav-tab"
aria-current={activeTab === "create" ? "page" : undefined}
onClick={() => onSelectTab("create")}
>
<PlusCircle size={16} aria-hidden="true" />
<span>Create quiz</span>
</button>
{user && (
<button
type="button"
className="nav-tab"
aria-current={activeTab === "scores" ? "page" : undefined}
onClick={() => onSelectTab("scores")}
>
<Award size={16} aria-hidden="true" />
<span>My scores</span>
</button>
)}
{user && (
<button
type="button"
className="nav-tab"
aria-current={activeTab === "account" ? "page" : undefined}
onClick={() => onSelectTab("account")}
>
<KeyRound size={16} aria-hidden="true" />
<span>Profile and API keys</span>
</button>
)}
{showModeratorTab && (
<button
type="button"
className="nav-tab"
aria-current={activeTab === "moderator" ? "page" : undefined}
onClick={() => onSelectTab("moderator")}
>
<ShieldCheck size={16} aria-hidden="true" />
<span>Moderation</span>
</button>
)}
</nav>
<div className="top-bar-user">
{user ? (
<>
<button
type="button"
className="user-pill"
onClick={() => onSelectTab("account")}
aria-label={`Signed in as ${user.nickname}. Open profile`}
>
<AnimalAvatar
animal={user.avatar_animal}
color={user.avatar_color}
size={26}
label={`${user.nickname} icon`}
/>
<span>{user.nickname}</span>
</button>
<button
type="button"
className="nav-tab"
onClick={onLogout}
aria-label="Sign out"
>
<LogOut size={16} aria-hidden="true" />
<span>Sign out</span>
</button>
</>
) : (
<button
type="button"
className="btn btn-primary"
onClick={onOpenAuth}
>
<LogIn size={16} aria-hidden="true" />
<span>Sign in</span>
</button>
)}
</div>
</div>
</header>
);
}

10
frontend/src/main.tsx Normal file
View File

@ -0,0 +1,10 @@
import { StrictMode } from "react";
import { createRoot } from "react-dom/client";
import "./styles/app.css";
import { App } from "./App";
createRoot(document.getElementById("root")!).render(
<StrictMode>
<App />
</StrictMode>
);

1041
frontend/src/styles/app.css Normal file

File diff suppressed because it is too large Load Diff

View File

@ -0,0 +1,27 @@
*, *::before, *::after { box-sizing: border-box; }
html, body { overflow-x: clip; }
html { scrollbar-color: var(--color-muted) var(--color-paper); scroll-padding-top: var(--space-3xl); }
body { margin: 0; background: var(--color-paper); color: var(--color-ink); font-family: var(--font-body); font-size: var(--text-md); line-height: 1.55; -webkit-font-smoothing: antialiased; caret-color: var(--color-accent); }
::selection { background: color-mix(in oklab, var(--color-accent) 22%, transparent); color: var(--color-ink); }
:focus-visible { outline: 2px solid var(--color-focus); outline-offset: 2px; }
a { color: inherit; text-decoration-thickness: 1px; text-underline-offset: 0.16em; }
h1, h2, h3 { margin: 0; font-family: var(--font-display); font-weight: var(--display-weight, 700); font-synthesis: none; letter-spacing: var(--tracking-display); line-height: 1.05; overflow-wrap: break-word; min-width: 0; text-wrap: balance; }
h1 { max-width: 20ch; font-size: var(--text-display); } h2 { max-width: 30ch; font-size: var(--text-section); } h3 { max-width: 40ch; font-size: var(--text-title); line-height: 1.2; }
.lead { font-size: var(--text-lead); line-height: 1.45; max-width: 52ch; }
p { margin: 0; text-wrap: pretty; }
p:not([class]) { max-width: 65ch; }
img, video { max-width: 100%; height: auto; display: block; }
svg { display: block; }
[hidden] { display: none !important; }
button, input, select, textarea { font: inherit; }
.container { width: 100%; max-width: var(--container); margin-inline: auto; padding-inline: var(--gutter); box-sizing: border-box; }
.num, .figure, td.num { font-variant-numeric: tabular-nums; }
.visually-hidden { position: absolute; width: 1px; height: 1px; overflow: hidden; clip-path: inset(50%); white-space: nowrap; }
[data-slot] { position: relative; overflow: hidden; background: linear-gradient(160deg, var(--color-paper-2, var(--color-paper)), var(--color-paper-3, var(--color-paper))); }
[data-slot] > img { position: absolute; inset: 0; width: 100%; height: 100%; object-fit: cover; z-index: 1; }
[data-slot]:has(> img) > :not(img):not([data-keep]) { display: none !important; }
@media (max-width: 60rem) { :root { --gutter: 24px; --section-gap-s: 80px; --section-gap: 96px; --section-gap-l: 120px; } }
@media (max-width: 40rem) { :root { --gutter: 16px; --section-gap-s: 56px; --section-gap: 72px; --section-gap-l: 96px; --gap-columns: 16px; } }
@media (prefers-reduced-motion: reduce) { *:not(.keep-motion), *:not(.keep-motion)::before, *:not(.keep-motion)::after { animation-duration: 0.01ms !important; animation-iteration-count: 1 !important; transition-duration: 0.01ms !important; scroll-behavior: auto !important; } }
/* photographs: natural vib=12 exp=4 */
[data-slot] > img { filter: saturate(1.06) brightness(1.04); }

View File

@ -0,0 +1,56 @@
:root {
color-scheme: light;
--space-3xs: 2px; --space-2xs: 4px; --space-xs: 8px; --space-sm: 12px; --space-md: 16px; --space-lg: 24px; --space-xl: 40px; --space-2xl: 64px; --space-3xl: 96px; --space-4xl: 144px;
--text-xs: 0.78rem; --text-sm: 0.889rem; --text-md: 1rem; --text-lg: 1.18rem; --text-xl: 1.42rem; --text-2xl: 1.7rem; --text-3xl: 2.04rem;
--text-display-s: 1.42rem; --text-display: 1.7rem; --text-section: 1.25rem; --text-title: 1.05rem; --text-lead: 1rem;
--ease-out: cubic-bezier(0.16, 1, 0.3, 1); --ease-in: cubic-bezier(0.7, 0, 0.84, 0); --ease-in-out: cubic-bezier(0.65, 0, 0.35, 1);
--ease-spring: cubic-bezier(0.34, 1.56, 0.64, 1); --ease-snap: cubic-bezier(0.22, 1, 0.36, 1);
--dur-micro: 120ms; --dur-short: 220ms; --dur-long: 420ms;
/* TryMe Festive Warm Paper & Brand Slate (#253450) + Warm Orange (#fb723a) */
--color-paper: oklch(97.2% 0.012 72);
--color-paper-2: oklch(94.4% 0.018 72);
--color-paper-3: oklch(90.8% 0.024 72);
--color-card: oklch(98.7% 0.008 72);
--color-ink: oklch(21% 0.048 258);
--color-ink-2: oklch(32.5% 0.058 258);
--color-on-dark: oklch(97.2% 0.012 72);
--color-muted: oklch(44% 0.036 258);
--color-rule: oklch(86% 0.018 72);
--color-edge: oklch(80% 0.032 72);
/* Primary #fb723a signal with contrast-safe #253450-derived ink */
--color-accent: oklch(70.6% 0.178 41);
--color-accent-deep: oklch(53% 0.165 39);
--color-accent-soft: oklch(93% 0.045 45);
--color-accent-ink: oklch(18% 0.046 258);
--color-accent-2: oklch(45% 0.145 252);
--color-focus: oklch(45% 0.165 252);
--color-error: oklch(48% 0.185 27);
--color-error-soft: oklch(94% 0.035 27);
--color-success: oklch(42% 0.12 152);
--color-success-soft: oklch(93% 0.045 152);
--color-backdrop: oklch(21% 0.048 258 / 0.55);
/* Curated Animal Avatar Colorways */
--color-avatar-1: oklch(70.6% 0.178 41);
--color-avatar-2: oklch(32.5% 0.058 258);
--color-avatar-3: oklch(78% 0.155 82);
--color-avatar-4: oklch(58% 0.135 162);
--color-avatar-5: oklch(54% 0.145 245);
--color-avatar-6: oklch(55% 0.165 325);
--color-avatar-7: oklch(62% 0.16 25);
--color-avatar-8: oklch(56% 0.115 200);
--font-display: "Chillax", ui-rounded, ui-sans-serif, system-ui, sans-serif;
--font-body: var(--font-display);
--font-outlier: "Geist Mono", ui-monospace, monospace;
--radius-card: 20px; --radius-stock: 14px; --radius-input: 12px; --radius-pill: 999px; --rule-hair: 1px;
--shadow-card: 0 1px 2px oklch(21% 0.048 258 / 0.08), 0 10px 24px -14px oklch(21% 0.048 258 / 0.2);
--tracking-display: -0.025em; --display-weight: 600;
--banner-height: 68px;
--container: 1440px; --gutter: 40px; --gap-columns: 24px; --section-gap-s: 64px; --section-gap: 96px; --section-gap-l: 120px;
--z-base: 1; --z-raised: 10; --z-dropdown: 100; --z-sticky: 200; --z-sticky-nav: 300; --z-modal: 400; --z-toast: 500; --z-tooltip: 600;
}

181
frontend/src/types.ts Normal file
View File

@ -0,0 +1,181 @@
export type AvatarAnimal =
| "fox"
| "owl"
| "bear"
| "otter"
| "koala"
| "tiger"
| "frog"
| "penguin";
export type AvatarColor =
| "avatar-1"
| "avatar-2"
| "avatar-3"
| "avatar-4"
| "avatar-5"
| "avatar-6"
| "avatar-7"
| "avatar-8";
export type GenderOption =
| "woman"
| "man"
| "non_binary"
| "agender"
| "genderqueer"
| "two_spirit"
| "other"
| "prefer_not_to_say";
export interface AuthConfig {
auth0_enabled: boolean;
auth0_domain: string;
auth0_client_id: string;
auth0_audience: string;
dev_auth_enabled: boolean;
admin_unclaimed: boolean;
}
export interface User {
id: number;
auth_sub: string;
email: string | null;
nickname: string;
first_name: string;
last_name: string;
age: number | null;
gender: GenderOption;
gender_custom: string | null;
avatar_animal: AvatarAnimal;
avatar_color: AvatarColor;
public_website_consent: boolean;
consent_at: string | null;
onboarded: boolean;
is_admin: boolean;
is_banned: boolean;
ban_reason: string | null;
created_at: string;
}
export interface ScoreSummary {
score_count: number;
total_questions: number;
score_percent: number;
completed_at: string;
}
export interface GlobalBestScore {
score_count: number;
total_questions: number;
score_percent: number;
holder_nickname: string;
holder_avatar_animal: AvatarAnimal;
holder_avatar_color: AvatarColor;
}
export interface QuizSummary {
id: number;
share_slug: string;
title: string;
description: string;
is_public: boolean;
author_id: number;
author_nickname: string;
author_avatar_animal: AvatarAnimal;
author_avatar_color: AvatarColor;
question_count: number;
quiz_mode: "multiple_choice" | "write_in" | "mixed" | "empty";
upvotes: number;
downvotes: number;
user_vote: number;
my_latest_score: ScoreSummary | null;
my_best_score: ScoreSummary | null;
global_best_score: GlobalBestScore | null;
is_favourited: boolean;
created_at: string;
updated_at: string;
}
export interface QuestionOption {
id: string;
text: string;
}
export interface QuestionPlay {
id: number;
position: number;
question_type: "multiple_choice" | "write_in";
prompt: string;
options: QuestionOption[];
}
export interface QuestionAuthor extends QuestionPlay {
correct_answer: string;
accepted_answers: string[];
}
export interface QuizDetail extends QuizSummary {
questions: QuestionPlay[];
editable_questions: QuestionAuthor[] | null;
}
export interface QuestionInput {
question_type: "multiple_choice" | "write_in";
prompt: string;
options: QuestionOption[];
correct_answer: string;
accepted_answers: string[];
}
export interface QuestionResult {
question_id: number;
prompt: string;
question_type: "multiple_choice" | "write_in";
submitted_answer: string;
is_correct: boolean;
correct_display: string;
}
export interface QuizSubmitResponse {
attempt_id: number;
score_count: number;
total_questions: number;
score_percent: number;
results: QuestionResult[];
my_latest_score: ScoreSummary;
my_best_score: ScoreSummary;
global_best_score: GlobalBestScore;
}
export interface FavouriteItem {
favourite_id: number;
position: number;
quiz: QuizSummary;
}
export interface ApiKeyItem {
id: number;
name: string;
key_prefix: string;
expires_at: string | null;
revoked_at: string | null;
last_used_at: string | null;
created_at: string;
is_active: boolean;
}
export interface ApiKeyCreated extends ApiKeyItem {
raw_key: string;
}
export interface UserScoreRow {
quiz_id: number;
share_slug: string;
quiz_title: string;
question_count: number;
latest_score: ScoreSummary;
best_score: ScoreSummary;
global_best_score: GlobalBestScore | null;
attempts_count: number;
}

View File

@ -0,0 +1,494 @@
import { useEffect, useState } from "react";
import { Check, Copy, KeyRound, ShieldOff } from "lucide-react";
import { api } from "../api/client";
import type {
ApiKeyCreated,
ApiKeyItem,
AvatarAnimal,
AvatarColor,
GenderOption,
User,
} from "../types";
import {
ANIMAL_META,
AVATAR_COLOR_LIST,
AnimalAvatar,
} from "../components/AnimalAvatar";
import { GENDER_LABELS } from "../components/OnboardingDialog";
interface AccountApiKeysViewProps {
user: User;
onUserUpdated: (user: User) => void;
}
export function AccountApiKeysView({
user,
onUserUpdated,
}: AccountApiKeysViewProps) {
const [nickname, setNickname] = useState(user.nickname);
const [firstName, setFirstName] = useState(user.first_name);
const [lastName, setLastName] = useState(user.last_name);
const [age, setAge] = useState(user.age != null ? String(user.age) : "");
const [gender, setGender] = useState<GenderOption>(user.gender);
const [genderCustom, setGenderCustom] = useState(user.gender_custom || "");
const [animal, setAnimal] = useState<AvatarAnimal>(user.avatar_animal);
const [color, setColor] = useState<AvatarColor>(user.avatar_color);
const [consent, setConsent] = useState(user.public_website_consent);
const [savingProfile, setSavingProfile] = useState(false);
const [profileMsg, setProfileMsg] = useState("");
const [profileErr, setProfileErr] = useState("");
const [keys, setKeys] = useState<ApiKeyItem[]>([]);
const [keysLoading, setKeysLoading] = useState(true);
const [keysErr, setKeysErr] = useState("");
const [keyName, setKeyName] = useState("");
const [expiryDays, setExpiryDays] = useState("90");
const [creatingKey, setCreatingKey] = useState(false);
const [newlyCreatedKey, setNewlyCreatedKey] = useState<ApiKeyCreated | null>(
null
);
const [copiedSecret, setCopiedSecret] = useState(false);
const loadKeys = () => {
setKeysLoading(true);
setKeysErr("");
api
.listApiKeys()
.then(setKeys)
.catch((err) =>
setKeysErr(
err instanceof Error ? err.message : "Failed to load API keys."
)
)
.finally(() => setKeysLoading(false));
};
useEffect(() => {
loadKeys();
}, []);
const handleProfileSave = async (e: React.FormEvent) => {
e.preventDefault();
setProfileErr("");
setProfileMsg("");
const parsedAge = age.trim() === "" ? null : Number.parseInt(age, 10);
if (
parsedAge !== null &&
(Number.isNaN(parsedAge) || parsedAge < 4 || parsedAge > 120)
) {
setProfileErr("Age must be between 4 and 120, or left blank.");
return;
}
setSavingProfile(true);
try {
const updated = await api.updateMe({
nickname: nickname.trim(),
first_name: firstName.trim(),
last_name: lastName.trim(),
age: parsedAge,
clear_age: parsedAge === null,
gender,
gender_custom: gender === "other" ? genderCustom.trim() : null,
avatar_animal: animal,
avatar_color: color,
public_website_consent: consent,
});
onUserUpdated(updated);
setProfileMsg("Profile updated.");
} catch (err) {
setProfileErr(
err instanceof Error ? err.message : "Could not update profile."
);
} finally {
setSavingProfile(false);
}
};
const handleCreateKey = async (e: React.FormEvent) => {
e.preventDefault();
if (!keyName.trim()) {
setKeysErr("Enter a descriptive label for the API key.");
return;
}
setKeysErr("");
setCreatingKey(true);
try {
const days =
expiryDays === "never" ? null : Number.parseInt(expiryDays, 10);
const created = await api.createApiKey(keyName.trim(), days);
setNewlyCreatedKey(created);
setKeyName("");
loadKeys();
} catch (err) {
setKeysErr(
err instanceof Error ? err.message : "Could not generate API key."
);
} finally {
setCreatingKey(false);
}
};
const handleExpireKey = async (keyId: number) => {
try {
await api.expireApiKey(keyId);
loadKeys();
} catch (err) {
setKeysErr(
err instanceof Error ? err.message : "Could not expire API key."
);
}
};
return (
<section className="main-column" aria-labelledby="account-heading">
<div className="screen-header">
<div className="screen-header-copy">
<h1 id="account-heading">Player profile and API keys</h1>
<p className="screen-subtitle">
Customize your animal emblem, manage profile details, or issue and expire personal API keys.
</p>
</div>
</div>
<form className="panel-surface" onSubmit={handleProfileSave} noValidate>
<div className="inline-row">
<AnimalAvatar animal={animal} color={color} size={48} />
<div>
<h2>Player identity</h2>
<p className="screen-subtitle">
Public quizzes and leaderboards show your nickname and animal icon.
</p>
</div>
</div>
<div className="field-group">
<span className="field-label">Animal icon</span>
<div className="avatar-picker-grid">
{ANIMAL_META.map((item) => (
<button
key={item.id}
type="button"
className="avatar-choice-btn"
aria-pressed={animal === item.id}
onClick={() => setAnimal(item.id)}
>
<AnimalAvatar
animal={item.id}
color={color}
size={30}
label={item.label}
/>
<span>{item.label}</span>
</button>
))}
</div>
</div>
<div className="field-group">
<span className="field-label">Icon color</span>
<div className="color-swatch-row">
{AVATAR_COLOR_LIST.map((sw) => (
<button
key={sw.id}
type="button"
className={`color-swatch-btn swatch-${sw.id}`}
aria-label={sw.label}
aria-pressed={color === sw.id}
onClick={() => setColor(sw.id)}
/>
))}
</div>
</div>
<div className="form-grid-two">
<div className="field-group">
<label className="field-label" htmlFor="acc-nickname">
Nickname
</label>
<input
id="acc-nickname"
type="text"
className="field-input"
value={nickname}
onChange={(e) => setNickname(e.target.value)}
/>
<span className="field-helper">Unique player handle.</span>
</div>
<div className="field-group">
<label className="field-label" htmlFor="acc-age">
Age (years only, no birthday)
</label>
<input
id="acc-age"
type="number"
min={4}
max={120}
className="field-input"
value={age}
onChange={(e) => setAge(e.target.value)}
/>
<span className="field-helper">Optional; never stores date of birth.</span>
</div>
</div>
<div className="form-grid-two">
<div className="field-group">
<label className="field-label" htmlFor="acc-first">
First name
</label>
<input
id="acc-first"
type="text"
className="field-input"
value={firstName}
onChange={(e) => setFirstName(e.target.value)}
/>
<span className="field-helper">Private account name.</span>
</div>
<div className="field-group">
<label className="field-label" htmlFor="acc-last">
Last name
</label>
<input
id="acc-last"
type="text"
className="field-input"
value={lastName}
onChange={(e) => setLastName(e.target.value)}
/>
<span className="field-helper">Private account name.</span>
</div>
</div>
<div className="form-grid-two">
<div className="field-group">
<label className="field-label" htmlFor="acc-gender">
Gender
</label>
<select
id="acc-gender"
className="field-select"
value={gender}
onChange={(e) => setGender(e.target.value as GenderOption)}
>
{GENDER_LABELS.map((g) => (
<option key={g.value} value={g.value}>
{g.label}
</option>
))}
</select>
<span className="field-helper">Inclusive options supported.</span>
</div>
{gender === "other" && (
<div className="field-group">
<label className="field-label" htmlFor="acc-gender-custom">
Self-described gender
</label>
<input
id="acc-gender-custom"
type="text"
className="field-input"
value={genderCustom}
onChange={(e) => setGenderCustom(e.target.value)}
/>
<span className="field-helper">Optional custom description.</span>
</div>
)}
</div>
<label className="consent-row" htmlFor="acc-consent">
<input
id="acc-consent"
type="checkbox"
checked={consent}
onChange={(e) => setConsent(e.target.checked)}
/>
<span>
I confirm that I have permission to use public websites and publish or vote on quizzes.
</span>
</label>
{profileErr && (
<div className="state-panel state-panel-error" role="alert">
<p>{profileErr}</p>
</div>
)}
{profileMsg && (
<p className="screen-subtitle" role="status">
{profileMsg}
</p>
)}
<div>
<button
type="submit"
className="btn btn-primary"
disabled={savingProfile}
>
{savingProfile ? "Saving…" : "Save profile"}
</button>
</div>
</form>
<div className="panel-surface">
<div className="inline-row">
<KeyRound size={20} aria-hidden="true" />
<div>
<h2>Personal API keys</h2>
<p className="screen-subtitle">
Pass <code className="mono-code">X-API-Key</code> to automate quiz uploads or study scripts, and expire old keys at any time.
</p>
</div>
</div>
<form className="form-grid-two" onSubmit={handleCreateKey} noValidate>
<div className="field-group">
<label className="field-label" htmlFor="new-key-name">
Key label
</label>
<input
id="new-key-name"
type="text"
className="field-input"
placeholder="Flashcard importer"
value={keyName}
onChange={(e) => setKeyName(e.target.value)}
/>
<span className="field-helper">Helps identify where the key is used.</span>
</div>
<div className="field-group">
<label className="field-label" htmlFor="new-key-exp">
Automatic expiration
</label>
<div className="inline-row">
<select
id="new-key-exp"
className="field-select"
style={{ flex: 1 }}
value={expiryDays}
onChange={(e) => setExpiryDays(e.target.value)}
>
<option value="30">30 days</option>
<option value="90">90 days</option>
<option value="365">365 days</option>
<option value="never">No automatic expiry</option>
</select>
<button
type="submit"
className="btn"
disabled={creatingKey}
>
{creatingKey ? "Generating…" : "Generate key"}
</button>
</div>
<span className="field-helper">
Raw secrets are shown once upon creation.
</span>
</div>
</form>
{newlyCreatedKey && (
<div className="question-block" role="status">
<div className="inline-between">
<strong>Copy your new API key now (shown only once):</strong>
<button
type="button"
className="btn"
onClick={async () => {
await navigator.clipboard.writeText(newlyCreatedKey.raw_key);
setCopiedSecret(true);
window.setTimeout(() => setCopiedSecret(false), 2500);
}}
>
{copiedSecret ? (
<>
<Check size={14} aria-hidden="true" />
<span>Copied</span>
</>
) : (
<>
<Copy size={14} aria-hidden="true" />
<span>Copy key</span>
</>
)}
</button>
</div>
<code className="mono-code">{newlyCreatedKey.raw_key}</code>
</div>
)}
{keysErr && (
<div className="state-panel state-panel-error" role="alert">
<p>{keysErr}</p>
</div>
)}
{keysLoading ? (
<div className="skeleton-row" style={{ height: "64px" }} />
) : keys.length === 0 ? (
<p className="screen-subtitle">
No API keys issued yet. Generate one above when you want programmatic access.
</p>
) : (
<div className="data-table-wrap">
<table className="data-table">
<thead>
<tr>
<th scope="col">Label</th>
<th scope="col">Prefix</th>
<th scope="col">Status</th>
<th scope="col">Expires</th>
<th scope="col">Action</th>
</tr>
</thead>
<tbody>
{keys.map((k) => (
<tr key={k.id}>
<td>{k.name}</td>
<td>
<code className="mono-code">{k.key_prefix}…</code>
</td>
<td>
<span
className={`status-chip ${
k.is_active ? "status-chip-ok" : "status-chip-bad"
}`}
>
{k.is_active ? "Active" : "Expired"}
</span>
</td>
<td className="num">
{k.revoked_at
? "Expired manually"
: k.expires_at
? new Date(k.expires_at).toLocaleDateString()
: "Never"}
</td>
<td>
{k.is_active ? (
<button
type="button"
className="btn btn-danger"
onClick={() => void handleExpireKey(k.id)}
>
<ShieldOff size={14} aria-hidden="true" />
<span>Expire key</span>
</button>
) : (
<span className="screen-subtitle">Revoked</span>
)}
</td>
</tr>
))}
</tbody>
</table>
</div>
)}
</div>
</section>
);
}

View File

@ -0,0 +1,393 @@
import { useState } from "react";
import {
Check,
Edit3,
GripHorizontal,
Link2,
Play,
PlusCircle,
Star,
ThumbsDown,
ThumbsUp,
} from "lucide-react";
import type { QuizSummary, User } from "../types";
import { AnimalAvatar } from "../components/AnimalAvatar";
import { DRAG_MIME_QUIZ_CARD } from "../components/FavouritesPane";
interface ExploreViewProps {
user: User | null;
quizzes: QuizSummary[];
loading: boolean;
error: string;
searchQuery: string;
modeFilter: string;
mineOnly: boolean;
onSearchChange: (q: string) => void;
onModeChange: (mode: string) => void;
onMineOnlyChange: (mine: boolean) => void;
onRetry: () => void;
onCreateQuiz: () => void;
onPlayQuiz: (slug: string) => void;
onEditQuiz: (slug: string) => void;
onVote: (quizId: number, nextVote: -1 | 0 | 1) => Promise<void>;
onToggleFavourite: (quiz: QuizSummary) => Promise<void>;
onCardDragStateChange: (dragging: boolean) => void;
}
function formatQuizMode(mode: QuizSummary["quiz_mode"]): string {
if (mode === "multiple_choice") return "Multiple choice";
if (mode === "write_in") return "Write-in";
if (mode === "mixed") return "Mixed format";
return "Draft (0 questions)";
}
export function ExploreView({
user,
quizzes,
loading,
error,
searchQuery,
modeFilter,
mineOnly,
onSearchChange,
onModeChange,
onMineOnlyChange,
onRetry,
onCreateQuiz,
onPlayQuiz,
onEditQuiz,
onVote,
onToggleFavourite,
onCardDragStateChange,
}: ExploreViewProps) {
const [draggingId, setDraggingId] = useState<number | null>(null);
const [copiedSlug, setCopiedSlug] = useState<string | null>(null);
const handleCopyShareLink = async (slug: string) => {
const shareUrl = `${window.location.origin}/quiz/${slug}`;
try {
await navigator.clipboard.writeText(shareUrl);
setCopiedSlug(slug);
window.setTimeout(() => {
setCopiedSlug((prev) => (prev === slug ? null : prev));
}, 2500);
} catch {
window.prompt("Copy this shareable quiz URL:", shareUrl);
}
};
return (
<section className="main-column" aria-labelledby="explore-heading">
<div className="screen-header">
<div className="screen-header-copy">
<h1 id="explore-heading">Shared student quizzes</h1>
<p className="screen-subtitle">
Play community quizzes, compare your latest and best runs, or drag cards into Favourites.
</p>
</div>
<button
type="button"
className="btn btn-primary"
onClick={onCreateQuiz}
>
<PlusCircle size={16} aria-hidden="true" />
<span>New quiz</span>
</button>
</div>
<div className="filter-bar">
<div className="search-box">
<label className="visually-hidden" htmlFor="quiz-search-input">
Search quizzes by title or topic
</label>
<input
id="quiz-search-input"
name="q"
type="search"
className="field-input"
placeholder="Search solar system, capitals, biology…"
value={searchQuery}
onChange={(e) => onSearchChange(e.target.value)}
/>
</div>
<div className="pill-group" role="group" aria-label="Filter by question format">
{[
{ id: "all", label: "All formats" },
{ id: "multiple_choice", label: "Multiple choice" },
{ id: "write_in", label: "Write-in" },
{ id: "mixed", label: "Mixed" },
].map((pill) => (
<button
key={pill.id}
type="button"
className="filter-pill"
aria-pressed={!mineOnly && modeFilter === pill.id}
onClick={() => {
onMineOnlyChange(false);
onModeChange(pill.id);
}}
>
{pill.label}
</button>
))}
{user && (
<button
type="button"
className="filter-pill"
aria-pressed={mineOnly}
onClick={() => onMineOnlyChange(!mineOnly)}
>
My quizzes
</button>
)}
</div>
</div>
{loading && (
<div className="skeleton-stack" aria-busy="true" aria-label="Loading quizzes">
<div className="skeleton-row" />
<div className="skeleton-row" />
<div className="skeleton-row" />
</div>
)}
{!loading && error && (
<div className="state-panel state-panel-error" role="alert">
<h2>Could not load shared quizzes</h2>
<p>{error}</p>
<button type="button" className="btn" onClick={onRetry}>
Retry loading quizzes
</button>
</div>
)}
{!loading && !error && quizzes.length === 0 && (
<div className="state-panel">
<h2>No matching quizzes yet</h2>
<p className="screen-subtitle">
{searchQuery || modeFilter !== "all" || mineOnly
? "No quizzes matched your current filter. Clear the filter or publish a new quiz."
: "Publish the first multiple-choice or write-in quiz so classmates can play and vote."}
</p>
<div className="inline-row">
<button
type="button"
className="btn btn-primary"
onClick={onCreateQuiz}
>
Create a quiz
</button>
{(searchQuery || modeFilter !== "all" || mineOnly) && (
<button
type="button"
className="btn"
onClick={() => {
onSearchChange("");
onModeChange("all");
onMineOnlyChange(false);
}}
>
Reset filters
</button>
)}
</div>
</div>
)}
{!loading && !error && quizzes.length > 0 && (
<div className="quiz-grid">
{quizzes.map((quiz) => {
const isOwner = Boolean(
user && (user.id === quiz.author_id || user.is_admin)
);
return (
<article
key={quiz.id}
draggable
onDragStart={(e) => {
e.dataTransfer.effectAllowed = "copy";
e.dataTransfer.setData(DRAG_MIME_QUIZ_CARD, String(quiz.id));
setDraggingId(quiz.id);
onCardDragStateChange(true);
}}
onDragEnd={() => {
setDraggingId(null);
onCardDragStateChange(false);
}}
className={`quiz-card ${
draggingId === quiz.id ? "is-dragging" : ""
}`}
>
<div className="quiz-card-top">
<div className="quiz-author-row">
<AnimalAvatar
animal={quiz.author_avatar_animal}
color={quiz.author_avatar_color}
size={28}
label={`${quiz.author_nickname} avatar`}
/>
<span>by {quiz.author_nickname}</span>
</div>
<div className="inline-row">
<span className="quiz-badge">
{quiz.question_count}{" "}
{quiz.question_count === 1 ? "question" : "questions"}
</span>
<span className="quiz-badge">
{formatQuizMode(quiz.quiz_mode)}
</span>
</div>
</div>
<div className="field-group">
<h2>{quiz.title}</h2>
{quiz.description && (
<p className="screen-subtitle">{quiz.description}</p>
)}
</div>
<div className="score-strip">
<div className="score-cell">
<span className="score-cell-label">Best by anyone</span>
<span className="score-cell-value">
{quiz.global_best_score
? `${quiz.global_best_score.score_percent}%`
: "None yet"}
</span>
<span className="score-holder">
{quiz.global_best_score
? quiz.global_best_score.holder_nickname
: "Be first to score"}
</span>
</div>
<div className="score-cell">
<span className="score-cell-label">Your latest</span>
<span className="score-cell-value">
{quiz.my_latest_score
? `${quiz.my_latest_score.score_percent}%`
: "Unplayed"}
</span>
<span className="score-holder">
{quiz.my_latest_score
? `${quiz.my_latest_score.score_count}/${quiz.my_latest_score.total_questions} right`
: "Play to record"}
</span>
</div>
<div className="score-cell">
<span className="score-cell-label">Your all-time best</span>
<span className="score-cell-value">
{quiz.my_best_score
? `${quiz.my_best_score.score_percent}%`
: "Unplayed"}
</span>
<span className="score-holder">
{quiz.my_best_score
? `${quiz.my_best_score.score_count}/${quiz.my_best_score.total_questions} right`
: "Personal record"}
</span>
</div>
</div>
<div className="quiz-card-footer">
<div className="vote-cluster">
<button
type="button"
className="vote-btn vote-up"
aria-label={`Upvote ${quiz.title} (${quiz.upvotes} upvotes)`}
aria-pressed={quiz.user_vote === 1}
onClick={() =>
void onVote(quiz.id, quiz.user_vote === 1 ? 0 : 1)
}
>
<ThumbsUp size={15} aria-hidden="true" />
<span>{quiz.upvotes}</span>
</button>
<button
type="button"
className="vote-btn vote-down"
aria-label={`Downvote ${quiz.title} (${quiz.downvotes} downvotes)`}
aria-pressed={quiz.user_vote === -1}
onClick={() =>
void onVote(quiz.id, quiz.user_vote === -1 ? 0 : -1)
}
>
<ThumbsDown size={15} aria-hidden="true" />
<span>{quiz.downvotes}</span>
</button>
<button
type="button"
className="icon-btn"
aria-label={
quiz.is_favourited
? `Remove ${quiz.title} from favourites`
: `Add ${quiz.title} to favourites`
}
aria-pressed={quiz.is_favourited}
onClick={() => void onToggleFavourite(quiz)}
>
<Star
size={16}
aria-hidden="true"
fill={
quiz.is_favourited ? "var(--color-accent)" : "none"
}
/>
</button>
<button
type="button"
className="btn btn-ghost"
onClick={() => void handleCopyShareLink(quiz.share_slug)}
>
{copiedSlug === quiz.share_slug ? (
<>
<Check size={15} aria-hidden="true" />
<span>Link copied</span>
</>
) : (
<>
<Link2 size={15} aria-hidden="true" />
<span>Share</span>
</>
)}
</button>
</div>
<div className="inline-row">
<span className="drag-hint" title="Drag card to Favourites">
<GripHorizontal size={14} aria-hidden="true" />
<span>Drag to pin</span>
</span>
{isOwner && (
<button
type="button"
className="btn"
onClick={() => onEditQuiz(quiz.share_slug)}
>
<Edit3 size={15} aria-hidden="true" />
<span>Edit</span>
</button>
)}
<button
type="button"
className="btn"
onClick={() => onPlayQuiz(quiz.share_slug)}
>
<Play size={15} aria-hidden="true" />
<span>Play</span>
</button>
</div>
</div>
</article>
);
})}
</div>
)}
</section>
);
}

View File

@ -0,0 +1,272 @@
import { useEffect, useState } from "react";
import { Ban, CheckCircle2, ShieldAlert, Trash2 } from "lucide-react";
import { api } from "../api/client";
import type { QuizSummary, User } from "../types";
import { AnimalAvatar } from "../components/AnimalAvatar";
interface ModeratorViewProps {
user: User | null;
adminUnclaimed: boolean;
onUserPromoted: (updated: User) => void;
onQuizzesChanged: () => void;
}
export function ModeratorView({
user,
adminUnclaimed,
onUserPromoted,
onQuizzesChanged,
}: ModeratorViewProps) {
const [users, setUsers] = useState<User[]>([]);
const [quizzes, setQuizzes] = useState<QuizSummary[]>([]);
const [loading, setLoading] = useState(false);
const [error, setError] = useState("");
const [claiming, setClaiming] = useState(false);
const loadModerationData = () => {
if (!user?.is_admin) return;
setLoading(true);
setError("");
Promise.all([api.adminListUsers(), api.adminListQuizzes()])
.then(([uList, qList]) => {
setUsers(uList);
setQuizzes(qList);
})
.catch((err) =>
setError(
err instanceof Error ? err.message : "Failed to load moderation tables."
)
)
.finally(() => setLoading(false));
};
useEffect(() => {
loadModerationData();
}, [user?.is_admin]);
if (!user) {
return (
<section className="state-panel">
<h1>Moderator access required</h1>
<p className="screen-subtitle">
Sign in with the configured moderator account to review users and public quizzes.
</p>
</section>
);
}
if (!user.is_admin) {
return (
<section className="state-panel">
<div className="inline-row">
<ShieldAlert size={22} aria-hidden="true" />
<h1>Initial moderator setup</h1>
</div>
{adminUnclaimed ? (
<>
<p className="screen-subtitle">
No moderator account was specified via ADMIN_USER at container launch. Claim initial moderator status for <strong>{user.nickname}</strong> now.
</p>
{error && <p className="field-helper-error">{error}</p>}
<button
type="button"
className="btn btn-primary"
disabled={claiming}
onClick={async () => {
setClaiming(true);
setError("");
try {
const promoted = await api.claimInitialAdmin();
onUserPromoted(promoted);
} catch (err) {
setError(
err instanceof Error
? err.message
: "Could not claim moderator role."
);
} finally {
setClaiming(false);
}
}}
>
{claiming ? "Claiming…" : "Claim moderator account"}
</button>
</>
) : (
<p className="screen-subtitle">
Your account does not hold moderator permissions on this instance.
</p>
)}
</section>
);
}
const handleToggleBan = async (target: User) => {
setError("");
try {
if (target.is_banned) {
await api.adminUnbanUser(target.id);
} else {
await api.adminBanUser(target.id, "Suspended by moderator");
}
loadModerationData();
} catch (err) {
setError(
err instanceof Error ? err.message : "Could not update user status."
);
}
};
const handleRemoveQuiz = async (quizId: number) => {
setError("");
try {
await api.adminRemoveQuiz(quizId);
loadModerationData();
onQuizzesChanged();
} catch (err) {
setError(err instanceof Error ? err.message : "Could not remove quiz.");
}
};
return (
<section className="main-column" aria-labelledby="mod-heading">
<div className="screen-header">
<div className="screen-header-copy">
<h1 id="mod-heading">Community moderation</h1>
<p className="screen-subtitle">
Suspend disruptive accounts and remove inappropriate quizzes from public discovery.
</p>
</div>
</div>
{error && (
<div className="state-panel state-panel-error" role="alert">
<p>{error}</p>
</div>
)}
{loading ? (
<div className="skeleton-stack" aria-busy="true">
<div className="skeleton-row" />
<div className="skeleton-row" />
</div>
) : (
<>
<div className="panel-surface">
<h2>Registered accounts</h2>
<div className="data-table-wrap">
<table className="data-table">
<thead>
<tr>
<th scope="col">Player</th>
<th scope="col">Role</th>
<th scope="col">Consent</th>
<th scope="col">Status</th>
<th scope="col">Action</th>
</tr>
</thead>
<tbody>
{users.map((u) => (
<tr key={u.id}>
<td>
<div className="inline-row">
<AnimalAvatar
animal={u.avatar_animal}
color={u.avatar_color}
size={26}
/>
<strong>{u.nickname}</strong>
</div>
</td>
<td>{u.is_admin ? "Moderator" : "Student"}</td>
<td>
{u.public_website_consent ? "Confirmed" : "Pending"}
</td>
<td>
<span
className={`status-chip ${
u.is_banned ? "status-chip-bad" : "status-chip-ok"
}`}
>
{u.is_banned ? "Banned" : "Active"}
</span>
</td>
<td>
{u.id !== user.id ? (
<button
type="button"
className={u.is_banned ? "btn" : "btn btn-danger"}
onClick={() => void handleToggleBan(u)}
>
{u.is_banned ? (
<>
<CheckCircle2 size={14} aria-hidden="true" />
<span>Restore user</span>
</>
) : (
<>
<Ban size={14} aria-hidden="true" />
<span>Ban user</span>
</>
)}
</button>
) : (
<span className="screen-subtitle">Current session</span>
)}
</td>
</tr>
))}
</tbody>
</table>
</div>
</div>
<div className="panel-surface">
<h2>Active quizzes ({quizzes.length})</h2>
{quizzes.length === 0 ? (
<p className="screen-subtitle">No active quizzes to moderate.</p>
) : (
<div className="data-table-wrap">
<table className="data-table">
<thead>
<tr>
<th scope="col">Title</th>
<th scope="col">Author</th>
<th scope="col">Questions</th>
<th scope="col">Votes</th>
<th scope="col">Action</th>
</tr>
</thead>
<tbody>
{quizzes.map((q) => (
<tr key={q.id}>
<td>
<strong>{q.title}</strong>
</td>
<td>{q.author_nickname}</td>
<td className="num">{q.question_count}</td>
<td className="num">
+{q.upvotes} / -{q.downvotes}
</td>
<td>
<button
type="button"
className="btn btn-danger"
onClick={() => void handleRemoveQuiz(q.id)}
>
<Trash2 size={14} aria-hidden="true" />
<span>Remove quiz</span>
</button>
</td>
</tr>
))}
</tbody>
</table>
</div>
)}
</div>
</>
)}
</section>
);
}

View File

@ -0,0 +1,127 @@
import { useEffect, useState } from "react";
import { Play } from "lucide-react";
import { api } from "../api/client";
import type { UserScoreRow } from "../types";
interface MyScoresViewProps {
onPlayQuiz: (slug: string) => void;
onExplore: () => void;
}
export function MyScoresView({ onPlayQuiz, onExplore }: MyScoresViewProps) {
const [rows, setRows] = useState<UserScoreRow[]>([]);
const [loading, setLoading] = useState(true);
const [error, setError] = useState("");
const load = () => {
setLoading(true);
setError("");
api
.getMyScores()
.then(setRows)
.catch((err) =>
setError(err instanceof Error ? err.message : "Could not load scores.")
)
.finally(() => setLoading(false));
};
useEffect(() => {
load();
}, []);
return (
<section className="main-column" aria-labelledby="scores-heading">
<div className="screen-header">
<div className="screen-header-copy">
<h1 id="scores-heading">Your quiz scoreboard</h1>
<p className="screen-subtitle">
Track your latest run, personal all-time best, and the top community score on every quiz you play.
</p>
</div>
</div>
{loading && (
<div className="skeleton-stack" aria-busy="true" aria-label="Loading scoreboard">
<div className="skeleton-row" />
<div className="skeleton-row" />
</div>
)}
{!loading && error && (
<div className="state-panel state-panel-error" role="alert">
<h2>Scoreboard unavailable</h2>
<p>{error}</p>
<button type="button" className="btn" onClick={load}>
Retry
</button>
</div>
)}
{!loading && !error && rows.length === 0 && (
<div className="state-panel">
<h2>No completed runs yet</h2>
<p className="screen-subtitle">
Finish any public quiz to record your latest score and personal best here.
</p>
<button
type="button"
className="btn btn-primary"
onClick={onExplore}
>
Browse quizzes to play
</button>
</div>
)}
{!loading && !error && rows.length > 0 && (
<div className="data-table-wrap">
<table className="data-table">
<thead>
<tr>
<th scope="col">Quiz</th>
<th scope="col">Runs</th>
<th scope="col">Your latest</th>
<th scope="col">Your best</th>
<th scope="col">Best by anyone</th>
<th scope="col">Action</th>
</tr>
</thead>
<tbody>
{rows.map((row) => (
<tr key={row.quiz_id}>
<td>
<strong>{row.quiz_title}</strong>
</td>
<td className="num">{row.attempts_count}</td>
<td className="num">
{row.latest_score.score_percent}% ({row.latest_score.score_count}/
{row.latest_score.total_questions})
</td>
<td className="num">
<strong>{row.best_score.score_percent}%</strong> (
{row.best_score.score_count}/{row.best_score.total_questions})
</td>
<td className="num">
{row.global_best_score
? `${row.global_best_score.score_percent}% (${row.global_best_score.holder_nickname})`
: "None"}
</td>
<td>
<button
type="button"
className="btn"
onClick={() => onPlayQuiz(row.share_slug)}
>
<Play size={14} aria-hidden="true" />
<span>Replay</span>
</button>
</td>
</tr>
))}
</tbody>
</table>
</div>
)}
</section>
);
}

View File

@ -0,0 +1,579 @@
import { useEffect, useState } from "react";
import { Plus, Trash2 } from "lucide-react";
import { api } from "../api/client";
import type { QuestionInput, QuizDetail, User } from "../types";
const MAX_QUESTIONS = 40;
interface DraftQuestion {
key: string;
question_type: "multiple_choice" | "write_in";
prompt: string;
options: { id: string; text: string }[];
correct_answer: string;
aliases_text: string;
}
function makeDefaultQuestion(index: number): DraftQuestion {
return {
key: `q-${Date.now()}-${index}`,
question_type: "multiple_choice",
prompt: "",
options: [
{ id: "a", text: "" },
{ id: "b", text: "" },
{ id: "c", text: "" },
{ id: "d", text: "" },
],
correct_answer: "a",
aliases_text: "",
};
}
interface QuizBuilderViewProps {
user: User | null;
editSlug: string | null;
onRequireAuth: () => void;
onSaved: (quiz: QuizDetail) => void;
onCancel: () => void;
}
export function QuizBuilderView({
user,
editSlug,
onRequireAuth,
onSaved,
onCancel,
}: QuizBuilderViewProps) {
const [quizId, setQuizId] = useState<number | null>(null);
const [title, setTitle] = useState("");
const [description, setDescription] = useState("");
const [isPublic, setIsPublic] = useState(true);
const [questions, setQuestions] = useState<DraftQuestion[]>([
makeDefaultQuestion(0),
]);
const [loadingExisting, setLoadingExisting] = useState(Boolean(editSlug));
const [saving, setSaving] = useState(false);
const [error, setError] = useState("");
useEffect(() => {
if (!editSlug) {
setQuizId(null);
setTitle("");
setDescription("");
setIsPublic(true);
setQuestions([makeDefaultQuestion(0)]);
setLoadingExisting(false);
return;
}
let cancelled = false;
setLoadingExisting(true);
api
.getQuiz(editSlug)
.then((detail) => {
if (cancelled) return;
setQuizId(detail.id);
setTitle(detail.title);
setDescription(detail.description);
setIsPublic(detail.is_public);
const source = detail.editable_questions || [];
setQuestions(
source.map((q, idx) => ({
key: `existing-${q.id}-${idx}`,
question_type: q.question_type,
prompt: q.prompt,
options:
q.question_type === "multiple_choice" && q.options.length >= 2
? q.options
: [
{ id: "a", text: "" },
{ id: "b", text: "" },
],
correct_answer: q.correct_answer,
aliases_text: (q.accepted_answers || []).join(", "),
}))
);
})
.catch((err) => {
if (!cancelled) {
setError(
err instanceof Error ? err.message : "Unable to load quiz for editing."
);
}
})
.finally(() => {
if (!cancelled) setLoadingExisting(false);
});
return () => {
cancelled = true;
};
}, [editSlug]);
if (!user) {
return (
<section className="state-panel">
<h1>Sign in to build quizzes</h1>
<p className="screen-subtitle">
Sign in with Auth0 or local player login to create multiple-choice and write-in quizzes.
</p>
<button
type="button"
className="btn btn-primary"
onClick={onRequireAuth}
>
Sign in to continue
</button>
</section>
);
}
if (loadingExisting) {
return (
<div className="skeleton-stack" aria-busy="true" aria-label="Loading quiz editor">
<div className="skeleton-row" />
<div className="skeleton-row" />
</div>
);
}
const updateQuestion = (
idx: number,
updater: (prev: DraftQuestion) => DraftQuestion
) => {
setQuestions((prev) => prev.map((q, i) => (i === idx ? updater(q) : q)));
};
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault();
setError("");
if (!title.trim()) {
setError("Give your quiz a clear title before saving.");
return;
}
if (questions.length > MAX_QUESTIONS) {
setError("Public free tier quizzes support between 0 and 40 questions.");
return;
}
const formatted: QuestionInput[] = [];
for (let i = 0; i < questions.length; i++) {
const q = questions[i];
if (!q.prompt.trim()) {
setError(`Question ${i + 1} needs a question prompt.`);
return;
}
if (q.question_type === "multiple_choice") {
const nonEmptyOpts = q.options
.map((o) => ({ id: o.id, text: o.text.trim() }))
.filter((o) => o.text.length > 0);
if (nonEmptyOpts.length < 2) {
setError(
`Question ${i + 1} needs at least 2 answer choices filled in.`
);
return;
}
const validCorrect = nonEmptyOpts.some(
(o) => o.id === q.correct_answer
)
? q.correct_answer
: nonEmptyOpts[0].id;
formatted.push({
question_type: "multiple_choice",
prompt: q.prompt.trim(),
options: nonEmptyOpts,
correct_answer: validCorrect,
accepted_answers: [],
});
} else {
if (!q.correct_answer.trim()) {
setError(`Question ${i + 1} needs a write-in answer.`);
return;
}
const aliases = q.aliases_text
.split(",")
.map((s) => s.trim())
.filter(Boolean);
formatted.push({
question_type: "write_in",
prompt: q.prompt.trim(),
options: [],
correct_answer: q.correct_answer.trim(),
accepted_answers: aliases,
});
}
}
setSaving(true);
try {
const payload = {
title: title.trim(),
description: description.trim(),
is_public: isPublic,
questions: formatted,
};
const saved =
quizId !== null
? await api.updateQuiz(quizId, payload)
: await api.createQuiz(payload);
onSaved(saved);
} catch (err) {
setError(err instanceof Error ? err.message : "Failed to save quiz.");
} finally {
setSaving(false);
}
};
return (
<section className="main-column" aria-labelledby="builder-heading">
<div className="screen-header">
<div className="screen-header-copy">
<h1 id="builder-heading">
{quizId !== null ? "Edit quiz" : "Create a student quiz"}
</h1>
<p className="screen-subtitle">
Mix multiple-choice and write-in prompts (0 to 40 questions on the public free tier).
</p>
</div>
<span className="quiz-badge num">
{questions.length} / {MAX_QUESTIONS} questions
</span>
</div>
<form className="main-column" onSubmit={handleSubmit} noValidate>
<div className="panel-surface">
<div className="field-group">
<label className="field-label" htmlFor="quiz-title-input">
Quiz title (required)
</label>
<input
id="quiz-title-input"
name="title"
type="text"
required
aria-required="true"
className="field-input"
placeholder="AP Biology Cell Organelles"
value={title}
onChange={(e) => setTitle(e.target.value)}
/>
<span className="field-helper">
Up to 140 characters. Used to generate your shareable link.
</span>
</div>
<div className="field-group">
<label className="field-label" htmlFor="quiz-desc-input">
Short study note (optional)
</label>
<textarea
id="quiz-desc-input"
name="description"
className="field-textarea"
placeholder="What chapter or topic does this cover?"
value={description}
onChange={(e) => setDescription(e.target.value)}
/>
<span className="field-helper">
Shown on the Explore feed and quiz intro card.
</span>
</div>
<label className="consent-row" htmlFor="quiz-public-toggle">
<input
id="quiz-public-toggle"
type="checkbox"
checked={isPublic}
onChange={(e) => setIsPublic(e.target.checked)}
/>
<span>
Share publicly so classmates can discover, play, vote, and share this quiz by URL
</span>
</label>
</div>
<div className="main-column">
{questions.length === 0 && (
<div className="state-panel">
<h2>0 questions in this draft</h2>
<p className="screen-subtitle">
You can save a 0-question placeholder draft now or add up to 40 questions below.
</p>
</div>
)}
{questions.map((q, idx) => (
<div key={q.key} className="question-block">
<div className="question-block-head">
<h2 className="num">Question {idx + 1}</h2>
<div className="inline-row">
<label className="visually-hidden" htmlFor={`q-type-${q.key}`}>
Question {idx + 1} format
</label>
<select
id={`q-type-${q.key}`}
className="field-select"
style={{ width: "auto" }}
value={q.question_type}
onChange={(e) => {
const nextType = e.target.value as
| "multiple_choice"
| "write_in";
updateQuestion(idx, (prev) => ({
...prev,
question_type: nextType,
options:
nextType === "multiple_choice" &&
prev.options.length === 0
? [
{ id: "a", text: "" },
{ id: "b", text: "" },
{ id: "c", text: "" },
{ id: "d", text: "" },
]
: prev.options,
correct_answer:
nextType === "multiple_choice" ? "a" : "",
}));
}}
>
<option value="multiple_choice">Multiple choice</option>
<option value="write_in">Write-in answer</option>
</select>
<button
type="button"
className="btn btn-danger"
onClick={() =>
setQuestions((prev) => prev.filter((_, i) => i !== idx))
}
>
<Trash2 size={15} aria-hidden="true" />
<span>Remove</span>
</button>
</div>
</div>
<div className="field-group">
<label className="field-label" htmlFor={`q-prompt-${q.key}`}>
Question prompt (required)
</label>
<input
id={`q-prompt-${q.key}`}
type="text"
className="field-input"
placeholder="Enter the question students will answer"
value={q.prompt}
onChange={(e) =>
updateQuestion(idx, (prev) => ({
...prev,
prompt: e.target.value,
}))
}
/>
<span className="field-helper">
Keep each prompt clear and self-contained.
</span>
</div>
{q.question_type === "multiple_choice" ? (
<div className="field-group">
<span className="field-label">
Answer choices (select the radio button next to the correct choice)
</span>
<div className="choice-list">
{q.options.map((opt, optIdx) => (
<div key={opt.id} className="inline-row">
<input
type="radio"
name={`correct-${q.key}`}
id={`radio-${q.key}-${opt.id}`}
checked={q.correct_answer === opt.id}
onChange={() =>
updateQuestion(idx, (prev) => ({
...prev,
correct_answer: opt.id,
}))
}
aria-label={`Mark choice ${optIdx + 1} as correct`}
/>
<label
className="visually-hidden"
htmlFor={`opt-text-${q.key}-${opt.id}`}
>
Choice {optIdx + 1} text
</label>
<input
id={`opt-text-${q.key}-${opt.id}`}
type="text"
className="field-input"
style={{ flex: 1 }}
placeholder={`Choice ${optIdx + 1}`}
value={opt.text}
onChange={(e) =>
updateQuestion(idx, (prev) => ({
...prev,
options: prev.options.map((o, oi) =>
oi === optIdx
? { ...o, text: e.target.value }
: o
),
}))
}
/>
{q.options.length > 2 && (
<button
type="button"
className="icon-btn"
aria-label={`Delete choice ${optIdx + 1}`}
onClick={() =>
updateQuestion(idx, (prev) => {
const remaining = prev.options.filter(
(_, oi) => oi !== optIdx
);
return {
...prev,
options: remaining,
correct_answer: remaining.some(
(r) => r.id === prev.correct_answer
)
? prev.correct_answer
: remaining[0].id,
};
})
}
>
<Trash2 size={15} aria-hidden="true" />
</button>
)}
</div>
))}
</div>
{q.options.length < 6 && (
<div>
<button
type="button"
className="btn btn-ghost"
onClick={() =>
updateQuestion(idx, (prev) => {
const nextId = String.fromCharCode(
97 + prev.options.length
);
return {
...prev,
options: [
...prev.options,
{ id: `${nextId}${Date.now() % 100}`, text: "" },
],
};
})
}
>
<Plus size={15} aria-hidden="true" />
<span>Add choice</span>
</button>
</div>
)}
</div>
) : (
<div className="form-grid-two">
<div className="field-group">
<label
className="field-label"
htmlFor={`q-writein-${q.key}`}
>
Expected write-in answer (required)
</label>
<input
id={`q-writein-${q.key}`}
type="text"
className="field-input"
placeholder="Tokyo"
value={q.correct_answer}
onChange={(e) =>
updateQuestion(idx, (prev) => ({
...prev,
correct_answer: e.target.value,
}))
}
/>
<span className="field-helper">
Checked without case sensitivity.
</span>
</div>
<div className="field-group">
<label
className="field-label"
htmlFor={`q-aliases-${q.key}`}
>
Accepted synonyms (optional, comma-separated)
</label>
<input
id={`q-aliases-${q.key}`}
type="text"
className="field-input"
placeholder="Tokyo City, Edo"
value={q.aliases_text}
onChange={(e) =>
updateQuestion(idx, (prev) => ({
...prev,
aliases_text: e.target.value,
}))
}
/>
<span className="field-helper">
Alternate spellings that also earn full credit.
</span>
</div>
</div>
)}
</div>
))}
</div>
<div className="inline-between">
<div className="inline-row">
<button
type="button"
className="btn"
disabled={questions.length >= MAX_QUESTIONS}
onClick={() =>
setQuestions((prev) => [
...prev,
makeDefaultQuestion(prev.length),
])
}
>
<Plus size={16} aria-hidden="true" />
<span>Add question</span>
</button>
<span className="screen-subtitle">
{questions.length >= MAX_QUESTIONS
? "Free tier limit of 40 questions reached."
: `${MAX_QUESTIONS - questions.length} question slots remaining on free tier.`}
</span>
</div>
<div className="inline-row">
<button type="button" className="btn btn-ghost" onClick={onCancel}>
Cancel
</button>
<button type="submit" className="btn btn-primary" disabled={saving}>
{saving
? "Saving quiz…"
: quizId !== null
? "Save changes"
: "Publish quiz"}
</button>
</div>
</div>
{error && (
<div className="state-panel state-panel-error" role="alert">
<p>{error}</p>
</div>
)}
</form>
</section>
);
}

View File

@ -0,0 +1,426 @@
import { useEffect, useState } from "react";
import {
ArrowLeft,
Check,
Link2,
RotateCcw,
Star,
ThumbsDown,
ThumbsUp,
} from "lucide-react";
import { api } from "../api/client";
import type { QuizDetail, QuizSubmitResponse, User } from "../types";
import { AnimalAvatar } from "../components/AnimalAvatar";
interface QuizPlayerViewProps {
slugOrId: string;
user: User | null;
onBack: () => void;
onRequireAuth: () => void;
onQuizStatsChanged: () => void;
}
export function QuizPlayerView({
slugOrId,
user,
onBack,
onRequireAuth,
onQuizStatsChanged,
}: QuizPlayerViewProps) {
const [quiz, setQuiz] = useState<QuizDetail | null>(null);
const [loading, setLoading] = useState(true);
const [error, setError] = useState("");
const [answers, setAnswers] = useState<Record<string, string>>({});
const [submitting, setSubmitting] = useState(false);
const [submitError, setSubmitError] = useState("");
const [result, setResult] = useState<QuizSubmitResponse | null>(null);
const [copied, setCopied] = useState(false);
const fetchQuiz = () => {
setLoading(true);
setError("");
api
.getQuiz(slugOrId)
.then((data) => {
setQuiz(data);
setAnswers({});
setResult(null);
})
.catch((err) => {
setError(err instanceof Error ? err.message : "Could not load quiz.");
})
.finally(() => setLoading(false));
};
useEffect(() => {
fetchQuiz();
}, [slugOrId]);
const handleCopyLink = async () => {
if (!quiz) return;
const url = `${window.location.origin}/quiz/${quiz.share_slug}`;
try {
await navigator.clipboard.writeText(url);
setCopied(true);
window.setTimeout(() => setCopied(false), 2500);
} catch {
window.prompt("Copy this quiz share URL:", url);
}
};
const handleVote = async (val: -1 | 0 | 1) => {
if (!quiz) return;
if (!user) {
onRequireAuth();
return;
}
try {
const updated = await api.voteQuiz(quiz.id, val);
setQuiz((prev) =>
prev
? {
...prev,
upvotes: updated.upvotes,
downvotes: updated.downvotes,
user_vote: updated.user_vote,
}
: prev
);
onQuizStatsChanged();
} catch (err) {
setSubmitError(err instanceof Error ? err.message : "Vote failed.");
}
};
const handleToggleFav = async () => {
if (!quiz) return;
if (!user) {
onRequireAuth();
return;
}
try {
if (quiz.is_favourited) {
await api.removeFavourite(quiz.id);
setQuiz((prev) => (prev ? { ...prev, is_favourited: false } : prev));
} else {
await api.addFavourite(quiz.id);
setQuiz((prev) => (prev ? { ...prev, is_favourited: true } : prev));
}
onQuizStatsChanged();
} catch (err) {
setSubmitError(
err instanceof Error ? err.message : "Could not update favourites."
);
}
};
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault();
if (!quiz) return;
if (!user) {
onRequireAuth();
return;
}
setSubmitError("");
setSubmitting(true);
try {
const graded = await api.submitQuiz(quiz.id, answers);
setResult(graded);
setQuiz((prev) =>
prev
? {
...prev,
my_latest_score: graded.my_latest_score,
my_best_score: graded.my_best_score,
global_best_score: graded.global_best_score,
}
: prev
);
onQuizStatsChanged();
} catch (err) {
setSubmitError(
err instanceof Error ? err.message : "Could not submit answers."
);
} finally {
setSubmitting(false);
}
};
if (loading) {
return (
<div className="skeleton-stack" aria-busy="true" aria-label="Loading quiz">
<div className="skeleton-row" />
<div className="skeleton-row" />
</div>
);
}
if (error || !quiz) {
return (
<section className="state-panel state-panel-error" role="alert">
<h1>Quiz unavailable</h1>
<p>{error || "That quiz link may have been removed."}</p>
<div className="inline-row">
<button type="button" className="btn" onClick={fetchQuiz}>
Retry
</button>
<button type="button" className="btn btn-ghost" onClick={onBack}>
Back to Explore
</button>
</div>
</section>
);
}
return (
<section className="main-column" aria-labelledby="player-heading">
<div className="inline-between">
<button type="button" className="btn btn-ghost" onClick={onBack}>
<ArrowLeft size={16} aria-hidden="true" />
<span>Back to Explore</span>
</button>
<div className="inline-row">
<button
type="button"
className="vote-btn vote-up"
aria-pressed={quiz.user_vote === 1}
onClick={() => void handleVote(quiz.user_vote === 1 ? 0 : 1)}
>
<ThumbsUp size={15} aria-hidden="true" />
<span>{quiz.upvotes}</span>
</button>
<button
type="button"
className="vote-btn vote-down"
aria-pressed={quiz.user_vote === -1}
onClick={() => void handleVote(quiz.user_vote === -1 ? 0 : -1)}
>
<ThumbsDown size={15} aria-hidden="true" />
<span>{quiz.downvotes}</span>
</button>
<button type="button" className="btn" onClick={() => void handleToggleFav()}>
<Star
size={15}
aria-hidden="true"
fill={quiz.is_favourited ? "var(--color-accent)" : "none"}
/>
<span>{quiz.is_favourited ? "Pinned" : "Pin to favourites"}</span>
</button>
<button type="button" className="btn" onClick={() => void handleCopyLink()}>
{copied ? (
<Check size={15} aria-hidden="true" />
) : (
<Link2 size={15} aria-hidden="true" />
)}
<span>{copied ? "Copied" : "Share URL"}</span>
</button>
</div>
</div>
<div className="panel-surface">
<div className="quiz-author-row">
<AnimalAvatar
animal={quiz.author_avatar_animal}
color={quiz.author_avatar_color}
size={30}
/>
<span>Created by {quiz.author_nickname}</span>
</div>
<h1 id="player-heading">{quiz.title}</h1>
{quiz.description && <p className="lead">{quiz.description}</p>}
<div className="score-strip">
<div className="score-cell">
<span className="score-cell-label">Best score by anyone</span>
<span className="score-cell-value">
{quiz.global_best_score
? `${quiz.global_best_score.score_percent}%`
: "No scores yet"}
</span>
<span className="score-holder">
{quiz.global_best_score
? `Held by ${quiz.global_best_score.holder_nickname}`
: "Open record"}
</span>
</div>
<div className="score-cell">
<span className="score-cell-label">Your latest score</span>
<span className="score-cell-value">
{quiz.my_latest_score
? `${quiz.my_latest_score.score_percent}%`
: "Unplayed"}
</span>
<span className="score-holder">
{quiz.my_latest_score
? `${quiz.my_latest_score.score_count}/${quiz.my_latest_score.total_questions} right`
: "Sign in & play"}
</span>
</div>
<div className="score-cell">
<span className="score-cell-label">Your all-time best</span>
<span className="score-cell-value">
{quiz.my_best_score
? `${quiz.my_best_score.score_percent}%`
: "Unplayed"}
</span>
<span className="score-holder">
{quiz.my_best_score
? `${quiz.my_best_score.score_count}/${quiz.my_best_score.total_questions} right`
: "Personal record"}
</span>
</div>
</div>
</div>
{result && (
<div className="panel-surface" role="status">
<div className="inline-between">
<div>
<h2 className="num">
You scored {result.score_percent}% ({result.score_count} of{" "}
{result.total_questions})
</h2>
<p className="screen-subtitle">
Your all-time best on this quiz is{" "}
{result.my_best_score.score_percent}%. Top overall score is{" "}
{result.global_best_score.score_percent}% by{" "}
{result.global_best_score.holder_nickname}.
</p>
</div>
<button
type="button"
className="btn btn-primary"
onClick={() => {
setResult(null);
setAnswers({});
}}
>
<RotateCcw size={16} aria-hidden="true" />
<span>Play again</span>
</button>
</div>
<div className="main-column">
{result.results.map((r, i) => (
<div key={r.question_id} className="question-block">
<div className="inline-between">
<h3 className="num">
{i + 1}. {r.prompt}
</h3>
<span
className={`status-chip ${
r.is_correct ? "status-chip-ok" : "status-chip-bad"
}`}
>
{r.is_correct ? "Correct" : "Needs review"}
</span>
</div>
<p className="screen-subtitle">
Correct answer: <strong>{r.correct_display}</strong>
</p>
</div>
))}
</div>
</div>
)}
{!result && quiz.questions.length === 0 && (
<div className="state-panel">
<h2>This quiz has 0 questions yet</h2>
<p className="screen-subtitle">
The author saved this quiz as an empty free-tier draft. Check back once questions are added.
</p>
</div>
)}
{!result && quiz.questions.length > 0 && (
<form className="main-column" onSubmit={handleSubmit}>
{quiz.questions.map((q, idx) => {
const key = String(q.id);
const currentVal = answers[key] || "";
return (
<div key={q.id} className="question-block">
<h2 className="num">
{idx + 1}. {q.prompt}
</h2>
{q.question_type === "multiple_choice" ? (
<div className="choice-list">
{q.options.map((opt) => {
const selected = currentVal === opt.id;
return (
<label
key={opt.id}
className={`choice-option ${
selected ? "is-selected" : ""
}`}
>
<input
type="radio"
name={`play-q-${q.id}`}
value={opt.id}
checked={selected}
onChange={() =>
setAnswers((prev) => ({
...prev,
[key]: opt.id,
}))
}
/>
<span>{opt.text}</span>
</label>
);
})}
</div>
) : (
<div className="field-group">
<label className="field-label" htmlFor={`writein-${q.id}`}>
Your write-in answer
</label>
<input
id={`writein-${q.id}`}
type="text"
className="field-input"
placeholder="Type your answer"
value={currentVal}
onChange={(e) =>
setAnswers((prev) => ({
...prev,
[key]: e.target.value,
}))
}
/>
<span className="field-helper">
Capitalization and extra spaces are ignored.
</span>
</div>
)}
</div>
);
})}
{submitError && (
<div className="state-panel state-panel-error" role="alert">
<p>{submitError}</p>
</div>
)}
<div className="inline-row">
<button
type="submit"
className="btn btn-primary"
disabled={submitting}
>
{submitting
? "Grading quiz…"
: user
? "Submit answers"
: "Sign in & submit"}
</button>
</div>
</form>
)}
</section>
);
}

20
frontend/tsconfig.json Normal file
View File

@ -0,0 +1,20 @@
{
"compilerOptions": {
"target": "ES2022",
"useDefineForClassFields": true,
"lib": ["ES2022", "DOM", "DOM.Iterable"],
"module": "ESNext",
"skipLibCheck": true,
"moduleResolution": "bundler",
"allowImportingTsExtensions": true,
"isolatedModules": true,
"moduleDetection": "force",
"noEmit": true,
"jsx": "react-jsx",
"strict": true,
"noUnusedLocals": true,
"noUnusedParameters": true,
"noFallthroughCasesInSwitch": true
},
"include": ["src"]
}

View File

@ -0,0 +1 @@
{"root":["./src/App.tsx","./src/main.tsx","./src/types.ts","./src/api/client.ts","./src/components/AnimalAvatar.tsx","./src/components/AuthDialog.tsx","./src/components/FavouritesPane.tsx","./src/components/OnboardingDialog.tsx","./src/components/TopNav.tsx","./src/views/AccountApiKeysView.tsx","./src/views/ExploreView.tsx","./src/views/ModeratorView.tsx","./src/views/MyScoresView.tsx","./src/views/QuizBuilderView.tsx","./src/views/QuizPlayerView.tsx"],"version":"5.9.3"}

19
frontend/vite.config.ts Normal file
View File

@ -0,0 +1,19 @@
import { defineConfig } from "vite";
import react from "@vitejs/plugin-react";
export default defineConfig({
plugins: [react()],
server: {
port: 5173,
proxy: {
"/api": {
target: "http://localhost:8000",
changeOrigin: true,
},
"/brand": {
target: "http://localhost:8000",
changeOrigin: true,
},
},
},
});

6
helm-chart/Chart.yaml Normal file
View File

@ -0,0 +1,6 @@
apiVersion: v2
name: trymeapp
description: Helm chart for the TryMe student quiz creation and sharing gaming platform
type: application
version: 0.1.0
appVersion: "0.1"

View File

@ -0,0 +1,34 @@
{{- define "trymeapp.name" -}}
{{- default .Chart.Name .Values.nameOverride | trunc 63 | trimSuffix "-" -}}
{{- end -}}
{{- define "trymeapp.fullname" -}}
{{- if .Values.fullnameOverride -}}
{{- .Values.fullnameOverride | trunc 63 | trimSuffix "-" -}}
{{- else -}}
{{- $name := default .Chart.Name .Values.nameOverride -}}
{{- if contains $name .Release.Name -}}
{{- .Release.Name | trunc 63 | trimSuffix "-" -}}
{{- else -}}
{{- printf "%s-%s" .Release.Name $name | trunc 63 | trimSuffix "-" -}}
{{- end -}}
{{- end -}}
{{- end -}}
{{- define "trymeapp.labels" -}}
helm.sh/chart: {{ printf "%s-%s" .Chart.Name .Chart.Version | replace "+" "_" | trunc 63 | trimSuffix "-" }}
{{ include "trymeapp.selectorLabels" . }}
app.kubernetes.io/version: {{ .Chart.AppVersion | quote }}
app.kubernetes.io/managed-by: {{ .Release.Service }}
{{- end -}}
{{- define "trymeapp.selectorLabels" -}}
app.kubernetes.io/name: {{ include "trymeapp.name" . }}
app.kubernetes.io/instance: {{ .Release.Name }}
{{- end -}}
{{- define "trymeapp.needsPvc" -}}
{{- if or .Values.persistence.enabled (eq .Values.database.mode "sqlite") -}}
true
{{- end -}}
{{- end -}}

View File

@ -0,0 +1,90 @@
{{- $needsPvc := eq (include "trymeapp.needsPvc" .) "true" -}}
apiVersion: apps/v1
kind: Deployment
metadata:
name: {{ include "trymeapp.fullname" . }}
labels:
{{- include "trymeapp.labels" . | nindent 4 }}
spec:
replicas: {{ if eq .Values.database.mode "sqlite" }}1{{ else }}{{ .Values.replicaCount }}{{ end }}
{{- if $needsPvc }}
strategy:
type: Recreate
{{- end }}
selector:
matchLabels:
{{- include "trymeapp.selectorLabels" . | nindent 6 }}
template:
metadata:
labels:
{{- include "trymeapp.selectorLabels" . | nindent 8 }}
spec:
securityContext:
fsGroup: 1001
containers:
- name: {{ .Chart.Name }}
image: "{{ .Values.image.repository }}:{{ .Values.image.tag | default .Chart.AppVersion }}"
imagePullPolicy: {{ .Values.image.pullPolicy }}
ports:
- name: http
containerPort: {{ .Values.service.targetPort }}
protocol: TCP
env:
- name: PORT
value: {{ .Values.service.targetPort | quote }}
- name: ADMIN_USER
value: {{ .Values.config.adminUser | quote }}
- name: DEV_AUTH_ENABLED
value: {{ .Values.config.devAuthEnabled | quote }}
- name: SEED_STARTER_QUIZZES
value: {{ .Values.config.seedStarterQuizzes | quote }}
- name: FRONTEND_BASE_URL
value: {{ .Values.config.frontendBaseUrl | quote }}
- name: AUTH0_DOMAIN
value: {{ .Values.config.auth0Domain | quote }}
- name: AUTH0_CLIENT_ID
value: {{ .Values.config.auth0ClientId | quote }}
- name: AUTH0_AUDIENCE
value: {{ .Values.config.auth0Audience | quote }}
- name: AUTH0_CALLBACK_URL
value: {{ .Values.config.auth0CallbackUrl | quote }}
- name: DATABASE_URL
valueFrom:
secretKeyRef:
name: {{ include "trymeapp.fullname" . }}-secret
key: DATABASE_URL
- name: SESSION_SECRET
valueFrom:
secretKeyRef:
name: {{ include "trymeapp.fullname" . }}-secret
key: SESSION_SECRET
- name: AUTH0_CLIENT_SECRET
valueFrom:
secretKeyRef:
name: {{ include "trymeapp.fullname" . }}-secret
key: AUTH0_CLIENT_SECRET
livenessProbe:
httpGet:
path: /health
port: http
initialDelaySeconds: 10
periodSeconds: 20
readinessProbe:
httpGet:
path: /health
port: http
initialDelaySeconds: 5
periodSeconds: 10
{{- if $needsPvc }}
volumeMounts:
- name: sqlite-storage
mountPath: {{ .Values.database.sqliteMountPath }}
{{- end }}
resources:
{{- toYaml .Values.resources | nindent 12 }}
{{- if $needsPvc }}
volumes:
- name: sqlite-storage
persistentVolumeClaim:
claimName: {{ include "trymeapp.fullname" . }}-sqlite-pvc
{{- end }}

View File

@ -0,0 +1,41 @@
{{- if .Values.ingress.enabled -}}
apiVersion: networking.k8s.io/v1
kind: Ingress
metadata:
name: {{ include "trymeapp.fullname" . }}
labels:
{{- include "trymeapp.labels" . | nindent 4 }}
{{- with .Values.ingress.annotations }}
annotations:
{{- toYaml . | nindent 4 }}
{{- end }}
spec:
{{- if .Values.ingress.className }}
ingressClassName: {{ .Values.ingress.className }}
{{- end }}
{{- if .Values.ingress.tls }}
tls:
{{- range .Values.ingress.tls }}
- hosts:
{{- range .hosts }}
- {{ . | quote }}
{{- end }}
secretName: {{ .secretName }}
{{- end }}
{{- end }}
rules:
{{- range .Values.ingress.hosts }}
- host: {{ .host | quote }}
http:
paths:
{{- range .paths }}
- path: {{ .path }}
pathType: {{ .pathType }}
backend:
service:
name: {{ include "trymeapp.fullname" $ }}
port:
number: {{ $.Values.service.port }}
{{- end }}
{{- end }}
{{- end }}

View File

@ -0,0 +1,17 @@
{{- if eq (include "trymeapp.needsPvc" .) "true" }}
apiVersion: v1
kind: PersistentVolumeClaim
metadata:
name: {{ include "trymeapp.fullname" . }}-sqlite-pvc
labels:
{{- include "trymeapp.labels" . | nindent 4 }}
spec:
accessModes:
- {{ .Values.persistence.accessMode }}
{{- if .Values.persistence.storageClass }}
storageClassName: {{ .Values.persistence.storageClass | quote }}
{{- end }}
resources:
requests:
storage: {{ .Values.persistence.size }}
{{- end }}

View File

@ -0,0 +1,11 @@
apiVersion: v1
kind: Secret
metadata:
name: {{ include "trymeapp.fullname" . }}-secret
labels:
{{- include "trymeapp.labels" . | nindent 4 }}
type: Opaque
stringData:
DATABASE_URL: {{ if eq .Values.database.mode "sqlite" }}{{ printf "sqlite:///%s/%s" (trimSuffix "/" .Values.database.sqliteMountPath) .Values.database.sqliteFilename | quote }}{{ else }}{{ .Values.database.url | quote }}{{ end }}
SESSION_SECRET: {{ .Values.secrets.sessionSecret | quote }}
AUTH0_CLIENT_SECRET: {{ .Values.secrets.auth0ClientSecret | quote }}

View File

@ -0,0 +1,15 @@
apiVersion: v1
kind: Service
metadata:
name: {{ include "trymeapp.fullname" . }}
labels:
{{- include "trymeapp.labels" . | nindent 4 }}
spec:
type: {{ .Values.service.type }}
ports:
- port: {{ .Values.service.port }}
targetPort: http
protocol: TCP
name: http
selector:
{{- include "trymeapp.selectorLabels" . | nindent 4 }}

58
helm-chart/values.yaml Normal file
View File

@ -0,0 +1,58 @@
replicaCount: 1
image:
repository: harbor.freshbrewed.science/library/trymeapp
pullPolicy: IfNotPresent
tag: "0.1"
service:
type: ClusterIP
port: 80
targetPort: 8000
ingress:
enabled: true
className: "nginx"
annotations: {}
hosts:
- host: tryme.local
paths:
- path: /
pathType: Prefix
tls: []
database:
# Set mode to "postgres" (external/cluster PostgreSQL) or "sqlite" (mounts PVC automatically)
mode: "postgres"
url: "postgresql+psycopg://tryme:tryme_secret@postgres:5432/trymedb"
sqliteMountPath: "/data"
sqliteFilename: "tryme.db"
persistence:
# Automatically enabled when database.mode == "sqlite", or set true explicitly
enabled: false
storageClass: ""
accessMode: ReadWriteOnce
size: 2Gi
config:
adminUser: ""
devAuthEnabled: "false"
seedStarterQuizzes: "false"
frontendBaseUrl: "https://tryme.local"
auth0Domain: ""
auth0ClientId: ""
auth0Audience: ""
auth0CallbackUrl: "https://tryme.local/api/v1/auth/callback"
secrets:
sessionSecret: "replace-with-strong-random-session-secret"
auth0ClientSecret: ""
resources:
requests:
cpu: 100m
memory: 256Mi
limits:
cpu: 500m
memory: 512Mi

2278
openapi.yaml Normal file

File diff suppressed because it is too large Load Diff

34
pyproject.toml Normal file
View File

@ -0,0 +1,34 @@
[build-system]
requires = ["setuptools>=68.0", "wheel"]
build-backend = "setuptools.build_meta"
[project]
name = "trymeapp"
version = "0.1.0"
description = "TryMe Gaming App — Student quiz creation, sharing, leaderboards, and voting platform"
readme = "README.md"
requires-python = ">=3.11"
dependencies = [
"fastapi>=0.115.0",
"uvicorn[standard]>=0.30.0",
"sqlalchemy>=2.0.30",
"alembic>=1.13.2",
"pydantic>=2.8.0",
"pydantic-settings>=2.4.0",
"python-jose[cryptography]>=3.3.0",
"httpx>=0.27.0",
"psycopg[binary]>=3.2.0",
"pyyaml>=6.0.1",
"itsdangerous>=2.2.0",
]
[project.optional-dependencies]
dev = [
"pytest>=8.2.0",
"pytest-asyncio>=0.23.0",
"pytest-cov>=5.0.0",
]
[tool.setuptools.packages.find]
where = ["."]
include = ["app*"]

6
pytest.ini Normal file
View File

@ -0,0 +1,6 @@
[pytest]
testpaths = tests
pythonpath = .
addopts = -ra -q
filterwarnings =
ignore::DeprecationWarning

1
tests/__init__.py Normal file
View File

@ -0,0 +1 @@
"""Test suite for TryMe Gaming App."""

77
tests/conftest.py Normal file
View File

@ -0,0 +1,77 @@
from __future__ import annotations
from collections.abc import Generator
from pathlib import Path
import pytest
from fastapi.testclient import TestClient
from sqlalchemy.orm import Session, sessionmaker
from app.config import Settings, get_settings
from app.database import Base, build_engine, get_db
from app.main import app
@pytest.fixture()
def test_settings(tmp_path: Path) -> Settings:
db_file = tmp_path / "test_tryme.db"
return Settings(
database_url=f"sqlite:///{db_file.as_posix()}",
dev_auth_enabled=True,
admin_user="",
session_secret="pytest-secret-key",
seed_starter_quizzes=False,
)
@pytest.fixture()
def client(test_settings: Settings) -> Generator[TestClient, None, None]:
test_engine = build_engine(test_settings.database_url)
Base.metadata.create_all(bind=test_engine)
TestingSessionLocal = sessionmaker(
bind=test_engine, autoflush=False, autocommit=False, expire_on_commit=False
)
def _override_get_db() -> Generator[Session, None, None]:
db = TestingSessionLocal()
try:
yield db
finally:
db.close()
app.dependency_overrides[get_db] = _override_get_db
app.dependency_overrides[get_settings] = lambda: test_settings
with TestClient(app) as test_client:
yield test_client
app.dependency_overrides.clear()
test_engine.dispose()
def create_onboarded_student(
client: TestClient,
nickname: str = "astro_fox",
animal: str = "fox",
color: str = "avatar-1",
) -> tuple[str, dict]:
resp = client.post("/api/v1/auth/dev-login", json={"nickname": nickname})
assert resp.status_code == 200
token = resp.json()["access_token"]
headers = {"Authorization": f"Bearer {token}"}
patch = client.patch(
"/api/v1/users/me",
headers=headers,
json={
"nickname": nickname,
"first_name": "Alex",
"last_name": "Rivera",
"age": 14,
"gender": "non_binary",
"avatar_animal": animal,
"avatar_color": color,
"public_website_consent": True,
},
)
assert patch.status_code == 200
return token, patch.json()

View File

@ -0,0 +1,96 @@
from __future__ import annotations
from fastapi.testclient import TestClient
def test_profile_onboarding_consent_gender_age_no_birthday(client: TestClient) -> None:
login = client.post("/api/v1/auth/dev-login", json={"nickname": "koala_kid"})
assert login.status_code == 200
token = login.json()["access_token"]
headers = {"Authorization": f"Bearer {token}"}
# Without public_website_consent, creating a quiz is blocked
blocked = client.post(
"/api/v1/quizzes",
headers=headers,
json={"title": "Early Quiz", "description": "Test", "is_public": True, "questions": []},
)
assert blocked.status_code == 403
# Update profile with stylized animal, color, inclusive gender, age (no birthday) & consent
updated = client.patch(
"/api/v1/users/me",
headers=headers,
json={
"nickname": "koala_kid",
"first_name": "Sam",
"last_name": "Chen",
"age": 16,
"gender": "other",
"gender_custom": "Demiboy",
"avatar_animal": "koala",
"avatar_color": "avatar-5",
"public_website_consent": True,
},
)
assert updated.status_code == 200
profile = updated.json()
assert profile["onboarded"] is True
assert profile["age"] == 16
assert "birthday" not in profile
assert "birth_date" not in profile
assert profile["gender"] == "other"
assert profile["gender_custom"] == "Demiboy"
assert profile["avatar_animal"] == "koala"
assert profile["avatar_color"] == "avatar-5"
assert profile["public_website_consent"] is True
def test_api_key_lifecycle_and_expiration(client: TestClient) -> None:
login = client.post("/api/v1/auth/dev-login", json={"nickname": "key_tester"})
token = login.json()["access_token"]
headers = {"Authorization": f"Bearer {token}"}
client.patch(
"/api/v1/users/me",
headers=headers,
json={"public_website_consent": True},
)
# Create API key
create_resp = client.post(
"/api/v1/users/me/api-keys",
headers=headers,
json={"name": "CLI Study Bot", "expires_in_days": 30},
)
assert create_resp.status_code == 201
key_data = create_resp.json()
raw_key = key_data["raw_key"]
key_id = key_data["id"]
assert raw_key.startswith("tm_live_")
assert key_data["is_active"] is True
# Authenticate using X-API-Key header
me_via_key = client.get("/api/v1/users/me", headers={"X-API-Key": raw_key})
assert me_via_key.status_code == 200
assert me_via_key.json()["nickname"] == "key_tester"
# List keys should not leak raw_key
list_resp = client.get("/api/v1/users/me/api-keys", headers=headers)
assert list_resp.status_code == 200
listed = list_resp.json()
assert len(listed) == 1
assert "raw_key" not in listed[0]
assert listed[0]["last_used_at"] is not None
# Expire old key
expire_resp = client.post(
f"/api/v1/users/me/api-keys/{key_id}/expire",
headers=headers,
)
assert expire_resp.status_code == 200
assert expire_resp.json()["is_active"] is False
# Subsequent calls with the expired API key must fail with 401
revoked_check = client.get("/api/v1/users/me", headers={"X-API-Key": raw_key})
assert revoked_check.status_code == 401

View File

@ -0,0 +1,122 @@
from __future__ import annotations
from pathlib import Path
from fastapi.testclient import TestClient
from sqlalchemy.orm import Session, sessionmaker
from app.config import Settings, get_settings
from app.database import Base, build_engine, get_db
from app.main import app
from tests.conftest import create_onboarded_student
def test_favourites_add_reorder_and_delete(client: TestClient) -> None:
token, _ = create_onboarded_student(client, "fav_tiger", "tiger", "avatar-3")
headers = {"Authorization": f"Bearer {token}"}
quiz_ids: list[int] = []
for title in ("Quiz Alpha", "Quiz Beta", "Quiz Gamma"):
r = client.post(
"/api/v1/quizzes",
headers=headers,
json={"title": title, "is_public": True, "questions": []},
)
assert r.status_code == 201
quiz_ids.append(r.json()["id"])
for qid in quiz_ids:
add_r = client.post("/api/v1/favourites", headers=headers, json={"quiz_id": qid})
assert add_r.status_code == 201
# Reorder to Gamma, Alpha, Beta
reordered = [quiz_ids[2], quiz_ids[0], quiz_ids[1]]
put_r = client.put(
"/api/v1/favourites/reorder",
headers=headers,
json={"quiz_ids": reordered},
)
assert put_r.status_code == 200
assert [item["quiz"]["id"] for item in put_r.json()] == reordered
# Delete Alpha (simulating recycle bin drop)
del_r = client.delete(f"/api/v1/favourites/{quiz_ids[0]}", headers=headers)
assert del_r.status_code == 200
assert [item["quiz"]["id"] for item in del_r.json()] == [quiz_ids[2], quiz_ids[1]]
def test_moderator_claim_ban_and_remove_quiz(client: TestClient) -> None:
mod_token, _ = create_onboarded_student(client, "chief_mod", "bear", "avatar-6")
mod_headers = {"Authorization": f"Bearer {mod_token}"}
bad_token, bad_user = create_onboarded_student(client, "spammer_99", "frog", "avatar-7")
bad_headers = {"Authorization": f"Bearer {bad_token}"}
quiz_r = client.post(
"/api/v1/quizzes",
headers=bad_headers,
json={"title": "Unwanted Quiz", "is_public": True, "questions": []},
)
assert quiz_r.status_code == 201
quiz_id = quiz_r.json()["id"]
# Claim initial moderator when ADMIN_USER env var is unset
claim_r = client.post("/api/v1/admin/claim", headers=mod_headers)
assert claim_r.status_code == 200
assert claim_r.json()["is_admin"] is True
# Second claim must fail with 409
second_claim = client.post("/api/v1/admin/claim", headers=bad_headers)
assert second_claim.status_code == 409
# Moderator removes quiz
rem_r = client.delete(f"/api/v1/admin/quizzes/{quiz_id}", headers=mod_headers)
assert rem_r.status_code == 204
assert client.get(f"/api/v1/quizzes/{quiz_id}").status_code == 404
# Moderator bans spammer
ban_r = client.post(
f"/api/v1/admin/users/{bad_user['id']}/ban",
headers=mod_headers,
json={"reason": "Posting spam quizzes"},
)
assert ban_r.status_code == 200
assert ban_r.json()["is_banned"] is True
# Banned user cannot create quizzes
blocked = client.post(
"/api/v1/quizzes",
headers=bad_headers,
json={"title": "Blocked Quiz", "is_public": True, "questions": []},
)
assert blocked.status_code == 403
def test_admin_user_container_env_var(tmp_path: Path) -> None:
env_settings = Settings(
database_url=f"sqlite:///{(tmp_path / 'env_admin.db').as_posix()}",
dev_auth_enabled=True,
admin_user="launch_moderator",
session_secret="env-admin-secret",
)
eng = build_engine(env_settings.database_url)
Base.metadata.create_all(bind=eng)
LocalSession = sessionmaker(bind=eng, autoflush=False, autocommit=False, expire_on_commit=False)
def _override_db():
db: Session = LocalSession()
try:
yield db
finally:
db.close()
app.dependency_overrides[get_db] = _override_db
app.dependency_overrides[get_settings] = lambda: env_settings
try:
with TestClient(app) as c:
login_r = c.post("/api/v1/auth/dev-login", json={"nickname": "launch_moderator"})
assert login_r.status_code == 200
assert login_r.json()["user"]["is_admin"] is True
finally:
app.dependency_overrides.clear()
eng.dispose()

44
tests/test_main.py Normal file
View File

@ -0,0 +1,44 @@
from __future__ import annotations
from pathlib import Path
import yaml
from fastapi.testclient import TestClient
from app.config import read_app_version
def test_healthcheck_and_version(client: TestClient) -> None:
resp = client.get("/health")
assert resp.status_code == 200
data = resp.json()
assert data["status"] == "ok"
assert data["service"] == "trymeapp"
assert data["version"] == read_app_version() == "0.1"
def test_openapi_yaml_and_docs_endpoints(client: TestClient) -> None:
yaml_resp = client.get("/openapi.yaml")
assert yaml_resp.status_code == 200
parsed = yaml.safe_load(yaml_resp.text)
assert parsed["info"]["title"] == "TryMe Gaming App API"
assert parsed["info"]["version"] == "0.1"
assert "/api/v1/quizzes" in parsed["paths"]
root_openapi = Path(__file__).resolve().parent.parent / "openapi.yaml"
assert root_openapi.exists()
root_parsed = yaml.safe_load(root_openapi.read_text(encoding="utf-8"))
assert root_parsed["paths"].keys() == parsed["paths"].keys()
docs_resp = client.get("/docs")
assert docs_resp.status_code == 200
assert "swagger" in docs_resp.text.lower()
def test_brand_logo_serving(client: TestClient) -> None:
resp = client.get("/brand/2dlogo_small_64x64.png")
assert resp.status_code == 200
assert resp.headers["content-type"] == "image/png"
missing = client.get("/brand/does_not_exist.png")
assert missing.status_code == 404

View File

@ -0,0 +1,113 @@
from __future__ import annotations
from fastapi.testclient import TestClient
from tests.conftest import create_onboarded_student
def test_zero_to_forty_question_limit_and_mixed_grading(client: TestClient) -> None:
token_a, _ = create_onboarded_student(client, "author_owl", "owl", "avatar-2")
headers_a = {"Authorization": f"Bearer {token_a}"}
# 0 questions is valid on free tier
zero_resp = client.post(
"/api/v1/quizzes",
headers=headers_a,
json={
"title": "Draft Empty Quiz",
"description": "Zero questions allowed",
"is_public": True,
"questions": [],
},
)
assert zero_resp.status_code == 201
assert zero_resp.json()["question_count"] == 0
assert zero_resp.json()["quiz_mode"] == "empty"
# 41 questions is rejected on free tier
too_many = [
{
"question_type": "write_in",
"prompt": f"Question {i}",
"correct_answer": str(i),
}
for i in range(41)
]
over_limit = client.post(
"/api/v1/quizzes",
headers=headers_a,
json={"title": "41 Questions", "is_public": True, "questions": too_many},
)
assert over_limit.status_code == 422
# Create mixed Multiple-Choice + Write-In quiz
quiz_resp = client.post(
"/api/v1/quizzes",
headers=headers_a,
json={
"title": "Science Sprint",
"description": "Test MC and write-in",
"is_public": True,
"questions": [
{
"question_type": "multiple_choice",
"prompt": "What is H2O?",
"options": [
{"id": "a", "text": "Water"},
{"id": "b", "text": "Helium"},
],
"correct_answer": "a",
},
{
"question_type": "write_in",
"prompt": "How many planets orbit the Sun?",
"correct_answer": "8",
"accepted_answers": ["eight"],
},
],
},
)
assert quiz_resp.status_code == 201
quiz = quiz_resp.json()
quiz_id = quiz["id"]
share_slug = quiz["share_slug"]
q1_id = str(quiz["questions"][0]["id"])
q2_id = str(quiz["questions"][1]["id"])
# Fetch via share_slug as unauthenticated visitor (clear session cookie first)
client.cookies.clear()
by_slug = client.get(f"/api/v1/quizzes/{share_slug}")
assert by_slug.status_code == 200
assert by_slug.json()["editable_questions"] is None
# Player B plays twice: first 100%, then 50% -> latest is 50%, all-time best stays 100%!
token_b, _ = create_onboarded_student(client, "speedy_otter", "otter", "avatar-4")
headers_b = {"Authorization": f"Bearer {token_b}"}
sub1 = client.post(
f"/api/v1/quizzes/{quiz_id}/submit",
headers=headers_b,
json={"answers": {q1_id: "a", q2_id: " EIGHT "}},
)
assert sub1.status_code == 200
assert sub1.json()["score_percent"] == 100.0
sub2 = client.post(
f"/api/v1/quizzes/{quiz_id}/submit",
headers=headers_b,
json={"answers": {q1_id: "a", q2_id: "nine"}},
)
assert sub2.status_code == 200
s2 = sub2.json()
assert s2["my_latest_score"]["score_percent"] == 50.0
assert s2["my_best_score"]["score_percent"] == 100.0
assert s2["global_best_score"]["score_percent"] == 100.0
assert s2["global_best_score"]["holder_nickname"] == "speedy_otter"
# Thumbs up and down voting
v_up = client.post(f"/api/v1/quizzes/{quiz_id}/vote", headers=headers_a, json={"value": 1})
assert v_up.status_code == 200
v_down = client.post(f"/api/v1/quizzes/{quiz_id}/vote", headers=headers_b, json={"value": -1})
assert v_down.status_code == 200
assert v_down.json()["upvotes"] == 1
assert v_down.json()["downvotes"] == 1

2
version.ini Normal file
View File

@ -0,0 +1,2 @@
[version]
version = 0.1