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
|
||||
|
||||
# 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
|
||||
|
||||

|
||||
|
||||
## 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
|
||||