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

10 KiB

Academic Group Submission Website — Architecture Plan

Implementation guide: See 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

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 builddist/.
  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