readme+bugfixes
This commit is contained in:
parent
b3ac11a85e
commit
ce7f9ac236
4 changed files with 74 additions and 14 deletions
64
README.md
64
README.md
|
|
@ -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
|
|
||||||
```
|
```
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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()
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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",
|
||||||
}
|
}
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue