From b47f825d54cbf84d67b806b4d50da25ce09046df Mon Sep 17 00:00:00 2001 From: Justin Visser Date: Mon, 2 Mar 2026 20:09:27 +0100 Subject: [PATCH] first commit --- .env.example | 8 + .github/workflows/ci.yml | 100 + .gitignore | 38 + Dockerfile | 51 + PLAN.md | 243 ++ README.md | 84 + SLICES.md | 175 ++ backend/app/__init__.py | 0 backend/app/auth.py | 14 + backend/app/config.py | 18 + backend/app/database.py | 41 + backend/app/main.py | 41 + backend/app/models.py | 62 + backend/app/routes/__init__.py | 0 backend/app/routes/assets.py | 69 + backend/app/routes/auth.py | 14 + backend/app/routes/segments.py | 93 + backend/app/routes/site.py | 33 + backend/app/services/__init__.py | 0 backend/app/services/asset_service.py | 35 + backend/app/services/segment_service.py | 147 ++ backend/app/services/site_service.py | 31 + backend/tests/__init__.py | 0 backend/tests/conftest.py | 60 + backend/tests/test_assets.py | 119 + backend/tests/test_concurrent.py | 81 + backend/tests/test_database.py | 78 + backend/tests/test_routes.py | 224 ++ backend/tests/test_services.py | 172 ++ coding-style.md | 133 ++ docker-compose.yml | 29 + frontend/env.d.ts | 7 + frontend/index.html | 12 + frontend/package-lock.json | 2125 +++++++++++++++++ frontend/package.json | 26 + frontend/src/App.vue | 7 + .../src/components/admin/AddSegmentModal.vue | 179 ++ .../src/components/admin/AdminToolbar.vue | 30 + .../src/components/admin/AssetUploader.vue | 68 + .../src/components/admin/SegmentEditor.vue | 188 ++ frontend/src/components/admin/TokenPrompt.vue | 79 + frontend/src/components/layout/AppShell.vue | 135 ++ .../src/components/layout/MobileHeader.vue | 129 + frontend/src/components/layout/SidebarNav.vue | 52 + .../src/components/segments/AudioSegment.vue | 17 + .../components/segments/GallerySegment.vue | 28 + .../src/components/segments/IframeSegment.vue | 17 + .../components/segments/MarkdownSegment.vue | 15 + .../src/components/segments/PdfSegment.vue | 15 + .../src/components/segments/SegmentList.vue | 139 ++ .../components/segments/SegmentRenderer.vue | 22 + .../src/components/segments/VideoSegment.vue | 17 + frontend/src/composables/useAdmin.ts | 62 + frontend/src/composables/useAssetUpload.ts | 42 + frontend/src/composables/useDarkMode.ts | 30 + frontend/src/composables/useSegments.ts | 38 + frontend/src/main.ts | 5 + frontend/src/style.css | 15 + frontend/src/types/segment.ts | 16 + frontend/src/types/vuedraggable.d.ts | 5 + frontend/src/views/HomePage.vue | 165 ++ frontend/tsconfig.json | 19 + frontend/vite.config.ts | 19 + prompts/slice-1-database-models.md | 241 ++ prompts/slice-2-segment-service.md | 393 +++ prompts/slice-3-api-routes.md | 670 ++++++ prompts/slice-4-assets-docker.md | 499 ++++ prompts/slice-5-frontend-shell.md | 323 +++ prompts/slice-6-admin-ui.md | 366 +++ prompts/slice-7-polish.md | 214 ++ pyproject.toml | 40 + ref/.github/FUNDING.yml | 1 + ref/.github/dependabot.yml | 14 + ref/.github/logo-dark.png | Bin 0 -> 88769 bytes ref/.github/workflows/ci.yml | 197 ++ ref/.github/workflows/codeql.yml | 26 + ref/.github/workflows/docs-pages.yml | 57 + ref/.github/workflows/release.yml | 32 + ref/.github/workflows/scheduled-probes.yml | 54 + ref/Dockerfile | 67 + ref/README.md | 149 ++ scripts/entrypoint.sh | 8 + uv.lock | 749 ++++++ 83 files changed, 10016 insertions(+) create mode 100644 .env.example create mode 100644 .github/workflows/ci.yml create mode 100644 .gitignore create mode 100644 Dockerfile create mode 100644 PLAN.md create mode 100644 README.md create mode 100644 SLICES.md create mode 100644 backend/app/__init__.py create mode 100644 backend/app/auth.py create mode 100644 backend/app/config.py create mode 100644 backend/app/database.py create mode 100644 backend/app/main.py create mode 100644 backend/app/models.py create mode 100644 backend/app/routes/__init__.py create mode 100644 backend/app/routes/assets.py create mode 100644 backend/app/routes/auth.py create mode 100644 backend/app/routes/segments.py create mode 100644 backend/app/routes/site.py create mode 100644 backend/app/services/__init__.py create mode 100644 backend/app/services/asset_service.py create mode 100644 backend/app/services/segment_service.py create mode 100644 backend/app/services/site_service.py create mode 100644 backend/tests/__init__.py create mode 100644 backend/tests/conftest.py create mode 100644 backend/tests/test_assets.py create mode 100644 backend/tests/test_concurrent.py create mode 100644 backend/tests/test_database.py create mode 100644 backend/tests/test_routes.py create mode 100644 backend/tests/test_services.py create mode 100644 coding-style.md create mode 100644 docker-compose.yml create mode 100644 frontend/env.d.ts create mode 100644 frontend/index.html create mode 100644 frontend/package-lock.json create mode 100644 frontend/package.json create mode 100644 frontend/src/App.vue create mode 100644 frontend/src/components/admin/AddSegmentModal.vue create mode 100644 frontend/src/components/admin/AdminToolbar.vue create mode 100644 frontend/src/components/admin/AssetUploader.vue create mode 100644 frontend/src/components/admin/SegmentEditor.vue create mode 100644 frontend/src/components/admin/TokenPrompt.vue create mode 100644 frontend/src/components/layout/AppShell.vue create mode 100644 frontend/src/components/layout/MobileHeader.vue create mode 100644 frontend/src/components/layout/SidebarNav.vue create mode 100644 frontend/src/components/segments/AudioSegment.vue create mode 100644 frontend/src/components/segments/GallerySegment.vue create mode 100644 frontend/src/components/segments/IframeSegment.vue create mode 100644 frontend/src/components/segments/MarkdownSegment.vue create mode 100644 frontend/src/components/segments/PdfSegment.vue create mode 100644 frontend/src/components/segments/SegmentList.vue create mode 100644 frontend/src/components/segments/SegmentRenderer.vue create mode 100644 frontend/src/components/segments/VideoSegment.vue create mode 100644 frontend/src/composables/useAdmin.ts create mode 100644 frontend/src/composables/useAssetUpload.ts create mode 100644 frontend/src/composables/useDarkMode.ts create mode 100644 frontend/src/composables/useSegments.ts create mode 100644 frontend/src/main.ts create mode 100644 frontend/src/style.css create mode 100644 frontend/src/types/segment.ts create mode 100644 frontend/src/types/vuedraggable.d.ts create mode 100644 frontend/src/views/HomePage.vue create mode 100644 frontend/tsconfig.json create mode 100644 frontend/vite.config.ts create mode 100644 prompts/slice-1-database-models.md create mode 100644 prompts/slice-2-segment-service.md create mode 100644 prompts/slice-3-api-routes.md create mode 100644 prompts/slice-4-assets-docker.md create mode 100644 prompts/slice-5-frontend-shell.md create mode 100644 prompts/slice-6-admin-ui.md create mode 100644 prompts/slice-7-polish.md create mode 100644 pyproject.toml create mode 100644 ref/.github/FUNDING.yml create mode 100644 ref/.github/dependabot.yml create mode 100644 ref/.github/logo-dark.png create mode 100644 ref/.github/workflows/ci.yml create mode 100644 ref/.github/workflows/codeql.yml create mode 100644 ref/.github/workflows/docs-pages.yml create mode 100644 ref/.github/workflows/release.yml create mode 100644 ref/.github/workflows/scheduled-probes.yml create mode 100644 ref/Dockerfile create mode 100644 ref/README.md create mode 100755 scripts/entrypoint.sh create mode 100644 uv.lock diff --git a/.env.example b/.env.example new file mode 100644 index 0000000..02c8c1e --- /dev/null +++ b/.env.example @@ -0,0 +1,8 @@ +# Required +HANDIN_ADMIN_TOKEN=changeme + +# Optional +HANDIN_SITE_TITLE=Untitled Site +# HANDIN_DATA_DIR=./data +# HANDIN_MAX_UPLOAD_BYTES=100000000 +# HANDIN_ALLOWED_UPLOAD_TYPES=pdf,png,jpg,jpeg,gif,mp4,webm,mp3,wav,ogg,webp diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..0420f55 --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,100 @@ +name: CI + +on: + push: + branches: [main] + pull_request: + +jobs: + repo-hygiene: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - name: Verify .env.example exists + run: test -f .env.example + + lint: + runs-on: ubuntu-latest + needs: [repo-hygiene] + steps: + - uses: actions/checkout@v4 + - uses: actions/setup-python@v5 + with: + python-version: "3.12" + - uses: astral-sh/setup-uv@v4 + - name: Install dependencies + run: uv sync --frozen --group dev + - name: Ruff check + run: uv run ruff check backend/ + - name: Ruff format check + run: uv run ruff format --check backend/ + - name: Mypy + run: uv run mypy + + test: + runs-on: ubuntu-latest + needs: [lint] + env: + HANDIN_ADMIN_TOKEN: ci-test-token + HANDIN_DATA_DIR: /tmp/handin-test-data + steps: + - uses: actions/checkout@v4 + - uses: actions/setup-python@v5 + with: + python-version: "3.12" + - uses: astral-sh/setup-uv@v4 + - name: Install dependencies + run: uv sync --frozen --group dev + - name: Run tests + run: uv run pytest + + frontend-quality: + runs-on: ubuntu-latest + needs: [repo-hygiene] + defaults: + run: + working-directory: frontend + steps: + - uses: actions/checkout@v4 + - uses: actions/setup-node@v4 + with: + node-version: "20" + - name: Install dependencies + run: npm ci + - name: Typecheck + run: npx vue-tsc --noEmit + - name: Build + run: npm run build + + docker-publish: + runs-on: ubuntu-latest + needs: [test, frontend-quality] + if: github.event_name == 'push' && github.ref == 'refs/heads/main' + permissions: + contents: read + steps: + - uses: actions/checkout@v4 + - uses: docker/setup-qemu-action@v3 + - uses: docker/setup-buildx-action@v3 + - uses: docker/login-action@v3 + with: + username: ${{ secrets.DOCKERHUB_USERNAME }} + password: ${{ secrets.DOCKERHUB_PASSWORD }} + - id: meta + uses: docker/metadata-action@v5 + with: + images: justinzeus/handin + tags: | + type=raw,value=latest + type=sha,format=short,prefix=sha- + - uses: docker/build-push-action@v6 + with: + context: . + file: ./Dockerfile + target: prod + push: true + platforms: linux/amd64,linux/arm64 + tags: ${{ steps.meta.outputs.tags }} + labels: ${{ steps.meta.outputs.labels }} + cache-from: type=gha + cache-to: type=gha,mode=max diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..0830dd4 --- /dev/null +++ b/.gitignore @@ -0,0 +1,38 @@ +# Python +__pycache__/ +*.py[cod] +*.egg-info/ +dist/ +build/ +.mypy_cache/ +.ruff_cache/ +.pytest_cache/ +*.egg + +# Virtual environments +.venv/ +venv/ + +# Node +node_modules/ +frontend/dist/ + +# Data (persistent volume content) +data/ + +# IDE +.vscode/ +.idea/ +*.swp +*.swo + +# Environment +.env +.env.local + +# OS +.DS_Store +Thumbs.db + +.claude/ +.claude \ No newline at end of file diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..66af4fd --- /dev/null +++ b/Dockerfile @@ -0,0 +1,51 @@ +# ---- Stage 1: Build frontend ---- +FROM node:20-alpine AS frontend-builder +WORKDIR /build +COPY frontend/package.json frontend/package-lock.json ./ +RUN npm ci +COPY frontend/ . +RUN npm run build + +# ---- Stage 2: Dev (includes dev dependencies) ---- +FROM python:3.12-slim AS dev + +ENV PYTHONDONTWRITEBYTECODE=1 \ + PYTHONUNBUFFERED=1 \ + UV_LINK_MODE=copy \ + UV_PROJECT_ENVIRONMENT=/opt/venv \ + PATH="/opt/venv/bin:$PATH" + +WORKDIR /app + +COPY --from=ghcr.io/astral-sh/uv:0.6 /uv /uvx /bin/ + +COPY pyproject.toml uv.lock ./ +RUN uv sync --frozen --group dev + +COPY backend/ backend/ +COPY scripts/ scripts/ + +ENTRYPOINT ["/bin/sh", "/app/scripts/entrypoint.sh"] + +# ---- Stage 3: Production (minimal, no dev deps) ---- +FROM python:3.12-slim AS prod + +ENV PYTHONDONTWRITEBYTECODE=1 \ + PYTHONUNBUFFERED=1 \ + UV_LINK_MODE=copy \ + UV_PROJECT_ENVIRONMENT=/opt/venv \ + PATH="/opt/venv/bin:$PATH" + +WORKDIR /app + +COPY --from=ghcr.io/astral-sh/uv:0.6 /uv /uvx /bin/ + +COPY pyproject.toml uv.lock ./ +RUN uv sync --frozen --no-dev + +COPY backend/ backend/ +COPY --from=frontend-builder /build/dist static/ +COPY scripts/ scripts/ + +EXPOSE 8000 +ENTRYPOINT ["/bin/sh", "/app/scripts/entrypoint.sh"] diff --git a/PLAN.md b/PLAN.md new file mode 100644 index 0000000..377d668 --- /dev/null +++ b/PLAN.md @@ -0,0 +1,243 @@ +# 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 `` 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/.`. 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 diff --git a/README.md b/README.md new file mode 100644 index 0000000..f53c1d0 --- /dev/null +++ b/README.md @@ -0,0 +1,84 @@ +
+ +# Handin + +**A simple hand-in page builder, made for Utrecht University.** + +[![CI](https://img.shields.io/github/actions/workflow/status/JustinZeus/handin-website/ci.yml?style=for-the-badge)](https://github.com/JustinZeus/handin-website/actions/workflows/ci.yml) +[![Docker Pulls](https://img.shields.io/docker/pulls/justinzeus/handin?style=for-the-badge&logo=docker)](https://hub.docker.com/r/justinzeus/handin) +[![Python](https://img.shields.io/badge/python-3.12+-3776AB?style=for-the-badge&logo=python&logoColor=white)](https://python.org) + +
+ +--- + +## 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. + +## Features + +- **Multimodal content** — markdown, PDF, video, audio, iframes, image galleries, and links +- **Drag-and-drop ordering** — reorder 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 + +```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 +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. + +## How It Works + +```mermaid +graph LR + Browser <-->|HTTP| FastAPI[FastAPI + Vue SPA] + FastAPI --> DB[(SQLite)] + FastAPI --> Assets[/data/assets/] +``` + +The Vue 3 frontend is built at image build time and served as static files by FastAPI. All data (database + uploaded assets) lives in a single `/data` volume. + +## Configuration + +| Variable | Default | Description | +|----------|---------|-------------| +| `HANDIN_ADMIN_TOKEN` | *required* | Bearer token for admin endpoints | +| `HANDIN_SITE_TITLE` | `Untitled Site` | Page title | +| `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 | + +## Tech Stack + +| Layer | Technology | +|-------|------------| +| Backend | Python 3.12, FastAPI, Pydantic v2, SQLite | +| Frontend | TypeScript, Vue 3, Vite, Tailwind CSS v4 | +| Infrastructure | Multi-stage Docker, Docker Compose, UV | + +## Development + +```bash +# Backend +uv sync --group dev +uv run ruff check backend/ +uv run ruff format --check backend/ +uv run mypy +uv run pytest + +# Frontend +cd frontend +npm ci +npx vue-tsc --noEmit +npm run dev +``` diff --git a/SLICES.md b/SLICES.md new file mode 100644 index 0000000..1106d17 --- /dev/null +++ b/SLICES.md @@ -0,0 +1,175 @@ +# Implementation Slices + +Each slice is a self-contained unit of work. Implement sequentially — each slice builds on the previous. Every slice follows TDD: write tests first, then implement until tests pass. + +**Reference:** See [PLAN.md](PLAN.md) for full architecture context, color system, layout details, and project structure. + +**Prompt files:** Each slice has a corresponding prompt in `prompts/` that can be handed to an agent for autonomous execution. + +--- + +## Slice 0: Project Scaffold ✅ + +**Goal:** Set up the project skeleton so all subsequent slices have a working dev environment. + +**Deliverables:** +- `pyproject.toml` — Python 3.12+, managed with `uv`. Deps: `fastapi`, `uvicorn[standard]`, `pydantic`, `pydantic-settings`, `python-multipart`. Dev deps (via `[dependency-groups]`): `pytest`, `httpx`, `ruff`, `mypy`. Ruff + mypy config sections included. +- `backend/app/__init__.py` (empty) +- `backend/app/config.py` — `Settings` class via `pydantic-settings` with fields: `admin_token: str`, `data_dir: str = "./data"`, `max_upload_bytes: int = 100_000_000`, `allowed_upload_types: str = "pdf,png,jpg,jpeg,gif,mp4,webm,mp3,wav,ogg,webp"`. Env prefix `HANDIN_`. +- `backend/tests/__init__.py` (empty) +- `backend/tests/conftest.py` — fixtures: `tmp_data_dir` (creates temp dir with `assets/` subdir), `settings` (overrides `data_dir` and `admin_token`), `db` (initializes SQLite in temp dir), `client` (FastAPI `TestClient`). +- `frontend/package.json` — deps: `vue`, `marked`, `vuedraggable`. Dev deps: `vite`, `@vitejs/plugin-vue`, `typescript`, `vue-tsc`, `tailwindcss`, `@tailwindcss/vite`, `@tailwindcss/typography`. Pin exact versions. +- `frontend/tsconfig.json`, `frontend/vite.config.ts` (with `@tailwindcss/vite` plugin). +- `frontend/src/main.ts`, `frontend/src/App.vue` (minimal), `frontend/src/style.css` (Tailwind directives). +- `frontend/index.html` +- `.gitignore` — Python, Node, data dir, IDE files. + +**Verify:** `uv run pytest backend/tests/ -v` runs (0 tests collected, no errors). `cd frontend && npm install && npm run build` succeeds. + +--- + +## Slice 1: Database + Models ✅ + +**Prompt:** `prompts/slice-1-database-models.md` + +**Goal:** SQLite persistence layer with schema initialization and Pydantic models. + +**Tests first (`backend/tests/test_database.py`):** +- Test: `init_db` creates tables (`site`, `segments`) in a fresh DB. +- Test: calling `init_db` twice is idempotent. +- Test: WAL mode is enabled after init. +- Test: `get_connection` returns a connection with `busy_timeout` set. + +**Implement:** +- `backend/app/database.py` — `init_db(db_path)` creates schema with `CREATE TABLE IF NOT EXISTS`. Sets `PRAGMA journal_mode=WAL` and `PRAGMA busy_timeout=5000`. `get_connection(db_path)` returns a `sqlite3.Connection` with row factory. +- `backend/app/models.py` — `SegmentType(StrEnum)`, `Segment(BaseModel)`, `SegmentCreateRequest`, `SegmentUpdateRequest`, `ReorderRequest`, `SegmentResponse`, `SiteResponse`, `SiteUpdateRequest`. + +**Verify:** `uv run pytest backend/tests/test_database.py -v` — all pass. `uv run mypy --strict backend/app/models.py backend/app/database.py` — clean. + +--- + +## Slice 2: Segment & Site Service Layer ✅ + +**Prompt:** `prompts/slice-2-segment-service.md` + +**Goal:** Business logic + persistence for segments and site metadata. + +**Tests first (`backend/tests/test_services.py`):** +- 12 segment service tests (create, auto-increment sort_order, list empty/ordered, get found/not found, update title/not found, delete found/not found, reorder valid/invalid) +- 3 site service tests (default title, update + get, overwrite) + +**Implement:** +- `backend/app/services/segment_service.py` — `create_segment`, `list_segments`, `get_segment`, `update_segment`, `delete_segment`, `reorder_segments`, `_row_to_segment`. +- `backend/app/services/site_service.py` — `get_site_title`, `update_site_title`. + +**Verify:** 15 service tests pass. Zero mypy/ruff errors. + +--- + +## Slice 3: Auth + API Routes + Concurrency + +**Prompt:** `prompts/slice-3-api-routes.md` + +**Goal:** Complete the backend — auth dependency, all API route handlers, concurrent write safety. + +**Covers (from original plan):** Auth (PLAN slice 2), Segment CRUD routes (3), Reorder route (4), Site routes (6), Concurrency tests (7). + +**Tests first:** +- `backend/tests/test_routes.py` — ~19 HTTP-level tests for segments, site, and auth (CRUD, reorder, unauthorized access, not-found cases) +- `backend/tests/test_concurrent.py` — 2 concurrency tests (parallel creates, concurrent create + reorder) + +**Implement:** +- `backend/app/auth.py` — `require_admin` dependency. Checks `?token=` query param first, falls back to `Authorization: Bearer` header. +- `backend/app/routes/auth.py` — `GET /api/auth/verify` endpoint. +- `backend/app/routes/segments.py` — CRUD + reorder at `/api/segments`. Public: GET list, GET by id. Admin: POST, PATCH, DELETE, PUT reorder. +- `backend/app/routes/site.py` — GET + PUT at `/api/site`. Public: GET. Admin: PUT. +- `backend/app/main.py` — register routers, wire lifespan with `init_db`. + +**Verify:** All backend tests pass (~40 total). Zero mypy/ruff errors across entire backend. + +--- + +## Slice 4: Asset Service + Docker + +**Prompt:** `prompts/slice-4-assets-docker.md` + +**Goal:** File upload/serve/delete and containerized deployment. + +**Covers (from original plan):** Asset Upload + Serve (5), Docker (13). + +**Tests first (`backend/tests/test_assets.py`):** +- 8 tests: upload valid file, file exists on disk, disallowed type → 415, too large → 413, no auth → 401, delete file, delete not found → 404, serve via StaticFiles. + +**Implement:** +- `backend/app/services/asset_service.py` — `save_asset`, `delete_asset`. +- `backend/app/routes/assets.py` — POST upload, DELETE by filename. +- Mount `StaticFiles` at `/api/assets` in `main.py`. +- `Dockerfile` — multi-stage build (Node → Python). +- `docker-compose.yml` — single service, volume mount, env vars. + +**Verify:** All backend tests pass. `docker compose up --build` serves at localhost:8000. + +--- + +## Slice 5: Frontend Shell + Renderers + +**Prompt:** `prompts/slice-5-frontend-shell.md` + +**Goal:** Read-only frontend — app shell, sidebar nav, mobile layout, all segment renderers. + +**Covers (from original plan):** Frontend Shell + Tailwind Theme (8), Segment Renderers (9). + +**Deliverables:** +- `style.css` — Tailwind v4 with `@theme` block for primary palette. +- `types/segment.ts` — TypeScript types matching backend models. +- Layout components: `AppShell.vue`, `SidebarNav.vue`, `MobileHeader.vue`. +- `composables/useSegments.ts` — fetch segments + site title. +- `views/HomePage.vue` — main page rendering. +- Segment components: `SegmentRenderer.vue`, `SegmentList.vue`, plus renderers for markdown, PDF, video, audio, iframe, gallery. + +**Verify:** `npm run build` succeeds. Dev server renders sidebar + segments correctly on desktop and mobile. + +--- + +## Slice 6: Admin UI ✅ + +**Prompt:** `prompts/slice-6-admin-ui.md` + +**Goal:** Full admin experience — token entry, inline editing, asset upload, drag-and-drop reorder. + +**Covers (from original plan):** Admin Token + Toolbar (10), Segment Editor + CRUD (11), Asset Upload + Drag-and-Drop Reorder (12). + +**Deliverables:** +- `composables/useAdmin.ts` — token management via module-level reactive state, auth headers, verify/login/logout, sessionStorage persistence. +- `composables/useAssetUpload.ts` — file upload via FormData + native fetch. +- `components/admin/TokenPrompt.vue` — subtle lock icon in title bar → inline token input. +- `components/admin/AdminToolbar.vue` — thin editing bar below tabs with "Editing" badge + "Add Segment" + "Logout". +- `components/admin/SegmentEditor.vue` — inline editor below each segment card, type-aware content editing, custom styled delete confirmation modal. +- `components/admin/AssetUploader.vue` — drag-and-drop file upload zone with click-to-browse. +- `components/admin/AddSegmentModal.vue` — modal dialog with button-group type selector for creating new segments. +- `types/vuedraggable.d.ts` — type declaration for vuedraggable 4.x. +- Drag-and-drop reorder via `vuedraggable` in `SegmentList.vue` with grip-dot handles. +- Modified `AppShell.vue` — TokenPrompt in header, AdminToolbar below tabs (when authenticated). +- Modified `SegmentList.vue` — pencil edit icons, inline editors, draggable reorder (admin), plain rendering (read-only). +- Modified `HomePage.vue` — wires all admin mutations (create/PATCH/DELETE/reorder) with auth headers + refresh(). +- **New segment type: `link`** — nav-only external links. Renders as `
` in SidebarNav/MobileHeader, excluded from content area. Backend `SegmentType` enum and frontend type updated. + +**Verify:** `npm run build` passes with zero errors. Full admin flow works end-to-end. No admin UI visible without token. + +--- + +## Slice 7: Polish + Final Validation + +**Prompt:** `prompts/slice-7-polish.md` + +**Goal:** End-to-end sweep, edge cases, visual polish. + +**Tasks:** +- Run full backend test suite: `uv run pytest backend/tests/ -v`. +- Run `uv run ruff check backend/` + `uv run mypy --strict backend/app/`. +- Run `npm run lint` + `npm run type-check` in frontend. +- Test mobile layout on narrow viewport (sidebar collapses, touch-friendly targets). +- Test all segment types render correctly in read-only mode. +- Test admin flow end-to-end: enter token, add each segment type, upload assets, reorder, delete, logout. +- Test concurrent edits: two browser tabs editing simultaneously. +- Verify no admin UI leaks in read-only mode. diff --git a/backend/app/__init__.py b/backend/app/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/backend/app/auth.py b/backend/app/auth.py new file mode 100644 index 0000000..772365a --- /dev/null +++ b/backend/app/auth.py @@ -0,0 +1,14 @@ +from fastapi import HTTPException, Request + +from app.config import get_settings + + +def require_admin(request: Request) -> None: + token = request.query_params.get("token") + if token is None: + auth_header = request.headers.get("authorization", "") + if auth_header.startswith("Bearer "): + token = auth_header.removeprefix("Bearer ") + + if token is None or token != get_settings().admin_token: + raise HTTPException(status_code=401, detail="Invalid or missing token") diff --git a/backend/app/config.py b/backend/app/config.py new file mode 100644 index 0000000..d23d18a --- /dev/null +++ b/backend/app/config.py @@ -0,0 +1,18 @@ +from functools import lru_cache + +from pydantic_settings import BaseSettings + + +class Settings(BaseSettings): + admin_token: str + site_title: str = "Untitled Site" + data_dir: str = "./data" + max_upload_bytes: int = 100_000_000 + allowed_upload_types: str = "pdf,png,jpg,jpeg,gif,mp4,webm,mp3,wav,ogg,webp" + + model_config = {"env_prefix": "HANDIN_"} + + +@lru_cache +def get_settings() -> Settings: + return Settings() diff --git a/backend/app/database.py b/backend/app/database.py new file mode 100644 index 0000000..98f91ef --- /dev/null +++ b/backend/app/database.py @@ -0,0 +1,41 @@ +import sqlite3 +from pathlib import Path + + +def init_db(db_path: Path) -> None: + db_path.parent.mkdir(parents=True, exist_ok=True) + conn = sqlite3.connect(db_path) + try: + conn.execute("PRAGMA journal_mode=WAL") + conn.execute("PRAGMA busy_timeout=5000") + conn.execute(""" + CREATE TABLE IF NOT EXISTS site ( + key TEXT PRIMARY KEY, + value TEXT NOT NULL + ) + """) + conn.execute(""" + CREATE TABLE IF NOT EXISTS segments ( + id TEXT PRIMARY KEY, + type TEXT NOT NULL, + sort_order INTEGER NOT NULL, + title TEXT NOT NULL, + content TEXT NOT NULL DEFAULT '', + metadata TEXT NOT NULL DEFAULT '{}', + created_at TEXT NOT NULL, + updated_at TEXT NOT NULL + ) + """) + conn.execute( + "CREATE INDEX IF NOT EXISTS idx_segments_order ON segments(sort_order)" + ) + conn.commit() + finally: + conn.close() + + +def get_connection(db_path: Path) -> sqlite3.Connection: + conn = sqlite3.connect(db_path) + conn.row_factory = sqlite3.Row + conn.execute("PRAGMA busy_timeout=5000") + return conn diff --git a/backend/app/main.py b/backend/app/main.py new file mode 100644 index 0000000..4bb9d75 --- /dev/null +++ b/backend/app/main.py @@ -0,0 +1,41 @@ +from collections.abc import AsyncIterator +from contextlib import asynccontextmanager +from pathlib import Path + +from fastapi import FastAPI +from fastapi.staticfiles import StaticFiles + +from app.config import get_settings +from app.database import init_db +from app.routes.assets import router as assets_router +from app.routes.auth import router as auth_router +from app.routes.segments import router as segments_router +from app.routes.site import router as site_router + + +@asynccontextmanager +async def lifespan(_app: FastAPI) -> AsyncIterator[None]: + data_dir = Path(get_settings().data_dir) + init_db(data_dir / "handin.db") + assets_path = data_dir / "assets" + assets_path.mkdir(parents=True, exist_ok=True) + yield + + +app = FastAPI(title="Handin Website", lifespan=lifespan) + +app.include_router(auth_router) +app.include_router(segments_router) +app.include_router(site_router) +app.include_router(assets_router) + + +@app.get("/api/health") +def health_check() -> dict[str, str]: + return {"status": "ok"} + + +# Serve built frontend SPA (must be registered last — catch-all) +_static_dir = Path(__file__).resolve().parent.parent.parent / "static" +if _static_dir.is_dir(): + app.mount("/", StaticFiles(directory=str(_static_dir), html=True), name="static") diff --git a/backend/app/models.py b/backend/app/models.py new file mode 100644 index 0000000..32299fd --- /dev/null +++ b/backend/app/models.py @@ -0,0 +1,62 @@ +from datetime import datetime +from enum import StrEnum +from uuid import UUID + +from pydantic import BaseModel, Field + + +class SegmentType(StrEnum): + MARKDOWN = "markdown" + PDF = "pdf" + VIDEO = "video" + AUDIO = "audio" + IFRAME = "iframe" + GALLERY = "gallery" + LINK = "link" + + +class Segment(BaseModel): + id: UUID + type: SegmentType + sort_order: int + title: str + content: str = "" + metadata: dict[str, object] = Field(default_factory=dict) + created_at: datetime + updated_at: datetime + + +class SegmentCreateRequest(BaseModel): + type: SegmentType + title: str + content: str = "" + metadata: dict[str, object] = Field(default_factory=dict) + + +class SegmentUpdateRequest(BaseModel): + title: str | None = None + content: str | None = None + metadata: dict[str, object] | None = None + + +class ReorderRequest(BaseModel): + segment_ids: list[UUID] + + +class SiteUpdateRequest(BaseModel): + title: str + + +class SegmentResponse(BaseModel): + id: UUID + type: SegmentType + sort_order: int + title: str + content: str + metadata: dict[str, object] + created_at: datetime + updated_at: datetime + + +class SiteResponse(BaseModel): + title: str diff --git a/backend/app/routes/__init__.py b/backend/app/routes/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/backend/app/routes/assets.py b/backend/app/routes/assets.py new file mode 100644 index 0000000..0a4ac0a --- /dev/null +++ b/backend/app/routes/assets.py @@ -0,0 +1,69 @@ +from pathlib import Path +from typing import Annotated + +from fastapi import APIRouter, Depends, HTTPException, Response, UploadFile +from fastapi.responses import FileResponse + +from app.auth import require_admin +from app.config import Settings, get_settings +from app.services import asset_service + +router = APIRouter(prefix="/api/assets", tags=["assets"]) + + +def get_assets_dir() -> Path: + return Path(get_settings().data_dir) / "assets" + + +AssetsDir = Annotated[Path, Depends(get_assets_dir)] +Admin = Annotated[None, Depends(require_admin)] +AppSettings = Annotated[Settings, Depends(get_settings)] + + +@router.post("/", status_code=201) +async def upload_asset( + file: UploadFile, + _admin: Admin, + assets_dir: AssetsDir, + settings: AppSettings, +) -> dict[str, str]: + content = await file.read() + try: + saved = asset_service.save_asset( + assets_dir, + file.filename or "upload", + content, + settings.allowed_upload_types, + settings.max_upload_bytes, + ) + except ValueError as exc: + msg = str(exc) + if "File too large" in msg: + raise HTTPException(status_code=413, detail=msg) from None + raise HTTPException(status_code=415, detail=msg) from None + return {"filename": saved} + + +@router.get("/{filename}") +def serve_asset( + filename: str, + assets_dir: AssetsDir, +) -> FileResponse: + file_path = (assets_dir / filename).resolve() + if not str(file_path).startswith(str(assets_dir.resolve())): + raise HTTPException(status_code=404, detail="Asset not found") + if not file_path.exists(): + raise HTTPException(status_code=404, detail="Asset not found") + return FileResponse(file_path) + + +@router.delete("/{filename}") +def delete_asset( + filename: str, + _admin: Admin, + assets_dir: AssetsDir, +) -> Response: + deleted = asset_service.delete_asset(assets_dir, filename) + if not deleted: + raise HTTPException(status_code=404, detail="Asset not found") + return Response(status_code=204) diff --git a/backend/app/routes/auth.py b/backend/app/routes/auth.py new file mode 100644 index 0000000..f956bc5 --- /dev/null +++ b/backend/app/routes/auth.py @@ -0,0 +1,14 @@ +from typing import Annotated + +from fastapi import APIRouter, Depends + +from app.auth import require_admin + +router = APIRouter(prefix="/api/auth", tags=["auth"]) + +Admin = Annotated[None, Depends(require_admin)] + + +@router.get("/verify") +def verify_token(_: Admin) -> dict[str, bool]: + return {"valid": True} diff --git a/backend/app/routes/segments.py b/backend/app/routes/segments.py new file mode 100644 index 0000000..e2a150b --- /dev/null +++ b/backend/app/routes/segments.py @@ -0,0 +1,93 @@ +import threading +from pathlib import Path +from typing import Annotated +from uuid import UUID + +from fastapi import APIRouter, Depends, HTTPException, Response + +from app.auth import require_admin +from app.config import get_settings +from app.database import init_db +from app.models import ( + ReorderRequest, + SegmentCreateRequest, + SegmentResponse, + SegmentUpdateRequest, +) +from app.services import segment_service + +router = APIRouter(prefix="/api/segments", tags=["segments"]) + +# Serialize writes to prevent sort_order race conditions in SQLite +_write_lock = threading.Lock() + + +def get_db_path() -> Path: + data_dir = get_settings().data_dir + db_path = Path(data_dir) / "handin.db" + init_db(db_path) + return db_path + + +DbPath = Annotated[Path, Depends(get_db_path)] +Admin = Annotated[None, Depends(require_admin)] + + +@router.get("/") +def list_segments(db_path: DbPath) -> list[SegmentResponse]: + segments = segment_service.list_segments(db_path) + return [SegmentResponse.model_validate(s.model_dump()) for s in segments] + + +@router.post("/", status_code=201) +def create_segment( + request: SegmentCreateRequest, + _: Admin, + db_path: DbPath, +) -> SegmentResponse: + with _write_lock: + segment = segment_service.create_segment(db_path, request) + return SegmentResponse.model_validate(segment.model_dump()) + + +@router.put("/reorder") +def reorder_segments( + request: ReorderRequest, + _: Admin, + db_path: DbPath, +) -> list[SegmentResponse]: + with _write_lock: + try: + segments = segment_service.reorder_segments(db_path, request.segment_ids) + except ValueError as e: + raise HTTPException(status_code=400, detail=str(e)) from e + return [SegmentResponse.model_validate(s.model_dump()) for s in segments] + + +@router.get("/{segment_id}") +def get_segment(segment_id: UUID, db_path: DbPath) -> SegmentResponse: + segment = segment_service.get_segment(db_path, segment_id) + if segment is None: + raise HTTPException(status_code=404, detail="Segment not found") + return SegmentResponse.model_validate(segment.model_dump()) + + +@router.patch("/{segment_id}") +def update_segment( + segment_id: UUID, + request: SegmentUpdateRequest, + _: Admin, + db_path: DbPath, +) -> SegmentResponse: + segment = segment_service.update_segment(db_path, segment_id, request) + if segment is None: + raise HTTPException(status_code=404, detail="Segment not found") + return SegmentResponse.model_validate(segment.model_dump()) + + +@router.delete("/{segment_id}", status_code=204) +def delete_segment(segment_id: UUID, _: Admin, db_path: DbPath) -> Response: + deleted = segment_service.delete_segment(db_path, segment_id) + if not deleted: + raise HTTPException(status_code=404, detail="Segment not found") + return Response(status_code=204) diff --git a/backend/app/routes/site.py b/backend/app/routes/site.py new file mode 100644 index 0000000..3a96e8f --- /dev/null +++ b/backend/app/routes/site.py @@ -0,0 +1,33 @@ +from pathlib import Path +from typing import Annotated + +from fastapi import APIRouter, Depends + +from app.auth import require_admin +from app.config import get_settings +from app.models import SiteResponse, SiteUpdateRequest +from app.routes.segments import get_db_path +from app.services import site_service + +router = APIRouter(prefix="/api/site", tags=["site"]) + +Admin = Annotated[None, Depends(require_admin)] +DbPath = Annotated[Path, Depends(get_db_path)] + + +@router.get("/") +def get_site(db_path: DbPath) -> SiteResponse: + title = site_service.get_site_title( + db_path, default=get_settings().site_title + ) + return SiteResponse(title=title) + + +@router.put("/") +def update_site( + request: SiteUpdateRequest, + _: Admin, + db_path: DbPath, +) -> SiteResponse: + title = site_service.update_site_title(db_path, request.title) + return SiteResponse(title=title) diff --git a/backend/app/services/__init__.py b/backend/app/services/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/backend/app/services/asset_service.py b/backend/app/services/asset_service.py new file mode 100644 index 0000000..614a354 --- /dev/null +++ b/backend/app/services/asset_service.py @@ -0,0 +1,35 @@ +from pathlib import Path +from uuid import uuid4 + + +def save_asset( + assets_dir: Path, + filename: str, + content: bytes, + allowed_types: str, + max_bytes: int, +) -> str: + ext = filename.rsplit(".", maxsplit=1)[-1].lower() if "." in filename else "" + allowed = {t.strip() for t in allowed_types.split(",")} + if ext not in allowed: + raise ValueError("Unsupported file type") + + if len(content) > max_bytes: + raise ValueError("File too large") + + new_filename = f"{uuid4()}.{ext}" + assets_dir.mkdir(parents=True, exist_ok=True) + (assets_dir / new_filename).write_bytes(content) + return new_filename + + +def delete_asset(assets_dir: Path, filename: str) -> bool: + file_path = (assets_dir / filename).resolve() + if not str(file_path).startswith(str(assets_dir.resolve())): + return False + + if not file_path.exists(): + return False + + file_path.unlink() + return True diff --git a/backend/app/services/segment_service.py b/backend/app/services/segment_service.py new file mode 100644 index 0000000..fc09e63 --- /dev/null +++ b/backend/app/services/segment_service.py @@ -0,0 +1,147 @@ +import json +import sqlite3 +from datetime import UTC, datetime +from pathlib import Path +from uuid import UUID, uuid4 + +from app.database import get_connection +from app.models import Segment, SegmentCreateRequest, SegmentType, SegmentUpdateRequest + + +def _row_to_segment(row: sqlite3.Row) -> Segment: + return Segment( + id=UUID(row["id"]), + type=SegmentType(row["type"]), + sort_order=row["sort_order"], + title=row["title"], + content=row["content"], + metadata=json.loads(row["metadata"]), + created_at=datetime.fromisoformat(row["created_at"]), + updated_at=datetime.fromisoformat(row["updated_at"]), + ) + + +def create_segment(db_path: Path, request: SegmentCreateRequest) -> Segment: + conn = get_connection(db_path) + try: + row = conn.execute( + "SELECT COALESCE(MAX(sort_order) + 1, 0) AS next_order FROM segments" + ).fetchone() + sort_order: int = row["next_order"] if row else 0 + + segment_id = uuid4() + now = datetime.now(UTC).isoformat() + metadata_json = json.dumps(request.metadata) + + cols = "id, type, sort_order, title, content, metadata, created_at, updated_at" + conn.execute( + f"INSERT INTO segments ({cols}) VALUES (?, ?, ?, ?, ?, ?, ?, ?)", + (str(segment_id), request.type.value, sort_order, request.title, + request.content, metadata_json, now, now), + ) + conn.commit() + + return Segment( + id=segment_id, + type=request.type, + sort_order=sort_order, + title=request.title, + content=request.content, + metadata=request.metadata, + created_at=datetime.fromisoformat(now), + updated_at=datetime.fromisoformat(now), + ) + finally: + conn.close() + + +def list_segments(db_path: Path) -> list[Segment]: + conn = get_connection(db_path) + try: + rows = conn.execute( + "SELECT * FROM segments ORDER BY sort_order ASC" + ).fetchall() + return [_row_to_segment(row) for row in rows] + finally: + conn.close() + + +def get_segment(db_path: Path, segment_id: UUID) -> Segment | None: + conn = get_connection(db_path) + try: + row = conn.execute( + "SELECT * FROM segments WHERE id = ?", (str(segment_id),) + ).fetchone() + if row is None: + return None + return _row_to_segment(row) + finally: + conn.close() + + +def update_segment( + db_path: Path, segment_id: UUID, request: SegmentUpdateRequest +) -> Segment | None: + conn = get_connection(db_path) + try: + existing = conn.execute( + "SELECT * FROM segments WHERE id = ?", (str(segment_id),) + ).fetchone() + if existing is None: + return None + + now = datetime.now(UTC).isoformat() + title = request.title if request.title is not None else existing["title"] + content = request.content if request.content is not None else existing["content"] + metadata_json = ( + json.dumps(request.metadata) if request.metadata is not None + else existing["metadata"] + ) + + conn.execute( + """UPDATE segments SET title = ?, content = ?, metadata = ?, updated_at = ? + WHERE id = ?""", + (title, content, metadata_json, now, str(segment_id)), + ) + conn.commit() + + return get_segment(db_path, segment_id) + finally: + conn.close() + + +def delete_segment(db_path: Path, segment_id: UUID) -> bool: + conn = get_connection(db_path) + try: + cursor = conn.execute( + "DELETE FROM segments WHERE id = ?", (str(segment_id),) + ) + conn.commit() + return cursor.rowcount > 0 + finally: + conn.close() + + +def reorder_segments(db_path: Path, segment_ids: list[UUID]) -> list[Segment]: + conn = get_connection(db_path) + try: + existing_ids = { + row["id"] + for row in conn.execute("SELECT id FROM segments").fetchall() + } + requested_ids = {str(sid) for sid in segment_ids} + missing = requested_ids - existing_ids + if missing: + raise ValueError(f"Segment IDs not found: {missing}") + + now = datetime.now(UTC).isoformat() + for index, sid in enumerate(segment_ids): + conn.execute( + "UPDATE segments SET sort_order = ?, updated_at = ? WHERE id = ?", + (index, now, str(sid)), + ) + conn.commit() + + return list_segments(db_path) + finally: + conn.close() diff --git a/backend/app/services/site_service.py b/backend/app/services/site_service.py new file mode 100644 index 0000000..2a650dd --- /dev/null +++ b/backend/app/services/site_service.py @@ -0,0 +1,31 @@ +from pathlib import Path + +from app.database import get_connection + +DEFAULT_SITE_TITLE = "Untitled Site" + + +def get_site_title(db_path: Path, *, default: str = DEFAULT_SITE_TITLE) -> str: + conn = get_connection(db_path) + try: + row = conn.execute( + "SELECT value FROM site WHERE key = ?", ("title",) + ).fetchone() + if row is None: + return default + return str(row["value"]) + finally: + conn.close() + + +def update_site_title(db_path: Path, title: str) -> str: + conn = get_connection(db_path) + try: + conn.execute( + "INSERT OR REPLACE INTO site (key, value) VALUES (?, ?)", + ("title", title), + ) + conn.commit() + return title + finally: + conn.close() diff --git a/backend/tests/__init__.py b/backend/tests/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/backend/tests/conftest.py b/backend/tests/conftest.py new file mode 100644 index 0000000..0267933 --- /dev/null +++ b/backend/tests/conftest.py @@ -0,0 +1,60 @@ +import os +import tempfile +from collections.abc import Generator +from pathlib import Path + +import pytest +from fastapi.testclient import TestClient + +from app.config import Settings + +TEST_ADMIN_TOKEN = "test-secret-token" + + +@pytest.fixture +def tmp_data_dir() -> Generator[Path, None, None]: + with tempfile.TemporaryDirectory() as tmpdir: + data_dir = Path(tmpdir) + (data_dir / "assets").mkdir() + yield data_dir + + +@pytest.fixture +def _env_settings(tmp_data_dir: Path) -> Generator[None, None, None]: + original_env = os.environ.copy() + os.environ["HANDIN_ADMIN_TOKEN"] = TEST_ADMIN_TOKEN + os.environ["HANDIN_DATA_DIR"] = str(tmp_data_dir) + + from app.config import get_settings + + get_settings.cache_clear() + yield + os.environ.clear() + os.environ.update(original_env) + get_settings.cache_clear() + + +@pytest.fixture +def settings(_env_settings: None) -> Settings: + from app.config import get_settings + + s = get_settings() + assert isinstance(s, Settings) + return s + + +@pytest.fixture +def db(tmp_data_dir: Path) -> Path: + """Return path to an initialized SQLite database in the temp dir.""" + from app.database import init_db + + db_path = tmp_data_dir / "handin.db" + init_db(db_path) + return db_path + + +@pytest.fixture +def client(_env_settings: None) -> TestClient: + from app.main import app + + return TestClient(app) diff --git a/backend/tests/test_assets.py b/backend/tests/test_assets.py new file mode 100644 index 0000000..b469b27 --- /dev/null +++ b/backend/tests/test_assets.py @@ -0,0 +1,119 @@ +import os +from pathlib import Path + +from fastapi.testclient import TestClient + +from tests.conftest import TEST_ADMIN_TOKEN + +BEARER_HEADERS = {"Authorization": f"Bearer {TEST_ADMIN_TOKEN}"} + + +def test_upload_asset(client: TestClient) -> None: + resp = client.post( + "/api/assets", + headers=BEARER_HEADERS, + files={"file": ("test.pdf", b"fake pdf content", "application/pdf")}, + ) + assert resp.status_code == 201 + data = resp.json() + assert "filename" in data + assert data["filename"] != "test.pdf" + assert data["filename"].endswith(".pdf") + + +def test_upload_asset_file_exists_on_disk(client: TestClient) -> None: + resp = client.post( + "/api/assets", + headers=BEARER_HEADERS, + files={"file": ("test.pdf", b"fake pdf content", "application/pdf")}, + ) + assert resp.status_code == 201 + filename = resp.json()["filename"] + + from app.config import get_settings + + assets_dir = Path(get_settings().data_dir) / "assets" + file_path = assets_dir / filename + assert file_path.exists() + assert file_path.read_bytes() == b"fake pdf content" + + +def test_upload_asset_disallowed_type(client: TestClient) -> None: + resp = client.post( + "/api/assets", + headers=BEARER_HEADERS, + files={"file": ("malware.exe", b"evil bytes", "application/octet-stream")}, + ) + assert resp.status_code == 415 + + +def test_upload_asset_too_large(client: TestClient) -> None: + from app.config import get_settings + + original = os.environ.get("HANDIN_MAX_UPLOAD_BYTES") + os.environ["HANDIN_MAX_UPLOAD_BYTES"] = "100" + get_settings.cache_clear() + try: + resp = client.post( + "/api/assets", + headers=BEARER_HEADERS, + files={"file": ("big.pdf", b"x" * 200, "application/pdf")}, + ) + assert resp.status_code == 413 + finally: + if original is None: + os.environ.pop("HANDIN_MAX_UPLOAD_BYTES", None) + else: + os.environ["HANDIN_MAX_UPLOAD_BYTES"] = original + get_settings.cache_clear() + + +def test_upload_asset_unauthorized(client: TestClient) -> None: + resp = client.post( + "/api/assets", + files={"file": ("test.pdf", b"fake pdf content", "application/pdf")}, + ) + assert resp.status_code == 401 + + +def test_delete_asset(client: TestClient) -> None: + resp = client.post( + "/api/assets", + headers=BEARER_HEADERS, + files={"file": ("test.pdf", b"fake pdf content", "application/pdf")}, + ) + assert resp.status_code == 201 + filename = resp.json()["filename"] + + from app.config import get_settings + + assets_dir = Path(get_settings().data_dir) / "assets" + + del_resp = client.delete(f"/api/assets/{filename}", headers=BEARER_HEADERS) + assert del_resp.status_code == 204 + assert not (assets_dir / filename).exists() + + +def test_delete_asset_not_found(client: TestClient) -> None: + resp = client.delete("/api/assets/nonexistent.pdf", headers=BEARER_HEADERS) + assert resp.status_code == 404 + + +def test_delete_asset_unauthorized(client: TestClient) -> None: + resp = client.delete("/api/assets/somefile.pdf") + assert resp.status_code == 401 + + +def test_serve_asset(client: TestClient) -> None: + content = b"fake pdf content for serving" + resp = client.post( + "/api/assets", + headers=BEARER_HEADERS, + files={"file": ("test.pdf", content, "application/pdf")}, + ) + assert resp.status_code == 201 + filename = resp.json()["filename"] + + get_resp = client.get(f"/api/assets/{filename}") + assert get_resp.status_code == 200 + assert get_resp.content == content diff --git a/backend/tests/test_concurrent.py b/backend/tests/test_concurrent.py new file mode 100644 index 0000000..f34a139 --- /dev/null +++ b/backend/tests/test_concurrent.py @@ -0,0 +1,81 @@ +from concurrent.futures import ThreadPoolExecutor + +from fastapi.testclient import TestClient + +from tests.conftest import TEST_ADMIN_TOKEN + +BEARER_HEADERS = {"Authorization": f"Bearer {TEST_ADMIN_TOKEN}"} + + +def test_concurrent_creates(client: TestClient) -> None: + def create_segment(i: int) -> dict[str, object]: + resp = client.post( + "/api/segments", + json={"type": "markdown", "title": f"Seg {i}"}, + headers=BEARER_HEADERS, + ) + assert resp.status_code == 201 + data: dict[str, object] = resp.json() + return data + + with ThreadPoolExecutor(max_workers=10) as pool: + results = list(pool.map(create_segment, range(10))) + + assert len(results) == 10 + + ids = {r["id"] for r in results} + assert len(ids) == 10 + + listing = client.get("/api/segments").json() + assert len(listing) == 10 + + sort_orders = sorted(s["sort_order"] for s in listing) + assert sort_orders == list(range(10)) + + +def test_concurrent_create_and_reorder(client: TestClient) -> None: + segments = [] + for i in range(3): + resp = client.post( + "/api/segments", + json={"type": "markdown", "title": f"Init {i}"}, + headers=BEARER_HEADERS, + ) + assert resp.status_code == 201 + segments.append(resp.json()) + + seg_ids = [s["id"] for s in segments] + + def create_extra(i: int) -> dict[str, object]: + resp = client.post( + "/api/segments", + json={"type": "markdown", "title": f"Extra {i}"}, + headers=BEARER_HEADERS, + ) + assert resp.status_code == 201 + data: dict[str, object] = resp.json() + return data + + def reorder() -> int: + resp = client.put( + "/api/segments/reorder", + json={"segment_ids": list(reversed(seg_ids))}, + headers=BEARER_HEADERS, + ) + return resp.status_code + + with ThreadPoolExecutor(max_workers=10) as pool: + create_futures = [pool.submit(create_extra, i) for i in range(2)] + reorder_future = pool.submit(reorder) + + for f in create_futures: + f.result() + reorder_status = reorder_future.result() + + assert 200 <= reorder_status < 300 + + listing = client.get("/api/segments").json() + assert len(listing) == 5 + + sort_orders = [s["sort_order"] for s in listing] + assert len(set(sort_orders)) == 5 diff --git a/backend/tests/test_database.py b/backend/tests/test_database.py new file mode 100644 index 0000000..f41becf --- /dev/null +++ b/backend/tests/test_database.py @@ -0,0 +1,78 @@ +import sqlite3 +from pathlib import Path + +from app.database import get_connection, init_db + + +def test_init_db_creates_tables(tmp_data_dir: Path) -> None: + db_path = tmp_data_dir / "handin.db" + init_db(db_path) + + conn = sqlite3.connect(db_path) + cursor = conn.execute( + "SELECT name FROM sqlite_master WHERE type='table' ORDER BY name" + ) + tables = {row[0] for row in cursor.fetchall()} + conn.close() + + assert "site" in tables + assert "segments" in tables + + +def test_init_db_idempotent(tmp_data_dir: Path) -> None: + db_path = tmp_data_dir / "handin.db" + init_db(db_path) + init_db(db_path) + + conn = sqlite3.connect(db_path) + cursor = conn.execute( + "SELECT name FROM sqlite_master WHERE type='table' ORDER BY name" + ) + tables = {row[0] for row in cursor.fetchall()} + conn.close() + + assert "site" in tables + assert "segments" in tables + + +def test_wal_mode_enabled(tmp_data_dir: Path) -> None: + db_path = tmp_data_dir / "handin.db" + init_db(db_path) + + conn = sqlite3.connect(db_path) + cursor = conn.execute("PRAGMA journal_mode") + mode = cursor.fetchone()[0] + conn.close() + + assert mode == "wal" + + +def test_get_connection_busy_timeout(db: Path) -> None: + conn = get_connection(db) + cursor = conn.execute("PRAGMA busy_timeout") + timeout = cursor.fetchone()[0] + conn.close() + + assert timeout == 5000 + + +def test_segments_table_schema(db: Path) -> None: + conn = sqlite3.connect(db) + cursor = conn.execute("PRAGMA table_info(segments)") + columns = {row[1] for row in cursor.fetchall()} + conn.close() + + expected = { + "id", "type", "sort_order", "title", + "content", "metadata", "created_at", "updated_at", + } + assert columns == expected + + +def test_site_table_schema(db: Path) -> None: + conn = sqlite3.connect(db) + cursor = conn.execute("PRAGMA table_info(site)") + columns = {row[1] for row in cursor.fetchall()} + conn.close() + + assert columns == {"key", "value"} diff --git a/backend/tests/test_routes.py b/backend/tests/test_routes.py new file mode 100644 index 0000000..082b264 --- /dev/null +++ b/backend/tests/test_routes.py @@ -0,0 +1,224 @@ +from uuid import uuid4 + +from fastapi.testclient import TestClient + +from tests.conftest import TEST_ADMIN_TOKEN + +BEARER_HEADERS = {"Authorization": f"Bearer {TEST_ADMIN_TOKEN}"} + + +def _create_segment( + client: TestClient, + title: str = "Test", + seg_type: str = "markdown", +) -> dict[str, object]: + resp = client.post( + "/api/segments", + json={"type": seg_type, "title": title}, + headers=BEARER_HEADERS, + ) + assert resp.status_code == 201 + data: dict[str, object] = resp.json() + return data + + +# --- Segment route tests --- + + +def test_list_segments_empty(client: TestClient) -> None: + resp = client.get("/api/segments") + assert resp.status_code == 200 + assert resp.json() == [] + + +def test_create_segment(client: TestClient) -> None: + data = _create_segment(client, title="Intro") + assert "id" in data + assert data["title"] == "Intro" + assert data["type"] == "markdown" + assert data["sort_order"] == 0 + + +def test_create_segment_unauthorized(client: TestClient) -> None: + resp = client.post("/api/segments", json={"type": "markdown", "title": "X"}) + assert resp.status_code == 401 + + +def test_create_segment_wrong_token(client: TestClient) -> None: + resp = client.post( + "/api/segments", + json={"type": "markdown", "title": "X"}, + headers={"Authorization": "Bearer wrong-token"}, + ) + assert resp.status_code == 401 + + +def test_create_segment_via_query_param(client: TestClient) -> None: + resp = client.post( + f"/api/segments?token={TEST_ADMIN_TOKEN}", + json={"type": "markdown", "title": "Query Auth"}, + ) + assert resp.status_code == 201 + + +def test_get_segment(client: TestClient) -> None: + created = _create_segment(client, title="Fetch Me") + seg_id = created["id"] + resp = client.get(f"/api/segments/{seg_id}") + assert resp.status_code == 200 + assert resp.json()["title"] == "Fetch Me" + + +def test_get_segment_not_found(client: TestClient) -> None: + resp = client.get(f"/api/segments/{uuid4()}") + assert resp.status_code == 404 + + +def test_update_segment(client: TestClient) -> None: + created = _create_segment(client, title="Original") + seg_id = created["id"] + resp = client.patch( + f"/api/segments/{seg_id}", + json={"title": "Updated"}, + headers=BEARER_HEADERS, + ) + assert resp.status_code == 200 + assert resp.json()["title"] == "Updated" + + +def test_update_segment_not_found(client: TestClient) -> None: + resp = client.patch( + f"/api/segments/{uuid4()}", + json={"title": "Nope"}, + headers=BEARER_HEADERS, + ) + assert resp.status_code == 404 + + +def test_update_segment_unauthorized(client: TestClient) -> None: + created = _create_segment(client, title="NoAuth") + seg_id = created["id"] + resp = client.patch( + f"/api/segments/{seg_id}", + json={"title": "Hacked"}, + ) + assert resp.status_code == 401 + + +def test_delete_segment(client: TestClient) -> None: + created = _create_segment(client, title="Delete Me") + seg_id = created["id"] + resp = client.delete(f"/api/segments/{seg_id}", headers=BEARER_HEADERS) + assert resp.status_code == 204 + resp2 = client.get(f"/api/segments/{seg_id}") + assert resp2.status_code == 404 + + +def test_delete_segment_not_found(client: TestClient) -> None: + resp = client.delete(f"/api/segments/{uuid4()}", headers=BEARER_HEADERS) + assert resp.status_code == 404 + + +def test_delete_segment_unauthorized(client: TestClient) -> None: + created = _create_segment(client, title="NoAuthDel") + seg_id = created["id"] + resp = client.delete(f"/api/segments/{seg_id}") + assert resp.status_code == 401 + + +def test_reorder_segments(client: TestClient) -> None: + a = _create_segment(client, title="A") + b = _create_segment(client, title="B") + c = _create_segment(client, title="C") + + resp = client.put( + "/api/segments/reorder", + json={"segment_ids": [c["id"], a["id"], b["id"]]}, + headers=BEARER_HEADERS, + ) + assert resp.status_code == 200 + + listing = client.get("/api/segments").json() + titles = [s["title"] for s in listing] + assert titles == ["C", "A", "B"] + + +def test_reorder_segments_invalid_ids(client: TestClient) -> None: + resp = client.put( + "/api/segments/reorder", + json={"segment_ids": [str(uuid4())]}, + headers=BEARER_HEADERS, + ) + assert resp.status_code == 400 + + +def test_reorder_segments_unauthorized(client: TestClient) -> None: + resp = client.put( + "/api/segments/reorder", + json={"segment_ids": []}, + ) + assert resp.status_code == 401 + + +# --- Site route tests --- + + +def test_get_site_title_default(client: TestClient) -> None: + resp = client.get("/api/site") + assert resp.status_code == 200 + assert resp.json()["title"] == "Untitled Site" + + +def test_update_site_title(client: TestClient) -> None: + resp = client.put( + "/api/site", + json={"title": "My Course"}, + headers=BEARER_HEADERS, + ) + assert resp.status_code == 200 + assert resp.json()["title"] == "My Course" + + resp2 = client.get("/api/site") + assert resp2.json()["title"] == "My Course" + + +def test_update_site_title_unauthorized(client: TestClient) -> None: + resp = client.put("/api/site", json={"title": "Hacked"}) + assert resp.status_code == 401 + + +# --- Auth route tests --- + + +def test_auth_verify_valid_bearer(client: TestClient) -> None: + resp = client.get("/api/auth/verify", headers=BEARER_HEADERS) + assert resp.status_code == 200 + assert resp.json() == {"valid": True} + + +def test_auth_verify_valid_query_param(client: TestClient) -> None: + resp = client.get(f"/api/auth/verify?token={TEST_ADMIN_TOKEN}") + assert resp.status_code == 200 + assert resp.json() == {"valid": True} + + +def test_auth_verify_invalid(client: TestClient) -> None: + resp = client.get( + "/api/auth/verify", + headers={"Authorization": "Bearer wrong-token"}, + ) + assert resp.status_code == 401 + + +def test_auth_verify_no_token(client: TestClient) -> None: + resp = client.get("/api/auth/verify") + assert resp.status_code == 401 + + +# --- Health test --- + + +def test_health(client: TestClient) -> None: + resp = client.get("/api/health") + assert resp.status_code == 200 + assert resp.json()["status"] == "ok" diff --git a/backend/tests/test_services.py b/backend/tests/test_services.py new file mode 100644 index 0000000..5f96fd2 --- /dev/null +++ b/backend/tests/test_services.py @@ -0,0 +1,172 @@ +import time +from pathlib import Path +from uuid import uuid4 + +import pytest + +from app.models import SegmentCreateRequest, SegmentType, SegmentUpdateRequest +from app.services.segment_service import ( + create_segment, + delete_segment, + get_segment, + list_segments, + reorder_segments, + update_segment, +) +from app.services.site_service import get_site_title, update_site_title + +# --- Segment Service Tests --- + + +def test_create_segment(db: Path) -> None: + request = SegmentCreateRequest( + type=SegmentType.MARKDOWN, + title="Intro", + content="Hello world", + ) + segment = create_segment(db, request) + + assert segment.id is not None + assert segment.sort_order == 0 + assert segment.title == "Intro" + assert segment.type == SegmentType.MARKDOWN + assert segment.content == "Hello world" + assert segment.created_at == segment.updated_at + + +def test_create_segment_auto_increments_sort_order(db: Path) -> None: + for i in range(3): + request = SegmentCreateRequest( + type=SegmentType.MARKDOWN, + title=f"Segment {i}", + ) + segment = create_segment(db, request) + assert segment.sort_order == i + + +def test_list_segments_empty(db: Path) -> None: + segments = list_segments(db) + assert segments == [] + + +def test_list_segments_ordered(db: Path) -> None: + titles = ["First", "Second", "Third"] + for title in titles: + create_segment( + db, + SegmentCreateRequest(type=SegmentType.MARKDOWN, title=title), + ) + + segments = list_segments(db) + assert [s.title for s in segments] == titles + assert [s.sort_order for s in segments] == [0, 1, 2] + + +def test_get_segment_found(db: Path) -> None: + created = create_segment( + db, + SegmentCreateRequest(type=SegmentType.PDF, title="Slides"), + ) + fetched = get_segment(db, created.id) + + assert fetched is not None + assert fetched.id == created.id + assert fetched.title == "Slides" + assert fetched.type == SegmentType.PDF + + +def test_get_segment_not_found(db: Path) -> None: + result = get_segment(db, uuid4()) + assert result is None + + +def test_update_segment_title(db: Path) -> None: + created = create_segment( + db, + SegmentCreateRequest( + type=SegmentType.MARKDOWN, + title="Old Title", + content="Keep me", + ), + ) + time.sleep(0.01) + updated = update_segment( + db, + created.id, + SegmentUpdateRequest(title="New Title"), + ) + + assert updated is not None + assert updated.title == "New Title" + assert updated.content == "Keep me" + assert updated.updated_at > updated.created_at + + +def test_update_segment_not_found(db: Path) -> None: + result = update_segment( + db, + uuid4(), + SegmentUpdateRequest(title="Nope"), + ) + assert result is None + + +def test_delete_segment(db: Path) -> None: + created = create_segment( + db, + SegmentCreateRequest(type=SegmentType.MARKDOWN, title="Bye"), + ) + assert delete_segment(db, created.id) is True + assert get_segment(db, created.id) is None + + +def test_delete_segment_not_found(db: Path) -> None: + assert delete_segment(db, uuid4()) is False + + +def test_reorder_segments(db: Path) -> None: + a = create_segment( + db, + SegmentCreateRequest(type=SegmentType.MARKDOWN, title="A"), + ) + b = create_segment( + db, + SegmentCreateRequest(type=SegmentType.MARKDOWN, title="B"), + ) + c = create_segment( + db, + SegmentCreateRequest(type=SegmentType.MARKDOWN, title="C"), + ) + + reorder_segments(db, [c.id, a.id, b.id]) + segments = list_segments(db) + + assert [s.title for s in segments] == ["C", "A", "B"] + assert [s.sort_order for s in segments] == [0, 1, 2] + + +def test_reorder_segments_invalid_ids(db: Path) -> None: + create_segment( + db, + SegmentCreateRequest(type=SegmentType.MARKDOWN, title="Only"), + ) + with pytest.raises(ValueError): + reorder_segments(db, [uuid4()]) + + +# --- Site Service Tests --- + + +def test_get_site_title_default(db: Path) -> None: + assert get_site_title(db) == "Untitled Site" + + +def test_update_and_get_site_title(db: Path) -> None: + update_site_title(db, "My Course") + assert get_site_title(db) == "My Course" + + +def test_update_site_title_overwrites(db: Path) -> None: + update_site_title(db, "First") + update_site_title(db, "Second") + assert get_site_title(db) == "Second" diff --git a/coding-style.md b/coding-style.md new file mode 100644 index 0000000..adfe248 --- /dev/null +++ b/coding-style.md @@ -0,0 +1,133 @@ +# Coding Style Rules + +## Python (Backend) + +### General + +- Python 3.12+ features encouraged (type parameter syntax, match statements where clearer). +- `ruff` handles formatting and linting. Config lives in `pyproject.toml`. +- `mypy` in strict mode. No `Any` types. No `# type: ignore` without a comment explaining why. + +### Naming + +| Entity | Convention | Example | +|---|---|---| +| Files/modules | `snake_case` | `order_service.py` | +| Functions | `snake_case` | `calculate_total()` | +| Classes | `PascalCase` | `Invoice` | +| Constants | `UPPER_SNAKE_CASE` | `MAX_RETRY_COUNT` | +| Database columns | `snake_case` | `created_at` | +| Pydantic models | `PascalCase` + suffix | `ItemCreateRequest`, `ItemResponse` | + +### Functions + +- Max 50 lines. If longer, extract helpers. +- Use early returns to avoid nesting (see Negative Space Programming below). + +### Negative Space Programming (Fail Early) + +Reject invalid state at the top of every function. The happy path is the code that +remains after all guard clauses. This applies at every layer. + +**Guard clauses before logic:** + +```python +# CORRECT - fail early, happy path is clean +def process_order(quantity: int, unit_price: Decimal) -> OrderResult: + if quantity <= 0: + raise ValidationError("Quantity must be positive") + if unit_price <= 0: + raise ValidationError("Unit price must be positive") + + total = quantity * unit_price + return OrderResult(total=total) + +# WRONG - nested conditionals, happy path buried +def process_order(quantity: int, unit_price: Decimal) -> OrderResult: + if quantity > 0: + if unit_price > 0: + total = quantity * unit_price + return OrderResult(total=total) + else: + raise ValidationError("Unit price must be positive") + else: + raise ValidationError("Quantity must be positive") +``` + +**Key rules:** +- Max 3 levels of indentation in any function. If deeper, extract or flatten. +- Never use `else` after a `return`, `raise`, or `throw` — the guard already exited. +- No silent failures: if something is wrong, raise/throw immediately with context. +- Optional/nullable returns are acceptable only when "not found" is a normal case (e.g., repository lookups). For business rule violations, always raise. + +### Imports + +- Group: stdlib -> third-party -> local. Ruff enforces this. +- No wildcard imports (`from module import *`). +- No relative imports across packages. Use absolute imports: `from app.module import ...`. + +### Type Hints + +- All function signatures must have type hints (params and return). +- Use `UUID` from `uuid`, not `str`, for ID fields. +- Use `datetime` from `datetime`, not `str`, for timestamps. +- Collections: `list[Item]`, not `List[Item]` (Python 3.12+). + +## TypeScript (Frontend) + +### General + +- Strict TypeScript. No `any`. No `@ts-ignore` without explanation. +- ESLint + Prettier via the project config. No overrides in individual files. + +### Naming + +| Entity | Convention | Example | +|---|---|---| +| Files (components) | `PascalCase.vue` | `OrderForm.vue` | +| Files (composables) | `camelCase.ts` | `useOrderForm.ts` | +| Files (utilities) | `camelCase.ts` | `formatCurrency.ts` | +| Variables/functions | `camelCase` | `calculateTotal()` | +| Types/interfaces | `PascalCase` | `Order` | +| Constants | `UPPER_SNAKE_CASE` | `DEFAULT_PAGE_SIZE` | +| Props | `camelCase` | `orderItems` | +| Events | `camelCase` verb | `@submit`, `@update:modelValue` | + +### Vue Components + +- Use ` + + diff --git a/frontend/package-lock.json b/frontend/package-lock.json new file mode 100644 index 0000000..221e9d2 --- /dev/null +++ b/frontend/package-lock.json @@ -0,0 +1,2125 @@ +{ + "name": "handin-frontend", + "version": "0.1.0", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "handin-frontend", + "version": "0.1.0", + "dependencies": { + "marked": "15.0.7", + "vue": "3.5.13", + "vuedraggable": "4.1.0" + }, + "devDependencies": { + "@tailwindcss/typography": "0.5.16", + "@tailwindcss/vite": "4.1.4", + "@vitejs/plugin-vue": "5.2.3", + "tailwindcss": "4.1.4", + "typescript": "5.7.3", + "vite": "6.2.6", + "vue-tsc": "2.2.8" + } + }, + "node_modules/@babel/helper-string-parser": { + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/helper-string-parser/-/helper-string-parser-7.27.1.tgz", + "integrity": "sha512-qMlSxKbpRlAridDExk92nSobyDdpPijUq2DW6oDnUqd0iOGxmQjyqhMIihI9+zv4LPyZdRje2cavWPbCbWm3eA==", + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-validator-identifier": { + "version": "7.28.5", + "resolved": "https://registry.npmjs.org/@babel/helper-validator-identifier/-/helper-validator-identifier-7.28.5.tgz", + "integrity": "sha512-qSs4ifwzKJSV39ucNjsvc6WVHs6b7S03sOh2OcHF9UHfVPqWWALUsNUVzhSBiItjRZoLHx7nIarVjqKVusUZ1Q==", + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/parser": { + "version": "7.29.0", + "resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.29.0.tgz", + "integrity": "sha512-IyDgFV5GeDUVX4YdF/3CPULtVGSXXMLh1xVIgdCgxApktqnQV0r7/8Nqthg+8YLGaAtdyIlo2qIdZrbCv4+7ww==", + "license": "MIT", + "dependencies": { + "@babel/types": "^7.29.0" + }, + "bin": { + "parser": "bin/babel-parser.js" + }, + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/@babel/types": { + "version": "7.29.0", + "resolved": "https://registry.npmjs.org/@babel/types/-/types-7.29.0.tgz", + "integrity": "sha512-LwdZHpScM4Qz8Xw2iKSzS+cfglZzJGvofQICy7W7v4caru4EaAmyUuO6BGrbyQ2mYV11W0U8j5mBhd14dd3B0A==", + "license": "MIT", + "dependencies": { + "@babel/helper-string-parser": "^7.27.1", + "@babel/helper-validator-identifier": "^7.28.5" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@esbuild/aix-ppc64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.25.12.tgz", + "integrity": "sha512-Hhmwd6CInZ3dwpuGTF8fJG6yoWmsToE+vYgD4nytZVxcu1ulHpUQRAB1UJ8+N1Am3Mz4+xOByoQoSZf4D+CpkA==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "aix" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-arm": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.25.12.tgz", + "integrity": "sha512-VJ+sKvNA/GE7Ccacc9Cha7bpS8nyzVv0jdVgwNDaR4gDMC/2TTRc33Ip8qrNYUcpkOHUT5OZ0bUcNNVZQ9RLlg==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.25.12.tgz", + "integrity": "sha512-6AAmLG7zwD1Z159jCKPvAxZd4y/VTO0VkprYy+3N2FtJ8+BQWFXU+OxARIwA46c5tdD9SsKGZ/1ocqBS/gAKHg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.25.12.tgz", + "integrity": "sha512-5jbb+2hhDHx5phYR2By8GTWEzn6I9UqR11Kwf22iKbNpYrsmRB18aX/9ivc5cabcUiAT/wM+YIZ6SG9QO6a8kg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/darwin-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.25.12.tgz", + "integrity": "sha512-N3zl+lxHCifgIlcMUP5016ESkeQjLj/959RxxNYIthIg+CQHInujFuXeWbWMgnTo4cp5XVHqFPmpyu9J65C1Yg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/darwin-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.25.12.tgz", + "integrity": "sha512-HQ9ka4Kx21qHXwtlTUVbKJOAnmG1ipXhdWTmNXiPzPfWKpXqASVcWdnf2bnL73wgjNrFXAa3yYvBSd9pzfEIpA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/freebsd-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.25.12.tgz", + "integrity": "sha512-gA0Bx759+7Jve03K1S0vkOu5Lg/85dou3EseOGUes8flVOGxbhDDh/iZaoek11Y8mtyKPGF3vP8XhnkDEAmzeg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/freebsd-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.25.12.tgz", + "integrity": "sha512-TGbO26Yw2xsHzxtbVFGEXBFH0FRAP7gtcPE7P5yP7wGy7cXK2oO7RyOhL5NLiqTlBh47XhmIUXuGciXEqYFfBQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-arm": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.25.12.tgz", + "integrity": "sha512-lPDGyC1JPDou8kGcywY0YILzWlhhnRjdof3UlcoqYmS9El818LLfJJc3PXXgZHrHCAKs/Z2SeZtDJr5MrkxtOw==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.25.12.tgz", + "integrity": "sha512-8bwX7a8FghIgrupcxb4aUmYDLp8pX06rGh5HqDT7bB+8Rdells6mHvrFHHW2JAOPZUbnjUpKTLg6ECyzvas2AQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-ia32": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.25.12.tgz", + "integrity": "sha512-0y9KrdVnbMM2/vG8KfU0byhUN+EFCny9+8g202gYqSSVMonbsCfLjUO+rCci7pM0WBEtz+oK/PIwHkzxkyharA==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-loong64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.25.12.tgz", + "integrity": "sha512-h///Lr5a9rib/v1GGqXVGzjL4TMvVTv+s1DPoxQdz7l/AYv6LDSxdIwzxkrPW438oUXiDtwM10o9PmwS/6Z0Ng==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-mips64el": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.25.12.tgz", + "integrity": "sha512-iyRrM1Pzy9GFMDLsXn1iHUm18nhKnNMWscjmp4+hpafcZjrr2WbT//d20xaGljXDBYHqRcl8HnxbX6uaA/eGVw==", + "cpu": [ + "mips64el" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-ppc64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.25.12.tgz", + "integrity": "sha512-9meM/lRXxMi5PSUqEXRCtVjEZBGwB7P/D4yT8UG/mwIdze2aV4Vo6U5gD3+RsoHXKkHCfSxZKzmDssVlRj1QQA==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-riscv64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.25.12.tgz", + "integrity": "sha512-Zr7KR4hgKUpWAwb1f3o5ygT04MzqVrGEGXGLnj15YQDJErYu/BGg+wmFlIDOdJp0PmB0lLvxFIOXZgFRrdjR0w==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-s390x": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.25.12.tgz", + "integrity": "sha512-MsKncOcgTNvdtiISc/jZs/Zf8d0cl/t3gYWX8J9ubBnVOwlk65UIEEvgBORTiljloIWnBzLs4qhzPkJcitIzIg==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.25.12.tgz", + "integrity": "sha512-uqZMTLr/zR/ed4jIGnwSLkaHmPjOjJvnm6TVVitAa08SLS9Z0VM8wIRx7gWbJB5/J54YuIMInDquWyYvQLZkgw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/netbsd-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.25.12.tgz", + "integrity": "sha512-xXwcTq4GhRM7J9A8Gv5boanHhRa/Q9KLVmcyXHCTaM4wKfIpWkdXiMog/KsnxzJ0A1+nD+zoecuzqPmCRyBGjg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/netbsd-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.25.12.tgz", + "integrity": "sha512-Ld5pTlzPy3YwGec4OuHh1aCVCRvOXdH8DgRjfDy/oumVovmuSzWfnSJg+VtakB9Cm0gxNO9BzWkj6mtO1FMXkQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openbsd-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.25.12.tgz", + "integrity": "sha512-fF96T6KsBo/pkQI950FARU9apGNTSlZGsv1jZBAlcLL1MLjLNIWPBkj5NlSz8aAzYKg+eNqknrUJ24QBybeR5A==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openbsd-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.25.12.tgz", + "integrity": "sha512-MZyXUkZHjQxUvzK7rN8DJ3SRmrVrke8ZyRusHlP+kuwqTcfWLyqMOE3sScPPyeIXN/mDJIfGXvcMqCgYKekoQw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openharmony-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/openharmony-arm64/-/openharmony-arm64-0.25.12.tgz", + "integrity": "sha512-rm0YWsqUSRrjncSXGA7Zv78Nbnw4XL6/dzr20cyrQf7ZmRcsovpcRBdhD43Nuk3y7XIoW2OxMVvwuRvk9XdASg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/sunos-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.25.12.tgz", + "integrity": "sha512-3wGSCDyuTHQUzt0nV7bocDy72r2lI33QL3gkDNGkod22EsYl04sMf0qLb8luNKTOmgF/eDEDP5BFNwoBKH441w==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "sunos" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.25.12.tgz", + "integrity": "sha512-rMmLrur64A7+DKlnSuwqUdRKyd3UE7oPJZmnljqEptesKM8wx9J8gx5u0+9Pq0fQQW8vqeKebwNXdfOyP+8Bsg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-ia32": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.25.12.tgz", + "integrity": "sha512-HkqnmmBoCbCwxUKKNPBixiWDGCpQGVsrQfJoVGYLPT41XWF8lHuE5N6WhVia2n4o5QK5M4tYr21827fNhi4byQ==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.25.12.tgz", + "integrity": "sha512-alJC0uCZpTFrSL0CCDjcgleBXPnCrEAhTBILpeAp7M/OFgoqtAetfBzX0xM00MUsVVPpVjlPuMbREqnZCXaTnA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@jridgewell/sourcemap-codec": { + "version": "1.5.5", + "resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.5.5.tgz", + "integrity": "sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og==", + "license": "MIT" + }, + "node_modules/@rollup/rollup-android-arm-eabi": { + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.59.0.tgz", + "integrity": "sha512-upnNBkA6ZH2VKGcBj9Fyl9IGNPULcjXRlg0LLeaioQWueH30p6IXtJEbKAgvyv+mJaMxSm1l6xwDXYjpEMiLMg==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ] + }, + "node_modules/@rollup/rollup-android-arm64": { + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm64/-/rollup-android-arm64-4.59.0.tgz", + "integrity": "sha512-hZ+Zxj3SySm4A/DylsDKZAeVg0mvi++0PYVceVyX7hemkw7OreKdCvW2oQ3T1FMZvCaQXqOTHb8qmBShoqk69Q==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ] + }, + "node_modules/@rollup/rollup-darwin-arm64": { + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-arm64/-/rollup-darwin-arm64-4.59.0.tgz", + "integrity": "sha512-W2Psnbh1J8ZJw0xKAd8zdNgF9HRLkdWwwdWqubSVk0pUuQkoHnv7rx4GiF9rT4t5DIZGAsConRE3AxCdJ4m8rg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@rollup/rollup-darwin-x64": { + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-x64/-/rollup-darwin-x64-4.59.0.tgz", + "integrity": "sha512-ZW2KkwlS4lwTv7ZVsYDiARfFCnSGhzYPdiOU4IM2fDbL+QGlyAbjgSFuqNRbSthybLbIJ915UtZBtmuLrQAT/w==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@rollup/rollup-freebsd-arm64": { + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-arm64/-/rollup-freebsd-arm64-4.59.0.tgz", + "integrity": "sha512-EsKaJ5ytAu9jI3lonzn3BgG8iRBjV4LxZexygcQbpiU0wU0ATxhNVEpXKfUa0pS05gTcSDMKpn3Sx+QB9RlTTA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ] + }, + "node_modules/@rollup/rollup-freebsd-x64": { + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-x64/-/rollup-freebsd-x64-4.59.0.tgz", + "integrity": "sha512-d3DuZi2KzTMjImrxoHIAODUZYoUUMsuUiY4SRRcJy6NJoZ6iIqWnJu9IScV9jXysyGMVuW+KNzZvBLOcpdl3Vg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ] + }, + "node_modules/@rollup/rollup-linux-arm-gnueabihf": { + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-gnueabihf/-/rollup-linux-arm-gnueabihf-4.59.0.tgz", + "integrity": "sha512-t4ONHboXi/3E0rT6OZl1pKbl2Vgxf9vJfWgmUoCEVQVxhW6Cw/c8I6hbbu7DAvgp82RKiH7TpLwxnJeKv2pbsw==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm-musleabihf": { + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-musleabihf/-/rollup-linux-arm-musleabihf-4.59.0.tgz", + "integrity": "sha512-CikFT7aYPA2ufMD086cVORBYGHffBo4K8MQ4uPS/ZnY54GKj36i196u8U+aDVT2LX4eSMbyHtyOh7D7Zvk2VvA==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm64-gnu": { + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-gnu/-/rollup-linux-arm64-gnu-4.59.0.tgz", + "integrity": "sha512-jYgUGk5aLd1nUb1CtQ8E+t5JhLc9x5WdBKew9ZgAXg7DBk0ZHErLHdXM24rfX+bKrFe+Xp5YuJo54I5HFjGDAA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm64-musl": { + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-musl/-/rollup-linux-arm64-musl-4.59.0.tgz", + "integrity": "sha512-peZRVEdnFWZ5Bh2KeumKG9ty7aCXzzEsHShOZEFiCQlDEepP1dpUl/SrUNXNg13UmZl+gzVDPsiCwnV1uI0RUA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-loong64-gnu": { + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-gnu/-/rollup-linux-loong64-gnu-4.59.0.tgz", + "integrity": "sha512-gbUSW/97f7+r4gHy3Jlup8zDG190AuodsWnNiXErp9mT90iCy9NKKU0Xwx5k8VlRAIV2uU9CsMnEFg/xXaOfXg==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-loong64-musl": { + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-musl/-/rollup-linux-loong64-musl-4.59.0.tgz", + "integrity": "sha512-yTRONe79E+o0FWFijasoTjtzG9EBedFXJMl888NBEDCDV9I2wGbFFfJQQe63OijbFCUZqxpHz1GzpbtSFikJ4Q==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-ppc64-gnu": { + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-gnu/-/rollup-linux-ppc64-gnu-4.59.0.tgz", + "integrity": "sha512-sw1o3tfyk12k3OEpRddF68a1unZ5VCN7zoTNtSn2KndUE+ea3m3ROOKRCZxEpmT9nsGnogpFP9x6mnLTCaoLkA==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-ppc64-musl": { + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-musl/-/rollup-linux-ppc64-musl-4.59.0.tgz", + "integrity": "sha512-+2kLtQ4xT3AiIxkzFVFXfsmlZiG5FXYW7ZyIIvGA7Bdeuh9Z0aN4hVyXS/G1E9bTP/vqszNIN/pUKCk/BTHsKA==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-riscv64-gnu": { + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-gnu/-/rollup-linux-riscv64-gnu-4.59.0.tgz", + "integrity": "sha512-NDYMpsXYJJaj+I7UdwIuHHNxXZ/b/N2hR15NyH3m2qAtb/hHPA4g4SuuvrdxetTdndfj9b1WOmy73kcPRoERUg==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-riscv64-musl": { + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-musl/-/rollup-linux-riscv64-musl-4.59.0.tgz", + "integrity": "sha512-nLckB8WOqHIf1bhymk+oHxvM9D3tyPndZH8i8+35p/1YiVoVswPid2yLzgX7ZJP0KQvnkhM4H6QZ5m0LzbyIAg==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-s390x-gnu": { + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-s390x-gnu/-/rollup-linux-s390x-gnu-4.59.0.tgz", + "integrity": "sha512-oF87Ie3uAIvORFBpwnCvUzdeYUqi2wY6jRFWJAy1qus/udHFYIkplYRW+wo+GRUP4sKzYdmE1Y3+rY5Gc4ZO+w==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-x64-gnu": { + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-gnu/-/rollup-linux-x64-gnu-4.59.0.tgz", + "integrity": "sha512-3AHmtQq/ppNuUspKAlvA8HtLybkDflkMuLK4DPo77DfthRb71V84/c4MlWJXixZz4uruIH4uaa07IqoAkG64fg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-x64-musl": { + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-musl/-/rollup-linux-x64-musl-4.59.0.tgz", + "integrity": "sha512-2UdiwS/9cTAx7qIUZB/fWtToJwvt0Vbo0zmnYt7ED35KPg13Q0ym1g442THLC7VyI6JfYTP4PiSOWyoMdV2/xg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-openbsd-x64": { + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-openbsd-x64/-/rollup-openbsd-x64-4.59.0.tgz", + "integrity": "sha512-M3bLRAVk6GOwFlPTIxVBSYKUaqfLrn8l0psKinkCFxl4lQvOSz8ZrKDz2gxcBwHFpci0B6rttydI4IpS4IS/jQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ] + }, + "node_modules/@rollup/rollup-openharmony-arm64": { + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-openharmony-arm64/-/rollup-openharmony-arm64-4.59.0.tgz", + "integrity": "sha512-tt9KBJqaqp5i5HUZzoafHZX8b5Q2Fe7UjYERADll83O4fGqJ49O1FsL6LpdzVFQcpwvnyd0i+K/VSwu/o/nWlA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ] + }, + "node_modules/@rollup/rollup-win32-arm64-msvc": { + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-arm64-msvc/-/rollup-win32-arm64-msvc-4.59.0.tgz", + "integrity": "sha512-V5B6mG7OrGTwnxaNUzZTDTjDS7F75PO1ae6MJYdiMu60sq0CqN5CVeVsbhPxalupvTX8gXVSU9gq+Rx1/hvu6A==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-ia32-msvc": { + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-ia32-msvc/-/rollup-win32-ia32-msvc-4.59.0.tgz", + "integrity": "sha512-UKFMHPuM9R0iBegwzKF4y0C4J9u8C6MEJgFuXTBerMk7EJ92GFVFYBfOZaSGLu6COf7FxpQNqhNS4c4icUPqxA==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-x64-gnu": { + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-gnu/-/rollup-win32-x64-gnu-4.59.0.tgz", + "integrity": "sha512-laBkYlSS1n2L8fSo1thDNGrCTQMmxjYY5G0WFWjFFYZkKPjsMBsgJfGf4TLxXrF6RyhI60L8TMOjBMvXiTcxeA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-x64-msvc": { + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-msvc/-/rollup-win32-x64-msvc-4.59.0.tgz", + "integrity": "sha512-2HRCml6OztYXyJXAvdDXPKcawukWY2GpR5/nxKp4iBgiO3wcoEGkAaqctIbZcNB6KlUQBIqt8VYkNSj2397EfA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@tailwindcss/node": { + "version": "4.1.4", + "resolved": "https://registry.npmjs.org/@tailwindcss/node/-/node-4.1.4.tgz", + "integrity": "sha512-MT5118zaiO6x6hNA04OWInuAiP1YISXql8Z+/Y8iisV5nuhM8VXlyhRuqc2PEviPszcXI66W44bCIk500Oolhw==", + "dev": true, + "license": "MIT", + "dependencies": { + "enhanced-resolve": "^5.18.1", + "jiti": "^2.4.2", + "lightningcss": "1.29.2", + "tailwindcss": "4.1.4" + } + }, + "node_modules/@tailwindcss/oxide": { + "version": "4.1.4", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide/-/oxide-4.1.4.tgz", + "integrity": "sha512-p5wOpXyOJx7mKh5MXh5oKk+kqcz8T+bA3z/5VWWeQwFrmuBItGwz8Y2CHk/sJ+dNb9B0nYFfn0rj/cKHZyjahQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 10" + }, + "optionalDependencies": { + "@tailwindcss/oxide-android-arm64": "4.1.4", + "@tailwindcss/oxide-darwin-arm64": "4.1.4", + "@tailwindcss/oxide-darwin-x64": "4.1.4", + "@tailwindcss/oxide-freebsd-x64": "4.1.4", + "@tailwindcss/oxide-linux-arm-gnueabihf": "4.1.4", + "@tailwindcss/oxide-linux-arm64-gnu": "4.1.4", + "@tailwindcss/oxide-linux-arm64-musl": "4.1.4", + "@tailwindcss/oxide-linux-x64-gnu": "4.1.4", + "@tailwindcss/oxide-linux-x64-musl": "4.1.4", + "@tailwindcss/oxide-wasm32-wasi": "4.1.4", + "@tailwindcss/oxide-win32-arm64-msvc": "4.1.4", + "@tailwindcss/oxide-win32-x64-msvc": "4.1.4" + } + }, + "node_modules/@tailwindcss/oxide-android-arm64": { + "version": "4.1.4", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-android-arm64/-/oxide-android-arm64-4.1.4.tgz", + "integrity": "sha512-xMMAe/SaCN/vHfQYui3fqaBDEXMu22BVwQ33veLc8ep+DNy7CWN52L+TTG9y1K397w9nkzv+Mw+mZWISiqhmlA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@tailwindcss/oxide-darwin-arm64": { + "version": "4.1.4", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-darwin-arm64/-/oxide-darwin-arm64-4.1.4.tgz", + "integrity": "sha512-JGRj0SYFuDuAGilWFBlshcexev2hOKfNkoX+0QTksKYq2zgF9VY/vVMq9m8IObYnLna0Xlg+ytCi2FN2rOL0Sg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@tailwindcss/oxide-darwin-x64": { + "version": "4.1.4", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-darwin-x64/-/oxide-darwin-x64-4.1.4.tgz", + "integrity": "sha512-sdDeLNvs3cYeWsEJ4H1DvjOzaGios4QbBTNLVLVs0XQ0V95bffT3+scptzYGPMjm7xv4+qMhCDrkHwhnUySEzA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@tailwindcss/oxide-freebsd-x64": { + "version": "4.1.4", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-freebsd-x64/-/oxide-freebsd-x64-4.1.4.tgz", + "integrity": "sha512-VHxAqxqdghM83HslPhRsNhHo91McsxRJaEnShJOMu8mHmEj9Ig7ToHJtDukkuLWLzLboh2XSjq/0zO6wgvykNA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@tailwindcss/oxide-linux-arm-gnueabihf": { + "version": "4.1.4", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-arm-gnueabihf/-/oxide-linux-arm-gnueabihf-4.1.4.tgz", + "integrity": "sha512-OTU/m/eV4gQKxy9r5acuesqaymyeSCnsx1cFto/I1WhPmi5HDxX1nkzb8KYBiwkHIGg7CTfo/AcGzoXAJBxLfg==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@tailwindcss/oxide-linux-arm64-gnu": { + "version": "4.1.4", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-arm64-gnu/-/oxide-linux-arm64-gnu-4.1.4.tgz", + "integrity": "sha512-hKlLNvbmUC6z5g/J4H+Zx7f7w15whSVImokLPmP6ff1QqTVE+TxUM9PGuNsjHvkvlHUtGTdDnOvGNSEUiXI1Ww==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@tailwindcss/oxide-linux-arm64-musl": { + "version": "4.1.4", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-arm64-musl/-/oxide-linux-arm64-musl-4.1.4.tgz", + "integrity": "sha512-X3As2xhtgPTY/m5edUtddmZ8rCruvBvtxYLMw9OsZdH01L2gS2icsHRwxdU0dMItNfVmrBezueXZCHxVeeb7Aw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@tailwindcss/oxide-linux-x64-gnu": { + "version": "4.1.4", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-x64-gnu/-/oxide-linux-x64-gnu-4.1.4.tgz", + "integrity": "sha512-2VG4DqhGaDSmYIu6C4ua2vSLXnJsb/C9liej7TuSO04NK+JJJgJucDUgmX6sn7Gw3Cs5ZJ9ZLrnI0QRDOjLfNQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@tailwindcss/oxide-linux-x64-musl": { + "version": "4.1.4", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-x64-musl/-/oxide-linux-x64-musl-4.1.4.tgz", + "integrity": "sha512-v+mxVgH2kmur/X5Mdrz9m7TsoVjbdYQT0b4Z+dr+I4RvreCNXyCFELZL/DO0M1RsidZTrm6O1eMnV6zlgEzTMQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@tailwindcss/oxide-wasm32-wasi": { + "version": "4.1.4", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-wasm32-wasi/-/oxide-wasm32-wasi-4.1.4.tgz", + "integrity": "sha512-2TLe9ir+9esCf6Wm+lLWTMbgklIjiF0pbmDnwmhR9MksVOq+e8aP3TSsXySnBDDvTTVd/vKu1aNttEGj3P6l8Q==", + "bundleDependencies": [ + "@napi-rs/wasm-runtime", + "@emnapi/core", + "@emnapi/runtime", + "@tybys/wasm-util", + "@emnapi/wasi-threads", + "tslib" + ], + "cpu": [ + "wasm32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "@emnapi/core": "^1.4.0", + "@emnapi/runtime": "^1.4.0", + "@emnapi/wasi-threads": "^1.0.1", + "@napi-rs/wasm-runtime": "^0.2.8", + "@tybys/wasm-util": "^0.9.0", + "tslib": "^2.8.0" + }, + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/@tailwindcss/oxide-win32-arm64-msvc": { + "version": "4.1.4", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-win32-arm64-msvc/-/oxide-win32-arm64-msvc-4.1.4.tgz", + "integrity": "sha512-VlnhfilPlO0ltxW9/BgfLI5547PYzqBMPIzRrk4W7uupgCt8z6Trw/tAj6QUtF2om+1MH281Pg+HHUJoLesmng==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@tailwindcss/oxide-win32-x64-msvc": { + "version": "4.1.4", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-win32-x64-msvc/-/oxide-win32-x64-msvc-4.1.4.tgz", + "integrity": "sha512-+7S63t5zhYjslUGb8NcgLpFXD+Kq1F/zt5Xv5qTv7HaFTG/DHyHD9GA6ieNAxhgyA4IcKa/zy7Xx4Oad2/wuhw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@tailwindcss/typography": { + "version": "0.5.16", + "resolved": "https://registry.npmjs.org/@tailwindcss/typography/-/typography-0.5.16.tgz", + "integrity": "sha512-0wDLwCVF5V3x3b1SGXPCDcdsbDHMBe+lkFzBRaHeLvNi+nrrnZ1lA18u+OTWO8iSWU2GxUOCvlXtDuqftc1oiA==", + "dev": true, + "license": "MIT", + "dependencies": { + "lodash.castarray": "^4.4.0", + "lodash.isplainobject": "^4.0.6", + "lodash.merge": "^4.6.2", + "postcss-selector-parser": "6.0.10" + }, + "peerDependencies": { + "tailwindcss": ">=3.0.0 || insiders || >=4.0.0-alpha.20 || >=4.0.0-beta.1" + } + }, + "node_modules/@tailwindcss/vite": { + "version": "4.1.4", + "resolved": "https://registry.npmjs.org/@tailwindcss/vite/-/vite-4.1.4.tgz", + "integrity": "sha512-4UQeMrONbvrsXKXXp/uxmdEN5JIJ9RkH7YVzs6AMxC/KC1+Np7WZBaNIco7TEjlkthqxZbt8pU/ipD+hKjm80A==", + "dev": true, + "license": "MIT", + "dependencies": { + "@tailwindcss/node": "4.1.4", + "@tailwindcss/oxide": "4.1.4", + "tailwindcss": "4.1.4" + }, + "peerDependencies": { + "vite": "^5.2.0 || ^6" + } + }, + "node_modules/@types/estree": { + "version": "1.0.8", + "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.8.tgz", + "integrity": "sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w==", + "dev": true, + "license": "MIT" + }, + "node_modules/@vitejs/plugin-vue": { + "version": "5.2.3", + "resolved": "https://registry.npmjs.org/@vitejs/plugin-vue/-/plugin-vue-5.2.3.tgz", + "integrity": "sha512-IYSLEQj4LgZZuoVpdSUCw3dIynTWQgPlaRP6iAvMle4My0HdYwr5g5wQAfwOeHQBmYwEkqF70nRpSilr6PoUDg==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^18.0.0 || >=20.0.0" + }, + "peerDependencies": { + "vite": "^5.0.0 || ^6.0.0", + "vue": "^3.2.25" + } + }, + "node_modules/@volar/language-core": { + "version": "2.4.28", + "resolved": "https://registry.npmjs.org/@volar/language-core/-/language-core-2.4.28.tgz", + "integrity": "sha512-w4qhIJ8ZSitgLAkVay6AbcnC7gP3glYM3fYwKV3srj8m494E3xtrCv6E+bWviiK/8hs6e6t1ij1s2Endql7vzQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@volar/source-map": "2.4.28" + } + }, + "node_modules/@volar/source-map": { + "version": "2.4.28", + "resolved": "https://registry.npmjs.org/@volar/source-map/-/source-map-2.4.28.tgz", + "integrity": "sha512-yX2BDBqJkRXfKw8my8VarTyjv48QwxdJtvRgUpNE5erCsgEUdI2DsLbpa+rOQVAJYshY99szEcRDmyHbF10ggQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/@volar/typescript": { + "version": "2.4.28", + "resolved": "https://registry.npmjs.org/@volar/typescript/-/typescript-2.4.28.tgz", + "integrity": "sha512-Ja6yvWrbis2QtN4ClAKreeUZPVYMARDYZl9LMEv1iQ1QdepB6wn0jTRxA9MftYmYa4DQ4k/DaSZpFPUfxl8giw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@volar/language-core": "2.4.28", + "path-browserify": "^1.0.1", + "vscode-uri": "^3.0.8" + } + }, + "node_modules/@vue/compiler-core": { + "version": "3.5.13", + "resolved": "https://registry.npmjs.org/@vue/compiler-core/-/compiler-core-3.5.13.tgz", + "integrity": "sha512-oOdAkwqUfW1WqpwSYJce06wvt6HljgY3fGeM9NcVA1HaYOij3mZG9Rkysn0OHuyUAGMbEbARIpsG+LPVlBJ5/Q==", + "license": "MIT", + "dependencies": { + "@babel/parser": "^7.25.3", + "@vue/shared": "3.5.13", + "entities": "^4.5.0", + "estree-walker": "^2.0.2", + "source-map-js": "^1.2.0" + } + }, + "node_modules/@vue/compiler-dom": { + "version": "3.5.13", + "resolved": "https://registry.npmjs.org/@vue/compiler-dom/-/compiler-dom-3.5.13.tgz", + "integrity": "sha512-ZOJ46sMOKUjO3e94wPdCzQ6P1Lx/vhp2RSvfaab88Ajexs0AHeV0uasYhi99WPaogmBlRHNRuly8xV75cNTMDA==", + "license": "MIT", + "dependencies": { + "@vue/compiler-core": "3.5.13", + "@vue/shared": "3.5.13" + } + }, + "node_modules/@vue/compiler-sfc": { + "version": "3.5.13", + "resolved": "https://registry.npmjs.org/@vue/compiler-sfc/-/compiler-sfc-3.5.13.tgz", + "integrity": "sha512-6VdaljMpD82w6c2749Zhf5T9u5uLBWKnVue6XWxprDobftnletJ8+oel7sexFfM3qIxNmVE7LSFGTpv6obNyaQ==", + "license": "MIT", + "dependencies": { + "@babel/parser": "^7.25.3", + "@vue/compiler-core": "3.5.13", + "@vue/compiler-dom": "3.5.13", + "@vue/compiler-ssr": "3.5.13", + "@vue/shared": "3.5.13", + "estree-walker": "^2.0.2", + "magic-string": "^0.30.11", + "postcss": "^8.4.48", + "source-map-js": "^1.2.0" + } + }, + "node_modules/@vue/compiler-ssr": { + "version": "3.5.13", + "resolved": "https://registry.npmjs.org/@vue/compiler-ssr/-/compiler-ssr-3.5.13.tgz", + "integrity": "sha512-wMH6vrYHxQl/IybKJagqbquvxpWCuVYpoUJfCqFZwa/JY1GdATAQ+TgVtgrwwMZ0D07QhA99rs/EAAWfvG6KpA==", + "license": "MIT", + "dependencies": { + "@vue/compiler-dom": "3.5.13", + "@vue/shared": "3.5.13" + } + }, + "node_modules/@vue/compiler-vue2": { + "version": "2.7.16", + "resolved": "https://registry.npmjs.org/@vue/compiler-vue2/-/compiler-vue2-2.7.16.tgz", + "integrity": "sha512-qYC3Psj9S/mfu9uVi5WvNZIzq+xnXMhOwbTFKKDD7b1lhpnn71jXSFdTQ+WsIEk0ONCd7VV2IMm7ONl6tbQ86A==", + "dev": true, + "license": "MIT", + "dependencies": { + "de-indent": "^1.0.2", + "he": "^1.2.0" + } + }, + "node_modules/@vue/language-core": { + "version": "2.2.8", + "resolved": "https://registry.npmjs.org/@vue/language-core/-/language-core-2.2.8.tgz", + "integrity": "sha512-rrzB0wPGBvcwaSNRriVWdNAbHQWSf0NlGqgKHK5mEkXpefjUlVRP62u03KvwZpvKVjRnBIQ/Lwre+Mx9N6juUQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@volar/language-core": "~2.4.11", + "@vue/compiler-dom": "^3.5.0", + "@vue/compiler-vue2": "^2.7.16", + "@vue/shared": "^3.5.0", + "alien-signals": "^1.0.3", + "minimatch": "^9.0.3", + "muggle-string": "^0.4.1", + "path-browserify": "^1.0.1" + }, + "peerDependencies": { + "typescript": "*" + }, + "peerDependenciesMeta": { + "typescript": { + "optional": true + } + } + }, + "node_modules/@vue/reactivity": { + "version": "3.5.13", + "resolved": "https://registry.npmjs.org/@vue/reactivity/-/reactivity-3.5.13.tgz", + "integrity": "sha512-NaCwtw8o48B9I6L1zl2p41OHo/2Z4wqYGGIK1Khu5T7yxrn+ATOixn/Udn2m+6kZKB/J7cuT9DbWWhRxqixACg==", + "license": "MIT", + "dependencies": { + "@vue/shared": "3.5.13" + } + }, + "node_modules/@vue/runtime-core": { + "version": "3.5.13", + "resolved": "https://registry.npmjs.org/@vue/runtime-core/-/runtime-core-3.5.13.tgz", + "integrity": "sha512-Fj4YRQ3Az0WTZw1sFe+QDb0aXCerigEpw418pw1HBUKFtnQHWzwojaukAs2X/c9DQz4MQ4bsXTGlcpGxU/RCIw==", + "license": "MIT", + "dependencies": { + "@vue/reactivity": "3.5.13", + "@vue/shared": "3.5.13" + } + }, + "node_modules/@vue/runtime-dom": { + "version": "3.5.13", + "resolved": "https://registry.npmjs.org/@vue/runtime-dom/-/runtime-dom-3.5.13.tgz", + "integrity": "sha512-dLaj94s93NYLqjLiyFzVs9X6dWhTdAlEAciC3Moq7gzAc13VJUdCnjjRurNM6uTLFATRHexHCTu/Xp3eW6yoog==", + "license": "MIT", + "dependencies": { + "@vue/reactivity": "3.5.13", + "@vue/runtime-core": "3.5.13", + "@vue/shared": "3.5.13", + "csstype": "^3.1.3" + } + }, + "node_modules/@vue/server-renderer": { + "version": "3.5.13", + "resolved": "https://registry.npmjs.org/@vue/server-renderer/-/server-renderer-3.5.13.tgz", + "integrity": "sha512-wAi4IRJV/2SAW3htkTlB+dHeRmpTiVIK1OGLWV1yeStVSebSQQOwGwIq0D3ZIoBj2C2qpgz5+vX9iEBkTdk5YA==", + "license": "MIT", + "dependencies": { + "@vue/compiler-ssr": "3.5.13", + "@vue/shared": "3.5.13" + }, + "peerDependencies": { + "vue": "3.5.13" + } + }, + "node_modules/@vue/shared": { + "version": "3.5.13", + "resolved": "https://registry.npmjs.org/@vue/shared/-/shared-3.5.13.tgz", + "integrity": "sha512-/hnE/qP5ZoGpol0a5mDi45bOd7t3tjYJBjsgCsivow7D48cJeV5l05RD82lPqi7gRiphZM37rnhW1l6ZoCNNnQ==", + "license": "MIT" + }, + "node_modules/alien-signals": { + "version": "1.0.13", + "resolved": "https://registry.npmjs.org/alien-signals/-/alien-signals-1.0.13.tgz", + "integrity": "sha512-OGj9yyTnJEttvzhTUWuscOvtqxq5vrhF7vL9oS0xJ2mK0ItPYP1/y+vCFebfxoEyAz0++1AIwJ5CMr+Fk3nDmg==", + "dev": true, + "license": "MIT" + }, + "node_modules/balanced-match": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.2.tgz", + "integrity": "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==", + "dev": true, + "license": "MIT" + }, + "node_modules/brace-expansion": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.0.2.tgz", + "integrity": "sha512-Jt0vHyM+jmUBqojB7E1NIYadt0vI0Qxjxd2TErW94wDz+E2LAm5vKMXXwg6ZZBTHPuUlDgQHKXvjGBdfcF1ZDQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "balanced-match": "^1.0.0" + } + }, + "node_modules/cssesc": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/cssesc/-/cssesc-3.0.0.tgz", + "integrity": "sha512-/Tb/JcjK111nNScGob5MNtsntNM1aCNUDipB/TkwZFhyDrrE47SOx/18wF2bbjgc3ZzCSKW1T5nt5EbFoAz/Vg==", + "dev": true, + "license": "MIT", + "bin": { + "cssesc": "bin/cssesc" + }, + "engines": { + "node": ">=4" + } + }, + "node_modules/csstype": { + "version": "3.2.3", + "resolved": "https://registry.npmjs.org/csstype/-/csstype-3.2.3.tgz", + "integrity": "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==", + "license": "MIT" + }, + "node_modules/de-indent": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/de-indent/-/de-indent-1.0.2.tgz", + "integrity": "sha512-e/1zu3xH5MQryN2zdVaF0OrdNLUbvWxzMbi+iNA6Bky7l1RoP8a2fIbRocyHclXt/arDrrR6lL3TqFD9pMQTsg==", + "dev": true, + "license": "MIT" + }, + "node_modules/detect-libc": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.1.2.tgz", + "integrity": "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=8" + } + }, + "node_modules/enhanced-resolve": { + "version": "5.20.0", + "resolved": "https://registry.npmjs.org/enhanced-resolve/-/enhanced-resolve-5.20.0.tgz", + "integrity": "sha512-/ce7+jQ1PQ6rVXwe+jKEg5hW5ciicHwIQUagZkp6IufBoY3YDgdTTY1azVs0qoRgVmvsNB+rbjLJxDAeHHtwsQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "graceful-fs": "^4.2.4", + "tapable": "^2.3.0" + }, + "engines": { + "node": ">=10.13.0" + } + }, + "node_modules/entities": { + "version": "4.5.0", + "resolved": "https://registry.npmjs.org/entities/-/entities-4.5.0.tgz", + "integrity": "sha512-V0hjH4dGPh9Ao5p0MoRY6BVqtwCjhz6vI5LT8AJ55H+4g9/4vbHx1I54fS0XuclLhDHArPQCiMjDxjaL8fPxhw==", + "license": "BSD-2-Clause", + "engines": { + "node": ">=0.12" + }, + "funding": { + "url": "https://github.com/fb55/entities?sponsor=1" + } + }, + "node_modules/esbuild": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.25.12.tgz", + "integrity": "sha512-bbPBYYrtZbkt6Os6FiTLCTFxvq4tt3JKall1vRwshA3fdVztsLAatFaZobhkBC8/BrPetoa0oksYoKXoG4ryJg==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "bin": { + "esbuild": "bin/esbuild" + }, + "engines": { + "node": ">=18" + }, + "optionalDependencies": { + "@esbuild/aix-ppc64": "0.25.12", + "@esbuild/android-arm": "0.25.12", + "@esbuild/android-arm64": "0.25.12", + "@esbuild/android-x64": "0.25.12", + "@esbuild/darwin-arm64": "0.25.12", + "@esbuild/darwin-x64": "0.25.12", + "@esbuild/freebsd-arm64": "0.25.12", + "@esbuild/freebsd-x64": "0.25.12", + "@esbuild/linux-arm": "0.25.12", + "@esbuild/linux-arm64": "0.25.12", + "@esbuild/linux-ia32": "0.25.12", + "@esbuild/linux-loong64": "0.25.12", + "@esbuild/linux-mips64el": "0.25.12", + "@esbuild/linux-ppc64": "0.25.12", + "@esbuild/linux-riscv64": "0.25.12", + "@esbuild/linux-s390x": "0.25.12", + "@esbuild/linux-x64": "0.25.12", + "@esbuild/netbsd-arm64": "0.25.12", + "@esbuild/netbsd-x64": "0.25.12", + "@esbuild/openbsd-arm64": "0.25.12", + "@esbuild/openbsd-x64": "0.25.12", + "@esbuild/openharmony-arm64": "0.25.12", + "@esbuild/sunos-x64": "0.25.12", + "@esbuild/win32-arm64": "0.25.12", + "@esbuild/win32-ia32": "0.25.12", + "@esbuild/win32-x64": "0.25.12" + } + }, + "node_modules/estree-walker": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/estree-walker/-/estree-walker-2.0.2.tgz", + "integrity": "sha512-Rfkk/Mp/DL7JVje3u18FxFujQlTNR2q6QfMSMB7AvCBx91NGj/ba3kCfza0f6dVDbw7YlRf/nDrn7pQrCCyQ/w==", + "license": "MIT" + }, + "node_modules/fsevents": { + "version": "2.3.3", + "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", + "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^8.16.0 || ^10.6.0 || >=11.0.0" + } + }, + "node_modules/graceful-fs": { + "version": "4.2.11", + "resolved": "https://registry.npmjs.org/graceful-fs/-/graceful-fs-4.2.11.tgz", + "integrity": "sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ==", + "dev": true, + "license": "ISC" + }, + "node_modules/he": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/he/-/he-1.2.0.tgz", + "integrity": "sha512-F/1DnUGPopORZi0ni+CvrCgHQ5FyEAHRLSApuYWMmrbSwoN2Mn/7k+Gl38gJnR7yyDZk6WLXwiGod1JOWNDKGw==", + "dev": true, + "license": "MIT", + "bin": { + "he": "bin/he" + } + }, + "node_modules/jiti": { + "version": "2.6.1", + "resolved": "https://registry.npmjs.org/jiti/-/jiti-2.6.1.tgz", + "integrity": "sha512-ekilCSN1jwRvIbgeg/57YFh8qQDNbwDb9xT/qu2DAHbFFZUicIl4ygVaAvzveMhMVr3LnpSKTNnwt8PoOfmKhQ==", + "dev": true, + "license": "MIT", + "bin": { + "jiti": "lib/jiti-cli.mjs" + } + }, + "node_modules/lightningcss": { + "version": "1.29.2", + "resolved": "https://registry.npmjs.org/lightningcss/-/lightningcss-1.29.2.tgz", + "integrity": "sha512-6b6gd/RUXKaw5keVdSEtqFVdzWnU5jMxTUjA2bVcMNPLwSQ08Sv/UodBVtETLCn7k4S1Ibxwh7k68IwLZPgKaA==", + "dev": true, + "license": "MPL-2.0", + "dependencies": { + "detect-libc": "^2.0.3" + }, + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + }, + "optionalDependencies": { + "lightningcss-darwin-arm64": "1.29.2", + "lightningcss-darwin-x64": "1.29.2", + "lightningcss-freebsd-x64": "1.29.2", + "lightningcss-linux-arm-gnueabihf": "1.29.2", + "lightningcss-linux-arm64-gnu": "1.29.2", + "lightningcss-linux-arm64-musl": "1.29.2", + "lightningcss-linux-x64-gnu": "1.29.2", + "lightningcss-linux-x64-musl": "1.29.2", + "lightningcss-win32-arm64-msvc": "1.29.2", + "lightningcss-win32-x64-msvc": "1.29.2" + } + }, + "node_modules/lightningcss-darwin-arm64": { + "version": "1.29.2", + "resolved": "https://registry.npmjs.org/lightningcss-darwin-arm64/-/lightningcss-darwin-arm64-1.29.2.tgz", + "integrity": "sha512-cK/eMabSViKn/PG8U/a7aCorpeKLMlK0bQeNHmdb7qUnBkNPnL+oV5DjJUo0kqWsJUapZsM4jCfYItbqBDvlcA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-darwin-x64": { + "version": "1.29.2", + "resolved": "https://registry.npmjs.org/lightningcss-darwin-x64/-/lightningcss-darwin-x64-1.29.2.tgz", + "integrity": "sha512-j5qYxamyQw4kDXX5hnnCKMf3mLlHvG44f24Qyi2965/Ycz829MYqjrVg2H8BidybHBp9kom4D7DR5VqCKDXS0w==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-freebsd-x64": { + "version": "1.29.2", + "resolved": "https://registry.npmjs.org/lightningcss-freebsd-x64/-/lightningcss-freebsd-x64-1.29.2.tgz", + "integrity": "sha512-wDk7M2tM78Ii8ek9YjnY8MjV5f5JN2qNVO+/0BAGZRvXKtQrBC4/cn4ssQIpKIPP44YXw6gFdpUF+Ps+RGsCwg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-arm-gnueabihf": { + "version": "1.29.2", + "resolved": "https://registry.npmjs.org/lightningcss-linux-arm-gnueabihf/-/lightningcss-linux-arm-gnueabihf-1.29.2.tgz", + "integrity": "sha512-IRUrOrAF2Z+KExdExe3Rz7NSTuuJ2HvCGlMKoquK5pjvo2JY4Rybr+NrKnq0U0hZnx5AnGsuFHjGnNT14w26sg==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-arm64-gnu": { + "version": "1.29.2", + "resolved": "https://registry.npmjs.org/lightningcss-linux-arm64-gnu/-/lightningcss-linux-arm64-gnu-1.29.2.tgz", + "integrity": "sha512-KKCpOlmhdjvUTX/mBuaKemp0oeDIBBLFiU5Fnqxh1/DZ4JPZi4evEH7TKoSBFOSOV3J7iEmmBaw/8dpiUvRKlQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-arm64-musl": { + "version": "1.29.2", + "resolved": "https://registry.npmjs.org/lightningcss-linux-arm64-musl/-/lightningcss-linux-arm64-musl-1.29.2.tgz", + "integrity": "sha512-Q64eM1bPlOOUgxFmoPUefqzY1yV3ctFPE6d/Vt7WzLW4rKTv7MyYNky+FWxRpLkNASTnKQUaiMJ87zNODIrrKQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-x64-gnu": { + "version": "1.29.2", + "resolved": "https://registry.npmjs.org/lightningcss-linux-x64-gnu/-/lightningcss-linux-x64-gnu-1.29.2.tgz", + "integrity": "sha512-0v6idDCPG6epLXtBH/RPkHvYx74CVziHo6TMYga8O2EiQApnUPZsbR9nFNrg2cgBzk1AYqEd95TlrsL7nYABQg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-x64-musl": { + "version": "1.29.2", + "resolved": "https://registry.npmjs.org/lightningcss-linux-x64-musl/-/lightningcss-linux-x64-musl-1.29.2.tgz", + "integrity": "sha512-rMpz2yawkgGT8RULc5S4WiZopVMOFWjiItBT7aSfDX4NQav6M44rhn5hjtkKzB+wMTRlLLqxkeYEtQ3dd9696w==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-win32-arm64-msvc": { + "version": "1.29.2", + "resolved": "https://registry.npmjs.org/lightningcss-win32-arm64-msvc/-/lightningcss-win32-arm64-msvc-1.29.2.tgz", + "integrity": "sha512-nL7zRW6evGQqYVu/bKGK+zShyz8OVzsCotFgc7judbt6wnB2KbiKKJwBE4SGoDBQ1O94RjW4asrCjQL4i8Fhbw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-win32-x64-msvc": { + "version": "1.29.2", + "resolved": "https://registry.npmjs.org/lightningcss-win32-x64-msvc/-/lightningcss-win32-x64-msvc-1.29.2.tgz", + "integrity": "sha512-EdIUW3B2vLuHmv7urfzMI/h2fmlnOQBk1xlsDxkN1tCWKjNFjfLhGxYk8C8mzpSfr+A6jFFIi8fU6LbQGsRWjA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lodash.castarray": { + "version": "4.4.0", + "resolved": "https://registry.npmjs.org/lodash.castarray/-/lodash.castarray-4.4.0.tgz", + "integrity": "sha512-aVx8ztPv7/2ULbArGJ2Y42bG1mEQ5mGjpdvrbJcJFU3TbYybe+QlLS4pst9zV52ymy2in1KpFPiZnAOATxD4+Q==", + "dev": true, + "license": "MIT" + }, + "node_modules/lodash.isplainobject": { + "version": "4.0.6", + "resolved": "https://registry.npmjs.org/lodash.isplainobject/-/lodash.isplainobject-4.0.6.tgz", + "integrity": "sha512-oSXzaWypCMHkPC3NvBEaPHf0KsA5mvPrOPgQWDsbg8n7orZ290M0BmC/jgRZ4vcJ6DTAhjrsSYgdsW/F+MFOBA==", + "dev": true, + "license": "MIT" + }, + "node_modules/lodash.merge": { + "version": "4.6.2", + "resolved": "https://registry.npmjs.org/lodash.merge/-/lodash.merge-4.6.2.tgz", + "integrity": "sha512-0KpjqXRVvrYyCsX1swR/XTK0va6VQkQM6MNo7PqW77ByjAhoARA8EfrP1N4+KlKj8YS0ZUCtRT/YUuhyYDujIQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/magic-string": { + "version": "0.30.21", + "resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.30.21.tgz", + "integrity": "sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==", + "license": "MIT", + "dependencies": { + "@jridgewell/sourcemap-codec": "^1.5.5" + } + }, + "node_modules/marked": { + "version": "15.0.7", + "resolved": "https://registry.npmjs.org/marked/-/marked-15.0.7.tgz", + "integrity": "sha512-dgLIeKGLx5FwziAnsk4ONoGwHwGPJzselimvlVskE9XLN4Orv9u2VA3GWw/lYUqjfA0rUT/6fqKwfZJapP9BEg==", + "license": "MIT", + "bin": { + "marked": "bin/marked.js" + }, + "engines": { + "node": ">= 18" + } + }, + "node_modules/minimatch": { + "version": "9.0.9", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-9.0.9.tgz", + "integrity": "sha512-OBwBN9AL4dqmETlpS2zasx+vTeWclWzkblfZk7KTA5j3jeOONz/tRCnZomUyvNg83wL5Zv9Ss6HMJXAgL8R2Yg==", + "dev": true, + "license": "ISC", + "dependencies": { + "brace-expansion": "^2.0.2" + }, + "engines": { + "node": ">=16 || 14 >=14.17" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/muggle-string": { + "version": "0.4.1", + "resolved": "https://registry.npmjs.org/muggle-string/-/muggle-string-0.4.1.tgz", + "integrity": "sha512-VNTrAak/KhO2i8dqqnqnAHOa3cYBwXEZe9h+D5h/1ZqFSTEFHdM65lR7RoIqq3tBBYavsOXV84NoHXZ0AkPyqQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/nanoid": { + "version": "3.3.11", + "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.11.tgz", + "integrity": "sha512-N8SpfPUnUp1bK+PMYW8qSWdl9U+wwNWI4QKxOYDy9JAro3WMX7p2OeVRF9v+347pnakNevPmiHhNmZ2HbFA76w==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "bin": { + "nanoid": "bin/nanoid.cjs" + }, + "engines": { + "node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1" + } + }, + "node_modules/path-browserify": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/path-browserify/-/path-browserify-1.0.1.tgz", + "integrity": "sha512-b7uo2UCUOYZcnF/3ID0lulOJi/bafxa1xPe7ZPsammBSpjSWQkjNxlt635YGS2MiR9GjvuXCtz2emr3jbsz98g==", + "dev": true, + "license": "MIT" + }, + "node_modules/picocolors": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz", + "integrity": "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==", + "license": "ISC" + }, + "node_modules/postcss": { + "version": "8.5.6", + "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.6.tgz", + "integrity": "sha512-3Ybi1tAuwAP9s0r1UQ2J4n5Y0G05bJkpUIO0/bI9MhwmD70S5aTWbXGBwxHrelT+XM1k6dM0pk+SwNkpTRN7Pg==", + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/postcss/" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/postcss" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "nanoid": "^3.3.11", + "picocolors": "^1.1.1", + "source-map-js": "^1.2.1" + }, + "engines": { + "node": "^10 || ^12 || >=14" + } + }, + "node_modules/postcss-selector-parser": { + "version": "6.0.10", + "resolved": "https://registry.npmjs.org/postcss-selector-parser/-/postcss-selector-parser-6.0.10.tgz", + "integrity": "sha512-IQ7TZdoaqbT+LCpShg46jnZVlhWD2w6iQYAcYXfHARZ7X1t/UGhhceQDs5X0cGqKvYlHNOuv7Oa1xmb0oQuA3w==", + "dev": true, + "license": "MIT", + "dependencies": { + "cssesc": "^3.0.0", + "util-deprecate": "^1.0.2" + }, + "engines": { + "node": ">=4" + } + }, + "node_modules/rollup": { + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/rollup/-/rollup-4.59.0.tgz", + "integrity": "sha512-2oMpl67a3zCH9H79LeMcbDhXW/UmWG/y2zuqnF2jQq5uq9TbM9TVyXvA4+t+ne2IIkBdrLpAaRQAvo7YI/Yyeg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/estree": "1.0.8" + }, + "bin": { + "rollup": "dist/bin/rollup" + }, + "engines": { + "node": ">=18.0.0", + "npm": ">=8.0.0" + }, + "optionalDependencies": { + "@rollup/rollup-android-arm-eabi": "4.59.0", + "@rollup/rollup-android-arm64": "4.59.0", + "@rollup/rollup-darwin-arm64": "4.59.0", + "@rollup/rollup-darwin-x64": "4.59.0", + "@rollup/rollup-freebsd-arm64": "4.59.0", + "@rollup/rollup-freebsd-x64": "4.59.0", + "@rollup/rollup-linux-arm-gnueabihf": "4.59.0", + "@rollup/rollup-linux-arm-musleabihf": "4.59.0", + "@rollup/rollup-linux-arm64-gnu": "4.59.0", + "@rollup/rollup-linux-arm64-musl": "4.59.0", + "@rollup/rollup-linux-loong64-gnu": "4.59.0", + "@rollup/rollup-linux-loong64-musl": "4.59.0", + "@rollup/rollup-linux-ppc64-gnu": "4.59.0", + "@rollup/rollup-linux-ppc64-musl": "4.59.0", + "@rollup/rollup-linux-riscv64-gnu": "4.59.0", + "@rollup/rollup-linux-riscv64-musl": "4.59.0", + "@rollup/rollup-linux-s390x-gnu": "4.59.0", + "@rollup/rollup-linux-x64-gnu": "4.59.0", + "@rollup/rollup-linux-x64-musl": "4.59.0", + "@rollup/rollup-openbsd-x64": "4.59.0", + "@rollup/rollup-openharmony-arm64": "4.59.0", + "@rollup/rollup-win32-arm64-msvc": "4.59.0", + "@rollup/rollup-win32-ia32-msvc": "4.59.0", + "@rollup/rollup-win32-x64-gnu": "4.59.0", + "@rollup/rollup-win32-x64-msvc": "4.59.0", + "fsevents": "~2.3.2" + } + }, + "node_modules/sortablejs": { + "version": "1.14.0", + "resolved": "https://registry.npmjs.org/sortablejs/-/sortablejs-1.14.0.tgz", + "integrity": "sha512-pBXvQCs5/33fdN1/39pPL0NZF20LeRbLQ5jtnheIPN9JQAaufGjKdWduZn4U7wCtVuzKhmRkI0DFYHYRbB2H1w==", + "license": "MIT" + }, + "node_modules/source-map-js": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz", + "integrity": "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==", + "license": "BSD-3-Clause", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/tailwindcss": { + "version": "4.1.4", + "resolved": "https://registry.npmjs.org/tailwindcss/-/tailwindcss-4.1.4.tgz", + "integrity": "sha512-1ZIUqtPITFbv/DxRmDr5/agPqJwF69d24m9qmM1939TJehgY539CtzeZRjbLt5G6fSy/7YqqYsfvoTEw9xUI2A==", + "dev": true, + "license": "MIT" + }, + "node_modules/tapable": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/tapable/-/tapable-2.3.0.tgz", + "integrity": "sha512-g9ljZiwki/LfxmQADO3dEY1CbpmXT5Hm2fJ+QaGKwSXUylMybePR7/67YW7jOrrvjEgL1Fmz5kzyAjWVWLlucg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/webpack" + } + }, + "node_modules/typescript": { + "version": "5.7.3", + "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.7.3.tgz", + "integrity": "sha512-84MVSjMEHP+FQRPy3pX9sTVV/INIex71s9TL2Gm5FG/WG1SqXeKyZ0k7/blY/4FdOzI12CBy1vGc4og/eus0fw==", + "devOptional": true, + "license": "Apache-2.0", + "bin": { + "tsc": "bin/tsc", + "tsserver": "bin/tsserver" + }, + "engines": { + "node": ">=14.17" + } + }, + "node_modules/util-deprecate": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/util-deprecate/-/util-deprecate-1.0.2.tgz", + "integrity": "sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==", + "dev": true, + "license": "MIT" + }, + "node_modules/vite": { + "version": "6.2.6", + "resolved": "https://registry.npmjs.org/vite/-/vite-6.2.6.tgz", + "integrity": "sha512-9xpjNl3kR4rVDZgPNdTL0/c6ao4km69a/2ihNQbcANz8RuCOK3hQBmLSJf3bRKVQjVMda+YvizNE8AwvogcPbw==", + "dev": true, + "license": "MIT", + "dependencies": { + "esbuild": "^0.25.0", + "postcss": "^8.5.3", + "rollup": "^4.30.1" + }, + "bin": { + "vite": "bin/vite.js" + }, + "engines": { + "node": "^18.0.0 || ^20.0.0 || >=22.0.0" + }, + "funding": { + "url": "https://github.com/vitejs/vite?sponsor=1" + }, + "optionalDependencies": { + "fsevents": "~2.3.3" + }, + "peerDependencies": { + "@types/node": "^18.0.0 || ^20.0.0 || >=22.0.0", + "jiti": ">=1.21.0", + "less": "*", + "lightningcss": "^1.21.0", + "sass": "*", + "sass-embedded": "*", + "stylus": "*", + "sugarss": "*", + "terser": "^5.16.0", + "tsx": "^4.8.1", + "yaml": "^2.4.2" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + }, + "jiti": { + "optional": true + }, + "less": { + "optional": true + }, + "lightningcss": { + "optional": true + }, + "sass": { + "optional": true + }, + "sass-embedded": { + "optional": true + }, + "stylus": { + "optional": true + }, + "sugarss": { + "optional": true + }, + "terser": { + "optional": true + }, + "tsx": { + "optional": true + }, + "yaml": { + "optional": true + } + } + }, + "node_modules/vscode-uri": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/vscode-uri/-/vscode-uri-3.1.0.tgz", + "integrity": "sha512-/BpdSx+yCQGnCvecbyXdxHDkuk55/G3xwnC0GqY4gmQ3j+A+g8kzzgB4Nk/SINjqn6+waqw3EgbVF2QKExkRxQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/vue": { + "version": "3.5.13", + "resolved": "https://registry.npmjs.org/vue/-/vue-3.5.13.tgz", + "integrity": "sha512-wmeiSMxkZCSc+PM2w2VRsOYAZC8GdipNFRTsLSfodVqI9mbejKeXEGr8SckuLnrQPGe3oJN5c3K0vpoU9q/wCQ==", + "license": "MIT", + "dependencies": { + "@vue/compiler-dom": "3.5.13", + "@vue/compiler-sfc": "3.5.13", + "@vue/runtime-dom": "3.5.13", + "@vue/server-renderer": "3.5.13", + "@vue/shared": "3.5.13" + }, + "peerDependencies": { + "typescript": "*" + }, + "peerDependenciesMeta": { + "typescript": { + "optional": true + } + } + }, + "node_modules/vue-tsc": { + "version": "2.2.8", + "resolved": "https://registry.npmjs.org/vue-tsc/-/vue-tsc-2.2.8.tgz", + "integrity": "sha512-jBYKBNFADTN+L+MdesNX/TB3XuDSyaWynKMDgR+yCSln0GQ9Tfb7JS2lr46s2LiFUT1WsmfWsSvIElyxzOPqcQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@volar/typescript": "~2.4.11", + "@vue/language-core": "2.2.8" + }, + "bin": { + "vue-tsc": "bin/vue-tsc.js" + }, + "peerDependencies": { + "typescript": ">=5.0.0" + } + }, + "node_modules/vuedraggable": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/vuedraggable/-/vuedraggable-4.1.0.tgz", + "integrity": "sha512-FU5HCWBmsf20GpP3eudURW3WdWTKIbEIQxh9/8GE806hydR9qZqRRxRE3RjqX7PkuLuMQG/A7n3cfj9rCEchww==", + "license": "MIT", + "dependencies": { + "sortablejs": "1.14.0" + }, + "peerDependencies": { + "vue": "^3.0.1" + } + } + } +} diff --git a/frontend/package.json b/frontend/package.json new file mode 100644 index 0000000..7ce7e27 --- /dev/null +++ b/frontend/package.json @@ -0,0 +1,26 @@ +{ + "name": "handin-frontend", + "private": true, + "version": "0.1.0", + "type": "module", + "scripts": { + "dev": "vite", + "build": "vue-tsc --noEmit && vite build", + "preview": "vite preview", + "type-check": "vue-tsc --noEmit" + }, + "dependencies": { + "vue": "3.5.13", + "marked": "15.0.7", + "vuedraggable": "4.1.0" + }, + "devDependencies": { + "@tailwindcss/vite": "4.1.4", + "@tailwindcss/typography": "0.5.16", + "@vitejs/plugin-vue": "5.2.3", + "tailwindcss": "4.1.4", + "typescript": "5.7.3", + "vite": "6.2.6", + "vue-tsc": "2.2.8" + } +} diff --git a/frontend/src/App.vue b/frontend/src/App.vue new file mode 100644 index 0000000..57c300f --- /dev/null +++ b/frontend/src/App.vue @@ -0,0 +1,7 @@ + + + diff --git a/frontend/src/components/admin/AddSegmentModal.vue b/frontend/src/components/admin/AddSegmentModal.vue new file mode 100644 index 0000000..b23f1db --- /dev/null +++ b/frontend/src/components/admin/AddSegmentModal.vue @@ -0,0 +1,179 @@ + + +