No description
Find a file
2026-03-26 13:54:11 +01:00
.github/workflows first commit 2026-03-02 20:09:27 +01:00
backend routing fix 2026-03-03 18:10:18 +01:00
frontend "sidebar 2" 2026-03-26 13:54:11 +01:00
prompts first commit 2026-03-02 20:09:27 +01:00
ref first commit 2026-03-02 20:09:27 +01:00
scripts first commit 2026-03-02 20:09:27 +01:00
.env.example first commit 2026-03-02 20:09:27 +01:00
.gitignore first commit 2026-03-02 20:09:27 +01:00
coding-style.md first commit 2026-03-02 20:09:27 +01:00
docker-compose.yml first commit 2026-03-02 20:09:27 +01:00
Dockerfile first commit 2026-03-02 20:09:27 +01:00
PLAN.md first commit 2026-03-02 20:09:27 +01:00
pyproject.toml first commit 2026-03-02 20:09:27 +01:00
README.md readme update 2026-03-03 18:50:58 +01:00
SLICES.md first commit 2026-03-02 20:09:27 +01:00
uv.lock first commit 2026-03-02 20:09:27 +01:00

Handin

A simple hand-in page builder, made for Utrecht University.

CI Docker Pulls Python


What is this?

I built this as a boilerplate for group hand-ins at Utrecht University. It lets you combine PDFs, video, audio, markdown, embedded content, image galleries, and team member listings into a multi-page website. I'm making it publicly available in case anyone else finds it useful.

Features

  • Multimodal content — markdown, PDF, video, audio, iframes, image galleries, and team members
  • Multi-page — create and manage multiple pages, each with its own slug and segments
  • Drag-and-drop ordering — reorder pages and segments visually
  • Token-based admin — single admin token, no user accounts needed
  • Dark mode — automatic dark/light theme
  • Zero-config database — SQLite with WAL mode, no external DB to manage
  • Single container — FastAPI backend + Vue 3 SPA ship as one Docker image

Deployment

Create a compose.yml:

name: handin

services:
  handin:
    image: justinzeus/handin:latest
    restart: unless-stopped
    environment:
      HANDIN_ADMIN_TOKEN: ${HANDIN_ADMIN_TOKEN:?set HANDIN_ADMIN_TOKEN}
      HANDIN_SITE_TITLE: ${HANDIN_SITE_TITLE:-Untitled Site}
      HANDIN_DATA_DIR: /data
    volumes:
      - handin_data:/data
    healthcheck:
      test: ["CMD", "python", "-c", "import urllib.request; urllib.request.urlopen('http://localhost:8000/api/health')"]
      interval: 10s
      timeout: 5s
      retries: 12

volumes:
  handin_data:

Then:

export HANDIN_ADMIN_TOKEN=$(openssl rand -hex 32)
docker compose up -d

The app listens on port 8000. Wire it through your reverse proxy (e.g. Caddy) to expose it.

How It Works

graph LR
    Browser <-->|HTTP| FastAPI[FastAPI + Vue SPA]
    FastAPI --> DB[(SQLite)]
    FastAPI --> Assets[/data/assets/]

The Vue 3 frontend is built at image build time and served as static files by FastAPI. All data (database + uploaded assets) lives in a single /data volume.

Configuration

Variable Default Description
HANDIN_ADMIN_TOKEN required Bearer token for admin endpoints
HANDIN_SITE_TITLE Untitled Site Page title shown in the browser tab
HANDIN_DATA_DIR ./data SQLite DB + uploaded assets directory
HANDIN_MAX_UPLOAD_BYTES 100000000 Max file upload size (~100 MB)
HANDIN_ALLOWED_UPLOAD_TYPES pdf,png,jpg,... Comma-separated allowed file extensions

Tech Stack

Layer Technology
Backend Python 3.12, FastAPI, Pydantic v2, SQLite
Frontend TypeScript, Vue 3, Vite, Tailwind CSS v4
Infrastructure Multi-stage Docker, Docker Compose, UV

Development

# Start backend + frontend dev server
docker compose up

# Or run locally:

# Backend
uv sync --group dev
uv run fastapi dev backend/app/main.py --port 8000

# Frontend (separate terminal)
cd frontend
npm ci
npm run dev

Lint and type-check:

# Backend
uv run ruff check backend/
uv run ruff format --check backend/
uv run mypy
uv run pytest

# Frontend
cd frontend
npx vue-tsc --noEmit