diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 0000000..47fe9b1 --- /dev/null +++ b/.dockerignore @@ -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 diff --git a/.gitea/workflows/cicd.yaml b/.gitea/workflows/cicd.yaml new file mode 100644 index 0000000..abbd1a2 --- /dev/null +++ b/.gitea/workflows/cicd.yaml @@ -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 }} diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..af580f0 --- /dev/null +++ b/.github/workflows/ci.yml @@ -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" diff --git a/.gitignore b/.gitignore index 600a4e9..91f568b 100644 --- a/.gitignore +++ b/.gitignore @@ -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/ diff --git a/.hallmark/.gitignore b/.hallmark/.gitignore new file mode 100644 index 0000000..35cbd5f --- /dev/null +++ b/.hallmark/.gitignore @@ -0,0 +1,5 @@ +shots/ +before/ +before.json +before.txt +compare.html diff --git a/.hallmark/log.json b/.hallmark/log.json new file mode 100644 index 0000000..86ae119 --- /dev/null +++ b/.hallmark/log.json @@ -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" + } +] diff --git a/2dlogo_large.png b/2dlogo_large.png new file mode 100644 index 0000000..070733c Binary files /dev/null and b/2dlogo_large.png differ diff --git a/2dlogo_medium_256x256.png b/2dlogo_medium_256x256.png new file mode 100644 index 0000000..811d45f Binary files /dev/null and b/2dlogo_medium_256x256.png differ diff --git a/2dlogo_small_64x64.png b/2dlogo_small_64x64.png new file mode 100644 index 0000000..4f5a7f1 Binary files /dev/null and b/2dlogo_small_64x64.png differ diff --git a/3dlogo_large.png b/3dlogo_large.png new file mode 100644 index 0000000..1fb855e Binary files /dev/null and b/3dlogo_large.png differ diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..d8c9aa7 --- /dev/null +++ b/Dockerfile @@ -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 \ No newline at end of file diff --git a/INSTALL.md b/INSTALL.md new file mode 100644 index 0000000..c40fde3 --- /dev/null +++ b/INSTALL.md @@ -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. diff --git a/README.md b/README.md index 71d7342..94e9db5 100644 --- a/README.md +++ b/README.md @@ -1 +1,72 @@ # TryMe Gaming App + +![TryMe Logo](./2dlogo_medium_256x256.png) + +## Purpose + +**TryMe** is a student quiz creation and sharing web application where students build multiple-choice and write-in quizzes (0 to 40 questions on the public free tier), share direct quiz URLs with classmates, vote with thumbs up/down, pin and reorder favourite quizzes in a drag-and-drop right-hand rail, and compete on per-quiz leaderboards. + +--- + +## Key Features + +- **Federated Auth0 Login & Local Dev Mode**: Sign in with federated IdPs via Auth0 OIDC/JWKS (`AUTH0_DOMAIN`, `AUTH0_CLIENT_ID`, `AUTH0_CLIENT_SECRET`), plus an optional `DEV_AUTH_ENABLED` local mode for instant development and automated testing. +- **Student-Friendly Player Profiles**: + - Pick from 8 stylized geometric animal emblems (`Fox`, `Owl`, `Bear`, `Otter`, `Koala`, `Tiger`, `Frog`, `Penguin`) across 8 festive colorways. + - Set a unique `nickname`, `first_name`, `last_name`, optional `age` (**never** asks for a birthday), inclusive `gender` options (including Non-binary, Two-Spirit, Agender, Genderqueer, Other / self-described, and Prefer not to say), and explicit confirmation of permission to use public websites. +- **Multiple-Choice & Write-In Quizzes (0–40 Questions)**: + - Combine multiple-choice and case-insensitive write-in questions (with optional accepted synonyms) up to 40 questions on the free tier. + - Share any public quiz via `/quiz/`, 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. diff --git a/SETUP.md b/SETUP.md new file mode 100644 index 0000000..aea6eb5 --- /dev/null +++ b/SETUP.md @@ -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 +``` diff --git a/SYSTEM.md b/SYSTEM.md new file mode 100644 index 0000000..1c94d17 --- /dev/null +++ b/SYSTEM.md @@ -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_` 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 `` modals, keyboard reorder/delete fallbacks alongside HTML5 drag-and-drop, and `prefers-reduced-motion: reduce` support. diff --git a/alembic.ini b/alembic.ini new file mode 100644 index 0000000..6db9aa6 --- /dev/null +++ b/alembic.ini @@ -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 diff --git a/alembic/env.py b/alembic/env.py new file mode 100644 index 0000000..3ed723b --- /dev/null +++ b/alembic/env.py @@ -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() diff --git a/alembic/script.py.mako b/alembic/script.py.mako new file mode 100644 index 0000000..17dcba0 --- /dev/null +++ b/alembic/script.py.mako @@ -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"} diff --git a/alembic/versions/0001_initial_schema.py b/alembic/versions/0001_initial_schema.py new file mode 100644 index 0000000..a7ad06e --- /dev/null +++ b/alembic/versions/0001_initial_schema.py @@ -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") diff --git a/app/__init__.py b/app/__init__.py new file mode 100644 index 0000000..499e05c --- /dev/null +++ b/app/__init__.py @@ -0,0 +1 @@ +"""TryMe Gaming App backend package.""" diff --git a/app/auth.py b/app/auth.py new file mode 100644 index 0000000..39c4d1e --- /dev/null +++ b/app/auth.py @@ -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 diff --git a/app/config.py b/app/config.py new file mode 100644 index 0000000..735dcbd --- /dev/null +++ b/app/config.py @@ -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() diff --git a/app/database.py b/app/database.py new file mode 100644 index 0000000..0f456d3 --- /dev/null +++ b/app/database.py @@ -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() diff --git a/app/main.py b/app/main.py new file mode 100644 index 0000000..b0079bb --- /dev/null +++ b/app/main.py @@ -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", + ) diff --git a/app/models.py b/app/models.py new file mode 100644 index 0000000..d436cfd --- /dev/null +++ b/app/models.py @@ -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") diff --git a/app/routers/__init__.py b/app/routers/__init__.py new file mode 100644 index 0000000..52b474a --- /dev/null +++ b/app/routers/__init__.py @@ -0,0 +1 @@ +"""FastAPI API v1 routers.""" diff --git a/app/routers/admin.py b/app/routers/admin.py new file mode 100644 index 0000000..7f52c93 --- /dev/null +++ b/app/routers/admin.py @@ -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() diff --git a/app/routers/auth_routes.py b/app/routers/auth_routes.py new file mode 100644 index 0000000..fbe7b75 --- /dev/null +++ b/app/routers/auth_routes.py @@ -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 diff --git a/app/routers/favourites.py b/app/routers/favourites.py new file mode 100644 index 0000000..fbd6910 --- /dev/null +++ b/app/routers/favourites.py @@ -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) diff --git a/app/routers/quizzes.py b/app/routers/quizzes.py new file mode 100644 index 0000000..f5f5c28 --- /dev/null +++ b/app/routers/quizzes.py @@ -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, + ) diff --git a/app/routers/users.py b/app/routers/users.py new file mode 100644 index 0000000..ee662b5 --- /dev/null +++ b/app/routers/users.py @@ -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) diff --git a/app/schemas.py b/app/schemas.py new file mode 100644 index 0000000..5d8e72f --- /dev/null +++ b/app/schemas.py @@ -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() diff --git a/app/seed.py b/app/seed.py new file mode 100644 index 0000000..0bd6da5 --- /dev/null +++ b/app/seed.py @@ -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() diff --git a/app/services/__init__.py b/app/services/__init__.py new file mode 100644 index 0000000..cf62b22 --- /dev/null +++ b/app/services/__init__.py @@ -0,0 +1 @@ +"""Domain service helpers.""" diff --git a/app/services/quiz_service.py b/app/services/quiz_service.py new file mode 100644 index 0000000..8b40300 --- /dev/null +++ b/app/services/quiz_service.py @@ -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 []) + ] diff --git a/docker-compose.yaml b/docker-compose.yaml new file mode 100644 index 0000000..13824f5 --- /dev/null +++ b/docker-compose.yaml @@ -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: diff --git a/docker-compose.yml b/docker-compose.yml new file mode 120000 index 0000000..e736cba --- /dev/null +++ b/docker-compose.yml @@ -0,0 +1 @@ +docker-compose.yaml \ No newline at end of file diff --git a/docker-entrypoint.sh b/docker-entrypoint.sh new file mode 100755 index 0000000..1669bde --- /dev/null +++ b/docker-entrypoint.sh @@ -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}" diff --git a/frontend/index.html b/frontend/index.html new file mode 100644 index 0000000..962409b --- /dev/null +++ b/frontend/index.html @@ -0,0 +1,25 @@ + + + + + + + + + + TryMe Quiz Studio + + +
+ + + diff --git a/frontend/package-lock.json b/frontend/package-lock.json new file mode 100644 index 0000000..aed3d1c --- /dev/null +++ b/frontend/package-lock.json @@ -0,0 +1,1848 @@ +{ + "name": "trymeapp-frontend", + "version": "0.1.0", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "trymeapp-frontend", + "version": "0.1.0", + "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" + } + }, + "node_modules/@babel/code-frame": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.29.7.tgz", + "integrity": "sha512-Aup7aUOfpbAUg2ROOJN6Iw5f9DMBlzu0mIkm/malLQFN/YQgO48wCj0Kxa3sEHJvPVFg7siR+qRInwXd2qhQKw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-validator-identifier": "^7.29.7", + "js-tokens": "^4.0.0", + "picocolors": "^1.1.1" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/compat-data": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/compat-data/-/compat-data-7.29.7.tgz", + "integrity": "sha512-locTkQyKvwIEgBzVrn8693ebc97F2U8ZHjbXwDXJ5Fn2TCpNwTlKcaKLkdHop5c/icOFE7qt7Q9JC5hnKNa6Gg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/core": { + "version": "7.29.13", + "resolved": "https://registry.npmjs.org/@babel/core/-/core-7.29.13.tgz", + "integrity": "sha512-j1nvOQCZYSgp7HLmisnCXFTpVvFODiijQ9QHkORfGKWH6YDtskTEeCJS7eV545GZ9UBzrq0e2xsOd5QQ5PjIqw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/code-frame": "^7.29.7", + "@babel/generator": "^7.29.13", + "@babel/helper-compilation-targets": "^7.29.13", + "@babel/helper-module-transforms": "^7.29.13", + "@babel/helpers": "^7.29.13", + "@babel/parser": "^7.29.13", + "@babel/template": "^7.29.7", + "@babel/traverse": "^7.29.13", + "@babel/types": "^7.29.11", + "@jridgewell/remapping": "^2.3.5", + "convert-source-map": "^2.0.0", + "debug": "^4.1.0", + "gensync": "^1.0.0-beta.2", + "json5": "^2.2.3", + "semver": "^6.3.1" + }, + "engines": { + "node": ">=6.9.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/babel" + } + }, + "node_modules/@babel/generator": { + "version": "7.29.14", + "resolved": "https://registry.npmjs.org/@babel/generator/-/generator-7.29.14.tgz", + "integrity": "sha512-qSY56jgW5JnSXzuekLVil6zaOQxOCtOHidr65KkaMBw772IuAY28Qx4ZykAcDn8+FRF15qk80XzKwgSc/RYW6g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/parser": "^7.29.14", + "@babel/types": "^7.29.14", + "@jridgewell/gen-mapping": "^0.3.12", + "@jridgewell/trace-mapping": "^0.3.28", + "jsesc": "^3.0.2" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-compilation-targets": { + "version": "7.29.13", + "resolved": "https://registry.npmjs.org/@babel/helper-compilation-targets/-/helper-compilation-targets-7.29.13.tgz", + "integrity": "sha512-8WxJ4/Vmes3ghacj8VOJy3NW8eyHiFY/4thU/LSBuBE470+igjR/j1ZTNn8fV5VdNrLx6iXUwig526UdOvfP0w==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/compat-data": "^7.29.7", + "@babel/helper-validator-option": "^7.29.7", + "browserslist": "^4.24.0", + "lru-cache": "^5.1.1", + "semver": "^6.3.1" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-globals": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-globals/-/helper-globals-7.29.7.tgz", + "integrity": "sha512-3nQVUAtvkKH9zahfWgw96Jc/uFOmjACE1kQz82E2lqWmHBgjzbNlsC22nuQTfahmWeQtTq5nQ/4Nnd2A1wj4zA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-module-imports": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-module-imports/-/helper-module-imports-7.29.7.tgz", + "integrity": "sha512-ejHwrQQYcm9xnTivShn2IDOlIzInN34AXskvq9QicvCtEzq1Vzclu/tKF8Jq1Cg8JG2GL6/EmjgsCT7lXepE3g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/traverse": "^7.29.7", + "@babel/types": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-module-transforms": { + "version": "7.29.14", + "resolved": "https://registry.npmjs.org/@babel/helper-module-transforms/-/helper-module-transforms-7.29.14.tgz", + "integrity": "sha512-2XDvQVd9JESaOgGc4jT2/jquTAO7ppA5noTZQqH+MmZ89yjCzScLwtCPbWtkPZylL+TLWR49IwWmk+QkRR4GsA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-module-imports": "^7.29.7", + "@babel/helper-validator-identifier": "^7.29.7", + "@babel/traverse": "^7.29.14" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0" + } + }, + "node_modules/@babel/helper-plugin-utils": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-plugin-utils/-/helper-plugin-utils-7.29.7.tgz", + "integrity": "sha512-G7sHYigPY17oO5SYWnfD/0MTBwVR781S/JI643e/JhUYgVgWE/61SoW3NH9KWUKyKq5LVh3npif99Wkt6j86Jw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-string-parser": { + "version": "7.29.14", + "resolved": "https://registry.npmjs.org/@babel/helper-string-parser/-/helper-string-parser-7.29.14.tgz", + "integrity": "sha512-CXvKMFvM80ihI7Vox9jb+kQjnyjESQNPM+vB7U0Zz6TWuZA+Z/FL0QPMH300h33qJtIx+REeBhXqWOPW48TcAQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-validator-identifier": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-validator-identifier/-/helper-validator-identifier-7.29.7.tgz", + "integrity": "sha512-qehxGkRj55h/ff8EMaJ+cYhyaKlHIxqYDn682wQD7RNp9UujOQsHog2uS0r2vzr4pW+sXf90NeeayjcNaX3fFg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-validator-option": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-validator-option/-/helper-validator-option-7.29.7.tgz", + "integrity": "sha512-N9ZErrD+yW5geCDtBqnOoxmR8+tNKiGuxKlDpuJxfsqpa2dFcexaziGAE/qoHLiDDreVNMupxGmSoNlyvsA3gw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helpers": { + "version": "7.29.14", + "resolved": "https://registry.npmjs.org/@babel/helpers/-/helpers-7.29.14.tgz", + "integrity": "sha512-6E3Mi1r0B0umq6vmZa4/78q+64fqO2YY0THUdjDPKUCKhNb0yQy5NQIaPHCSD3ryTgiTss//P4JsWS80AsZ6LQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/template": "^7.29.7", + "@babel/types": "^7.29.14" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/parser": { + "version": "7.29.14", + "resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.29.14.tgz", + "integrity": "sha512-VL220vXuyO2z3rNIQwY7ftj9UwpqOt+33sH+sBWkOekbsSR2g7EEMnOx24ZlCH0xOA3DM/33SifVFGbnJD8r/w==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/types": "^7.29.14" + }, + "bin": { + "parser": "bin/babel-parser.js" + }, + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/@babel/plugin-transform-react-jsx-self": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-react-jsx-self/-/plugin-transform-react-jsx-self-7.29.7.tgz", + "integrity": "sha512-TL0hMc9xzy86VD31nUiwzd5otRAcyEPcsegCxolO0PvcXuH1v0kECe/UIznYFihpkvU5wg/jk4v0TTEFfm53fw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-transform-react-jsx-source": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-react-jsx-source/-/plugin-transform-react-jsx-source-7.29.7.tgz", + "integrity": "sha512-06IyK09H3wi4cGbhDBwp5gUGo0IKtnYa8tyTiephirPCK6fbobVGiXMMI5zLQ4aKEYP3wZ3ArU44o+8KMrSG/Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/template": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/template/-/template-7.29.7.tgz", + "integrity": "sha512-puq+Gf35oI24FeN11LkoUQFqv9uwNeWpxXZi/Ji3rRIoKAzKnxRaZ+Gkj0vKS9ZCiTESfng1N9LyOyXvo+m+Gg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/code-frame": "^7.29.7", + "@babel/parser": "^7.29.7", + "@babel/types": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/traverse": { + "version": "7.29.14", + "resolved": "https://registry.npmjs.org/@babel/traverse/-/traverse-7.29.14.tgz", + "integrity": "sha512-RAdQJrBgmIH1FWvN0X+1hoDxzMzlP4cv/1Zi4dn4tG1H0cTb+rw7CzUxQJhp1f4ANd8j5vH3fy8BAWFGkBDEsA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/code-frame": "^7.29.7", + "@babel/generator": "^7.29.14", + "@babel/helper-globals": "^7.29.7", + "@babel/parser": "^7.29.14", + "@babel/template": "^7.29.7", + "@babel/types": "^7.29.14", + "debug": "^4.3.1" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/types": { + "version": "7.29.14", + "resolved": "https://registry.npmjs.org/@babel/types/-/types-7.29.14.tgz", + "integrity": "sha512-UjI/dKmwJwT9pOszJitq2G6EObZPnsZUfdCr92z2TlF3EZK9J3EdfOKUrCzHl6wFthbzwhzl1Soc/UIwxRiq6w==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-string-parser": "^7.29.14", + "@babel/helper-validator-identifier": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@esbuild/aix-ppc64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.25.12.tgz", + "integrity": "sha512-Hhmwd6CInZ3dwpuGTF8fJG6yoWmsToE+vYgD4nytZVxcu1ulHpUQRAB1UJ8+N1Am3Mz4+xOByoQoSZf4D+CpkA==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "aix" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-arm": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.25.12.tgz", + "integrity": "sha512-VJ+sKvNA/GE7Ccacc9Cha7bpS8nyzVv0jdVgwNDaR4gDMC/2TTRc33Ip8qrNYUcpkOHUT5OZ0bUcNNVZQ9RLlg==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.25.12.tgz", + "integrity": "sha512-6AAmLG7zwD1Z159jCKPvAxZd4y/VTO0VkprYy+3N2FtJ8+BQWFXU+OxARIwA46c5tdD9SsKGZ/1ocqBS/gAKHg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.25.12.tgz", + "integrity": "sha512-5jbb+2hhDHx5phYR2By8GTWEzn6I9UqR11Kwf22iKbNpYrsmRB18aX/9ivc5cabcUiAT/wM+YIZ6SG9QO6a8kg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/darwin-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.25.12.tgz", + "integrity": "sha512-N3zl+lxHCifgIlcMUP5016ESkeQjLj/959RxxNYIthIg+CQHInujFuXeWbWMgnTo4cp5XVHqFPmpyu9J65C1Yg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/darwin-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.25.12.tgz", + "integrity": "sha512-HQ9ka4Kx21qHXwtlTUVbKJOAnmG1ipXhdWTmNXiPzPfWKpXqASVcWdnf2bnL73wgjNrFXAa3yYvBSd9pzfEIpA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/freebsd-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.25.12.tgz", + "integrity": "sha512-gA0Bx759+7Jve03K1S0vkOu5Lg/85dou3EseOGUes8flVOGxbhDDh/iZaoek11Y8mtyKPGF3vP8XhnkDEAmzeg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/freebsd-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.25.12.tgz", + "integrity": "sha512-TGbO26Yw2xsHzxtbVFGEXBFH0FRAP7gtcPE7P5yP7wGy7cXK2oO7RyOhL5NLiqTlBh47XhmIUXuGciXEqYFfBQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-arm": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.25.12.tgz", + "integrity": "sha512-lPDGyC1JPDou8kGcywY0YILzWlhhnRjdof3UlcoqYmS9El818LLfJJc3PXXgZHrHCAKs/Z2SeZtDJr5MrkxtOw==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.25.12.tgz", + "integrity": "sha512-8bwX7a8FghIgrupcxb4aUmYDLp8pX06rGh5HqDT7bB+8Rdells6mHvrFHHW2JAOPZUbnjUpKTLg6ECyzvas2AQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-ia32": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.25.12.tgz", + "integrity": "sha512-0y9KrdVnbMM2/vG8KfU0byhUN+EFCny9+8g202gYqSSVMonbsCfLjUO+rCci7pM0WBEtz+oK/PIwHkzxkyharA==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-loong64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.25.12.tgz", + "integrity": "sha512-h///Lr5a9rib/v1GGqXVGzjL4TMvVTv+s1DPoxQdz7l/AYv6LDSxdIwzxkrPW438oUXiDtwM10o9PmwS/6Z0Ng==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-mips64el": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.25.12.tgz", + "integrity": "sha512-iyRrM1Pzy9GFMDLsXn1iHUm18nhKnNMWscjmp4+hpafcZjrr2WbT//d20xaGljXDBYHqRcl8HnxbX6uaA/eGVw==", + "cpu": [ + "mips64el" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-ppc64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.25.12.tgz", + "integrity": "sha512-9meM/lRXxMi5PSUqEXRCtVjEZBGwB7P/D4yT8UG/mwIdze2aV4Vo6U5gD3+RsoHXKkHCfSxZKzmDssVlRj1QQA==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-riscv64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.25.12.tgz", + "integrity": "sha512-Zr7KR4hgKUpWAwb1f3o5ygT04MzqVrGEGXGLnj15YQDJErYu/BGg+wmFlIDOdJp0PmB0lLvxFIOXZgFRrdjR0w==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-s390x": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.25.12.tgz", + "integrity": "sha512-MsKncOcgTNvdtiISc/jZs/Zf8d0cl/t3gYWX8J9ubBnVOwlk65UIEEvgBORTiljloIWnBzLs4qhzPkJcitIzIg==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.25.12.tgz", + "integrity": "sha512-uqZMTLr/zR/ed4jIGnwSLkaHmPjOjJvnm6TVVitAa08SLS9Z0VM8wIRx7gWbJB5/J54YuIMInDquWyYvQLZkgw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/netbsd-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.25.12.tgz", + "integrity": "sha512-xXwcTq4GhRM7J9A8Gv5boanHhRa/Q9KLVmcyXHCTaM4wKfIpWkdXiMog/KsnxzJ0A1+nD+zoecuzqPmCRyBGjg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/netbsd-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.25.12.tgz", + "integrity": "sha512-Ld5pTlzPy3YwGec4OuHh1aCVCRvOXdH8DgRjfDy/oumVovmuSzWfnSJg+VtakB9Cm0gxNO9BzWkj6mtO1FMXkQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openbsd-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.25.12.tgz", + "integrity": "sha512-fF96T6KsBo/pkQI950FARU9apGNTSlZGsv1jZBAlcLL1MLjLNIWPBkj5NlSz8aAzYKg+eNqknrUJ24QBybeR5A==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openbsd-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.25.12.tgz", + "integrity": "sha512-MZyXUkZHjQxUvzK7rN8DJ3SRmrVrke8ZyRusHlP+kuwqTcfWLyqMOE3sScPPyeIXN/mDJIfGXvcMqCgYKekoQw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openharmony-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/openharmony-arm64/-/openharmony-arm64-0.25.12.tgz", + "integrity": "sha512-rm0YWsqUSRrjncSXGA7Zv78Nbnw4XL6/dzr20cyrQf7ZmRcsovpcRBdhD43Nuk3y7XIoW2OxMVvwuRvk9XdASg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/sunos-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.25.12.tgz", + "integrity": "sha512-3wGSCDyuTHQUzt0nV7bocDy72r2lI33QL3gkDNGkod22EsYl04sMf0qLb8luNKTOmgF/eDEDP5BFNwoBKH441w==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "sunos" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.25.12.tgz", + "integrity": "sha512-rMmLrur64A7+DKlnSuwqUdRKyd3UE7oPJZmnljqEptesKM8wx9J8gx5u0+9Pq0fQQW8vqeKebwNXdfOyP+8Bsg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-ia32": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.25.12.tgz", + "integrity": "sha512-HkqnmmBoCbCwxUKKNPBixiWDGCpQGVsrQfJoVGYLPT41XWF8lHuE5N6WhVia2n4o5QK5M4tYr21827fNhi4byQ==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.25.12.tgz", + "integrity": "sha512-alJC0uCZpTFrSL0CCDjcgleBXPnCrEAhTBILpeAp7M/OFgoqtAetfBzX0xM00MUsVVPpVjlPuMbREqnZCXaTnA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@jridgewell/gen-mapping": { + "version": "0.3.13", + "resolved": "https://registry.npmjs.org/@jridgewell/gen-mapping/-/gen-mapping-0.3.13.tgz", + "integrity": "sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/sourcemap-codec": "^1.5.0", + "@jridgewell/trace-mapping": "^0.3.24" + } + }, + "node_modules/@jridgewell/remapping": { + "version": "2.3.5", + "resolved": "https://registry.npmjs.org/@jridgewell/remapping/-/remapping-2.3.5.tgz", + "integrity": "sha512-LI9u/+laYG4Ds1TDKSJW2YPrIlcVYOwi2fUC6xB43lueCjgxV4lffOCZCtYFiH6TNOX+tQKXx97T4IKHbhyHEQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/gen-mapping": "^0.3.5", + "@jridgewell/trace-mapping": "^0.3.24" + } + }, + "node_modules/@jridgewell/resolve-uri": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/@jridgewell/resolve-uri/-/resolve-uri-3.1.2.tgz", + "integrity": "sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/@jridgewell/sourcemap-codec": { + "version": "1.6.0", + "resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.6.0.tgz", + "integrity": "sha512-T7jf+5zgsZHwNJ4lvQ7/aezbyk0nNX+zJVWpmHA7VYsEx7a7qr5Rg5IbtJFqkgze5Y2sruq1RUY8Q837Od7iFw==", + "dev": true, + "license": "MIT" + }, + "node_modules/@jridgewell/trace-mapping": { + "version": "0.3.31", + "resolved": "https://registry.npmjs.org/@jridgewell/trace-mapping/-/trace-mapping-0.3.31.tgz", + "integrity": "sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/resolve-uri": "^3.1.0", + "@jridgewell/sourcemap-codec": "^1.4.14" + } + }, + "node_modules/@rolldown/pluginutils": { + "version": "1.0.0-beta.27", + "resolved": "https://registry.npmjs.org/@rolldown/pluginutils/-/pluginutils-1.0.0-beta.27.tgz", + "integrity": "sha512-+d0F4MKMCbeVUJwG96uQ4SgAznZNSq93I3V+9NHA4OpvqG8mRCpGdKmK8l/dl02h2CCDHwW2FqilnTyDcAnqjA==", + "dev": true, + "license": "MIT" + }, + "node_modules/@rollup/rollup-android-arm-eabi": { + "version": "4.68.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.68.2.tgz", + "integrity": "sha512-ItKrNMkEL3hy85kLBXwB0+M6CWWVI8vr7RV+Eb7oDlQIMXLaiEhGfdaj5jAUadYn2v3nwzAhIRl/nLyjAqV9vQ==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ] + }, + "node_modules/@rollup/rollup-android-arm64": { + "version": "4.68.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm64/-/rollup-android-arm64-4.68.2.tgz", + "integrity": "sha512-dCkT8BMPLo6X6GCFVtuzrnr981PpJSeK4ZO/d/mZdJ/ao61Qa1Usd/OHIQ69MQgw4qQclR/X6uRRUONiWwD8cw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ] + }, + "node_modules/@rollup/rollup-android-x64": { + "version": "4.68.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-android-x64/-/rollup-android-x64-4.68.2.tgz", + "integrity": "sha512-p2jkKiscqQOGmyL6qu+KvJctvBc3SQTd+Xi2lQYn5QvFLEWz+QI8Z9iI+xcIjez6vKRsSnWP56Y3Ep7g3Stiug==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ] + }, + "node_modules/@rollup/rollup-darwin-arm64": { + "version": "4.68.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-arm64/-/rollup-darwin-arm64-4.68.2.tgz", + "integrity": "sha512-aVIfOteo5zoX8aL742H/S3QsyN6aokNYjdzrLnQBKxgRVoh4cibTErpi61RzSmrv2hBzDB1i751hwwdc8QK0vQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@rollup/rollup-darwin-x64": { + "version": "4.68.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-x64/-/rollup-darwin-x64-4.68.2.tgz", + "integrity": "sha512-+Wuf4TFTHiE/WnjKgH1SkNf/9D5ddoed1UR0p3jxjNAGemdeeQRL+r1bJ1LlZyha0VKDBYLB2U/a9a9KlSw4Kw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@rollup/rollup-freebsd-arm64": { + "version": "4.68.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-arm64/-/rollup-freebsd-arm64-4.68.2.tgz", + "integrity": "sha512-feKK2dc13j/6D9mAp7XKhBM/VUqn0N/xRgP6rT7/mdRVVJUaeZ4lsJ9hN90aTi0k3OD+mkjlcN01vQ96Ac9VzQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ] + }, + "node_modules/@rollup/rollup-freebsd-x64": { + "version": "4.68.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-x64/-/rollup-freebsd-x64-4.68.2.tgz", + "integrity": "sha512-s1oGXPGX1PNqdi9TLWD+RwtPEYf/BOTbCcq4anJBqNbzyjucti86ldYH37eKvsyTP7Bl5/Hf8XmbIxCz/iafTA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ] + }, + "node_modules/@rollup/rollup-linux-arm-gnueabihf": { + "version": "4.68.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-gnueabihf/-/rollup-linux-arm-gnueabihf-4.68.2.tgz", + "integrity": "sha512-ksR3ZmzHrm6HOUP8UlDBy5hNTcG9cEIa3EyGnxn5BjorLRjBh4ucxhwjwwVEm0dqcIN4Egc/XjDUiPQwN1ic9A==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm-musleabihf": { + "version": "4.68.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-musleabihf/-/rollup-linux-arm-musleabihf-4.68.2.tgz", + "integrity": "sha512-STZVVBM35wULVsQah0xKfCBQMPpHliOfjnRaUNT35jrbpfapUrD8ix6Yhl7tLHW0xQSWotXSSghjUi/DclwRSw==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm64-gnu": { + "version": "4.68.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-gnu/-/rollup-linux-arm64-gnu-4.68.2.tgz", + "integrity": "sha512-Fm/GPC2R0zlJYQIp9aY9L1lO2z+P+UrcvXG6fhubfo/BbMOLZlLXshnbECOTqr3pxfZFb6nNn0QgDETDtrX7TQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm64-musl": { + "version": "4.68.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-musl/-/rollup-linux-arm64-musl-4.68.2.tgz", + "integrity": "sha512-VoqlckWcwNF3WqsUkLZg91zYFgBvrC37Lc5e8AU+BKdCUFDMPoN0Rsin/LvF+rBVF527gWo5KT0oHkaAZaL1GA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-loong64-gnu": { + "version": "4.68.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-gnu/-/rollup-linux-loong64-gnu-4.68.2.tgz", + "integrity": "sha512-Zr8X/SUf7Jjwxt99BqPDF/Wq+sz4oUBIP6HBaFQWwb8wO1nV6LBIpD6jB9cMybLho93Z+B8rrN0uXEL6oNOUJQ==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-loong64-musl": { + "version": "4.68.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-musl/-/rollup-linux-loong64-musl-4.68.2.tgz", + "integrity": "sha512-2EhrYEJNXIyhSZESwm/kCke1tiH7QAPOU3v3MsLM9J+Nfn2QCDe/pGROUAvYFK0BL2MEbzNgfrqrbnE5CFXkLw==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-ppc64-gnu": { + "version": "4.68.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-gnu/-/rollup-linux-ppc64-gnu-4.68.2.tgz", + "integrity": "sha512-IC0dYuEQknw0m2FYBNBKlbrgBJBH5OMRiaRfCc4CFSpYnmDnqTpNSv+TgX+loezGTdRjBNZuYZi3t+Oc5NxNYg==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-ppc64-musl": { + "version": "4.68.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-musl/-/rollup-linux-ppc64-musl-4.68.2.tgz", + "integrity": "sha512-Zlzm6fGuDvtAqMEl071/g17vxq6u2Y2v/rON/gDY4NvzWLJ41BGAlwhF3QuAzwq05zIPo4LYzdqwhx3oisYCUw==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-riscv64-gnu": { + "version": "4.68.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-gnu/-/rollup-linux-riscv64-gnu-4.68.2.tgz", + "integrity": "sha512-KhEpYRtHZ3Kj4shavjXeH+K1bQlA6UbP6MJ45rMPF/11JlZb1SRNLeuWCdigUEoMMaFO4+LwwcS+ew1SoLJu/g==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-riscv64-musl": { + "version": "4.68.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-musl/-/rollup-linux-riscv64-musl-4.68.2.tgz", + "integrity": "sha512-LPn9qvPsM/Ofd6Ekq3im6nB8Tk1ruJhTd3x/2bJkTR8m/XTsfxA+EU0RJ23WJqgJg123kitHAqbEK7B4cL1uPg==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-s390x-gnu": { + "version": "4.68.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-s390x-gnu/-/rollup-linux-s390x-gnu-4.68.2.tgz", + "integrity": "sha512-Q1+E+pzSP+FiXuIZtxKR+pK7l2KQ9gsM+VaFPv1LR277kp7l61crG46WGLKLLay3Js5cvyU9Keqdzxa1EcFO+g==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-x64-gnu": { + "version": "4.68.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-gnu/-/rollup-linux-x64-gnu-4.68.2.tgz", + "integrity": "sha512-3Btp4Lca/WzsmbaKu9Lh9d/XwyRDIegd/5khRBusHGw/Ll6SoF5Shn63OrjPdkLcpNi2PGDApXzNmxriRxGYjA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-x64-musl": { + "version": "4.68.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-musl/-/rollup-linux-x64-musl-4.68.2.tgz", + "integrity": "sha512-5SKVaUiVs18+DGBnpg0mORGxgq/0udczngGxV/vf2nvK1djqpOKIxY/+8rBWxAbWGRE4RnnwxWabDwJN3pCwIQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-openbsd-x64": { + "version": "4.68.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-openbsd-x64/-/rollup-openbsd-x64-4.68.2.tgz", + "integrity": "sha512-KBpm48/lESsHgWd+dRlcy18APBYhXU4Jb6VlJoqbpWNwfpztFXwP22qVv9aHd3k1quSrmLeJwYLc7rpQ+BFZrw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ] + }, + "node_modules/@rollup/rollup-openharmony-arm64": { + "version": "4.68.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-openharmony-arm64/-/rollup-openharmony-arm64-4.68.2.tgz", + "integrity": "sha512-o0D0bfRW8QGA1Ph6OzqVHMq5LRu1xkr1iM7qQIXUSFuR60ddXeDhN9UR4bUBPLYsrT46sZwwgQa51TWtBV3r5g==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ] + }, + "node_modules/@rollup/rollup-win32-arm64-msvc": { + "version": "4.68.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-arm64-msvc/-/rollup-win32-arm64-msvc-4.68.2.tgz", + "integrity": "sha512-r17kcu5kmd6WQwokYmfBnfwM/icureCWCPjpJVALcrSv5Lfy6Ggk9azxXor6hILhCHbc/4IfEiv+L5RXaOHinA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-ia32-msvc": { + "version": "4.68.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-ia32-msvc/-/rollup-win32-ia32-msvc-4.68.2.tgz", + "integrity": "sha512-yUtOqq7ZrQoZ3WiHSR6QGTdOl4YgMHqXIYSBYU6siIcC0YF0pEwx7aGsPTm2mRwrmW6SMJkxzEdFm6wqXpmbNw==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-x64-gnu": { + "version": "4.68.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-gnu/-/rollup-win32-x64-gnu-4.68.2.tgz", + "integrity": "sha512-5IYoWxQGo5WECbJbImIf2w33uCHJNLv4o/FQtSyeYDed7AC6zx84NsEcGYccoI2geDEYkrxUXcsYK/6EhwVSIg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-x64-msvc": { + "version": "4.68.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-msvc/-/rollup-win32-x64-msvc-4.68.2.tgz", + "integrity": "sha512-ee+c14ggUiM7lg4PtCjSBYU9O9dHp4FFespRnd5yj7SilUBJCL3x3aHlB+sIqbGjcPo3pEyODASnfKJuZ26YBw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@types/babel__core": { + "version": "7.20.5", + "resolved": "https://registry.npmjs.org/@types/babel__core/-/babel__core-7.20.5.tgz", + "integrity": "sha512-qoQprZvz5wQFJwMDqeseRXWv3rqMvhgpbXFfVyWhbx9X47POIA6i/+dXefEmZKoAgOaTdaIgNSMqMIU61yRyzA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/parser": "^7.20.7", + "@babel/types": "^7.20.7", + "@types/babel__generator": "*", + "@types/babel__template": "*", + "@types/babel__traverse": "*" + } + }, + "node_modules/@types/babel__generator": { + "version": "7.27.0", + "resolved": "https://registry.npmjs.org/@types/babel__generator/-/babel__generator-7.27.0.tgz", + "integrity": "sha512-ufFd2Xi92OAVPYsy+P4n7/U7e68fex0+Ee8gSG9KX7eo084CWiQ4sdxktvdl0bOPupXtVJPY19zk6EwWqUQ8lg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/types": "^7.0.0" + } + }, + "node_modules/@types/babel__template": { + "version": "7.4.4", + "resolved": "https://registry.npmjs.org/@types/babel__template/-/babel__template-7.4.4.tgz", + "integrity": "sha512-h/NUaSyG5EyxBIp8YRxo4RMe2/qQgvyowRwVMzhYhBCONbW8PUsg4lkFMrhgZhUe5z3L3MiLDuvyJ/CaPa2A8A==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/parser": "^7.1.0", + "@babel/types": "^7.0.0" + } + }, + "node_modules/@types/babel__traverse": { + "version": "7.28.0", + "resolved": "https://registry.npmjs.org/@types/babel__traverse/-/babel__traverse-7.28.0.tgz", + "integrity": "sha512-8PvcXf70gTDZBgt9ptxJ8elBeBjcLOAcOtoO/mPJjtji1+CdGbHgm77om1GrsPxsiE+uXIpNSK64UYaIwQXd4Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/types": "^7.28.2" + } + }, + "node_modules/@types/estree": { + "version": "1.0.10", + "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.10.tgz", + "integrity": "sha512-lTyOMnOcmRBzdCv6/4zUZCrEhV0xrqQiUZBZtVcbmLHBXKLnN8q89v/wKRF/Q0HbwVsqbIG3o5HDoFl9sduM+g==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/react": { + "version": "19.3.0", + "resolved": "https://registry.npmjs.org/@types/react/-/react-19.3.0.tgz", + "integrity": "sha512-N0rFCuH9YoxG9/m61l9MfpJKfmLOVU0em7ipIz6TRgSSkvReLB9vL85GB+yr8Bs5leqpvg96JSwF4ZS1s4viQg==", + "dev": true, + "license": "MIT", + "dependencies": { + "csstype": "^3.2.2" + } + }, + "node_modules/@types/react-dom": { + "version": "19.3.0", + "resolved": "https://registry.npmjs.org/@types/react-dom/-/react-dom-19.3.0.tgz", + "integrity": "sha512-ZI7bU42mZXXKHn/qNLEw2IrbiINU7X5+vfgdixBHkCNpYWXjKgfQ/P+uyGb5CjOLB9UcnTeg3rylQtV2hym44Q==", + "dev": true, + "license": "MIT", + "peerDependencies": { + "@types/react": "^19.3.0" + } + }, + "node_modules/@vitejs/plugin-react": { + "version": "4.7.0", + "resolved": "https://registry.npmjs.org/@vitejs/plugin-react/-/plugin-react-4.7.0.tgz", + "integrity": "sha512-gUu9hwfWvvEDBBmgtAowQCojwZmJ5mcLn3aufeCsitijs3+f2NsrPtlAWIR6OPiqljl96GVCUbLe0HyqIpVaoA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/core": "^7.28.0", + "@babel/plugin-transform-react-jsx-self": "^7.27.1", + "@babel/plugin-transform-react-jsx-source": "^7.27.1", + "@rolldown/pluginutils": "1.0.0-beta.27", + "@types/babel__core": "^7.20.5", + "react-refresh": "^0.17.0" + }, + "engines": { + "node": "^14.18.0 || >=16.0.0" + }, + "peerDependencies": { + "vite": "^4.2.0 || ^5.0.0 || ^6.0.0 || ^7.0.0" + } + }, + "node_modules/baseline-browser-mapping": { + "version": "2.11.35", + "resolved": "https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.11.35.tgz", + "integrity": "sha512-GNk8KX52CHqeNKGk8+7tZnQab/2k1t/BgrUHPKNdijzpEhF1saPQZHVKb6m5loSAxB39gyrTYw34Qf9CQ3u1qQ==", + "dev": true, + "license": "Apache-2.0", + "bin": { + "baseline-browser-mapping": "dist/cli.cjs" + }, + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/browserslist": { + "version": "4.29.5", + "resolved": "https://registry.npmjs.org/browserslist/-/browserslist-4.29.5.tgz", + "integrity": "sha512-khTmKu5NGYTotqcYo8Bew2k4RAE9PuXRSFRXG2lQBrCEQHyeQ/vX6gKMxfokwywBsLiXZMnHsmWMG1pnO8J7uQ==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/browserslist" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/browserslist" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "baseline-browser-mapping": "^2.11.33", + "caniuse-lite": "^1.0.30001819", + "electron-to-chromium": "^1.5.464", + "node-releases": "^2.0.60", + "update-browserslist-db": "^1.3.4" + }, + "bin": { + "browserslist": "cli.js" + }, + "engines": { + "node": "^6 || ^7 || ^8 || ^9 || ^10 || ^11 || ^12 || >=13.7" + } + }, + "node_modules/caniuse-lite": { + "version": "1.0.30001826", + "resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001826.tgz", + "integrity": "sha512-wTB+9tIbLro/BCXwLuNV1meHE4L9dS6dArMDwgYfNBPMYScqClHyGunhkCSDtHhe/69Xl0sHlKgBVZ+fNsYtWA==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/browserslist" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/caniuse-lite" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "CC-BY-4.0" + }, + "node_modules/convert-source-map": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/convert-source-map/-/convert-source-map-2.0.0.tgz", + "integrity": "sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg==", + "dev": true, + "license": "MIT" + }, + "node_modules/csstype": { + "version": "3.2.3", + "resolved": "https://registry.npmjs.org/csstype/-/csstype-3.2.3.tgz", + "integrity": "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/debug": { + "version": "4.4.3", + "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", + "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==", + "dev": true, + "license": "MIT", + "dependencies": { + "ms": "^2.1.3" + }, + "engines": { + "node": ">=6.0" + }, + "peerDependenciesMeta": { + "supports-color": { + "optional": true + } + } + }, + "node_modules/electron-to-chromium": { + "version": "1.5.481", + "resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.481.tgz", + "integrity": "sha512-dNUsbAP6E2S1dHIYEuN3cQ0dViThjMtpVRheGdvv5HgHFtzLi4Cu4Mbzms85YggP3Eh2VVZTl5+XxEN8ofCbmQ==", + "dev": true, + "license": "ISC" + }, + "node_modules/esbuild": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.25.12.tgz", + "integrity": "sha512-bbPBYYrtZbkt6Os6FiTLCTFxvq4tt3JKall1vRwshA3fdVztsLAatFaZobhkBC8/BrPetoa0oksYoKXoG4ryJg==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "bin": { + "esbuild": "bin/esbuild" + }, + "engines": { + "node": ">=18" + }, + "optionalDependencies": { + "@esbuild/aix-ppc64": "0.25.12", + "@esbuild/android-arm": "0.25.12", + "@esbuild/android-arm64": "0.25.12", + "@esbuild/android-x64": "0.25.12", + "@esbuild/darwin-arm64": "0.25.12", + "@esbuild/darwin-x64": "0.25.12", + "@esbuild/freebsd-arm64": "0.25.12", + "@esbuild/freebsd-x64": "0.25.12", + "@esbuild/linux-arm": "0.25.12", + "@esbuild/linux-arm64": "0.25.12", + "@esbuild/linux-ia32": "0.25.12", + "@esbuild/linux-loong64": "0.25.12", + "@esbuild/linux-mips64el": "0.25.12", + "@esbuild/linux-ppc64": "0.25.12", + "@esbuild/linux-riscv64": "0.25.12", + "@esbuild/linux-s390x": "0.25.12", + "@esbuild/linux-x64": "0.25.12", + "@esbuild/netbsd-arm64": "0.25.12", + "@esbuild/netbsd-x64": "0.25.12", + "@esbuild/openbsd-arm64": "0.25.12", + "@esbuild/openbsd-x64": "0.25.12", + "@esbuild/openharmony-arm64": "0.25.12", + "@esbuild/sunos-x64": "0.25.12", + "@esbuild/win32-arm64": "0.25.12", + "@esbuild/win32-ia32": "0.25.12", + "@esbuild/win32-x64": "0.25.12" + } + }, + "node_modules/escalade": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/escalade/-/escalade-3.2.0.tgz", + "integrity": "sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/fdir": { + "version": "6.5.0", + "resolved": "https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz", + "integrity": "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12.0.0" + }, + "peerDependencies": { + "picomatch": "^3 || ^4" + }, + "peerDependenciesMeta": { + "picomatch": { + "optional": true + } + } + }, + "node_modules/fsevents": { + "version": "2.3.3", + "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", + "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^8.16.0 || ^10.6.0 || >=11.0.0" + } + }, + "node_modules/gensync": { + "version": "1.0.0-beta.2", + "resolved": "https://registry.npmjs.org/gensync/-/gensync-1.0.0-beta.2.tgz", + "integrity": "sha512-3hN7NaskYvMDLQY55gnW3NQ+mesEAepTqlg+VEbj7zzqEMBVNhzcGYYeqFo/TlYz6eQiFcp1HcsCZO+nGgS8zg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/js-tokens": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-4.0.0.tgz", + "integrity": "sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/jsesc": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/jsesc/-/jsesc-3.1.0.tgz", + "integrity": "sha512-/sM3dO2FOzXjKQhJuo0Q173wf2KOo8t4I8vHy6lF9poUp7bKT0/NHE8fPX23PwfhnykfqnC2xRxOnVw5XuGIaA==", + "dev": true, + "license": "MIT", + "bin": { + "jsesc": "bin/jsesc" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/json5": { + "version": "2.2.3", + "resolved": "https://registry.npmjs.org/json5/-/json5-2.2.3.tgz", + "integrity": "sha512-XmOWe7eyHYH14cLdVPoyg+GOH3rYX++KpzrylJwSW98t3Nk+U8XOl8FWKOgwtzdb8lXGf6zYwDUzeHMWfxasyg==", + "dev": true, + "license": "MIT", + "bin": { + "json5": "lib/cli.js" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/lru-cache": { + "version": "5.1.1", + "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-5.1.1.tgz", + "integrity": "sha512-KpNARQA3Iwv+jTA0utUVVbrh+Jlrr1Fv0e56GGzAFOXN7dk/FviaDW8LHmK52DlcH4WP2n6gI8vN1aesBFgo9w==", + "dev": true, + "license": "ISC", + "dependencies": { + "yallist": "^3.0.2" + } + }, + "node_modules/lucide-react": { + "version": "0.468.0", + "resolved": "https://registry.npmjs.org/lucide-react/-/lucide-react-0.468.0.tgz", + "integrity": "sha512-6koYRhnM2N0GGZIdXzSeiNwguv1gt/FAjZOiPl76roBi3xKEXa4WmfpxgQwTTL4KipXjefrnf3oV4IsYhi4JFA==", + "license": "ISC", + "peerDependencies": { + "react": "^16.5.1 || ^17.0.0 || ^18.0.0 || ^19.0.0-rc" + } + }, + "node_modules/ms": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", + "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", + "dev": true, + "license": "MIT" + }, + "node_modules/nanoid": { + "version": "3.3.20", + "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.20.tgz", + "integrity": "sha512-uKdg2G3GNCKQn9byYOpxbGqrT2fGO5KRt5J/8b3pok8rT6qxGWF6hxMyJiEYtAf+FVyYuD9hRaDqX5uPFYJ4ZQ==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "bin": { + "nanoid": "bin/nanoid.cjs" + }, + "engines": { + "node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1" + } + }, + "node_modules/node-releases": { + "version": "2.0.63", + "resolved": "https://registry.npmjs.org/node-releases/-/node-releases-2.0.63.tgz", + "integrity": "sha512-6li2QanLsAXpIH2R6j77MzRyvOyrvdTqlKtO4CgBdHGNvN99ZZfyxszHc5o5+IZBWD5u6Hzwg83JoGXmqZNG9Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + } + }, + "node_modules/picocolors": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz", + "integrity": "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==", + "dev": true, + "license": "ISC" + }, + "node_modules/picomatch": { + "version": "4.0.7", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.7.tgz", + "integrity": "sha512-qcJu88Q2IWqJsDD529JKMdwGm/dvInW4HvQnRwiH9JtihJvzGOscDtHE3x1pBKeUOTysQ8kVmLnJ2kJu7yhcGA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" + } + }, + "node_modules/postcss": { + "version": "8.5.32", + "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.32.tgz", + "integrity": "sha512-LJlKCCAUFKOuZxipUdYw1NzIITEsPcgyOG9LkvRJvuKj/bRzbuRY0ARXvL0pWGpsZvn8jku+LMep2eU0seHp8g==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/postcss/" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/postcss" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "nanoid": "^3.3.20", + "picocolors": "^1.1.1", + "source-map-js": "^1.2.2" + }, + "engines": { + "node": "^10 || ^12 || >=14" + } + }, + "node_modules/react": { + "version": "19.3.1", + "resolved": "https://registry.npmjs.org/react/-/react-19.3.1.tgz", + "integrity": "sha512-QNhWdt7nrgVWWQG58QuGsAR3jv3Hbe4mb4vz7w2csnHavciZJgda1CPNaldF6eVxv90ud3jUXpKlHL69gO38yw==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/react-dom": { + "version": "19.3.1", + "resolved": "https://registry.npmjs.org/react-dom/-/react-dom-19.3.1.tgz", + "integrity": "sha512-Y0bARz53lhEC1NMCASs0/j+faB/zqt3R8hpvg1e8+61ZVJ4UL0UdC4cBbXfZMcIuhrfgeILTLe+ZeBCavQPtKQ==", + "license": "MIT", + "dependencies": { + "scheduler": "^0.28.0" + }, + "peerDependencies": { + "react": "^19.3.1" + } + }, + "node_modules/react-refresh": { + "version": "0.17.0", + "resolved": "https://registry.npmjs.org/react-refresh/-/react-refresh-0.17.0.tgz", + "integrity": "sha512-z6F7K9bV85EfseRCp2bzrpyQ0Gkw1uLoCel9XBVWPg/TjRj94SkJzUTGfOa4bs7iJvBWtQG0Wq7wnI0syw3EBQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/rollup": { + "version": "4.68.2", + "resolved": "https://registry.npmjs.org/rollup/-/rollup-4.68.2.tgz", + "integrity": "sha512-xzt45rKOxzEuqjTdkGTIEaePcq9xRELlFI1uoazlKap8T6bENJkONSCfhK51P6XFTixa4rosPcDHihSdduXndQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/estree": "^1.0.10" + }, + "bin": { + "rollup": "dist/bin/rollup" + }, + "engines": { + "node": ">=18.0.0", + "npm": ">=8.0.0" + }, + "optionalDependencies": { + "@rollup/rollup-android-arm-eabi": "4.68.2", + "@rollup/rollup-android-arm64": "4.68.2", + "@rollup/rollup-android-x64": "4.68.2", + "@rollup/rollup-darwin-arm64": "4.68.2", + "@rollup/rollup-darwin-x64": "4.68.2", + "@rollup/rollup-freebsd-arm64": "4.68.2", + "@rollup/rollup-freebsd-x64": "4.68.2", + "@rollup/rollup-linux-arm-gnueabihf": "4.68.2", + "@rollup/rollup-linux-arm-musleabihf": "4.68.2", + "@rollup/rollup-linux-arm64-gnu": "4.68.2", + "@rollup/rollup-linux-arm64-musl": "4.68.2", + "@rollup/rollup-linux-loong64-gnu": "4.68.2", + "@rollup/rollup-linux-loong64-musl": "4.68.2", + "@rollup/rollup-linux-ppc64-gnu": "4.68.2", + "@rollup/rollup-linux-ppc64-musl": "4.68.2", + "@rollup/rollup-linux-riscv64-gnu": "4.68.2", + "@rollup/rollup-linux-riscv64-musl": "4.68.2", + "@rollup/rollup-linux-s390x-gnu": "4.68.2", + "@rollup/rollup-linux-x64-gnu": "4.68.2", + "@rollup/rollup-linux-x64-musl": "4.68.2", + "@rollup/rollup-openbsd-x64": "4.68.2", + "@rollup/rollup-openharmony-arm64": "4.68.2", + "@rollup/rollup-win32-arm64-msvc": "4.68.2", + "@rollup/rollup-win32-ia32-msvc": "4.68.2", + "@rollup/rollup-win32-x64-gnu": "4.68.2", + "@rollup/rollup-win32-x64-msvc": "4.68.2", + "fsevents": "~2.3.2" + } + }, + "node_modules/scheduler": { + "version": "0.28.0", + "resolved": "https://registry.npmjs.org/scheduler/-/scheduler-0.28.0.tgz", + "integrity": "sha512-juorfCmIkIw8tT+p5BXSm6PJjQF/ycEYmKyzURCIt/RaZIhL+PulbQ9Yu2z1HdOJDdqDTlxA1+xKBmHXJsczAw==", + "license": "MIT" + }, + "node_modules/semver": { + "version": "6.3.2", + "resolved": "https://registry.npmjs.org/semver/-/semver-6.3.2.tgz", + "integrity": "sha512-GYrCADwpu6yV5OAvchkQ27Z5wwMxWAEMg5NNWjyHh/5DboFFqbdbvYNIvZouQpxXyeMPoTOh0vdPYKlf0ZswdQ==", + "dev": true, + "license": "ISC", + "bin": { + "semver": "bin/semver.js" + } + }, + "node_modules/source-map-js": { + "version": "1.2.2", + "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.2.tgz", + "integrity": "sha512-KGj/8Y43x35aZVDtt+J4mK1hoLGHULMYfSkODJNQjNDC3oW1PqPoxMwo0pLUsWM/UEGzON/NxeHywEfNXNP3Vw==", + "dev": true, + "license": "BSD-3-Clause", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/tinyglobby": { + "version": "0.2.17", + "resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.17.tgz", + "integrity": "sha512-wXR/dYpcqKmfWpEdZjiKJOwCNFndD0DMnrW/cYjVGttEkBfVgcLFHoNrlj47mjOVic9yyNu65alsgF4NQyTa2g==", + "dev": true, + "license": "MIT", + "dependencies": { + "fdir": "^6.5.0", + "picomatch": "^4.0.4" + }, + "engines": { + "node": ">=12.0.0" + }, + "funding": { + "url": "https://github.com/sponsors/SuperchupuDev" + } + }, + "node_modules/typescript": { + "version": "5.9.3", + "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz", + "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==", + "dev": true, + "license": "Apache-2.0", + "bin": { + "tsc": "bin/tsc", + "tsserver": "bin/tsserver" + }, + "engines": { + "node": ">=14.17" + } + }, + "node_modules/update-browserslist-db": { + "version": "1.3.4", + "resolved": "https://registry.npmjs.org/update-browserslist-db/-/update-browserslist-db-1.3.4.tgz", + "integrity": "sha512-t8wonIOdQyOjtPd2VktlBu6QjTVTks6v8bSi7kd0r2Vll8TK1JYDZtRRdbmCHOAMphkZgrzK+L+vXK4pyZ8t4A==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/browserslist" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/browserslist" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "escalade": "^3.2.0", + "picocolors": "^1.1.1" + }, + "bin": { + "update-browserslist-db": "cli.js" + }, + "peerDependencies": { + "browserslist": ">= 4.21.0" + } + }, + "node_modules/vite": { + "version": "6.4.6", + "resolved": "https://registry.npmjs.org/vite/-/vite-6.4.6.tgz", + "integrity": "sha512-RtsraAwEG68idOA6iUOoIpOlcSKROnf815aIq9qHTSrDNpLoBrBa12suyfcEd8qn0xwOVQI1Yhm1GbmTjF600A==", + "dev": true, + "license": "MIT", + "dependencies": { + "esbuild": "^0.25.0", + "fdir": "^6.4.4", + "picomatch": "^4.0.2", + "postcss": "^8.5.3", + "rollup": "^4.34.9", + "tinyglobby": "^0.2.13" + }, + "bin": { + "vite": "bin/vite.js" + }, + "engines": { + "node": "^18.0.0 || ^20.0.0 || >=22.0.0" + }, + "funding": { + "url": "https://github.com/vitejs/vite?sponsor=1" + }, + "optionalDependencies": { + "fsevents": "~2.3.3" + }, + "peerDependencies": { + "@types/node": "^18.0.0 || ^20.0.0 || >=22.0.0", + "jiti": ">=1.21.0", + "less": "*", + "lightningcss": "^1.21.0", + "sass": "*", + "sass-embedded": "*", + "stylus": "*", + "sugarss": "*", + "terser": "^5.16.0", + "tsx": "^4.8.1", + "yaml": "^2.4.2" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + }, + "jiti": { + "optional": true + }, + "less": { + "optional": true + }, + "lightningcss": { + "optional": true + }, + "sass": { + "optional": true + }, + "sass-embedded": { + "optional": true + }, + "stylus": { + "optional": true + }, + "sugarss": { + "optional": true + }, + "terser": { + "optional": true + }, + "tsx": { + "optional": true + }, + "yaml": { + "optional": true + } + } + }, + "node_modules/yallist": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/yallist/-/yallist-3.1.1.tgz", + "integrity": "sha512-a4UGQaWPH59mOXUYnAG2ewncQS4i4F43Tv3JoAM+s2VDAmS9NsK8GpDMLrCHPksFT7h3K6TOoUNn2pb7RoXx4g==", + "dev": true, + "license": "ISC" + } + } +} diff --git a/frontend/package.json b/frontend/package.json new file mode 100644 index 0000000..1a2a5d3 --- /dev/null +++ b/frontend/package.json @@ -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" + } +} diff --git a/frontend/public/brand/2dlogo_large.png b/frontend/public/brand/2dlogo_large.png new file mode 100644 index 0000000..070733c Binary files /dev/null and b/frontend/public/brand/2dlogo_large.png differ diff --git a/frontend/public/brand/2dlogo_medium_256x256.png b/frontend/public/brand/2dlogo_medium_256x256.png new file mode 100644 index 0000000..811d45f Binary files /dev/null and b/frontend/public/brand/2dlogo_medium_256x256.png differ diff --git a/frontend/public/brand/2dlogo_small_64x64.png b/frontend/public/brand/2dlogo_small_64x64.png new file mode 100644 index 0000000..4f5a7f1 Binary files /dev/null and b/frontend/public/brand/2dlogo_small_64x64.png differ diff --git a/frontend/public/brand/3dlogo_large.png b/frontend/public/brand/3dlogo_large.png new file mode 100644 index 0000000..1fb855e Binary files /dev/null and b/frontend/public/brand/3dlogo_large.png differ diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx new file mode 100644 index 0000000..705dd63 --- /dev/null +++ b/frontend/src/App.tsx @@ -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(() => readRouteFromWindow()); + const [authConfig, setAuthConfig] = useState(null); + const [user, setUser] = useState(null); + const [authOpen, setAuthOpen] = useState(false); + + const [quizzes, setQuizzes] = useState([]); + const [quizzesLoading, setQuizzesLoading] = useState(true); + const [quizzesError, setQuizzesError] = useState(""); + + const [favourites, setFavourites] = useState([]); + 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, 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 ( +
+ + 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 }); + }} + /> + +
+
+ {route.tab === "explore" && ( + 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" && ( + 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 && ( + + navigate({ tab: "explore", quizSlug: null, editSlug: null }) + } + onRequireAuth={() => setAuthOpen(true)} + onQuizStatsChanged={syncAllQuizData} + /> + )} + + {route.tab === "scores" && ( + + navigate({ tab: "play", quizSlug: slug, editSlug: null }) + } + onExplore={() => + navigate({ tab: "explore", quizSlug: null, editSlug: null }) + } + /> + )} + + {route.tab === "account" && + (user ? ( + { + setUser(u); + syncAllQuizData(); + }} + /> + ) : ( +
+

Sign in to view your account

+

+ Sign in to update your player profile or manage API keys. +

+ +
+ ))} + + {route.tab === "moderator" && ( + { + setUser(promoted); + void refreshAuthAndUser(); + }} + onQuizzesChanged={syncAllQuizData} + /> + )} +
+ + void loadFavourites()} + onOpenQuiz={(slug) => + navigate({ tab: "play", quizSlug: slug, editSlug: null }) + } + onAddQuizAt={handleAddFavouriteAt} + onReorder={handleReorderFavourites} + onRemove={handleRemoveFavourite} + onRequireAuth={() => setAuthOpen(true)} + /> +
+ + setAuthOpen(false)} + onSignedIn={(signedUser) => { + setUser(signedUser); + void refreshAuthAndUser(); + }} + /> + + { + setUser(updated); + syncAllQuizData(); + }} + /> +
+ ); +} diff --git a/frontend/src/api/client.ts b/frontend/src/api/client.ts new file mode 100644 index 0000000..92f86cd --- /dev/null +++ b/frontend/src/api/client.ts @@ -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(path: string, init: RequestInit = {}): Promise { + 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("/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("/api/v1/auth/logout", { method: "POST" }); + }, + + getMe: () => request("/api/v1/users/me"), + + updateMe: (data: Record) => + request("/api/v1/users/me", { + method: "PATCH", + body: JSON.stringify(data), + }), + + getMyScores: () => request("/api/v1/users/me/scores"), + + listApiKeys: () => request("/api/v1/users/me/api-keys"), + + createApiKey: (name: string, expiresInDays: number | null) => + request("/api/v1/users/me/api-keys", { + method: "POST", + body: JSON.stringify({ name, expires_in_days: expiresInDays }), + }), + + expireApiKey: (keyId: number) => + request(`/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(`/api/v1/quizzes${qs ? `?${qs}` : ""}`); + }, + + getQuiz: (slugOrId: string | number) => + request(`/api/v1/quizzes/${encodeURIComponent(String(slugOrId))}`), + + createQuiz: (data: { + title: string; + description: string; + is_public: boolean; + questions: QuestionInput[]; + }) => + request("/api/v1/quizzes", { + method: "POST", + body: JSON.stringify(data), + }), + + updateQuiz: ( + quizId: number, + data: { + title: string; + description: string; + is_public: boolean; + questions: QuestionInput[]; + } + ) => + request(`/api/v1/quizzes/${quizId}`, { + method: "PUT", + body: JSON.stringify(data), + }), + + deleteQuiz: (quizId: number) => + request(`/api/v1/quizzes/${quizId}`, { method: "DELETE" }), + + submitQuiz: (quizId: number, answers: Record) => + request(`/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("/api/v1/favourites"), + + addFavourite: (quizId: number, position?: number) => + request("/api/v1/favourites", { + method: "POST", + body: JSON.stringify({ quiz_id: quizId, position }), + }), + + reorderFavourites: (quizIds: number[]) => + request("/api/v1/favourites/reorder", { + method: "PUT", + body: JSON.stringify({ quiz_ids: quizIds }), + }), + + removeFavourite: (quizId: number) => + request(`/api/v1/favourites/${quizId}`, { + method: "DELETE", + }), + + claimInitialAdmin: () => + request("/api/v1/admin/claim", { method: "POST" }), + + adminListUsers: () => request("/api/v1/admin/users"), + + adminBanUser: (userId: number, reason: string) => + request(`/api/v1/admin/users/${userId}/ban`, { + method: "POST", + body: JSON.stringify({ reason }), + }), + + adminUnbanUser: (userId: number) => + request(`/api/v1/admin/users/${userId}/unban`, { method: "POST" }), + + adminListQuizzes: () => request("/api/v1/admin/quizzes"), + + adminRemoveQuiz: (quizId: number) => + request(`/api/v1/admin/quizzes/${quizId}`, { method: "DELETE" }), +}; diff --git a/frontend/src/components/AnimalAvatar.tsx b/frontend/src/components/AnimalAvatar.tsx new file mode 100644 index 0000000..e05a138 --- /dev/null +++ b/frontend/src/components/AnimalAvatar.tsx @@ -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 ( + + + {animal === "fox" && ( + + + + + + + + )} + {animal === "owl" && ( + + + + + + + + )} + {animal === "bear" && ( + + + + + + + + + )} + {animal === "otter" && ( + + + + + + + + + )} + {animal === "koala" && ( + + + + + + + )} + {animal === "tiger" && ( + + + + + + + + + )} + {animal === "frog" && ( + + + + + + + + )} + {animal === "penguin" && ( + + + + + + )} + + ); +} diff --git a/frontend/src/components/AuthDialog.tsx b/frontend/src/components/AuthDialog.tsx new file mode 100644 index 0000000..7fe58cf --- /dev/null +++ b/frontend/src/components/AuthDialog.tsx @@ -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(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 ( + { + if (e.target === dialogRef.current) onClose(); + }} + > +
+
+ TryMe marquee emblem +
+

Sign in to TryMe

+

+ Save scores, build quizzes, vote, and pin favourites. +

+
+
+ +
+ +
+ {authConfig?.auth0_enabled && ( +
+

Federated Single Sign-On

+

+ Continue with your school or personal identity provider via Auth0. +

+ +
+ )} + + {authConfig?.dev_auth_enabled && ( +
+

Quick local player sign-in

+

+ Local development mode is enabled on this server for testing without external IdP keys. +

+ +
+ + setNickname(e.target.value)} + /> + + {error || "2 to 48 letters, numbers, underscores, or hyphens."} + +
+ +
+ + setEmail(e.target.value)} + /> + + Used only for matching a configured moderator email. + +
+ + + +
+ + +
+
+ )} + + {!authConfig?.auth0_enabled && !authConfig?.dev_auth_enabled && ( +
+

Sign-in provider not configured

+

+ Set AUTH0_DOMAIN and AUTH0_CLIENT_ID on the container or enable DEV_AUTH_ENABLED for local testing. +

+
+ )} +
+
+ ); +} diff --git a/frontend/src/components/FavouritesPane.tsx b/frontend/src/components/FavouritesPane.tsx new file mode 100644 index 0000000..50e8ee2 --- /dev/null +++ b/frontend/src/components/FavouritesPane.tsx @@ -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; + onReorder: (quizIds: number[]) => Promise; + onRemove: (quizId: number) => Promise; + onRequireAuth: () => void; +} + +export function FavouritesPane({ + user, + items, + loading, + error, + externalCardDragging, + onRetry, + onOpenQuiz, + onAddQuizAt, + onReorder, + onRemove, + onRequireAuth, +}: FavouritesPaneProps) { + const [draggingFavQuizId, setDraggingFavQuizId] = useState( + null + ); + const [overQuizId, setOverQuizId] = useState(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) => { + 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) => { + 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, + 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 ( + + ); +} diff --git a/frontend/src/components/OnboardingDialog.tsx b/frontend/src/components/OnboardingDialog.tsx new file mode 100644 index 0000000..9e7978f --- /dev/null +++ b/frontend/src/components/OnboardingDialog.tsx @@ -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(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( + user?.age != null ? String(user.age) : "" + ); + const [gender, setGender] = useState( + user?.gender || "prefer_not_to_say" + ); + const [genderCustom, setGenderCustom] = useState(user?.gender_custom || ""); + const [animal, setAnimal] = useState( + user?.avatar_animal || "fox" + ); + const [color, setColor] = useState( + user?.avatar_color || "avatar-1" + ); + const [consent, setConsent] = useState( + 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 ( + e.preventDefault()} + > +
+
+ +
+

Set up your player card

+

+ Choose an animal icon, nickname, and confirm public web access. +

+
+
+
+ +
+
+ Pick your animal emblem +
+ {ANIMAL_META.map((item) => ( + + ))} +
+
+ +
+ Pick an emblem color +
+ {AVATAR_COLOR_LIST.map((sw) => ( +
+
+ +
+ + setNickname(e.target.value)} + /> + + Shown next to quizzes you publish and top leaderboard scores. + +
+ +
+
+ + setFirstName(e.target.value)} + /> + Kept private to your account. +
+ +
+ + setLastName(e.target.value)} + /> + Kept private to your account. +
+
+ +
+
+ + setAge(e.target.value)} + /> + + Age in years only. We never ask for your birthday. + +
+ +
+ + + Choose whichever fits best. +
+
+ + {gender === "other" && ( +
+ + setGenderCustom(e.target.value)} + /> + Up to 80 characters. +
+ )} + + + + {error && ( +
+

{error}

+
+ )} + +
+ +
+
+
+ ); +} diff --git a/frontend/src/components/TopNav.tsx b/frontend/src/components/TopNav.tsx new file mode 100644 index 0000000..007da9f --- /dev/null +++ b/frontend/src/components/TopNav.tsx @@ -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 ( +
+
+ { + e.preventDefault(); + onSelectTab("explore"); + }} + > + + TryMe + + + + +
+ {user ? ( + <> + + + + ) : ( + + )} +
+
+
+ ); +} diff --git a/frontend/src/main.tsx b/frontend/src/main.tsx new file mode 100644 index 0000000..060b6f3 --- /dev/null +++ b/frontend/src/main.tsx @@ -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( + + + +); diff --git a/frontend/src/styles/app.css b/frontend/src/styles/app.css new file mode 100644 index 0000000..e907923 --- /dev/null +++ b/frontend/src/styles/app.css @@ -0,0 +1,1041 @@ +/* Hallmark · shape: app screen · genre: playful · theme: Hum (deal 3/3, seed tryme-student-quiz-creation-951c·fb6a7014·2026-11-11·r0) + * nav: top bar with right favourites rail · signature: draggable card-stock quiz workspace with pop-up recycle drop zone · states: loading, empty, error, full + * axes: light/rounded-sans/chromatic-ember · posture: full-palette · contrast: pass · v2.0.0 */ +/* Hallmark · pre-emit critique: P4 H5 E5 S5 R4 V5 · weakest: R, kept tactile card-stock edges strictly on primary controls so quiz lists and score tables stay calm */ +@import url("./tokens.css"); +@import url("./base.css"); + +/* App Shell */ +.app-shell { + min-height: 100dvh; + display: flex; + flex-direction: column; + background: var(--color-paper); + color: var(--color-ink); +} + +.top-bar { + --color-focus: var(--color-on-dark); + position: sticky; + top: 0; + z-index: var(--z-sticky-nav); + background: var(--color-ink-2); + color: var(--color-on-dark); + border-bottom: var(--rule-hair) solid color-mix(in oklab, var(--color-on-dark) 14%, transparent); +} + +.top-bar-inner { + width: 100%; + max-width: var(--container); + margin-inline: auto; + padding-block: var(--space-xs); + padding-inline: var(--gutter); + min-height: var(--banner-height); + display: flex; + align-items: center; + justify-content: space-between; + gap: var(--space-md); +} + +.brand-link { + display: inline-flex; + align-items: center; + gap: var(--space-sm); + text-decoration: none; + color: var(--color-on-dark); + font-weight: 600; + font-size: var(--text-lg); + white-space: nowrap; + flex-shrink: 0; +} + +.brand-logo-img { + width: 42px; + height: 42px; + border-radius: var(--radius-input); + background: var(--color-card); + padding: var(--space-3xs); + flex-shrink: 0; +} + +.top-nav-tabs { + display: flex; + align-items: center; + gap: var(--space-2xs); + overflow-x: auto; + scrollbar-width: none; +} + +.nav-tab { + border: 0; + background: transparent; + color: color-mix(in oklab, var(--color-on-dark) 84%, transparent); + padding-block: var(--space-xs); + padding-inline: var(--space-md); + min-height: 40px; + border-radius: var(--radius-pill); + font-size: var(--text-sm); + font-weight: 500; + cursor: pointer; + white-space: nowrap; + display: inline-flex; + align-items: center; + gap: var(--space-xs); + transition: background-color var(--dur-micro) var(--ease-out), color var(--dur-micro) var(--ease-out); +} + +@media (hover: hover) { + .nav-tab:hover { + background: color-mix(in oklab, var(--color-on-dark) 12%, transparent); + color: var(--color-on-dark); + } +} + +.nav-tab[aria-current="page"] { + border: var(--rule-hair) solid var(--color-accent); + color: var(--color-on-dark); + font-weight: 600; +} + +.top-bar-user { + display: flex; + align-items: center; + gap: var(--space-sm); + flex-shrink: 0; +} + +.user-pill { + display: inline-flex; + align-items: center; + gap: var(--space-xs); + padding-block: var(--space-2xs); + padding-inline: var(--space-sm); + min-height: 40px; + border-radius: var(--radius-pill); + border: var(--rule-hair) solid color-mix(in oklab, var(--color-on-dark) 22%, transparent); + background: color-mix(in oklab, var(--color-on-dark) 8%, transparent); + color: var(--color-on-dark); + cursor: pointer; + font-size: var(--text-sm); + font-weight: 500; + white-space: nowrap; + transition: background-color var(--dur-micro) var(--ease-out); +} + +@media (hover: hover) { + .user-pill:hover { + background: color-mix(in oklab, var(--color-on-dark) 16%, transparent); + } +} + +/* Main Workspace Grid with Right-Hand Favourites Pane */ +.workspace-grid { + width: 100%; + max-width: var(--container); + margin-inline: auto; + padding-block: var(--space-lg); + padding-inline: var(--gutter); + display: grid; + grid-template-columns: minmax(0, 1fr) 336px; + gap: var(--space-lg); + align-items: start; + flex: 1; +} + +@media (max-width: 60rem) { + .workspace-grid { + grid-template-columns: minmax(0, 1fr); + padding-inline: var(--space-md); + } +} + +.main-column { + min-width: 0; + display: flex; + flex-direction: column; + gap: var(--space-lg); +} + +/* Buttons (8 states, card-stock press on primary) */ +.btn { + position: relative; + display: inline-flex; + align-items: center; + justify-content: center; + gap: var(--space-xs); + min-height: 42px; + padding-block: var(--space-xs); + padding-inline: var(--space-md); + border-radius: var(--radius-stock); + font-size: var(--text-sm); + font-weight: 600; + white-space: nowrap; + cursor: pointer; + text-decoration: none; + border: var(--rule-hair) solid var(--color-muted); + background: transparent; + color: var(--color-ink); + transition: transform var(--dur-micro) var(--ease-snap), box-shadow var(--dur-micro) var(--ease-snap), background-color var(--dur-micro) var(--ease-out); +} + +@media (hover: hover) { + .btn:hover:not(:disabled) { + transform: translateY(-1px); + background: var(--color-paper-2); + } +} + +.btn:active:not(:disabled) { + transform: translateY(1px); +} + +.btn:disabled, +.btn[aria-disabled="true"] { + opacity: 0.55; + cursor: not-allowed; + box-shadow: none; + transform: none; +} + +.btn-primary { + border-color: var(--color-accent-deep); + background: var(--color-accent); + color: var(--color-accent-ink); + box-shadow: 0 4px 0 0 var(--color-accent-deep); + transition-timing-function: var(--ease-spring); +} + +@media (hover: hover) { + .btn-primary:hover:not(:disabled) { + background: color-mix(in oklab, var(--color-accent) 92%, var(--color-card)); + box-shadow: 0 5px 0 0 var(--color-accent-deep); + } +} + +.btn-primary:active:not(:disabled) { + transform: translateY(3px); + box-shadow: 0 1px 0 0 var(--color-accent-deep); +} + +.btn-ghost { + border-color: transparent; + background: transparent; + color: var(--color-ink); + box-shadow: none; +} + +@media (hover: hover) { + .btn-ghost:hover:not(:disabled) { + transform: none; + box-shadow: none; + background: var(--color-paper-2); + } +} + +.btn-ghost:active:not(:disabled) { + transform: translateY(1px); + box-shadow: none; +} + +.btn-danger { + border-color: var(--color-error); + background: transparent; + color: var(--color-error); + box-shadow: none; +} + +@media (hover: hover) { + .btn-danger:hover:not(:disabled) { + transform: none; + box-shadow: none; + background: var(--color-error-soft); + } +} + +.icon-btn { + position: relative; + display: inline-flex; + align-items: center; + justify-content: center; + width: 36px; + height: 36px; + padding: 0; + border-radius: var(--radius-input); + border: var(--rule-hair) solid transparent; + background: transparent; + color: var(--color-ink); + cursor: pointer; + transition: background-color var(--dur-micro) var(--ease-out), color var(--dur-micro) var(--ease-out); +} + +.icon-btn::before { + content: ""; + position: absolute; + inset: -4px; +} + +@media (hover: hover) { + .icon-btn:hover:not(:disabled) { + background: var(--color-paper-2); + } +} + +.icon-btn:disabled { + opacity: 0.55; + cursor: not-allowed; +} + +/* Inputs & Fields (Gate 39a, F14) */ +.field-group { + display: flex; + flex-direction: column; + gap: var(--space-2xs); +} + +.field-label { + font-size: var(--text-sm); + font-weight: 600; + color: var(--color-ink); +} + +.field-input, +.field-select, +.field-textarea { + width: 100%; + min-height: 42px; + padding-block: var(--space-xs); + padding-inline: var(--space-sm); + border-radius: var(--radius-input); + border: 1px solid var(--color-muted); + outline: 2px solid transparent; + outline-offset: -3px; + background: var(--color-card); + color: var(--color-ink); + font-size: var(--text-md); + transition: background-color var(--dur-micro) var(--ease-out), border-color var(--dur-micro) var(--ease-out); +} + +.field-textarea { + min-height: 96px; + resize: vertical; +} + +@media (hover: hover) { + .field-input:hover:not(:disabled), + .field-select:hover:not(:disabled), + .field-textarea:hover:not(:disabled) { + background: var(--color-paper-2); + } +} + +.field-input:focus-visible, +.field-select:focus-visible, +.field-textarea:focus-visible { + outline-color: var(--color-focus); + border-color: var(--color-ink-2); +} + +.field-input[aria-invalid="true"], +.field-textarea[aria-invalid="true"] { + border-color: var(--color-error); +} + +.field-input:disabled, +.field-select:disabled, +.field-textarea:disabled { + opacity: 0.55; + cursor: not-allowed; +} + +.field-helper { + min-height: 1lh; + font-size: var(--text-xs); + color: var(--color-muted); +} + +.field-helper-error { + color: var(--color-error); + font-weight: 500; +} + +/* Header & Filter Toolbar */ +.screen-header { + display: flex; + flex-wrap: wrap; + align-items: flex-end; + justify-content: space-between; + gap: var(--space-md); + padding-bottom: var(--space-md); + border-bottom: var(--rule-hair) solid var(--color-rule); +} + +.screen-header-copy { + display: flex; + flex-direction: column; + gap: var(--space-2xs); +} + +.screen-subtitle { + color: var(--color-muted); + font-size: var(--text-sm); +} + +.filter-bar { + display: flex; + flex-wrap: wrap; + align-items: center; + justify-content: space-between; + gap: var(--space-md); + padding: var(--space-md); + background: var(--color-paper-2); + border-radius: var(--radius-card); +} + +.search-box { + flex: 1; + min-width: 220px; + max-width: 380px; +} + +.pill-group { + display: flex; + flex-wrap: wrap; + align-items: center; + gap: var(--space-xs); +} + +.filter-pill { + min-height: 36px; + padding-block: var(--space-2xs); + padding-inline: var(--space-md); + border-radius: var(--radius-pill); + border: var(--rule-hair) solid var(--color-muted); + background: transparent; + color: var(--color-ink); + font-size: var(--text-sm); + font-weight: 500; + cursor: pointer; + white-space: nowrap; + transition: background-color var(--dur-micro) var(--ease-out), color var(--dur-micro) var(--ease-out), border-color var(--dur-micro) var(--ease-out); +} + +.filter-pill[aria-pressed="true"] { + border-color: var(--color-ink-2); + color: var(--color-ink); + font-weight: 600; + box-shadow: inset 0 0 0 1px var(--color-ink-2); +} + +/* Quiz Feed Cards (Surface Ladder: single card layer, hairlines inside, NO card-in-card) */ +.quiz-grid { + display: grid; + grid-template-columns: repeat(2, minmax(0, 1fr)); + gap: var(--space-md); +} + +@media (max-width: 52rem) { + .quiz-grid { + grid-template-columns: minmax(0, 1fr); + } +} + +.quiz-card { + background: var(--color-card); + border-radius: var(--radius-card); + box-shadow: var(--shadow-card); + padding: var(--space-lg); + display: flex; + flex-direction: column; + gap: var(--space-md); + transition: transform var(--dur-micro) var(--ease-out), opacity var(--dur-micro) var(--ease-out); +} + +@media (hover: hover) { + .quiz-card:hover { + transform: translateY(-2px); + } +} + +.quiz-card.is-dragging { + opacity: 0.55; +} + +.quiz-card-top { + display: flex; + align-items: flex-start; + justify-content: space-between; + gap: var(--space-sm); +} + +.quiz-author-row { + display: flex; + align-items: center; + gap: var(--space-xs); + color: var(--color-muted); + font-size: var(--text-sm); +} + +.quiz-badge { + display: inline-flex; + align-items: center; + gap: var(--space-2xs); + padding-block: var(--space-3xs); + padding-inline: var(--space-sm); + border-radius: var(--radius-pill); + background: var(--color-paper-2); + color: var(--color-ink); + font-size: var(--text-xs); + font-weight: 600; + white-space: nowrap; +} + +.drag-hint { + display: inline-flex; + align-items: center; + gap: var(--space-2xs); + color: var(--color-muted); + font-size: var(--text-xs); + cursor: grab; + user-select: none; +} + +.drag-hint:active { + cursor: grabbing; +} + +/* Scoreboard Strip inside Quiz Card (separated by hairlines, no nested box) */ +.score-strip { + display: grid; + grid-template-columns: repeat(3, minmax(0, 1fr)); + gap: var(--space-sm); + padding-block: var(--space-sm); + border-top: var(--rule-hair) solid var(--color-rule); + border-bottom: var(--rule-hair) solid var(--color-rule); +} + +.score-cell { + display: flex; + flex-direction: column; + gap: var(--space-3xs); + min-width: 0; +} + +.score-cell-label { + font-size: var(--text-xs); + color: var(--color-muted); + white-space: nowrap; +} + +.score-cell-value { + font-size: var(--text-md); + font-weight: 600; + font-variant-numeric: tabular-nums; + color: var(--color-ink); + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +.score-holder { + font-size: var(--text-xs); + color: var(--color-muted); + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +.quiz-card-footer { + display: flex; + flex-wrap: wrap; + align-items: center; + justify-content: space-between; + gap: var(--space-sm); + margin-top: auto; +} + +.vote-cluster { + display: inline-flex; + align-items: center; + gap: var(--space-xs); +} + +.vote-btn { + display: inline-flex; + align-items: center; + gap: var(--space-2xs); + min-height: 36px; + padding-block: var(--space-2xs); + padding-inline: var(--space-sm); + border-radius: var(--radius-pill); + border: var(--rule-hair) solid var(--color-rule); + background: transparent; + color: var(--color-ink); + font-size: var(--text-sm); + font-variant-numeric: tabular-nums; + cursor: pointer; + transition: background-color var(--dur-micro) var(--ease-out), border-color var(--dur-micro) var(--ease-out); +} + +@media (hover: hover) { + .vote-btn:hover:not(:disabled) { + background: var(--color-paper-2); + } +} + +.vote-btn[aria-pressed="true"].vote-up { + border-color: var(--color-success); + color: var(--color-success); + font-weight: 600; +} + +.vote-btn[aria-pressed="true"].vote-down { + border-color: var(--color-error); + color: var(--color-error); + font-weight: 600; +} + +/* Right-Hand Favourites Rail & Pop-Up Recycle Bin */ +.favourites-rail { + position: sticky; + top: calc(var(--banner-height) + var(--space-md)); + z-index: var(--z-sticky); + background: var(--color-paper-2); + border-radius: var(--radius-card); + padding: var(--space-md); + display: flex; + flex-direction: column; + gap: var(--space-md); + transition: background-color var(--dur-micro) var(--ease-out); +} + +.favourites-rail.is-drop-active { + background: var(--color-accent-soft); + outline: 2px dashed var(--color-accent-deep); + outline-offset: -4px; +} + +.favourites-header { + display: flex; + align-items: center; + justify-content: space-between; + gap: var(--space-xs); +} + +.favourites-count { + font-size: var(--text-xs); + font-variant-numeric: tabular-nums; + padding-block: var(--space-3xs); + padding-inline: var(--space-xs); + border-radius: var(--radius-pill); + background: var(--color-ink-2); + color: var(--color-on-dark); + font-weight: 600; +} + +.favourites-list { + list-style: none; + margin: 0; + padding: 0; + display: flex; + flex-direction: column; + gap: var(--space-xs); +} + +.favourite-row { + display: flex; + align-items: center; + justify-content: space-between; + gap: var(--space-xs); + padding: var(--space-sm); + border-radius: var(--radius-input); + background: var(--color-card); + border: var(--rule-hair) solid var(--color-rule); + cursor: grab; + transition: transform var(--dur-micro) var(--ease-out), opacity var(--dur-micro) var(--ease-out), border-color var(--dur-micro) var(--ease-out); +} + +.favourite-row:active { + cursor: grabbing; +} + +.favourite-row.is-dragging { + opacity: 0.45; +} + +.favourite-row.is-drag-over { + border-color: var(--color-accent-deep); + transform: translateY(-2px); +} + +.favourite-row-main { + min-width: 0; + flex: 1; + display: flex; + flex-direction: column; + gap: var(--space-3xs); +} + +.favourite-title-btn { + border: 0; + padding: 0; + background: transparent; + color: var(--color-ink); + font-weight: 600; + font-size: var(--text-sm); + text-align: left; + cursor: pointer; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +@media (hover: hover) { + .favourite-title-btn:hover { + color: var(--color-accent-2); + } +} + +.favourite-meta { + font-size: var(--text-xs); + color: var(--color-muted); + font-variant-numeric: tabular-nums; +} + +.favourite-controls { + display: flex; + align-items: center; + gap: var(--space-3xs); + flex-shrink: 0; +} + +.recycle-bin-zone { + display: flex; + align-items: center; + justify-content: center; + gap: var(--space-xs); + padding: var(--space-md); + border-radius: var(--radius-stock); + border: 2px dashed var(--color-error); + background: var(--color-error-soft); + color: var(--color-error); + font-size: var(--text-sm); + font-weight: 600; + opacity: 1; + transform: translateY(0); + transition: opacity var(--dur-short) var(--ease-out), transform var(--dur-short) var(--ease-spring), background-color var(--dur-micro) var(--ease-out); +} + +@starting-style { + .recycle-bin-zone { + opacity: 0; + transform: translateY(8px); + } +} + +.recycle-bin-zone.is-over { + background: var(--color-error); + color: var(--color-on-dark); +} + +.undo-bar { + --color-focus: var(--color-on-dark); + display: flex; + align-items: center; + justify-content: space-between; + gap: var(--space-xs); + padding-block: var(--space-xs); + padding-inline: var(--space-sm); + border-radius: var(--radius-input); + background: var(--color-ink-2); + color: var(--color-on-dark); + font-size: var(--text-xs); +} + +/* Tables (App Scope: sentence-case heads, horizontal rules only, full width) */ +.data-table-wrap { + width: 100%; + overflow-x: auto; +} + +.data-table { + width: 100%; + border-collapse: collapse; + text-align: left; +} + +.data-table th { + padding-block: var(--space-sm); + padding-inline: var(--space-sm); + border-bottom: var(--rule-hair) solid var(--color-muted); + font-size: var(--text-sm); + font-weight: 600; + color: var(--color-muted); + white-space: nowrap; +} + +.data-table td { + padding-block: var(--space-sm); + padding-inline: var(--space-sm); + border-bottom: var(--rule-hair) solid var(--color-rule); + font-size: var(--text-sm); + vertical-align: middle; +} + +/* Quiz Builder & Player Panels */ +.panel-surface { + background: var(--color-paper-2); + border-radius: var(--radius-card); + padding: var(--space-lg); + display: flex; + flex-direction: column; + gap: var(--space-md); +} + +.question-block { + background: var(--color-card); + border-radius: var(--radius-stock); + border: var(--rule-hair) solid var(--color-rule); + padding: var(--space-md); + display: flex; + flex-direction: column; + gap: var(--space-md); +} + +.question-block-head { + display: flex; + flex-wrap: wrap; + align-items: center; + justify-content: space-between; + gap: var(--space-sm); +} + +.choice-list { + display: flex; + flex-direction: column; + gap: var(--space-xs); +} + +.choice-option { + display: flex; + align-items: center; + gap: var(--space-sm); + padding-block: var(--space-sm); + padding-inline: var(--space-md); + border-radius: var(--radius-input); + border: var(--rule-hair) solid var(--color-rule); + background: var(--color-card); + cursor: pointer; + transition: background-color var(--dur-micro) var(--ease-out), border-color var(--dur-micro) var(--ease-out); +} + +@media (hover: hover) { + .choice-option:hover { + background: var(--color-paper-2); + } +} + +.choice-option.is-selected { + border-color: var(--color-ink-2); + background: var(--color-accent-soft); + font-weight: 600; +} + +/* Status States: Skeleton, Empty, Error, Banners */ +.state-panel { + padding: var(--space-xl); + border-radius: var(--radius-card); + background: var(--color-paper-2); + display: flex; + flex-direction: column; + align-items: flex-start; + gap: var(--space-sm); +} + +.state-panel-error { + background: var(--color-error-soft); + color: var(--color-ink); + border: var(--rule-hair) solid var(--color-error); +} + +.skeleton-stack { + display: flex; + flex-direction: column; + gap: var(--space-md); +} + +.skeleton-row { + height: 112px; + border-radius: var(--radius-card); + background: var(--color-paper-2); + animation: pulse-paper var(--dur-long) var(--ease-in-out) infinite alternate; +} + +@keyframes pulse-paper { + from { + opacity: 0.55; + } + to { + opacity: 0.95; + } +} + +/* Native Dialogs (Onboarding & Sign In) */ +.app-dialog { + position: fixed; + inset: 0; + margin: auto; + width: min(92%, 560px); + height: fit-content; + max-height: min(88dvh, 680px); + overflow-y: auto; + border: var(--rule-hair) solid var(--color-rule); + border-radius: var(--radius-card); + background: var(--color-paper); + color: var(--color-ink); + padding: var(--space-lg); + box-shadow: var(--shadow-card); +} + +.app-dialog::backdrop { + background: var(--color-backdrop); +} + +.dialog-head { + display: flex; + align-items: center; + justify-content: space-between; + gap: var(--space-md); + margin-bottom: var(--space-md); +} + +.avatar-picker-grid { + display: grid; + grid-template-columns: repeat(4, minmax(0, 1fr)); + gap: var(--space-xs); +} + +.avatar-choice-btn { + display: flex; + flex-direction: column; + align-items: center; + gap: var(--space-2xs); + padding: var(--space-xs); + border-radius: var(--radius-input); + border: var(--rule-hair) solid var(--color-rule); + background: transparent; + color: var(--color-ink); + font-size: var(--text-xs); + cursor: pointer; +} + +.avatar-choice-btn[aria-pressed="true"] { + border-color: var(--color-ink-2); + box-shadow: inset 0 0 0 1px var(--color-ink-2); + font-weight: 600; +} + +.color-swatch-row { + display: flex; + flex-wrap: wrap; + gap: var(--space-xs); +} + +.color-swatch-btn { + width: 36px; + height: 36px; + border-radius: var(--radius-pill); + border: 2px solid var(--color-card); + cursor: pointer; +} + +.color-swatch-btn[aria-pressed="true"] { + outline: 2px solid var(--color-ink); + outline-offset: 2px; +} + +.swatch-avatar-1 { background: var(--color-avatar-1); } +.swatch-avatar-2 { background: var(--color-avatar-2); } +.swatch-avatar-3 { background: var(--color-avatar-3); } +.swatch-avatar-4 { background: var(--color-avatar-4); } +.swatch-avatar-5 { background: var(--color-avatar-5); } +.swatch-avatar-6 { background: var(--color-avatar-6); } +.swatch-avatar-7 { background: var(--color-avatar-7); } +.swatch-avatar-8 { background: var(--color-avatar-8); } + +.mono-code { + font-family: var(--font-outlier); + font-size: var(--text-sm); + background: var(--color-paper-2); + padding-block: var(--space-3xs); + padding-inline: var(--space-xs); + border-radius: var(--radius-input); + word-break: break-all; +} + +.consent-row { + display: flex; + align-items: flex-start; + gap: var(--space-sm); + padding: var(--space-sm); + border-radius: var(--radius-input); + background: var(--color-paper-2); + font-size: var(--text-sm); +} + +.consent-row input[type="checkbox"] { + width: 20px; + height: 20px; + margin-top: var(--space-3xs); + accent-color: var(--color-ink-2); + flex-shrink: 0; +} + +.form-grid-two { + display: grid; + grid-template-columns: repeat(2, minmax(0, 1fr)); + gap: var(--space-md); +} + +@media (max-width: 40rem) { + .form-grid-two { + grid-template-columns: minmax(0, 1fr); + } +} + +.inline-row { + display: flex; + flex-wrap: wrap; + align-items: center; + gap: var(--space-sm); +} + +.inline-between { + display: flex; + flex-wrap: wrap; + align-items: center; + justify-content: space-between; + gap: var(--space-sm); +} + +.status-chip { + display: inline-flex; + align-items: center; + gap: var(--space-2xs); + padding-block: var(--space-3xs); + padding-inline: var(--space-sm); + border-radius: var(--radius-pill); + font-size: var(--text-xs); + font-weight: 600; + background: var(--color-paper-2); + color: var(--color-ink); +} + +.status-chip-ok { + background: var(--color-success-soft); + color: var(--color-success); +} + +.status-chip-bad { + background: var(--color-error-soft); + color: var(--color-error); +} diff --git a/frontend/src/styles/base.css b/frontend/src/styles/base.css new file mode 100644 index 0000000..3deef74 --- /dev/null +++ b/frontend/src/styles/base.css @@ -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); } diff --git a/frontend/src/styles/tokens.css b/frontend/src/styles/tokens.css new file mode 100644 index 0000000..ae3edbb --- /dev/null +++ b/frontend/src/styles/tokens.css @@ -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; +} diff --git a/frontend/src/types.ts b/frontend/src/types.ts new file mode 100644 index 0000000..af13d99 --- /dev/null +++ b/frontend/src/types.ts @@ -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; +} diff --git a/frontend/src/views/AccountApiKeysView.tsx b/frontend/src/views/AccountApiKeysView.tsx new file mode 100644 index 0000000..153ec43 --- /dev/null +++ b/frontend/src/views/AccountApiKeysView.tsx @@ -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(user.gender); + const [genderCustom, setGenderCustom] = useState(user.gender_custom || ""); + const [animal, setAnimal] = useState(user.avatar_animal); + const [color, setColor] = useState(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([]); + 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( + 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 ( +
+
+
+

Player profile and API keys

+

+ Customize your animal emblem, manage profile details, or issue and expire personal API keys. +

+
+
+ +
+
+ +
+

Player identity

+

+ Public quizzes and leaderboards show your nickname and animal icon. +

+
+
+ +
+ Animal icon +
+ {ANIMAL_META.map((item) => ( + + ))} +
+
+ +
+ Icon color +
+ {AVATAR_COLOR_LIST.map((sw) => ( +
+
+ +
+
+ + setNickname(e.target.value)} + /> + Unique player handle. +
+ +
+ + setAge(e.target.value)} + /> + Optional; never stores date of birth. +
+
+ +
+
+ + setFirstName(e.target.value)} + /> + Private account name. +
+ +
+ + setLastName(e.target.value)} + /> + Private account name. +
+
+ +
+
+ + + Inclusive options supported. +
+ + {gender === "other" && ( +
+ + setGenderCustom(e.target.value)} + /> + Optional custom description. +
+ )} +
+ + + + {profileErr && ( +
+

{profileErr}

+
+ )} + {profileMsg && ( +

+ {profileMsg} +

+ )} + +
+ +
+
+ +
+
+
+ +
+
+ + setKeyName(e.target.value)} + /> + Helps identify where the key is used. +
+ +
+ +
+ + +
+ + Raw secrets are shown once upon creation. + +
+
+ + {newlyCreatedKey && ( +
+
+ Copy your new API key now (shown only once): + +
+ {newlyCreatedKey.raw_key} +
+ )} + + {keysErr && ( +
+

{keysErr}

+
+ )} + + {keysLoading ? ( +
+ ) : keys.length === 0 ? ( +

+ No API keys issued yet. Generate one above when you want programmatic access. +

+ ) : ( +
+ + + + + + + + + + + + {keys.map((k) => ( + + + + + + + + ))} + +
LabelPrefixStatusExpiresAction
{k.name} + {k.key_prefix}… + + + {k.is_active ? "Active" : "Expired"} + + + {k.revoked_at + ? "Expired manually" + : k.expires_at + ? new Date(k.expires_at).toLocaleDateString() + : "Never"} + + {k.is_active ? ( + + ) : ( + Revoked + )} +
+
+ )} +
+
+ ); +} diff --git a/frontend/src/views/ExploreView.tsx b/frontend/src/views/ExploreView.tsx new file mode 100644 index 0000000..22af9e1 --- /dev/null +++ b/frontend/src/views/ExploreView.tsx @@ -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; + onToggleFavourite: (quiz: QuizSummary) => Promise; + 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(null); + const [copiedSlug, setCopiedSlug] = useState(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 ( +
+
+
+

Shared student quizzes

+

+ Play community quizzes, compare your latest and best runs, or drag cards into Favourites. +

+
+ +
+ +
+
+ + onSearchChange(e.target.value)} + /> +
+ +
+ {[ + { id: "all", label: "All formats" }, + { id: "multiple_choice", label: "Multiple choice" }, + { id: "write_in", label: "Write-in" }, + { id: "mixed", label: "Mixed" }, + ].map((pill) => ( + + ))} + {user && ( + + )} +
+
+ + {loading && ( +
+
+
+
+
+ )} + + {!loading && error && ( +
+

Could not load shared quizzes

+

{error}

+ +
+ )} + + {!loading && !error && quizzes.length === 0 && ( +
+

No matching quizzes yet

+

+ {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."} +

+
+ + {(searchQuery || modeFilter !== "all" || mineOnly) && ( + + )} +
+
+ )} + + {!loading && !error && quizzes.length > 0 && ( +
+ {quizzes.map((quiz) => { + const isOwner = Boolean( + user && (user.id === quiz.author_id || user.is_admin) + ); + return ( +
{ + 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" : "" + }`} + > +
+
+ + by {quiz.author_nickname} +
+
+ + {quiz.question_count}{" "} + {quiz.question_count === 1 ? "question" : "questions"} + + + {formatQuizMode(quiz.quiz_mode)} + +
+
+ +
+

{quiz.title}

+ {quiz.description && ( +

{quiz.description}

+ )} +
+ +
+
+ Best by anyone + + {quiz.global_best_score + ? `${quiz.global_best_score.score_percent}%` + : "None yet"} + + + {quiz.global_best_score + ? quiz.global_best_score.holder_nickname + : "Be first to score"} + +
+ +
+ Your latest + + {quiz.my_latest_score + ? `${quiz.my_latest_score.score_percent}%` + : "Unplayed"} + + + {quiz.my_latest_score + ? `${quiz.my_latest_score.score_count}/${quiz.my_latest_score.total_questions} right` + : "Play to record"} + +
+ +
+ Your all-time best + + {quiz.my_best_score + ? `${quiz.my_best_score.score_percent}%` + : "Unplayed"} + + + {quiz.my_best_score + ? `${quiz.my_best_score.score_count}/${quiz.my_best_score.total_questions} right` + : "Personal record"} + +
+
+ +
+
+ + + + + + + +
+ +
+ + + {isOwner && ( + + )} + +
+
+
+ ); + })} +
+ )} +
+ ); +} diff --git a/frontend/src/views/ModeratorView.tsx b/frontend/src/views/ModeratorView.tsx new file mode 100644 index 0000000..50b0b13 --- /dev/null +++ b/frontend/src/views/ModeratorView.tsx @@ -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([]); + const [quizzes, setQuizzes] = useState([]); + 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 ( +
+

Moderator access required

+

+ Sign in with the configured moderator account to review users and public quizzes. +

+
+ ); + } + + if (!user.is_admin) { + return ( +
+
+
+ {adminUnclaimed ? ( + <> +

+ No moderator account was specified via ADMIN_USER at container launch. Claim initial moderator status for {user.nickname} now. +

+ {error &&

{error}

} + + + ) : ( +

+ Your account does not hold moderator permissions on this instance. +

+ )} +
+ ); + } + + 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 ( +
+
+
+

Community moderation

+

+ Suspend disruptive accounts and remove inappropriate quizzes from public discovery. +

+
+
+ + {error && ( +
+

{error}

+
+ )} + + {loading ? ( +
+
+
+
+ ) : ( + <> +
+

Registered accounts

+
+ + + + + + + + + + + + {users.map((u) => ( + + + + + + + + ))} + +
PlayerRoleConsentStatusAction
+
+ + {u.nickname} +
+
{u.is_admin ? "Moderator" : "Student"} + {u.public_website_consent ? "Confirmed" : "Pending"} + + + {u.is_banned ? "Banned" : "Active"} + + + {u.id !== user.id ? ( + + ) : ( + Current session + )} +
+
+
+ +
+

Active quizzes ({quizzes.length})

+ {quizzes.length === 0 ? ( +

No active quizzes to moderate.

+ ) : ( +
+ + + + + + + + + + + + {quizzes.map((q) => ( + + + + + + + + ))} + +
TitleAuthorQuestionsVotesAction
+ {q.title} + {q.author_nickname}{q.question_count} + +{q.upvotes} / -{q.downvotes} + + +
+
+ )} +
+ + )} +
+ ); +} diff --git a/frontend/src/views/MyScoresView.tsx b/frontend/src/views/MyScoresView.tsx new file mode 100644 index 0000000..d1f5425 --- /dev/null +++ b/frontend/src/views/MyScoresView.tsx @@ -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([]); + 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 ( +
+
+
+

Your quiz scoreboard

+

+ Track your latest run, personal all-time best, and the top community score on every quiz you play. +

+
+
+ + {loading && ( +
+
+
+
+ )} + + {!loading && error && ( +
+

Scoreboard unavailable

+

{error}

+ +
+ )} + + {!loading && !error && rows.length === 0 && ( +
+

No completed runs yet

+

+ Finish any public quiz to record your latest score and personal best here. +

+ +
+ )} + + {!loading && !error && rows.length > 0 && ( +
+ + + + + + + + + + + + + {rows.map((row) => ( + + + + + + + + + ))} + +
QuizRunsYour latestYour bestBest by anyoneAction
+ {row.quiz_title} + {row.attempts_count} + {row.latest_score.score_percent}% ({row.latest_score.score_count}/ + {row.latest_score.total_questions}) + + {row.best_score.score_percent}% ( + {row.best_score.score_count}/{row.best_score.total_questions}) + + {row.global_best_score + ? `${row.global_best_score.score_percent}% (${row.global_best_score.holder_nickname})` + : "None"} + + +
+
+ )} +
+ ); +} diff --git a/frontend/src/views/QuizBuilderView.tsx b/frontend/src/views/QuizBuilderView.tsx new file mode 100644 index 0000000..fca065e --- /dev/null +++ b/frontend/src/views/QuizBuilderView.tsx @@ -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(null); + const [title, setTitle] = useState(""); + const [description, setDescription] = useState(""); + const [isPublic, setIsPublic] = useState(true); + const [questions, setQuestions] = useState([ + 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 ( +
+

Sign in to build quizzes

+

+ Sign in with Auth0 or local player login to create multiple-choice and write-in quizzes. +

+ +
+ ); + } + + if (loadingExisting) { + return ( +
+
+
+
+ ); + } + + 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 ( +
+
+
+

+ {quizId !== null ? "Edit quiz" : "Create a student quiz"} +

+

+ Mix multiple-choice and write-in prompts (0 to 40 questions on the public free tier). +

+
+ + {questions.length} / {MAX_QUESTIONS} questions + +
+ +
+
+
+ + setTitle(e.target.value)} + /> + + Up to 140 characters. Used to generate your shareable link. + +
+ +
+ +