readme+bugfixes

This commit is contained in:
Justin Visser 2026-03-03 16:29:15 +01:00
parent b3ac11a85e
commit ce7f9ac236
4 changed files with 74 additions and 14 deletions

View file

@ -14,28 +14,53 @@
## What is this? ## 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, and image galleries into a single web page. I'm making it publicly available in case anyone else finds it useful. 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 ## Features
- **Multimodal content** — markdown, PDF, video, audio, iframes, image galleries, and links - **Multimodal content** — markdown, PDF, video, audio, iframes, image galleries, and team members
- **Drag-and-drop ordering** — reorder segments visually - **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 - **Token-based admin** — single admin token, no user accounts needed
- **Dark mode** — automatic dark/light theme - **Dark mode** — automatic dark/light theme
- **Zero-config database** — SQLite with WAL mode, no external DB to manage - **Zero-config database** — SQLite with WAL mode, no external DB to manage
- **Single container** — FastAPI backend + Vue 3 SPA ship as one Docker image - **Single container** — FastAPI backend + Vue 3 SPA ship as one Docker image
## Quick Start ## Deployment
Create a `compose.yml`:
```yaml
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-SHELL", "curl -fsS http://localhost:8000/api/health >/dev/null || exit 1"]
interval: 10s
timeout: 5s
retries: 12
volumes:
handin_data:
```
Then:
```bash ```bash
git clone https://github.com/JustinZeus/handin-website.git export HANDIN_ADMIN_TOKEN=$(openssl rand -hex 32)
cd handin-website
cp .env.example .env
# Edit .env and set HANDIN_ADMIN_TOKEN to something secure
docker compose up -d docker compose up -d
``` ```
The app listens on port 8000 inside the container. Wire it through your reverse proxy (e.g. Caddy) to expose it. The app listens on port 8000. Wire it through your reverse proxy (e.g. Caddy) to expose it.
## How It Works ## How It Works
@ -53,7 +78,7 @@ The Vue 3 frontend is built at image build time and served as static files by Fa
| Variable | Default | Description | | Variable | Default | Description |
|----------|---------|-------------| |----------|---------|-------------|
| `HANDIN_ADMIN_TOKEN` | *required* | Bearer token for admin endpoints | | `HANDIN_ADMIN_TOKEN` | *required* | Bearer token for admin endpoints |
| `HANDIN_SITE_TITLE` | `Untitled Site` | Page title | | `HANDIN_SITE_TITLE` | `Untitled Site` | Page title shown in the browser tab |
| `HANDIN_DATA_DIR` | `./data` | SQLite DB + uploaded assets directory | | `HANDIN_DATA_DIR` | `./data` | SQLite DB + uploaded assets directory |
| `HANDIN_MAX_UPLOAD_BYTES` | `100000000` | Max file upload size (~100 MB) | | `HANDIN_MAX_UPLOAD_BYTES` | `100000000` | Max file upload size (~100 MB) |
| `HANDIN_ALLOWED_UPLOAD_TYPES` | `pdf,png,jpg,...` | Comma-separated allowed file extensions | | `HANDIN_ALLOWED_UPLOAD_TYPES` | `pdf,png,jpg,...` | Comma-separated allowed file extensions |
@ -69,8 +94,25 @@ The Vue 3 frontend is built at image build time and served as static files by Fa
## Development ## Development
```bash ```bash
# Start backend + frontend dev server
docker compose up
# Or run locally:
# Backend # Backend
uv sync --group dev 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:
```bash
# Backend
uv run ruff check backend/ uv run ruff check backend/
uv run ruff format --check backend/ uv run ruff format --check backend/
uv run mypy uv run mypy
@ -78,7 +120,5 @@ uv run pytest
# Frontend # Frontend
cd frontend cd frontend
npm ci
npx vue-tsc --noEmit npx vue-tsc --noEmit
npm run dev
``` ```

View file

@ -1,8 +1,22 @@
import sqlite3 import sqlite3
import threading
from pathlib import Path from pathlib import Path
_init_lock = threading.Lock()
_initialized_dbs: set[Path] = set()
def init_db(db_path: Path) -> None: def init_db(db_path: Path) -> None:
if db_path in _initialized_dbs:
return
with _init_lock:
if db_path in _initialized_dbs:
return
_init_db_locked(db_path)
_initialized_dbs.add(db_path)
def _init_db_locked(db_path: Path) -> None:
db_path.parent.mkdir(parents=True, exist_ok=True) db_path.parent.mkdir(parents=True, exist_ok=True)
conn = sqlite3.connect(db_path) conn = sqlite3.connect(db_path)
conn.row_factory = sqlite3.Row conn.row_factory = sqlite3.Row

View file

@ -24,10 +24,12 @@ def _row_to_segment(row: sqlite3.Row) -> Segment:
def create_segment(db_path: Path, request: SegmentCreateRequest) -> Segment: def create_segment(db_path: Path, request: SegmentCreateRequest) -> Segment:
conn = get_connection(db_path) conn = get_connection(db_path)
conn.isolation_level = None # manual transaction control
try: try:
conn.execute("BEGIN IMMEDIATE")
row = conn.execute( row = conn.execute(
"SELECT COALESCE(MAX(sort_order) + 1, 0) AS next_order" "SELECT COALESCE(MAX(sort_order) + 1, 0) AS next_order"
" FROM segments WHERE page_id = ?", " FROM segments WHERE page_id IS ?",
(request.page_id,), (request.page_id,),
).fetchone() ).fetchone()
sort_order: int = row["next_order"] if row else 0 sort_order: int = row["next_order"] if row else 0
@ -51,7 +53,7 @@ def create_segment(db_path: Path, request: SegmentCreateRequest) -> Segment:
now, now,
), ),
) )
conn.commit() conn.execute("COMMIT")
return Segment( return Segment(
id=segment_id, id=segment_id,
@ -64,6 +66,9 @@ def create_segment(db_path: Path, request: SegmentCreateRequest) -> Segment:
created_at=datetime.fromisoformat(now), created_at=datetime.fromisoformat(now),
updated_at=datetime.fromisoformat(now), updated_at=datetime.fromisoformat(now),
) )
except Exception:
conn.execute("ROLLBACK")
raise
finally: finally:
conn.close() conn.close()

View file

@ -65,6 +65,7 @@ def test_segments_table_schema(db: Path) -> None:
"title", "title",
"content", "content",
"metadata", "metadata",
"page_id",
"created_at", "created_at",
"updated_at", "updated_at",
} }