handin-website/PLAN.md
2026-03-02 20:09:27 +01:00

243 lines
10 KiB
Markdown

# Academic Group Submission Website — Architecture Plan
> **Implementation guide:** See [SLICES.md](SLICES.md) for the step-by-step slice-based implementation order.
## Context
Build a lightweight, containerized CMS for academic group submissions. The site aggregates multimodal content (PDFs, video, audio, markdown, embeds, galleries) into a single web page. Admin access is gated by a shared token — no user accounts. Multiple students may edit concurrently. All data persists in SQLite + uploaded assets on a Docker volume.
**Stack:** Python 3.12+ / FastAPI, Vue 3 / TypeScript / Tailwind CSS, SQLite (stdlib), single Docker container.
---
## Project Structure
```
handin-website/
├── pyproject.toml
├── Dockerfile
├── docker-compose.yml
├── .gitignore
├── coding-style.md
├── PLAN.md # This file — architecture reference
├── SLICES.md # Slice-based implementation guide
├── backend/
│ ├── app/
│ │ ├── main.py # FastAPI app, static mounts, lifespan
│ │ ├── config.py # pydantic-settings: token, paths, limits
│ │ ├── auth.py # require_admin dependency (token via query/header)
│ │ ├── models.py # Pydantic models for requests/responses
│ │ ├── database.py # SQLite connection, schema init, WAL mode
│ │ ├── routes/
│ │ │ ├── segments.py # CRUD + reorder endpoints
│ │ │ └── assets.py # Upload + delete endpoints
│ │ └── services/
│ │ ├── segment_service.py
│ │ └── asset_service.py
│ └── tests/
│ ├── conftest.py # Fixtures: test client, temp DB, temp asset dir
│ ├── test_auth.py
│ ├── test_segments.py
│ ├── test_assets.py
│ └── test_concurrent.py
├── frontend/
│ ├── package.json
│ ├── tsconfig.json
│ ├── vite.config.ts # Includes @tailwindcss/vite plugin
│ ├── index.html
│ └── src/
│ ├── App.vue
│ ├── main.ts
│ ├── style.css # Tailwind directives + custom theme
│ ├── types/segment.ts
│ ├── types/vuedraggable.d.ts # Type declarations for vuedraggable 4.x
│ ├── composables/
│ │ ├── useSegments.ts
│ │ ├── useAdmin.ts
│ │ └── useAssetUpload.ts
│ ├── components/
│ │ ├── layout/
│ │ │ ├── AppShell.vue # Sidebar + main content layout
│ │ │ ├── SidebarNav.vue # Segment nav, collapses on mobile
│ │ │ └── MobileHeader.vue # Hamburger menu for mobile
│ │ ├── segments/
│ │ │ ├── SegmentRenderer.vue
│ │ │ ├── SegmentList.vue
│ │ │ ├── MarkdownSegment.vue
│ │ │ ├── PdfSegment.vue
│ │ │ ├── VideoSegment.vue
│ │ │ ├── AudioSegment.vue
│ │ │ ├── IframeSegment.vue
│ │ │ └── GallerySegment.vue
│ │ └── admin/
│ │ ├── AdminToolbar.vue
│ │ ├── SegmentEditor.vue
│ │ ├── AssetUploader.vue
│ │ ├── AddSegmentModal.vue # Modal for creating new segments
│ │ └── TokenPrompt.vue
│ └── views/
│ └── HomePage.vue
└── data/ # Docker volume mount
├── handin.db # SQLite database
└── assets/ # Uploaded files
```
---
## Data Persistence — SQLite
SQLite with WAL mode for concurrent student edits. Single portable file, no external service.
### Schema
```sql
CREATE TABLE site (key TEXT PRIMARY KEY, value TEXT NOT NULL);
CREATE TABLE segments (
id TEXT PRIMARY KEY, -- UUID as text
type TEXT NOT NULL, -- markdown|pdf|video|audio|iframe|gallery
sort_order INTEGER NOT NULL,
title TEXT NOT NULL,
content TEXT NOT NULL DEFAULT '',
metadata TEXT NOT NULL DEFAULT '{}', -- JSON string
created_at TEXT NOT NULL, -- ISO 8601
updated_at TEXT NOT NULL -- ISO 8601
);
CREATE INDEX idx_segments_order ON segments(sort_order);
```
**Segment types:** `markdown`, `pdf`, `video`, `audio`, `iframe`, `gallery`, `link`.
> **`link` type:** Nav-only segment — renders as an external `<a target="_blank">` in the tab bar and mobile menu. Does not appear in the main content area. The `content` field stores the destination URL.
### Database layer (`database.py`)
- WAL mode: `PRAGMA journal_mode=WAL` for concurrent read/write.
- `PRAGMA busy_timeout=5000` so concurrent writers wait instead of failing.
- Schema auto-created on startup via `CREATE TABLE IF NOT EXISTS`.
- Connection managed per-request via FastAPI dependency.
### Assets
Uploaded files stored as `data/assets/<uuid>.<ext>`. Original filename preserved only in segment metadata (prevents path traversal and collisions).
---
## Auth
- Single token set via `HANDIN_ADMIN_TOKEN` env var.
- Accepted via `?token=` query param or `Authorization: Bearer` header.
- `require_admin` FastAPI dependency on all write endpoints.
- Public endpoints (GET) require no auth.
- `GET /api/auth/verify` for frontend token validation on load.
---
## API Endpoints
```
PUBLIC:
GET /api/site/ Site metadata (title)
GET /api/segments/ All segments, ordered by sort_order
GET /api/segments/{id} Single segment
GET /api/assets/{filename} Served via StaticFiles
NOTE: Collection routes use trailing slashes. Omitting them causes 307 redirects.
ADMIN (token required):
PUT /api/site/ Update site title
POST /api/segments/ Create segment
PATCH /api/segments/{id} Update segment (partial — send only changed fields)
DELETE /api/segments/{id} Delete segment
PUT /api/segments/reorder Reorder (body: ordered UUID list)
POST /api/assets/ Upload file (multipart)
DELETE /api/assets/{filename} Delete asset
GET /api/auth/verify Validate token
```
**Note:** Register `/reorder` route before `/{id}` to avoid path conflict.
---
## UI Design — Tailwind + #ffcd00 Theme
### Color System
Primary color `#ffcd00` (golden yellow). Tailwind extended palette:
| Token | Hex | Usage |
|-------------|-----------|----------------------------------|
| primary-50 | `#fffbeb` | Backgrounds, hover states |
| primary-100 | `#fff3c4` | Editor panel borders |
| primary-200 | `#fce588` | Subtle highlights |
| primary-300 | `#ffcd00` | **Brand color** — accents, active states |
| primary-400 | `#e6b800` | Hover on primary elements |
| primary-500 | `#cc9900` | Text on light backgrounds |
| primary-600 | `#997300` | Dark accent text |
| primary-700 | `#664d00` | Darkest accent |
Neutrals: Tailwind `slate` for text and backgrounds. Dark sidebar (`slate-900`), light main content.
### Layout — Top Bar with Tabs
> **Note:** The original plan specified a sidebar layout. Implementation changed to a horizontal top bar with tabs (Slice 5). Component **file names** from the original plan are preserved but their implementations differ.
**Desktop (≥768px `md+`):**
- **Title bar:** `bg-primary-300` (golden yellow) full-width at top, dark text, lock icon (admin entry) on the right.
- **Tab navigation:** Horizontal tab bar below the title bar, `border-b-2 border-primary-400` on the active tab.
- **Content area:** Centered `max-w-4xl` on white background.
**Mobile (<768px):**
- Title bar still visible. Tab bar hidden.
- `MobileHeader.vue` shows hamburger icon → dropdown from top with backdrop overlay.
- Segments stack vertically, full-width, comfortable touch targets.
**Component mapping:**
- `SidebarNav.vue` → horizontal tab bar (desktop, hidden below `md`).
- `MobileHeader.vue` → hamburger dropdown (mobile, hidden at `md+`).
- `AppShell.vue` → composes title bar + TokenPrompt + tab nav + mobile header + AdminToolbar + main content slot.
### Admin Integration (Minimal Interference)
No separate admin page. Controls blend into the existing UI:
- **Token active:** subtle edit icons on segment cards (pencil, muted → `primary-300` on hover). Drag handles in sidebar nav. Thin `primary-300` top bar with "Editing" badge + "Add Segment" button.
- **Segment editing:** inline — editor panel expands below the card. Same card styling, `primary-100` border.
- **Asset upload:** dashed-border drag-and-drop zone matching card aesthetics.
- **Token inactive:** zero admin UI visible.
---
## Dependencies
**Backend:** `fastapi`, `uvicorn[standard]`, `pydantic`, `pydantic-settings`, `python-multipart`
**Backend dev:** `pytest`, `httpx`, `ruff`, `mypy`
**Frontend runtime:** `vue`, `marked`, `vuedraggable`
**Frontend dev:** `vite`, `@vitejs/plugin-vue`, `typescript`, `vue-tsc`, `tailwindcss`, `@tailwindcss/vite`, `@tailwindcss/typography`
---
## Docker Strategy
Multi-stage Dockerfile:
1. **Stage 1** (`node:20-alpine`): `npm ci && npm run build``dist/`.
2. **Stage 2** (`python:3.12-slim`): `uv sync --frozen --no-dev`, copy backend + built frontend to `/app/static/`.
FastAPI serves:
- API routes at `/api/*` (registered first)
- Uploaded assets at `/api/assets` via `StaticFiles`
- Vue SPA at `/` via `StaticFiles(directory="/app/static", html=True)` (registered last, catch-all)
`docker-compose.yml`: named volume for `/data`, `HANDIN_ADMIN_TOKEN` from environment.
---
## Verification
1. `uv run pytest backend/tests/ -v` — all pass
2. `uv run ruff check backend/` + `uv run mypy --strict backend/app/` — clean
3. `docker compose up --build` — serves at `localhost:8000`
4. No-token visit: read-only, sidebar nav works, mobile responsive
5. Token visit: admin controls appear inline, CRUD + reorder works
6. Concurrent edits from two browsers: no corruption
7. Container restart: data persists