Updates
|
|
@ -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
|
||||||
|
|
@ -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 }}
|
||||||
|
|
@ -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"
|
||||||
|
|
@ -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/
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,5 @@
|
||||||
|
shots/
|
||||||
|
before/
|
||||||
|
before.json
|
||||||
|
before.txt
|
||||||
|
compare.html
|
||||||
|
|
@ -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"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
After Width: | Height: | Size: 348 KiB |
|
After Width: | Height: | Size: 38 KiB |
|
After Width: | Height: | Size: 4.3 KiB |
|
After Width: | Height: | Size: 2.7 MiB |
|
|
@ -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
|
||||||
|
|
@ -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.
|
||||||
71
README.md
|
|
@ -1 +1,72 @@
|
||||||
# TryMe Gaming App
|
# TryMe Gaming App
|
||||||
|
|
||||||
|

|
||||||
|
|
||||||
|
## 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.
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
```
|
||||||
|
|
@ -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.
|
||||||
|
|
@ -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
|
||||||
|
|
@ -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()
|
||||||
|
|
@ -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"}
|
||||||
|
|
@ -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")
|
||||||
|
|
@ -0,0 +1 @@
|
||||||
|
"""TryMe Gaming App backend package."""
|
||||||
|
|
@ -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
|
||||||
|
|
@ -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()
|
||||||
|
|
@ -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()
|
||||||
|
|
@ -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",
|
||||||
|
)
|
||||||
|
|
@ -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")
|
||||||
|
|
@ -0,0 +1 @@
|
||||||
|
"""FastAPI API v1 routers."""
|
||||||
|
|
@ -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()
|
||||||
|
|
@ -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
|
||||||
|
|
@ -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)
|
||||||
|
|
@ -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,
|
||||||
|
)
|
||||||
|
|
@ -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)
|
||||||
|
|
@ -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()
|
||||||
|
|
@ -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()
|
||||||
|
|
@ -0,0 +1 @@
|
||||||
|
"""Domain service helpers."""
|
||||||
|
|
@ -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 [])
|
||||||
|
]
|
||||||
|
|
@ -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:
|
||||||
|
|
@ -0,0 +1 @@
|
||||||
|
docker-compose.yaml
|
||||||
|
|
@ -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}"
|
||||||
|
|
@ -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>
|
||||||
|
|
@ -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"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
After Width: | Height: | Size: 348 KiB |
|
After Width: | Height: | Size: 38 KiB |
|
After Width: | Height: | Size: 4.3 KiB |
|
After Width: | Height: | Size: 2.7 MiB |
|
|
@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -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" }),
|
||||||
|
};
|
||||||
|
|
@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -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>
|
||||||
|
);
|
||||||
|
|
@ -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); }
|
||||||
|
|
@ -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;
|
||||||
|
}
|
||||||
|
|
@ -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;
|
||||||
|
}
|
||||||
|
|
@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -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"]
|
||||||
|
}
|
||||||
|
|
@ -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"}
|
||||||
|
|
@ -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,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
@ -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"
|
||||||
|
|
@ -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 -}}
|
||||||
|
|
@ -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 }}
|
||||||
|
|
@ -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 }}
|
||||||
|
|
@ -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 }}
|
||||||
|
|
@ -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 }}
|
||||||
|
|
@ -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 }}
|
||||||
|
|
@ -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
|
||||||
|
|
@ -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*"]
|
||||||
|
|
@ -0,0 +1,6 @@
|
||||||
|
[pytest]
|
||||||
|
testpaths = tests
|
||||||
|
pythonpath = .
|
||||||
|
addopts = -ra -q
|
||||||
|
filterwarnings =
|
||||||
|
ignore::DeprecationWarning
|
||||||
|
|
@ -0,0 +1 @@
|
||||||
|
"""Test suite for TryMe Gaming App."""
|
||||||
|
|
@ -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()
|
||||||
|
|
@ -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
|
||||||
|
|
@ -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()
|
||||||
|
|
@ -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
|
||||||
|
|
@ -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
|
||||||
|
|
@ -0,0 +1,2 @@
|
||||||
|
[version]
|
||||||
|
version = 0.1
|
||||||