From ce7f9ac23614cdb937fabe98c3d93c958f544f84 Mon Sep 17 00:00:00 2001 From: Justin Visser Date: Tue, 3 Mar 2026 16:29:15 +0100 Subject: [PATCH] readme+bugfixes --- README.md | 64 ++++++++++++++++++++----- backend/app/database.py | 14 ++++++ backend/app/services/segment_service.py | 9 +++- backend/tests/test_database.py | 1 + 4 files changed, 74 insertions(+), 14 deletions(-) diff --git a/README.md b/README.md index f53c1d0..466e60d 100644 --- a/README.md +++ b/README.md @@ -14,28 +14,53 @@ ## 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 -- **Multimodal content** — markdown, PDF, video, audio, iframes, image galleries, and links -- **Drag-and-drop ordering** — reorder segments visually +- **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 -## 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 -git clone https://github.com/JustinZeus/handin-website.git -cd handin-website -cp .env.example .env -# Edit .env and set HANDIN_ADMIN_TOKEN to something secure +export HANDIN_ADMIN_TOKEN=$(openssl rand -hex 32) 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 @@ -53,7 +78,7 @@ The Vue 3 frontend is built at image build time and served as static files by Fa | Variable | Default | Description | |----------|---------|-------------| | `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_MAX_UPLOAD_BYTES` | `100000000` | Max file upload size (~100 MB) | | `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 ```bash +# 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: + +```bash +# Backend uv run ruff check backend/ uv run ruff format --check backend/ uv run mypy @@ -78,7 +120,5 @@ uv run pytest # Frontend cd frontend -npm ci npx vue-tsc --noEmit -npm run dev ``` diff --git a/backend/app/database.py b/backend/app/database.py index 2426afb..b3bcfb0 100644 --- a/backend/app/database.py +++ b/backend/app/database.py @@ -1,8 +1,22 @@ import sqlite3 +import threading from pathlib import Path +_init_lock = threading.Lock() +_initialized_dbs: set[Path] = set() + 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) conn = sqlite3.connect(db_path) conn.row_factory = sqlite3.Row diff --git a/backend/app/services/segment_service.py b/backend/app/services/segment_service.py index 0e8c985..4caf1db 100644 --- a/backend/app/services/segment_service.py +++ b/backend/app/services/segment_service.py @@ -24,10 +24,12 @@ def _row_to_segment(row: sqlite3.Row) -> Segment: def create_segment(db_path: Path, request: SegmentCreateRequest) -> Segment: conn = get_connection(db_path) + conn.isolation_level = None # manual transaction control try: + conn.execute("BEGIN IMMEDIATE") row = conn.execute( "SELECT COALESCE(MAX(sort_order) + 1, 0) AS next_order" - " FROM segments WHERE page_id = ?", + " FROM segments WHERE page_id IS ?", (request.page_id,), ).fetchone() sort_order: int = row["next_order"] if row else 0 @@ -51,7 +53,7 @@ def create_segment(db_path: Path, request: SegmentCreateRequest) -> Segment: now, ), ) - conn.commit() + conn.execute("COMMIT") return Segment( id=segment_id, @@ -64,6 +66,9 @@ def create_segment(db_path: Path, request: SegmentCreateRequest) -> Segment: created_at=datetime.fromisoformat(now), updated_at=datetime.fromisoformat(now), ) + except Exception: + conn.execute("ROLLBACK") + raise finally: conn.close() diff --git a/backend/tests/test_database.py b/backend/tests/test_database.py index cc6003b..1b11326 100644 --- a/backend/tests/test_database.py +++ b/backend/tests/test_database.py @@ -65,6 +65,7 @@ def test_segments_table_schema(db: Path) -> None: "title", "content", "metadata", + "page_id", "created_at", "updated_at", }