first commit
This commit is contained in:
commit
b47f825d54
83 changed files with 10016 additions and 0 deletions
8
.env.example
Normal file
8
.env.example
Normal file
|
|
@ -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
|
||||||
100
.github/workflows/ci.yml
vendored
Normal file
100
.github/workflows/ci.yml
vendored
Normal file
|
|
@ -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
|
||||||
38
.gitignore
vendored
Normal file
38
.gitignore
vendored
Normal file
|
|
@ -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
|
||||||
51
Dockerfile
Normal file
51
Dockerfile
Normal file
|
|
@ -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"]
|
||||||
243
PLAN.md
Normal file
243
PLAN.md
Normal file
|
|
@ -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 `<a target="_blank">` in the tab bar and mobile menu. Does not appear in the main content area. The `content` field stores the destination URL.
|
||||||
|
|
||||||
|
### Database layer (`database.py`)
|
||||||
|
|
||||||
|
- WAL mode: `PRAGMA journal_mode=WAL` for concurrent read/write.
|
||||||
|
- `PRAGMA busy_timeout=5000` so concurrent writers wait instead of failing.
|
||||||
|
- Schema auto-created on startup via `CREATE TABLE IF NOT EXISTS`.
|
||||||
|
- Connection managed per-request via FastAPI dependency.
|
||||||
|
|
||||||
|
### Assets
|
||||||
|
|
||||||
|
Uploaded files stored as `data/assets/<uuid>.<ext>`. Original filename preserved only in segment metadata (prevents path traversal and collisions).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Auth
|
||||||
|
|
||||||
|
- Single token set via `HANDIN_ADMIN_TOKEN` env var.
|
||||||
|
- Accepted via `?token=` query param or `Authorization: Bearer` header.
|
||||||
|
- `require_admin` FastAPI dependency on all write endpoints.
|
||||||
|
- Public endpoints (GET) require no auth.
|
||||||
|
- `GET /api/auth/verify` for frontend token validation on load.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## API Endpoints
|
||||||
|
|
||||||
|
```
|
||||||
|
PUBLIC:
|
||||||
|
GET /api/site/ Site metadata (title)
|
||||||
|
GET /api/segments/ All segments, ordered by sort_order
|
||||||
|
GET /api/segments/{id} Single segment
|
||||||
|
GET /api/assets/{filename} Served via StaticFiles
|
||||||
|
|
||||||
|
NOTE: Collection routes use trailing slashes. Omitting them causes 307 redirects.
|
||||||
|
|
||||||
|
ADMIN (token required):
|
||||||
|
PUT /api/site/ Update site title
|
||||||
|
POST /api/segments/ Create segment
|
||||||
|
PATCH /api/segments/{id} Update segment (partial — send only changed fields)
|
||||||
|
DELETE /api/segments/{id} Delete segment
|
||||||
|
PUT /api/segments/reorder Reorder (body: ordered UUID list)
|
||||||
|
POST /api/assets/ Upload file (multipart)
|
||||||
|
DELETE /api/assets/{filename} Delete asset
|
||||||
|
GET /api/auth/verify Validate token
|
||||||
|
```
|
||||||
|
|
||||||
|
**Note:** Register `/reorder` route before `/{id}` to avoid path conflict.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## UI Design — Tailwind + #ffcd00 Theme
|
||||||
|
|
||||||
|
### Color System
|
||||||
|
|
||||||
|
Primary color `#ffcd00` (golden yellow). Tailwind extended palette:
|
||||||
|
|
||||||
|
| Token | Hex | Usage |
|
||||||
|
|-------------|-----------|----------------------------------|
|
||||||
|
| primary-50 | `#fffbeb` | Backgrounds, hover states |
|
||||||
|
| primary-100 | `#fff3c4` | Editor panel borders |
|
||||||
|
| primary-200 | `#fce588` | Subtle highlights |
|
||||||
|
| primary-300 | `#ffcd00` | **Brand color** — accents, active states |
|
||||||
|
| primary-400 | `#e6b800` | Hover on primary elements |
|
||||||
|
| primary-500 | `#cc9900` | Text on light backgrounds |
|
||||||
|
| primary-600 | `#997300` | Dark accent text |
|
||||||
|
| primary-700 | `#664d00` | Darkest accent |
|
||||||
|
|
||||||
|
Neutrals: Tailwind `slate` for text and backgrounds. Dark sidebar (`slate-900`), light main content.
|
||||||
|
|
||||||
|
### Layout — Top Bar with Tabs
|
||||||
|
|
||||||
|
> **Note:** The original plan specified a sidebar layout. Implementation changed to a horizontal top bar with tabs (Slice 5). Component **file names** from the original plan are preserved but their implementations differ.
|
||||||
|
|
||||||
|
**Desktop (≥768px `md+`):**
|
||||||
|
- **Title bar:** `bg-primary-300` (golden yellow) full-width at top, dark text, lock icon (admin entry) on the right.
|
||||||
|
- **Tab navigation:** Horizontal tab bar below the title bar, `border-b-2 border-primary-400` on the active tab.
|
||||||
|
- **Content area:** Centered `max-w-4xl` on white background.
|
||||||
|
|
||||||
|
**Mobile (<768px):**
|
||||||
|
- Title bar still visible. Tab bar hidden.
|
||||||
|
- `MobileHeader.vue` shows hamburger icon → dropdown from top with backdrop overlay.
|
||||||
|
- Segments stack vertically, full-width, comfortable touch targets.
|
||||||
|
|
||||||
|
**Component mapping:**
|
||||||
|
- `SidebarNav.vue` → horizontal tab bar (desktop, hidden below `md`).
|
||||||
|
- `MobileHeader.vue` → hamburger dropdown (mobile, hidden at `md+`).
|
||||||
|
- `AppShell.vue` → composes title bar + TokenPrompt + tab nav + mobile header + AdminToolbar + main content slot.
|
||||||
|
|
||||||
|
### Admin Integration (Minimal Interference)
|
||||||
|
|
||||||
|
No separate admin page. Controls blend into the existing UI:
|
||||||
|
|
||||||
|
- **Token active:** subtle edit icons on segment cards (pencil, muted → `primary-300` on hover). Drag handles in sidebar nav. Thin `primary-300` top bar with "Editing" badge + "Add Segment" button.
|
||||||
|
- **Segment editing:** inline — editor panel expands below the card. Same card styling, `primary-100` border.
|
||||||
|
- **Asset upload:** dashed-border drag-and-drop zone matching card aesthetics.
|
||||||
|
- **Token inactive:** zero admin UI visible.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Dependencies
|
||||||
|
|
||||||
|
**Backend:** `fastapi`, `uvicorn[standard]`, `pydantic`, `pydantic-settings`, `python-multipart`
|
||||||
|
**Backend dev:** `pytest`, `httpx`, `ruff`, `mypy`
|
||||||
|
**Frontend runtime:** `vue`, `marked`, `vuedraggable`
|
||||||
|
**Frontend dev:** `vite`, `@vitejs/plugin-vue`, `typescript`, `vue-tsc`, `tailwindcss`, `@tailwindcss/vite`, `@tailwindcss/typography`
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Docker Strategy
|
||||||
|
|
||||||
|
Multi-stage Dockerfile:
|
||||||
|
1. **Stage 1** (`node:20-alpine`): `npm ci && npm run build` → `dist/`.
|
||||||
|
2. **Stage 2** (`python:3.12-slim`): `uv sync --frozen --no-dev`, copy backend + built frontend to `/app/static/`.
|
||||||
|
|
||||||
|
FastAPI serves:
|
||||||
|
- API routes at `/api/*` (registered first)
|
||||||
|
- Uploaded assets at `/api/assets` via `StaticFiles`
|
||||||
|
- Vue SPA at `/` via `StaticFiles(directory="/app/static", html=True)` (registered last, catch-all)
|
||||||
|
|
||||||
|
`docker-compose.yml`: named volume for `/data`, `HANDIN_ADMIN_TOKEN` from environment.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Verification
|
||||||
|
|
||||||
|
1. `uv run pytest backend/tests/ -v` — all pass
|
||||||
|
2. `uv run ruff check backend/` + `uv run mypy --strict backend/app/` — clean
|
||||||
|
3. `docker compose up --build` — serves at `localhost:8000`
|
||||||
|
4. No-token visit: read-only, sidebar nav works, mobile responsive
|
||||||
|
5. Token visit: admin controls appear inline, CRUD + reorder works
|
||||||
|
6. Concurrent edits from two browsers: no corruption
|
||||||
|
7. Container restart: data persists
|
||||||
84
README.md
Normal file
84
README.md
Normal file
|
|
@ -0,0 +1,84 @@
|
||||||
|
<div align="center">
|
||||||
|
|
||||||
|
# Handin
|
||||||
|
|
||||||
|
**A simple hand-in page builder, made for Utrecht University.**
|
||||||
|
|
||||||
|
[](https://github.com/JustinZeus/handin-website/actions/workflows/ci.yml)
|
||||||
|
[](https://hub.docker.com/r/justinzeus/handin)
|
||||||
|
[](https://python.org)
|
||||||
|
|
||||||
|
</div>
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 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
|
||||||
|
```
|
||||||
175
SLICES.md
Normal file
175
SLICES.md
Normal file
|
|
@ -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 `<a target="_blank">` 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.
|
||||||
0
backend/app/__init__.py
Normal file
0
backend/app/__init__.py
Normal file
14
backend/app/auth.py
Normal file
14
backend/app/auth.py
Normal file
|
|
@ -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")
|
||||||
18
backend/app/config.py
Normal file
18
backend/app/config.py
Normal file
|
|
@ -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()
|
||||||
41
backend/app/database.py
Normal file
41
backend/app/database.py
Normal file
|
|
@ -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
|
||||||
41
backend/app/main.py
Normal file
41
backend/app/main.py
Normal file
|
|
@ -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")
|
||||||
62
backend/app/models.py
Normal file
62
backend/app/models.py
Normal file
|
|
@ -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
|
||||||
0
backend/app/routes/__init__.py
Normal file
0
backend/app/routes/__init__.py
Normal file
69
backend/app/routes/assets.py
Normal file
69
backend/app/routes/assets.py
Normal file
|
|
@ -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)
|
||||||
14
backend/app/routes/auth.py
Normal file
14
backend/app/routes/auth.py
Normal file
|
|
@ -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}
|
||||||
93
backend/app/routes/segments.py
Normal file
93
backend/app/routes/segments.py
Normal file
|
|
@ -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)
|
||||||
33
backend/app/routes/site.py
Normal file
33
backend/app/routes/site.py
Normal file
|
|
@ -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)
|
||||||
0
backend/app/services/__init__.py
Normal file
0
backend/app/services/__init__.py
Normal file
35
backend/app/services/asset_service.py
Normal file
35
backend/app/services/asset_service.py
Normal file
|
|
@ -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
|
||||||
147
backend/app/services/segment_service.py
Normal file
147
backend/app/services/segment_service.py
Normal file
|
|
@ -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()
|
||||||
31
backend/app/services/site_service.py
Normal file
31
backend/app/services/site_service.py
Normal file
|
|
@ -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()
|
||||||
0
backend/tests/__init__.py
Normal file
0
backend/tests/__init__.py
Normal file
60
backend/tests/conftest.py
Normal file
60
backend/tests/conftest.py
Normal file
|
|
@ -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)
|
||||||
119
backend/tests/test_assets.py
Normal file
119
backend/tests/test_assets.py
Normal file
|
|
@ -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
|
||||||
81
backend/tests/test_concurrent.py
Normal file
81
backend/tests/test_concurrent.py
Normal file
|
|
@ -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
|
||||||
78
backend/tests/test_database.py
Normal file
78
backend/tests/test_database.py
Normal file
|
|
@ -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"}
|
||||||
224
backend/tests/test_routes.py
Normal file
224
backend/tests/test_routes.py
Normal file
|
|
@ -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"
|
||||||
172
backend/tests/test_services.py
Normal file
172
backend/tests/test_services.py
Normal file
|
|
@ -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"
|
||||||
133
coding-style.md
Normal file
133
coding-style.md
Normal file
|
|
@ -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 `<script setup lang="ts">` exclusively. No Options API.
|
||||||
|
- Props and emits must be typed with `defineProps<{}>()` and `defineEmits<{}>()`.
|
||||||
|
- One component per file. Max 400 lines target.
|
||||||
|
- Composables for reusable stateful logic. Components for reusable UI.
|
||||||
|
|
||||||
|
### No Default Exports (except Vue components)
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
// CORRECT
|
||||||
|
export function calculateTotal(...) { ... }
|
||||||
|
export interface Order { ... }
|
||||||
|
|
||||||
|
// WRONG
|
||||||
|
export default function calculateTotal(...) { ... }
|
||||||
|
```
|
||||||
|
|
||||||
|
Vue components are the exception (required by Vue conventions).
|
||||||
|
|
||||||
|
## Shared Rules
|
||||||
|
|
||||||
|
### Dependencies
|
||||||
|
|
||||||
|
- Do not add dependencies without discussing first. Check if stdlib or an existing dep solves it.
|
||||||
|
- Pin exact versions in `pyproject.toml` and `package.json`.
|
||||||
|
|
||||||
|
### Comments
|
||||||
|
|
||||||
|
- Code should be self-documenting. Comments explain WHY, not WHAT.
|
||||||
|
- No commented-out code. Use git history.
|
||||||
|
- Docstrings on public functions in the domain layer (they're the API for other layers).
|
||||||
|
|
||||||
|
### File Organization
|
||||||
|
|
||||||
|
- No barrel files (`__init__.py` re-exports or `index.ts` re-exports). Import directly.
|
||||||
|
- No "utils" grab-bag files. If a utility is specific to a domain, put it in that domain's directory.
|
||||||
|
- If a file exceeds 600 lines, it must be split before adding more code.
|
||||||
29
docker-compose.yml
Normal file
29
docker-compose.yml
Normal file
|
|
@ -0,0 +1,29 @@
|
||||||
|
services:
|
||||||
|
web:
|
||||||
|
build:
|
||||||
|
context: .
|
||||||
|
target: prod
|
||||||
|
environment:
|
||||||
|
- HANDIN_ADMIN_TOKEN=${HANDIN_ADMIN_TOKEN:?Set HANDIN_ADMIN_TOKEN}
|
||||||
|
- HANDIN_SITE_TITLE=${HANDIN_SITE_TITLE:-Untitled Site}
|
||||||
|
- HANDIN_DATA_DIR=/data
|
||||||
|
volumes:
|
||||||
|
- handin-data:/data
|
||||||
|
|
||||||
|
frontend:
|
||||||
|
image: node:20-alpine
|
||||||
|
working_dir: /app
|
||||||
|
command: sh -c "npm install && npm run dev -- --host"
|
||||||
|
ports:
|
||||||
|
- "5174:5174"
|
||||||
|
environment:
|
||||||
|
- API_URL=http://web:8000
|
||||||
|
volumes:
|
||||||
|
- ./frontend:/app
|
||||||
|
- frontend-node-modules:/app/node_modules
|
||||||
|
depends_on:
|
||||||
|
- web
|
||||||
|
|
||||||
|
volumes:
|
||||||
|
handin-data:
|
||||||
|
frontend-node-modules:
|
||||||
7
frontend/env.d.ts
vendored
Normal file
7
frontend/env.d.ts
vendored
Normal file
|
|
@ -0,0 +1,7 @@
|
||||||
|
/// <reference types="vite/client" />
|
||||||
|
|
||||||
|
declare module "*.vue" {
|
||||||
|
import type { DefineComponent } from "vue";
|
||||||
|
const component: DefineComponent<object, object, unknown>;
|
||||||
|
export default component;
|
||||||
|
}
|
||||||
12
frontend/index.html
Normal file
12
frontend/index.html
Normal file
|
|
@ -0,0 +1,12 @@
|
||||||
|
<!doctype html>
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8" />
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||||
|
<title>Handin</title>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div id="app"></div>
|
||||||
|
<script type="module" src="/src/main.ts"></script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
2125
frontend/package-lock.json
generated
Normal file
2125
frontend/package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load diff
26
frontend/package.json
Normal file
26
frontend/package.json
Normal file
|
|
@ -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"
|
||||||
|
}
|
||||||
|
}
|
||||||
7
frontend/src/App.vue
Normal file
7
frontend/src/App.vue
Normal file
|
|
@ -0,0 +1,7 @@
|
||||||
|
<script setup lang="ts">
|
||||||
|
import HomePage from "@/views/HomePage.vue";
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<HomePage />
|
||||||
|
</template>
|
||||||
179
frontend/src/components/admin/AddSegmentModal.vue
Normal file
179
frontend/src/components/admin/AddSegmentModal.vue
Normal file
|
|
@ -0,0 +1,179 @@
|
||||||
|
<script setup lang="ts">
|
||||||
|
import { ref, computed } from "vue";
|
||||||
|
import type { SegmentType } from "@/types/segment";
|
||||||
|
import AssetUploader from "@/components/admin/AssetUploader.vue";
|
||||||
|
|
||||||
|
const emit = defineEmits<{
|
||||||
|
create: [data: { type: SegmentType; title: string; content: string; metadata?: Record<string, unknown> }];
|
||||||
|
cancel: [];
|
||||||
|
}>();
|
||||||
|
|
||||||
|
const segmentTypes: { value: SegmentType; label: string }[] = [
|
||||||
|
{ value: "markdown", label: "Markdown" },
|
||||||
|
{ value: "pdf", label: "PDF" },
|
||||||
|
{ value: "video", label: "Video" },
|
||||||
|
{ value: "audio", label: "Audio" },
|
||||||
|
{ value: "iframe", label: "Iframe" },
|
||||||
|
{ value: "gallery", label: "Gallery" },
|
||||||
|
{ value: "link", label: "Link" },
|
||||||
|
];
|
||||||
|
|
||||||
|
const selectedType = ref<SegmentType>("markdown");
|
||||||
|
const title = ref("");
|
||||||
|
const content = ref("");
|
||||||
|
const galleryImages = ref<string[]>([]);
|
||||||
|
|
||||||
|
const canSubmit = computed(() => {
|
||||||
|
if (!title.value.trim()) return false;
|
||||||
|
if (selectedType.value === "gallery") return galleryImages.value.length > 0;
|
||||||
|
if (selectedType.value === "markdown") return true;
|
||||||
|
return content.value.trim().length > 0;
|
||||||
|
});
|
||||||
|
|
||||||
|
function handleAssetUploaded(filename: string) {
|
||||||
|
content.value = `/api/assets/${filename}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleGalleryImageUploaded(filename: string) {
|
||||||
|
galleryImages.value = [...galleryImages.value, `/api/assets/${filename}`];
|
||||||
|
}
|
||||||
|
|
||||||
|
function removeGalleryImage(index: number) {
|
||||||
|
galleryImages.value = galleryImages.value.filter((_, i) => i !== index);
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleSubmit() {
|
||||||
|
const data: { type: SegmentType; title: string; content: string; metadata?: Record<string, unknown> } = {
|
||||||
|
type: selectedType.value,
|
||||||
|
title: title.value.trim(),
|
||||||
|
content: content.value,
|
||||||
|
};
|
||||||
|
if (selectedType.value === "gallery") {
|
||||||
|
data.metadata = { images: galleryImages.value };
|
||||||
|
}
|
||||||
|
emit("create", data);
|
||||||
|
}
|
||||||
|
|
||||||
|
const assetAcceptMap: Record<string, string> = {
|
||||||
|
pdf: "application/pdf",
|
||||||
|
video: "video/*",
|
||||||
|
audio: "audio/*",
|
||||||
|
};
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<Teleport to="body">
|
||||||
|
<div
|
||||||
|
class="fixed inset-0 z-50 flex items-center justify-center bg-black/40"
|
||||||
|
@click.self="$emit('cancel')"
|
||||||
|
>
|
||||||
|
<div class="mx-4 w-full max-w-lg rounded-lg bg-white p-6 shadow-xl dark:bg-slate-800">
|
||||||
|
<h2 class="mb-4 text-lg font-semibold text-slate-900 dark:text-slate-100">Add New Segment</h2>
|
||||||
|
|
||||||
|
<!-- Type selector: button group -->
|
||||||
|
<div class="mb-4">
|
||||||
|
<label class="mb-2 block text-sm font-medium text-slate-700 dark:text-slate-300">Type</label>
|
||||||
|
<div class="flex flex-wrap gap-2">
|
||||||
|
<button
|
||||||
|
v-for="st in segmentTypes"
|
||||||
|
:key="st.value"
|
||||||
|
class="rounded-full px-3 py-1 text-sm font-medium transition-colors"
|
||||||
|
:class="
|
||||||
|
selectedType === st.value
|
||||||
|
? 'bg-primary-300 text-slate-900 dark:bg-primary-600 dark:text-white'
|
||||||
|
: 'bg-slate-100 text-slate-600 hover:bg-slate-200 dark:bg-slate-700 dark:text-slate-300 dark:hover:bg-slate-600'
|
||||||
|
"
|
||||||
|
@click="selectedType = st.value; content = ''; galleryImages = []"
|
||||||
|
>
|
||||||
|
{{ st.label }}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Title -->
|
||||||
|
<div class="mb-4">
|
||||||
|
<label class="mb-1 block text-sm font-medium text-slate-700 dark:text-slate-300">Title</label>
|
||||||
|
<input
|
||||||
|
v-model="title"
|
||||||
|
type="text"
|
||||||
|
placeholder="Segment title"
|
||||||
|
class="w-full rounded border border-slate-300 px-3 py-2 text-sm focus:border-primary-400 focus:outline-none dark:border-slate-600 dark:bg-slate-700 dark:text-slate-100 dark:placeholder-slate-500"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Content: type-dependent -->
|
||||||
|
<div class="mb-4">
|
||||||
|
<label class="mb-1 block text-sm font-medium text-slate-700 dark:text-slate-300">Content</label>
|
||||||
|
|
||||||
|
<!-- Markdown: textarea -->
|
||||||
|
<textarea
|
||||||
|
v-if="selectedType === 'markdown'"
|
||||||
|
v-model="content"
|
||||||
|
rows="6"
|
||||||
|
placeholder="Markdown content..."
|
||||||
|
class="w-full rounded border border-slate-300 px-3 py-2 font-mono text-sm focus:border-primary-400 focus:outline-none dark:border-slate-600 dark:bg-slate-700 dark:text-slate-100 dark:placeholder-slate-500"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<!-- Iframe / Link: URL input -->
|
||||||
|
<input
|
||||||
|
v-else-if="selectedType === 'iframe' || selectedType === 'link'"
|
||||||
|
v-model="content"
|
||||||
|
type="url"
|
||||||
|
:placeholder="selectedType === 'link' ? 'https://external-site.com' : 'https://...'"
|
||||||
|
class="w-full rounded border border-slate-300 px-3 py-2 text-sm focus:border-primary-400 focus:outline-none dark:border-slate-600 dark:bg-slate-700 dark:text-slate-100 dark:placeholder-slate-500"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<!-- PDF / Video / Audio -->
|
||||||
|
<div v-else-if="selectedType === 'pdf' || selectedType === 'video' || selectedType === 'audio'">
|
||||||
|
<div v-if="content" class="mb-2 flex items-center gap-2 rounded bg-slate-50 px-3 py-2 text-sm text-slate-600">
|
||||||
|
<span class="truncate">{{ content }}</span>
|
||||||
|
<button class="shrink-0 text-red-400 hover:text-red-600" @click="content = ''">×</button>
|
||||||
|
</div>
|
||||||
|
<AssetUploader
|
||||||
|
:accept="assetAcceptMap[selectedType]"
|
||||||
|
:label="`Upload ${selectedType} file`"
|
||||||
|
@uploaded="handleAssetUploaded"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Gallery -->
|
||||||
|
<div v-else-if="selectedType === 'gallery'">
|
||||||
|
<div v-if="galleryImages.length > 0" class="mb-3 grid grid-cols-4 gap-2">
|
||||||
|
<div
|
||||||
|
v-for="(img, i) in galleryImages"
|
||||||
|
:key="i"
|
||||||
|
class="group relative aspect-square overflow-hidden rounded bg-slate-100"
|
||||||
|
>
|
||||||
|
<img :src="img" class="h-full w-full object-cover" />
|
||||||
|
<button
|
||||||
|
class="absolute right-1 top-1 hidden rounded bg-red-500 px-1.5 text-xs text-white group-hover:block"
|
||||||
|
@click="removeGalleryImage(i)"
|
||||||
|
>
|
||||||
|
×
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<AssetUploader accept="image/*" label="Upload gallery image" @uploaded="handleGalleryImageUploaded" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Actions -->
|
||||||
|
<div class="flex justify-end gap-2">
|
||||||
|
<button
|
||||||
|
class="rounded px-3 py-1.5 text-sm text-slate-500 transition-colors hover:text-slate-700 dark:text-slate-400 dark:hover:text-slate-200"
|
||||||
|
@click="$emit('cancel')"
|
||||||
|
>
|
||||||
|
Cancel
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
:disabled="!canSubmit"
|
||||||
|
class="rounded bg-primary-300 px-4 py-1.5 text-sm font-medium text-slate-900 transition-colors hover:bg-primary-400 disabled:opacity-50 dark:bg-primary-600 dark:text-white dark:hover:bg-primary-500"
|
||||||
|
@click="handleSubmit"
|
||||||
|
>
|
||||||
|
Create
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</Teleport>
|
||||||
|
</template>
|
||||||
30
frontend/src/components/admin/AdminToolbar.vue
Normal file
30
frontend/src/components/admin/AdminToolbar.vue
Normal file
|
|
@ -0,0 +1,30 @@
|
||||||
|
<script setup lang="ts">
|
||||||
|
defineEmits<{
|
||||||
|
"add-segment": [];
|
||||||
|
logout: [];
|
||||||
|
}>();
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div class="border-b border-primary-100 bg-primary-50 px-6 py-2 dark:border-primary-700/30 dark:bg-slate-800">
|
||||||
|
<div class="mx-auto flex max-w-6xl items-center justify-between">
|
||||||
|
<span class="rounded bg-primary-200 px-2 py-0.5 text-xs font-semibold text-primary-700 dark:bg-primary-700/30 dark:text-primary-300">
|
||||||
|
Editing
|
||||||
|
</span>
|
||||||
|
<div class="flex items-center gap-3">
|
||||||
|
<button
|
||||||
|
class="rounded bg-primary-300 px-3 py-1.5 text-sm font-medium text-slate-900 transition-colors hover:bg-primary-400 dark:bg-primary-600 dark:text-white dark:hover:bg-primary-500"
|
||||||
|
@click="$emit('add-segment')"
|
||||||
|
>
|
||||||
|
+ Add Segment
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
class="rounded px-3 py-1.5 text-sm text-slate-500 transition-colors hover:text-slate-700 dark:text-slate-400 dark:hover:text-slate-200"
|
||||||
|
@click="$emit('logout')"
|
||||||
|
>
|
||||||
|
Logout
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
68
frontend/src/components/admin/AssetUploader.vue
Normal file
68
frontend/src/components/admin/AssetUploader.vue
Normal file
|
|
@ -0,0 +1,68 @@
|
||||||
|
<script setup lang="ts">
|
||||||
|
import { ref } from "vue";
|
||||||
|
import { useAdmin } from "@/composables/useAdmin";
|
||||||
|
import { useAssetUpload } from "@/composables/useAssetUpload";
|
||||||
|
|
||||||
|
const props = defineProps<{
|
||||||
|
accept?: string;
|
||||||
|
label?: string;
|
||||||
|
}>();
|
||||||
|
|
||||||
|
const emit = defineEmits<{
|
||||||
|
uploaded: [filename: string];
|
||||||
|
}>();
|
||||||
|
|
||||||
|
const { authHeaders } = useAdmin();
|
||||||
|
const { uploading, error, upload } = useAssetUpload();
|
||||||
|
const dragging = ref(false);
|
||||||
|
const fileInput = ref<HTMLInputElement | null>(null);
|
||||||
|
|
||||||
|
async function handleFile(file: File) {
|
||||||
|
const filename = await upload(file, authHeaders.value);
|
||||||
|
if (filename) {
|
||||||
|
emit("uploaded", filename);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function onDrop(e: DragEvent) {
|
||||||
|
dragging.value = false;
|
||||||
|
const file = e.dataTransfer?.files[0];
|
||||||
|
if (file) void handleFile(file);
|
||||||
|
}
|
||||||
|
|
||||||
|
function onFileSelect(e: Event) {
|
||||||
|
const target = e.target as HTMLInputElement;
|
||||||
|
const file = target.files?.[0];
|
||||||
|
if (file) void handleFile(file);
|
||||||
|
target.value = "";
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div
|
||||||
|
class="relative cursor-pointer rounded-lg border-2 border-dashed p-6 text-center transition-colors"
|
||||||
|
:class="dragging ? 'border-primary-300 bg-primary-50 dark:border-primary-500 dark:bg-primary-900/20' : 'border-slate-300 hover:border-primary-300 dark:border-slate-600 dark:hover:border-primary-500'"
|
||||||
|
@dragover.prevent="dragging = true"
|
||||||
|
@dragleave="dragging = false"
|
||||||
|
@drop.prevent="onDrop"
|
||||||
|
@click="fileInput?.click()"
|
||||||
|
>
|
||||||
|
<input
|
||||||
|
ref="fileInput"
|
||||||
|
type="file"
|
||||||
|
class="hidden"
|
||||||
|
:accept="props.accept"
|
||||||
|
@change="onFileSelect"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<div v-if="uploading" class="text-sm text-slate-500 dark:text-slate-400">Uploading...</div>
|
||||||
|
<div v-else>
|
||||||
|
<svg class="mx-auto mb-2 h-8 w-8 text-slate-400 dark:text-slate-500" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="1.5" d="M7 16a4 4 0 01-.88-7.903A5 5 0 1115.9 6L16 6a5 5 0 011 9.9M15 13l-3-3m0 0l-3 3m3-3v12" />
|
||||||
|
</svg>
|
||||||
|
<p class="text-sm text-slate-500 dark:text-slate-400">{{ props.label ?? "Drop file here or click to browse" }}</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<p v-if="error" class="mt-2 text-sm text-red-500">{{ error }}</p>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
188
frontend/src/components/admin/SegmentEditor.vue
Normal file
188
frontend/src/components/admin/SegmentEditor.vue
Normal file
|
|
@ -0,0 +1,188 @@
|
||||||
|
<script setup lang="ts">
|
||||||
|
import { ref } from "vue";
|
||||||
|
import type { Segment } from "@/types/segment";
|
||||||
|
import AssetUploader from "@/components/admin/AssetUploader.vue";
|
||||||
|
|
||||||
|
const props = defineProps<{
|
||||||
|
segment: Segment;
|
||||||
|
}>();
|
||||||
|
|
||||||
|
const emit = defineEmits<{
|
||||||
|
save: [updates: { title?: string; content?: string; metadata?: Record<string, unknown> }];
|
||||||
|
cancel: [];
|
||||||
|
delete: [];
|
||||||
|
}>();
|
||||||
|
|
||||||
|
const title = ref(props.segment.title);
|
||||||
|
const content = ref(props.segment.content);
|
||||||
|
const metadata = ref<Record<string, unknown>>({ ...props.segment.metadata });
|
||||||
|
const showDeleteConfirm = ref(false);
|
||||||
|
|
||||||
|
function handleSave() {
|
||||||
|
const updates: { title?: string; content?: string; metadata?: Record<string, unknown> } = {};
|
||||||
|
if (title.value !== props.segment.title) updates.title = title.value;
|
||||||
|
if (content.value !== props.segment.content) updates.content = content.value;
|
||||||
|
if (JSON.stringify(metadata.value) !== JSON.stringify(props.segment.metadata)) {
|
||||||
|
updates.metadata = metadata.value;
|
||||||
|
}
|
||||||
|
emit("save", updates);
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleAssetUploaded(filename: string) {
|
||||||
|
content.value = `/api/assets/${filename}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleGalleryImageUploaded(filename: string) {
|
||||||
|
const images = Array.isArray(metadata.value.images)
|
||||||
|
? [...(metadata.value.images as string[])]
|
||||||
|
: [];
|
||||||
|
images.push(`/api/assets/${filename}`);
|
||||||
|
metadata.value = { ...metadata.value, images };
|
||||||
|
}
|
||||||
|
|
||||||
|
function removeGalleryImage(index: number) {
|
||||||
|
const images = Array.isArray(metadata.value.images)
|
||||||
|
? [...(metadata.value.images as string[])]
|
||||||
|
: [];
|
||||||
|
images.splice(index, 1);
|
||||||
|
metadata.value = { ...metadata.value, images };
|
||||||
|
}
|
||||||
|
|
||||||
|
const assetAcceptMap: Record<string, string> = {
|
||||||
|
pdf: "application/pdf",
|
||||||
|
video: "video/*",
|
||||||
|
audio: "audio/*",
|
||||||
|
};
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div class="mt-4 rounded-lg border border-primary-100 bg-primary-50/50 p-4 dark:border-slate-600 dark:bg-slate-700/50">
|
||||||
|
<!-- Title -->
|
||||||
|
<div class="mb-4">
|
||||||
|
<label class="mb-1 block text-sm font-medium text-slate-700 dark:text-slate-300">Title</label>
|
||||||
|
<input
|
||||||
|
v-model="title"
|
||||||
|
type="text"
|
||||||
|
class="w-full rounded border border-slate-300 px-3 py-2 text-sm focus:border-primary-400 focus:outline-none dark:border-slate-600 dark:bg-slate-700 dark:text-slate-100"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Content: depends on segment type -->
|
||||||
|
<div class="mb-4">
|
||||||
|
<label class="mb-1 block text-sm font-medium text-slate-700 dark:text-slate-300">Content</label>
|
||||||
|
|
||||||
|
<!-- Markdown: textarea -->
|
||||||
|
<textarea
|
||||||
|
v-if="segment.type === 'markdown'"
|
||||||
|
v-model="content"
|
||||||
|
rows="8"
|
||||||
|
class="w-full rounded border border-slate-300 px-3 py-2 font-mono text-sm focus:border-primary-400 focus:outline-none dark:border-slate-600 dark:bg-slate-700 dark:text-slate-100"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<!-- Iframe / Link: URL input -->
|
||||||
|
<input
|
||||||
|
v-else-if="segment.type === 'iframe' || segment.type === 'link'"
|
||||||
|
v-model="content"
|
||||||
|
type="url"
|
||||||
|
:placeholder="segment.type === 'link' ? 'https://external-site.com' : 'https://...'"
|
||||||
|
class="w-full rounded border border-slate-300 px-3 py-2 text-sm focus:border-primary-400 focus:outline-none dark:border-slate-600 dark:bg-slate-700 dark:text-slate-100"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<!-- PDF / Video / Audio: file upload + URL display -->
|
||||||
|
<div v-else-if="segment.type === 'pdf' || segment.type === 'video' || segment.type === 'audio'">
|
||||||
|
<div v-if="content" class="mb-2 flex items-center gap-2 rounded bg-white px-3 py-2 text-sm text-slate-600 dark:bg-slate-700 dark:text-slate-300">
|
||||||
|
<span class="truncate">{{ content }}</span>
|
||||||
|
<button
|
||||||
|
class="shrink-0 text-red-400 hover:text-red-600"
|
||||||
|
@click="content = ''"
|
||||||
|
>
|
||||||
|
×
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<AssetUploader
|
||||||
|
:accept="assetAcceptMap[segment.type]"
|
||||||
|
:label="`Upload ${segment.type} file`"
|
||||||
|
@uploaded="handleAssetUploaded"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Gallery: multiple image upload -->
|
||||||
|
<div v-else-if="segment.type === 'gallery'">
|
||||||
|
<div v-if="Array.isArray(metadata.images) && (metadata.images as string[]).length > 0" class="mb-3 grid grid-cols-4 gap-2">
|
||||||
|
<div
|
||||||
|
v-for="(img, i) in (metadata.images as string[])"
|
||||||
|
:key="i"
|
||||||
|
class="group relative aspect-square overflow-hidden rounded bg-slate-100"
|
||||||
|
>
|
||||||
|
<img :src="img" class="h-full w-full object-cover" />
|
||||||
|
<button
|
||||||
|
class="absolute right-1 top-1 hidden rounded bg-red-500 px-1.5 text-xs text-white group-hover:block"
|
||||||
|
@click="removeGalleryImage(i)"
|
||||||
|
>
|
||||||
|
×
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<AssetUploader
|
||||||
|
accept="image/*"
|
||||||
|
label="Upload gallery image"
|
||||||
|
@uploaded="handleGalleryImageUploaded"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Actions -->
|
||||||
|
<div class="flex items-center justify-between">
|
||||||
|
<button
|
||||||
|
class="rounded px-3 py-1.5 text-sm text-red-500 transition-colors hover:bg-red-50 hover:text-red-700 dark:hover:bg-red-950/30"
|
||||||
|
@click="showDeleteConfirm = true"
|
||||||
|
>
|
||||||
|
Delete
|
||||||
|
</button>
|
||||||
|
<div class="flex gap-2">
|
||||||
|
<button
|
||||||
|
class="rounded px-3 py-1.5 text-sm text-slate-500 transition-colors hover:text-slate-700 dark:text-slate-400 dark:hover:text-slate-200"
|
||||||
|
@click="$emit('cancel')"
|
||||||
|
>
|
||||||
|
Cancel
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
class="rounded bg-primary-300 px-4 py-1.5 text-sm font-medium text-slate-900 transition-colors hover:bg-primary-400 dark:bg-primary-600 dark:text-white dark:hover:bg-primary-500"
|
||||||
|
@click="handleSave"
|
||||||
|
>
|
||||||
|
Save
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Delete confirmation modal -->
|
||||||
|
<Teleport to="body">
|
||||||
|
<div
|
||||||
|
v-if="showDeleteConfirm"
|
||||||
|
class="fixed inset-0 z-50 flex items-center justify-center bg-black/40"
|
||||||
|
@click.self="showDeleteConfirm = false"
|
||||||
|
>
|
||||||
|
<div class="w-full max-w-sm rounded-lg bg-white p-6 shadow-xl dark:bg-slate-800">
|
||||||
|
<h3 class="mb-2 text-lg font-semibold text-slate-900 dark:text-slate-100">Delete Segment</h3>
|
||||||
|
<p class="mb-4 text-sm text-slate-600 dark:text-slate-400">
|
||||||
|
Are you sure you want to delete <strong>"{{ segment.title }}"</strong>? This action cannot be undone.
|
||||||
|
</p>
|
||||||
|
<div class="flex justify-end gap-2">
|
||||||
|
<button
|
||||||
|
class="rounded px-3 py-1.5 text-sm text-slate-500 transition-colors hover:text-slate-700 dark:text-slate-400 dark:hover:text-slate-200"
|
||||||
|
@click="showDeleteConfirm = false"
|
||||||
|
>
|
||||||
|
Cancel
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
class="rounded bg-red-500 px-4 py-1.5 text-sm font-medium text-white transition-colors hover:bg-red-600"
|
||||||
|
@click="showDeleteConfirm = false; $emit('delete')"
|
||||||
|
>
|
||||||
|
Delete
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</Teleport>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
79
frontend/src/components/admin/TokenPrompt.vue
Normal file
79
frontend/src/components/admin/TokenPrompt.vue
Normal file
|
|
@ -0,0 +1,79 @@
|
||||||
|
<script setup lang="ts">
|
||||||
|
import { ref } from "vue";
|
||||||
|
import { useAdmin } from "@/composables/useAdmin";
|
||||||
|
|
||||||
|
const { isAdmin, verifying, login } = useAdmin();
|
||||||
|
|
||||||
|
const emit = defineEmits<{
|
||||||
|
authenticated: [];
|
||||||
|
}>();
|
||||||
|
|
||||||
|
const showInput = ref(false);
|
||||||
|
const tokenInput = ref("");
|
||||||
|
const loginError = ref(false);
|
||||||
|
|
||||||
|
async function handleSubmit() {
|
||||||
|
loginError.value = false;
|
||||||
|
const success = await login(tokenInput.value);
|
||||||
|
if (success) {
|
||||||
|
showInput.value = false;
|
||||||
|
tokenInput.value = "";
|
||||||
|
emit("authenticated");
|
||||||
|
} else {
|
||||||
|
loginError.value = true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleCancel() {
|
||||||
|
showInput.value = false;
|
||||||
|
tokenInput.value = "";
|
||||||
|
loginError.value = false;
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div v-if="!isAdmin" class="flex items-center">
|
||||||
|
<!-- Lock icon button -->
|
||||||
|
<button
|
||||||
|
v-if="!showInput"
|
||||||
|
class="rounded p-1.5 text-slate-700 transition-colors hover:bg-primary-400/30"
|
||||||
|
title="Admin login"
|
||||||
|
@click="showInput = true"
|
||||||
|
>
|
||||||
|
<svg class="h-5 w-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
|
<path
|
||||||
|
stroke-linecap="round"
|
||||||
|
stroke-linejoin="round"
|
||||||
|
stroke-width="2"
|
||||||
|
d="M12 15v2m-6 4h12a2 2 0 002-2v-6a2 2 0 00-2-2H6a2 2 0 00-2 2v6a2 2 0 002 2zm10-10V7a4 4 0 00-8 0v4h8z"
|
||||||
|
/>
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<!-- Inline token input -->
|
||||||
|
<form v-else class="flex items-center gap-2" @submit.prevent="handleSubmit">
|
||||||
|
<input
|
||||||
|
v-model="tokenInput"
|
||||||
|
type="password"
|
||||||
|
placeholder="Admin token"
|
||||||
|
class="h-8 w-40 rounded border px-2 text-sm text-slate-900 placeholder-slate-400 focus:border-primary-400 focus:outline-none dark:bg-slate-700 dark:text-slate-100 dark:placeholder-slate-500"
|
||||||
|
:class="loginError ? 'border-red-400' : 'border-slate-300 dark:border-slate-600'"
|
||||||
|
autofocus
|
||||||
|
/>
|
||||||
|
<button
|
||||||
|
type="submit"
|
||||||
|
:disabled="verifying || !tokenInput"
|
||||||
|
class="h-8 rounded bg-slate-800 px-3 text-sm font-medium text-white transition-colors hover:bg-slate-700 disabled:opacity-50 dark:bg-slate-600 dark:hover:bg-slate-500"
|
||||||
|
>
|
||||||
|
{{ verifying ? "..." : "Go" }}
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="h-8 rounded px-2 text-sm text-slate-600 transition-colors hover:text-slate-900 dark:text-slate-400 dark:hover:text-slate-100"
|
||||||
|
@click="handleCancel"
|
||||||
|
>
|
||||||
|
×
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
135
frontend/src/components/layout/AppShell.vue
Normal file
135
frontend/src/components/layout/AppShell.vue
Normal file
|
|
@ -0,0 +1,135 @@
|
||||||
|
<script setup lang="ts">
|
||||||
|
import { ref, onMounted, onUnmounted } from "vue";
|
||||||
|
import type { Segment } from "@/types/segment";
|
||||||
|
import { useAdmin } from "@/composables/useAdmin";
|
||||||
|
import { useDarkMode } from "@/composables/useDarkMode";
|
||||||
|
import SidebarNav from "@/components/layout/SidebarNav.vue";
|
||||||
|
import MobileHeader from "@/components/layout/MobileHeader.vue";
|
||||||
|
import TokenPrompt from "@/components/admin/TokenPrompt.vue";
|
||||||
|
import AdminToolbar from "@/components/admin/AdminToolbar.vue";
|
||||||
|
|
||||||
|
defineProps<{
|
||||||
|
segments: Segment[];
|
||||||
|
siteTitle: string;
|
||||||
|
activeId: string | null;
|
||||||
|
}>();
|
||||||
|
|
||||||
|
const emit = defineEmits<{
|
||||||
|
navigate: [id: string];
|
||||||
|
"add-segment": [];
|
||||||
|
logout: [];
|
||||||
|
}>();
|
||||||
|
|
||||||
|
const { isAdmin } = useAdmin();
|
||||||
|
const { isDark, toggle: toggleDark } = useDarkMode();
|
||||||
|
|
||||||
|
const showBackToTop = ref(false);
|
||||||
|
|
||||||
|
function handleScroll() {
|
||||||
|
showBackToTop.value = window.scrollY > 300;
|
||||||
|
}
|
||||||
|
|
||||||
|
function scrollToTop() {
|
||||||
|
window.scrollTo({ top: 0, behavior: "smooth" });
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleNavigate(id: string) {
|
||||||
|
emit("navigate", id);
|
||||||
|
}
|
||||||
|
|
||||||
|
onMounted(() => {
|
||||||
|
window.addEventListener("scroll", handleScroll, { passive: true });
|
||||||
|
});
|
||||||
|
|
||||||
|
onUnmounted(() => {
|
||||||
|
window.removeEventListener("scroll", handleScroll);
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div class="min-h-screen bg-slate-50 dark:bg-slate-900">
|
||||||
|
<!-- Sticky header: title bar + nav -->
|
||||||
|
<div class="sticky top-0 z-20">
|
||||||
|
<!-- Title bar -->
|
||||||
|
<header class="flex items-center justify-between bg-primary-300 px-6 py-4">
|
||||||
|
<h1 class="text-xl font-bold text-slate-900">{{ siteTitle }}</h1>
|
||||||
|
<div class="flex items-center gap-2">
|
||||||
|
<!-- Dark mode toggle -->
|
||||||
|
<button
|
||||||
|
class="rounded p-1.5 text-slate-700 transition-colors hover:bg-primary-400/30"
|
||||||
|
:title="isDark ? 'Switch to light mode' : 'Switch to dark mode'"
|
||||||
|
@click="toggleDark"
|
||||||
|
>
|
||||||
|
<!-- Sun icon (shown in dark mode) -->
|
||||||
|
<svg v-if="isDark" class="h-5 w-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 3v1m0 16v1m9-9h-1M4 12H3m15.364 6.364l-.707-.707M6.343 6.343l-.707-.707m12.728 0l-.707.707M6.343 17.657l-.707.707M16 12a4 4 0 11-8 0 4 4 0 018 0z" />
|
||||||
|
</svg>
|
||||||
|
<!-- Moon icon (shown in light mode) -->
|
||||||
|
<svg v-else class="h-5 w-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M20.354 15.354A9 9 0 018.646 3.646 9.003 9.003 0 0012 21a9.003 9.003 0 008.354-5.646z" />
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
|
<TokenPrompt />
|
||||||
|
</div>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<!-- Desktop tab nav -->
|
||||||
|
<div class="hidden md:block">
|
||||||
|
<SidebarNav
|
||||||
|
:segments="segments"
|
||||||
|
:site-title="siteTitle"
|
||||||
|
:active-id="activeId"
|
||||||
|
@navigate="handleNavigate"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Mobile header with hamburger -->
|
||||||
|
<div class="md:hidden">
|
||||||
|
<MobileHeader
|
||||||
|
:site-title="siteTitle"
|
||||||
|
:segments="segments"
|
||||||
|
:active-id="activeId"
|
||||||
|
@navigate="handleNavigate"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Admin toolbar -->
|
||||||
|
<AdminToolbar
|
||||||
|
v-if="isAdmin"
|
||||||
|
@add-segment="$emit('add-segment')"
|
||||||
|
@logout="$emit('logout')"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<!-- Main content -->
|
||||||
|
<main class="mx-auto max-w-6xl px-6 py-8">
|
||||||
|
<slot />
|
||||||
|
</main>
|
||||||
|
|
||||||
|
<!-- Back to top button -->
|
||||||
|
<Transition name="fade-up">
|
||||||
|
<button
|
||||||
|
v-if="showBackToTop"
|
||||||
|
class="fixed right-6 bottom-6 z-30 rounded-full bg-primary-300 p-3 shadow-lg transition-colors hover:bg-primary-400"
|
||||||
|
title="Back to top"
|
||||||
|
@click="scrollToTop"
|
||||||
|
>
|
||||||
|
<svg class="h-5 w-5 text-slate-900" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M5 15l7-7 7 7" />
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
|
</Transition>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.fade-up-enter-active,
|
||||||
|
.fade-up-leave-active {
|
||||||
|
transition: opacity 200ms ease, transform 200ms ease;
|
||||||
|
}
|
||||||
|
.fade-up-enter-from,
|
||||||
|
.fade-up-leave-to {
|
||||||
|
opacity: 0;
|
||||||
|
transform: translateY(8px);
|
||||||
|
}
|
||||||
|
</style>
|
||||||
129
frontend/src/components/layout/MobileHeader.vue
Normal file
129
frontend/src/components/layout/MobileHeader.vue
Normal file
|
|
@ -0,0 +1,129 @@
|
||||||
|
<script setup lang="ts">
|
||||||
|
import { ref } from "vue";
|
||||||
|
import type { Segment } from "@/types/segment";
|
||||||
|
|
||||||
|
defineProps<{
|
||||||
|
siteTitle: string;
|
||||||
|
segments: Segment[];
|
||||||
|
activeId: string | null;
|
||||||
|
}>();
|
||||||
|
|
||||||
|
const emit = defineEmits<{
|
||||||
|
navigate: [id: string];
|
||||||
|
}>();
|
||||||
|
|
||||||
|
const open = ref(false);
|
||||||
|
|
||||||
|
function toggle() {
|
||||||
|
open.value = !open.value;
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleNav(id: string) {
|
||||||
|
open.value = false;
|
||||||
|
emit("navigate", id);
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleBackdropClick() {
|
||||||
|
open.value = false;
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<!-- Mobile nav bar -->
|
||||||
|
<div class="flex items-center justify-between border-b border-slate-200 bg-white px-4 py-2.5 dark:border-slate-700 dark:bg-slate-800">
|
||||||
|
<span class="text-sm font-medium text-slate-500 dark:text-slate-400">Sections</span>
|
||||||
|
<button
|
||||||
|
class="text-slate-600 hover:text-slate-900 dark:text-slate-400 dark:hover:text-slate-100"
|
||||||
|
aria-label="Open navigation"
|
||||||
|
@click="toggle"
|
||||||
|
>
|
||||||
|
<svg class="h-5 w-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4 6h16M4 12h16M4 18h16" />
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Backdrop -->
|
||||||
|
<Transition name="fade">
|
||||||
|
<div
|
||||||
|
v-if="open"
|
||||||
|
class="fixed inset-0 z-40 bg-black/40"
|
||||||
|
@click="handleBackdropClick"
|
||||||
|
/>
|
||||||
|
</Transition>
|
||||||
|
|
||||||
|
<!-- Dropdown panel -->
|
||||||
|
<Transition name="dropdown">
|
||||||
|
<div
|
||||||
|
v-if="open"
|
||||||
|
class="fixed top-0 right-0 left-0 z-50"
|
||||||
|
>
|
||||||
|
<div class="bg-white shadow-lg dark:bg-slate-800">
|
||||||
|
<div class="flex items-center justify-between border-b border-slate-200 px-4 py-3 dark:border-slate-700">
|
||||||
|
<span class="text-sm font-semibold text-slate-900 dark:text-slate-100">{{ siteTitle }}</span>
|
||||||
|
<button
|
||||||
|
class="text-slate-500 hover:text-slate-900 dark:text-slate-400 dark:hover:text-slate-100"
|
||||||
|
aria-label="Close navigation"
|
||||||
|
@click="toggle"
|
||||||
|
>
|
||||||
|
<svg class="h-5 w-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M6 18L18 6M6 6l12 12" />
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<ul class="max-h-80 overflow-y-auto py-1">
|
||||||
|
<li v-for="segment in segments" :key="segment.id">
|
||||||
|
<!-- External link segments -->
|
||||||
|
<a
|
||||||
|
v-if="segment.type === 'link'"
|
||||||
|
:href="segment.content"
|
||||||
|
target="_blank"
|
||||||
|
rel="noopener noreferrer"
|
||||||
|
class="flex w-full items-center gap-1 px-4 py-2.5 text-left text-sm text-slate-600 transition-colors duration-150 hover:bg-slate-50 hover:text-slate-900 dark:text-slate-400 dark:hover:bg-slate-700 dark:hover:text-slate-100"
|
||||||
|
@click="open = false"
|
||||||
|
>
|
||||||
|
{{ segment.title }}
|
||||||
|
<svg class="h-3 w-3" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M10 6H6a2 2 0 00-2 2v10a2 2 0 002 2h10a2 2 0 002-2v-4M14 4h6m0 0v6m0-6L10 14" />
|
||||||
|
</svg>
|
||||||
|
</a>
|
||||||
|
<!-- Regular segment nav buttons -->
|
||||||
|
<button
|
||||||
|
v-else
|
||||||
|
class="w-full px-4 py-2.5 text-left text-sm transition-colors duration-150"
|
||||||
|
:class="
|
||||||
|
activeId === segment.id
|
||||||
|
? 'bg-primary-50 font-medium text-slate-900 dark:bg-primary-700/20 dark:text-slate-100'
|
||||||
|
: 'text-slate-600 hover:bg-slate-50 hover:text-slate-900 dark:text-slate-400 dark:hover:bg-slate-700 dark:hover:text-slate-100'
|
||||||
|
"
|
||||||
|
@click="handleNav(segment.id)"
|
||||||
|
>
|
||||||
|
{{ segment.title }}
|
||||||
|
</button>
|
||||||
|
</li>
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</Transition>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.fade-enter-active,
|
||||||
|
.fade-leave-active {
|
||||||
|
transition: opacity 200ms ease;
|
||||||
|
}
|
||||||
|
.fade-enter-from,
|
||||||
|
.fade-leave-to {
|
||||||
|
opacity: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dropdown-enter-active,
|
||||||
|
.dropdown-leave-active {
|
||||||
|
transition: transform 200ms ease, opacity 200ms ease;
|
||||||
|
}
|
||||||
|
.dropdown-enter-from,
|
||||||
|
.dropdown-leave-to {
|
||||||
|
transform: translateY(-100%);
|
||||||
|
opacity: 0;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
52
frontend/src/components/layout/SidebarNav.vue
Normal file
52
frontend/src/components/layout/SidebarNav.vue
Normal file
|
|
@ -0,0 +1,52 @@
|
||||||
|
<script setup lang="ts">
|
||||||
|
import type { Segment } from "@/types/segment";
|
||||||
|
|
||||||
|
defineProps<{
|
||||||
|
segments: Segment[];
|
||||||
|
siteTitle: string;
|
||||||
|
activeId: string | null;
|
||||||
|
}>();
|
||||||
|
|
||||||
|
const emit = defineEmits<{
|
||||||
|
navigate: [id: string];
|
||||||
|
}>();
|
||||||
|
|
||||||
|
function handleClick(id: string) {
|
||||||
|
emit("navigate", id);
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<nav class="border-b border-slate-200 bg-white px-6 dark:border-slate-700 dark:bg-slate-800">
|
||||||
|
<ul class="flex gap-1 overflow-x-auto">
|
||||||
|
<li v-for="segment in segments" :key="segment.id">
|
||||||
|
<!-- External link segments -->
|
||||||
|
<a
|
||||||
|
v-if="segment.type === 'link'"
|
||||||
|
:href="segment.content"
|
||||||
|
target="_blank"
|
||||||
|
rel="noopener noreferrer"
|
||||||
|
class="inline-flex items-center gap-1 whitespace-nowrap border-b-2 border-transparent px-3 py-2.5 text-sm font-medium text-slate-500 transition-colors duration-150 hover:text-slate-900 dark:text-slate-400 dark:hover:text-slate-100"
|
||||||
|
>
|
||||||
|
{{ segment.title }}
|
||||||
|
<svg class="h-3 w-3" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M10 6H6a2 2 0 00-2 2v10a2 2 0 002 2h10a2 2 0 002-2v-4M14 4h6m0 0v6m0-6L10 14" />
|
||||||
|
</svg>
|
||||||
|
</a>
|
||||||
|
<!-- Regular segment nav buttons -->
|
||||||
|
<button
|
||||||
|
v-else
|
||||||
|
class="whitespace-nowrap border-b-2 px-3 py-2.5 text-sm font-medium transition-colors duration-150"
|
||||||
|
:class="
|
||||||
|
activeId === segment.id
|
||||||
|
? 'border-primary-400 text-slate-900 dark:text-slate-100'
|
||||||
|
: 'border-transparent text-slate-500 hover:text-slate-900 dark:text-slate-400 dark:hover:text-slate-100'
|
||||||
|
"
|
||||||
|
@click="handleClick(segment.id)"
|
||||||
|
>
|
||||||
|
{{ segment.title }}
|
||||||
|
</button>
|
||||||
|
</li>
|
||||||
|
</ul>
|
||||||
|
</nav>
|
||||||
|
</template>
|
||||||
17
frontend/src/components/segments/AudioSegment.vue
Normal file
17
frontend/src/components/segments/AudioSegment.vue
Normal file
|
|
@ -0,0 +1,17 @@
|
||||||
|
<script setup lang="ts">
|
||||||
|
import type { Segment } from "@/types/segment";
|
||||||
|
|
||||||
|
defineProps<{
|
||||||
|
segment: Segment;
|
||||||
|
}>();
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<audio
|
||||||
|
:src="segment.content"
|
||||||
|
controls
|
||||||
|
class="w-full"
|
||||||
|
>
|
||||||
|
Your browser does not support the audio element.
|
||||||
|
</audio>
|
||||||
|
</template>
|
||||||
28
frontend/src/components/segments/GallerySegment.vue
Normal file
28
frontend/src/components/segments/GallerySegment.vue
Normal file
|
|
@ -0,0 +1,28 @@
|
||||||
|
<script setup lang="ts">
|
||||||
|
import { computed } from "vue";
|
||||||
|
import type { Segment } from "@/types/segment";
|
||||||
|
|
||||||
|
const props = defineProps<{
|
||||||
|
segment: Segment;
|
||||||
|
}>();
|
||||||
|
|
||||||
|
const images = computed(() => {
|
||||||
|
const meta = props.segment.metadata;
|
||||||
|
if (Array.isArray(meta.images)) {
|
||||||
|
return meta.images as string[];
|
||||||
|
}
|
||||||
|
return [];
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div class="grid grid-cols-2 gap-3 md:grid-cols-3 lg:grid-cols-4">
|
||||||
|
<img
|
||||||
|
v-for="(src, index) in images"
|
||||||
|
:key="index"
|
||||||
|
:src="src"
|
||||||
|
:alt="`${segment.title} image ${index + 1}`"
|
||||||
|
class="aspect-square w-full rounded object-cover"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
17
frontend/src/components/segments/IframeSegment.vue
Normal file
17
frontend/src/components/segments/IframeSegment.vue
Normal file
|
|
@ -0,0 +1,17 @@
|
||||||
|
<script setup lang="ts">
|
||||||
|
import type { Segment } from "@/types/segment";
|
||||||
|
|
||||||
|
defineProps<{
|
||||||
|
segment: Segment;
|
||||||
|
}>();
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<iframe
|
||||||
|
:src="segment.content"
|
||||||
|
:title="segment.title"
|
||||||
|
class="h-[600px] w-full rounded border-none"
|
||||||
|
sandbox="allow-scripts allow-same-origin allow-popups"
|
||||||
|
allow="fullscreen"
|
||||||
|
/>
|
||||||
|
</template>
|
||||||
15
frontend/src/components/segments/MarkdownSegment.vue
Normal file
15
frontend/src/components/segments/MarkdownSegment.vue
Normal file
|
|
@ -0,0 +1,15 @@
|
||||||
|
<script setup lang="ts">
|
||||||
|
import { computed } from "vue";
|
||||||
|
import { marked } from "marked";
|
||||||
|
import type { Segment } from "@/types/segment";
|
||||||
|
|
||||||
|
const props = defineProps<{
|
||||||
|
segment: Segment;
|
||||||
|
}>();
|
||||||
|
|
||||||
|
const html = computed(() => marked.parse(props.segment.content) as string);
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div class="prose max-w-none dark:prose-invert" v-html="html" />
|
||||||
|
</template>
|
||||||
15
frontend/src/components/segments/PdfSegment.vue
Normal file
15
frontend/src/components/segments/PdfSegment.vue
Normal file
|
|
@ -0,0 +1,15 @@
|
||||||
|
<script setup lang="ts">
|
||||||
|
import type { Segment } from "@/types/segment";
|
||||||
|
|
||||||
|
defineProps<{
|
||||||
|
segment: Segment;
|
||||||
|
}>();
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<iframe
|
||||||
|
:src="segment.content"
|
||||||
|
class="h-[600px] w-full rounded border-none"
|
||||||
|
:title="segment.title"
|
||||||
|
/>
|
||||||
|
</template>
|
||||||
139
frontend/src/components/segments/SegmentList.vue
Normal file
139
frontend/src/components/segments/SegmentList.vue
Normal file
|
|
@ -0,0 +1,139 @@
|
||||||
|
<script setup lang="ts">
|
||||||
|
import { ref, watch, computed } from "vue";
|
||||||
|
import type { Segment } from "@/types/segment";
|
||||||
|
import { useAdmin } from "@/composables/useAdmin";
|
||||||
|
import SegmentRenderer from "@/components/segments/SegmentRenderer.vue";
|
||||||
|
import SegmentEditor from "@/components/admin/SegmentEditor.vue";
|
||||||
|
import draggable from "vuedraggable";
|
||||||
|
|
||||||
|
const props = defineProps<{
|
||||||
|
segments: Segment[];
|
||||||
|
}>();
|
||||||
|
|
||||||
|
// Filter out link segments from content rendering (they only appear in nav)
|
||||||
|
const contentSegments = computed(() => props.segments.filter((s) => s.type !== "link"));
|
||||||
|
|
||||||
|
const emit = defineEmits<{
|
||||||
|
save: [id: string, updates: { title?: string; content?: string; metadata?: Record<string, unknown> }];
|
||||||
|
delete: [id: string];
|
||||||
|
reorder: [ids: string[]];
|
||||||
|
}>();
|
||||||
|
|
||||||
|
const { isAdmin } = useAdmin();
|
||||||
|
const editingId = ref<string | null>(null);
|
||||||
|
|
||||||
|
// Local copy for draggable
|
||||||
|
const localSegments = ref<Segment[]>([...props.segments]);
|
||||||
|
|
||||||
|
watch(
|
||||||
|
() => props.segments,
|
||||||
|
(val) => {
|
||||||
|
localSegments.value = [...val];
|
||||||
|
},
|
||||||
|
{ deep: true }
|
||||||
|
);
|
||||||
|
|
||||||
|
function toggleEdit(id: string) {
|
||||||
|
editingId.value = editingId.value === id ? null : id;
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleSave(
|
||||||
|
id: string,
|
||||||
|
updates: { title?: string; content?: string; metadata?: Record<string, unknown> }
|
||||||
|
) {
|
||||||
|
editingId.value = null;
|
||||||
|
emit("save", id, updates);
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleDelete(id: string) {
|
||||||
|
editingId.value = null;
|
||||||
|
emit("delete", id);
|
||||||
|
}
|
||||||
|
|
||||||
|
function onDragEnd() {
|
||||||
|
const ids = localSegments.value.map((s) => s.id);
|
||||||
|
emit("reorder", ids);
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div class="space-y-6 p-6">
|
||||||
|
<!-- Admin mode: draggable with edit controls -->
|
||||||
|
<draggable
|
||||||
|
v-if="isAdmin"
|
||||||
|
v-model="localSegments"
|
||||||
|
item-key="id"
|
||||||
|
handle=".drag-handle"
|
||||||
|
class="space-y-6"
|
||||||
|
@end="onDragEnd"
|
||||||
|
>
|
||||||
|
<template #item="{ element: segment }: { element: Segment }">
|
||||||
|
<section
|
||||||
|
:id="segment.id"
|
||||||
|
class="rounded-lg bg-white p-6 shadow-md dark:bg-slate-800 dark:shadow-slate-950/30"
|
||||||
|
>
|
||||||
|
<div class="flex items-start gap-3">
|
||||||
|
<!-- Drag handle: grip dots -->
|
||||||
|
<button
|
||||||
|
class="drag-handle mt-1 cursor-grab text-slate-300 transition-colors hover:text-primary-300 active:cursor-grabbing dark:text-slate-600 dark:hover:text-primary-400"
|
||||||
|
title="Drag to reorder"
|
||||||
|
>
|
||||||
|
<svg class="h-5 w-5" viewBox="0 0 24 24" fill="currentColor">
|
||||||
|
<circle cx="9" cy="6" r="1.5" />
|
||||||
|
<circle cx="15" cy="6" r="1.5" />
|
||||||
|
<circle cx="9" cy="12" r="1.5" />
|
||||||
|
<circle cx="15" cy="12" r="1.5" />
|
||||||
|
<circle cx="9" cy="18" r="1.5" />
|
||||||
|
<circle cx="15" cy="18" r="1.5" />
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<div class="min-w-0 flex-1">
|
||||||
|
<div class="mb-4 flex items-center justify-between">
|
||||||
|
<h2 class="text-xl font-semibold text-slate-900 dark:text-slate-100">{{ segment.title }}</h2>
|
||||||
|
<!-- Edit pencil icon -->
|
||||||
|
<button
|
||||||
|
class="rounded p-1 text-slate-300 transition-colors hover:text-primary-300 dark:text-slate-600 dark:hover:text-primary-400"
|
||||||
|
title="Edit segment"
|
||||||
|
@click="toggleEdit(segment.id)"
|
||||||
|
>
|
||||||
|
<svg class="h-4 w-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M15.232 5.232l3.536 3.536m-2.036-5.036a2.5 2.5 0 113.536 3.536L6.5 21.036H3v-3.572L16.732 3.732z" />
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Link segments show URL instead of content -->
|
||||||
|
<p v-if="segment.type === 'link'" class="text-sm text-slate-400">
|
||||||
|
External link: <a :href="segment.content" target="_blank" rel="noopener noreferrer" class="text-primary-500 underline">{{ segment.content }}</a>
|
||||||
|
</p>
|
||||||
|
<SegmentRenderer v-else :segment="segment" />
|
||||||
|
|
||||||
|
<!-- Inline editor -->
|
||||||
|
<SegmentEditor
|
||||||
|
v-if="editingId === segment.id"
|
||||||
|
:segment="segment"
|
||||||
|
@save="(updates) => handleSave(segment.id, updates)"
|
||||||
|
@cancel="editingId = null"
|
||||||
|
@delete="handleDelete(segment.id)"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
</template>
|
||||||
|
</draggable>
|
||||||
|
|
||||||
|
<!-- Read-only mode: plain rendering (link segments excluded) -->
|
||||||
|
<template v-else>
|
||||||
|
<section
|
||||||
|
v-for="segment in contentSegments"
|
||||||
|
:key="segment.id"
|
||||||
|
:id="segment.id"
|
||||||
|
class="rounded-lg bg-white p-6 shadow-md dark:bg-slate-800 dark:shadow-slate-950/30"
|
||||||
|
>
|
||||||
|
<h2 class="mb-4 text-xl font-semibold text-slate-900 dark:text-slate-100">{{ segment.title }}</h2>
|
||||||
|
<SegmentRenderer :segment="segment" />
|
||||||
|
</section>
|
||||||
|
</template>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
22
frontend/src/components/segments/SegmentRenderer.vue
Normal file
22
frontend/src/components/segments/SegmentRenderer.vue
Normal file
|
|
@ -0,0 +1,22 @@
|
||||||
|
<script setup lang="ts">
|
||||||
|
import type { Segment } from "@/types/segment";
|
||||||
|
import MarkdownSegment from "@/components/segments/MarkdownSegment.vue";
|
||||||
|
import PdfSegment from "@/components/segments/PdfSegment.vue";
|
||||||
|
import VideoSegment from "@/components/segments/VideoSegment.vue";
|
||||||
|
import AudioSegment from "@/components/segments/AudioSegment.vue";
|
||||||
|
import IframeSegment from "@/components/segments/IframeSegment.vue";
|
||||||
|
import GallerySegment from "@/components/segments/GallerySegment.vue";
|
||||||
|
|
||||||
|
defineProps<{
|
||||||
|
segment: Segment;
|
||||||
|
}>();
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<MarkdownSegment v-if="segment.type === 'markdown'" :segment="segment" />
|
||||||
|
<PdfSegment v-else-if="segment.type === 'pdf'" :segment="segment" />
|
||||||
|
<VideoSegment v-else-if="segment.type === 'video'" :segment="segment" />
|
||||||
|
<AudioSegment v-else-if="segment.type === 'audio'" :segment="segment" />
|
||||||
|
<IframeSegment v-else-if="segment.type === 'iframe'" :segment="segment" />
|
||||||
|
<GallerySegment v-else-if="segment.type === 'gallery'" :segment="segment" />
|
||||||
|
</template>
|
||||||
17
frontend/src/components/segments/VideoSegment.vue
Normal file
17
frontend/src/components/segments/VideoSegment.vue
Normal file
|
|
@ -0,0 +1,17 @@
|
||||||
|
<script setup lang="ts">
|
||||||
|
import type { Segment } from "@/types/segment";
|
||||||
|
|
||||||
|
defineProps<{
|
||||||
|
segment: Segment;
|
||||||
|
}>();
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<video
|
||||||
|
:src="segment.content"
|
||||||
|
controls
|
||||||
|
class="w-full rounded"
|
||||||
|
>
|
||||||
|
Your browser does not support the video element.
|
||||||
|
</video>
|
||||||
|
</template>
|
||||||
62
frontend/src/composables/useAdmin.ts
Normal file
62
frontend/src/composables/useAdmin.ts
Normal file
|
|
@ -0,0 +1,62 @@
|
||||||
|
import { ref, computed } from "vue";
|
||||||
|
import type { Ref, ComputedRef } from "vue";
|
||||||
|
|
||||||
|
const token: Ref<string | null> = ref(null);
|
||||||
|
const isAdmin: Ref<boolean> = ref(false);
|
||||||
|
const verifying: Ref<boolean> = ref(false);
|
||||||
|
|
||||||
|
async function verifyToken(t: string): Promise<boolean> {
|
||||||
|
try {
|
||||||
|
const res = await fetch("/api/auth/verify", {
|
||||||
|
headers: { Authorization: `Bearer ${t}` },
|
||||||
|
});
|
||||||
|
return res.ok;
|
||||||
|
} catch {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check sessionStorage on module load
|
||||||
|
const stored = sessionStorage.getItem("admin_token");
|
||||||
|
if (stored) {
|
||||||
|
verifying.value = true;
|
||||||
|
verifyToken(stored).then((valid) => {
|
||||||
|
if (valid) {
|
||||||
|
token.value = stored;
|
||||||
|
isAdmin.value = true;
|
||||||
|
} else {
|
||||||
|
sessionStorage.removeItem("admin_token");
|
||||||
|
}
|
||||||
|
verifying.value = false;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useAdmin() {
|
||||||
|
async function login(t: string): Promise<boolean> {
|
||||||
|
verifying.value = true;
|
||||||
|
const valid = await verifyToken(t);
|
||||||
|
if (valid) {
|
||||||
|
token.value = t;
|
||||||
|
isAdmin.value = true;
|
||||||
|
sessionStorage.setItem("admin_token", t);
|
||||||
|
}
|
||||||
|
verifying.value = false;
|
||||||
|
return valid;
|
||||||
|
}
|
||||||
|
|
||||||
|
function logout(): void {
|
||||||
|
token.value = null;
|
||||||
|
isAdmin.value = false;
|
||||||
|
sessionStorage.removeItem("admin_token");
|
||||||
|
}
|
||||||
|
|
||||||
|
const authHeaders = computed((): Record<string, string> =>
|
||||||
|
token.value ? { Authorization: `Bearer ${token.value}` } : {}
|
||||||
|
);
|
||||||
|
|
||||||
|
const authQuery: ComputedRef<string> = computed(() =>
|
||||||
|
token.value ? `?token=${encodeURIComponent(token.value)}` : ""
|
||||||
|
);
|
||||||
|
|
||||||
|
return { token, isAdmin, verifying, login, logout, authHeaders, authQuery };
|
||||||
|
}
|
||||||
42
frontend/src/composables/useAssetUpload.ts
Normal file
42
frontend/src/composables/useAssetUpload.ts
Normal file
|
|
@ -0,0 +1,42 @@
|
||||||
|
import { ref } from "vue";
|
||||||
|
|
||||||
|
export function useAssetUpload() {
|
||||||
|
const uploading = ref(false);
|
||||||
|
const error = ref<string | null>(null);
|
||||||
|
|
||||||
|
async function upload(
|
||||||
|
file: File,
|
||||||
|
authHeaders: Record<string, string>
|
||||||
|
): Promise<string | null> {
|
||||||
|
uploading.value = true;
|
||||||
|
error.value = null;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const formData = new FormData();
|
||||||
|
formData.append("file", file);
|
||||||
|
|
||||||
|
const res = await fetch("/api/assets/", {
|
||||||
|
method: "POST",
|
||||||
|
headers: authHeaders,
|
||||||
|
body: formData,
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!res.ok) {
|
||||||
|
const body = await res.json().catch(() => ({ detail: "Upload failed" }));
|
||||||
|
const detail = (body as { detail?: string }).detail ?? "Upload failed";
|
||||||
|
error.value = detail;
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const data = (await res.json()) as { filename: string };
|
||||||
|
return data.filename;
|
||||||
|
} catch (e) {
|
||||||
|
error.value = e instanceof Error ? e.message : "Upload failed";
|
||||||
|
return null;
|
||||||
|
} finally {
|
||||||
|
uploading.value = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return { uploading, error, upload };
|
||||||
|
}
|
||||||
30
frontend/src/composables/useDarkMode.ts
Normal file
30
frontend/src/composables/useDarkMode.ts
Normal file
|
|
@ -0,0 +1,30 @@
|
||||||
|
import { ref, watch } from "vue";
|
||||||
|
|
||||||
|
const isDark = ref(false);
|
||||||
|
|
||||||
|
// Initialize from localStorage or system preference
|
||||||
|
const stored = localStorage.getItem("dark_mode");
|
||||||
|
if (stored !== null) {
|
||||||
|
isDark.value = stored === "true";
|
||||||
|
} else {
|
||||||
|
isDark.value = window.matchMedia("(prefers-color-scheme: dark)").matches;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Apply class to <html>
|
||||||
|
function applyClass() {
|
||||||
|
document.documentElement.classList.toggle("dark", isDark.value);
|
||||||
|
}
|
||||||
|
applyClass();
|
||||||
|
|
||||||
|
watch(isDark, () => {
|
||||||
|
applyClass();
|
||||||
|
localStorage.setItem("dark_mode", String(isDark.value));
|
||||||
|
});
|
||||||
|
|
||||||
|
export function useDarkMode() {
|
||||||
|
function toggle() {
|
||||||
|
isDark.value = !isDark.value;
|
||||||
|
}
|
||||||
|
|
||||||
|
return { isDark, toggle };
|
||||||
|
}
|
||||||
38
frontend/src/composables/useSegments.ts
Normal file
38
frontend/src/composables/useSegments.ts
Normal file
|
|
@ -0,0 +1,38 @@
|
||||||
|
import { ref, onMounted } from "vue";
|
||||||
|
import type { Segment, SiteInfo } from "@/types/segment";
|
||||||
|
|
||||||
|
export function useSegments() {
|
||||||
|
const segments = ref<Segment[]>([]);
|
||||||
|
const siteTitle = ref("Handin");
|
||||||
|
const loading = ref(true);
|
||||||
|
const error = ref<string | null>(null);
|
||||||
|
|
||||||
|
async function refresh(): Promise<void> {
|
||||||
|
loading.value = true;
|
||||||
|
error.value = null;
|
||||||
|
try {
|
||||||
|
const [siteRes, segmentsRes] = await Promise.all([
|
||||||
|
fetch("/api/site/"),
|
||||||
|
fetch("/api/segments/"),
|
||||||
|
]);
|
||||||
|
if (!siteRes.ok) throw new Error(`Site fetch failed: ${siteRes.status}`);
|
||||||
|
if (!segmentsRes.ok) throw new Error(`Segments fetch failed: ${segmentsRes.status}`);
|
||||||
|
|
||||||
|
const siteData = (await siteRes.json()) as SiteInfo;
|
||||||
|
const segmentsData = (await segmentsRes.json()) as Segment[];
|
||||||
|
|
||||||
|
siteTitle.value = siteData.title;
|
||||||
|
segments.value = segmentsData.sort((a, b) => a.sort_order - b.sort_order);
|
||||||
|
} catch (e) {
|
||||||
|
error.value = e instanceof Error ? e.message : "Unknown error";
|
||||||
|
} finally {
|
||||||
|
loading.value = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
onMounted(() => {
|
||||||
|
void refresh();
|
||||||
|
});
|
||||||
|
|
||||||
|
return { segments, siteTitle, loading, error, refresh };
|
||||||
|
}
|
||||||
5
frontend/src/main.ts
Normal file
5
frontend/src/main.ts
Normal file
|
|
@ -0,0 +1,5 @@
|
||||||
|
import { createApp } from "vue";
|
||||||
|
import App from "./App.vue";
|
||||||
|
import "./style.css";
|
||||||
|
|
||||||
|
createApp(App).mount("#app");
|
||||||
15
frontend/src/style.css
Normal file
15
frontend/src/style.css
Normal file
|
|
@ -0,0 +1,15 @@
|
||||||
|
@import "tailwindcss";
|
||||||
|
@plugin "@tailwindcss/typography";
|
||||||
|
|
||||||
|
@custom-variant dark (&:where(.dark, .dark *));
|
||||||
|
|
||||||
|
@theme {
|
||||||
|
--color-primary-50: #fffbeb;
|
||||||
|
--color-primary-100: #fff3c4;
|
||||||
|
--color-primary-200: #fce588;
|
||||||
|
--color-primary-300: #ffcd00;
|
||||||
|
--color-primary-400: #e6b800;
|
||||||
|
--color-primary-500: #cc9900;
|
||||||
|
--color-primary-600: #997300;
|
||||||
|
--color-primary-700: #664d00;
|
||||||
|
}
|
||||||
16
frontend/src/types/segment.ts
Normal file
16
frontend/src/types/segment.ts
Normal file
|
|
@ -0,0 +1,16 @@
|
||||||
|
export type SegmentType = "markdown" | "pdf" | "video" | "audio" | "iframe" | "gallery" | "link";
|
||||||
|
|
||||||
|
export interface Segment {
|
||||||
|
id: string;
|
||||||
|
type: SegmentType;
|
||||||
|
sort_order: number;
|
||||||
|
title: string;
|
||||||
|
content: string;
|
||||||
|
metadata: Record<string, unknown>;
|
||||||
|
created_at: string;
|
||||||
|
updated_at: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface SiteInfo {
|
||||||
|
title: string;
|
||||||
|
}
|
||||||
5
frontend/src/types/vuedraggable.d.ts
vendored
Normal file
5
frontend/src/types/vuedraggable.d.ts
vendored
Normal file
|
|
@ -0,0 +1,5 @@
|
||||||
|
declare module "vuedraggable" {
|
||||||
|
import type { DefineComponent } from "vue";
|
||||||
|
const component: DefineComponent;
|
||||||
|
export default component;
|
||||||
|
}
|
||||||
165
frontend/src/views/HomePage.vue
Normal file
165
frontend/src/views/HomePage.vue
Normal file
|
|
@ -0,0 +1,165 @@
|
||||||
|
<script setup lang="ts">
|
||||||
|
import { ref, onMounted, onUnmounted } from "vue";
|
||||||
|
import { useSegments } from "@/composables/useSegments";
|
||||||
|
import { useAdmin } from "@/composables/useAdmin";
|
||||||
|
import type { SegmentType } from "@/types/segment";
|
||||||
|
import AppShell from "@/components/layout/AppShell.vue";
|
||||||
|
import SegmentList from "@/components/segments/SegmentList.vue";
|
||||||
|
import AddSegmentModal from "@/components/admin/AddSegmentModal.vue";
|
||||||
|
|
||||||
|
const { segments, siteTitle, loading, error, refresh } = useSegments();
|
||||||
|
const { authHeaders, logout } = useAdmin();
|
||||||
|
const activeId = ref<string | null>(null);
|
||||||
|
const showAddModal = ref(false);
|
||||||
|
|
||||||
|
let observer: IntersectionObserver | null = null;
|
||||||
|
|
||||||
|
function handleNavigate(id: string) {
|
||||||
|
const el = document.getElementById(id);
|
||||||
|
if (el) {
|
||||||
|
el.scrollIntoView({ behavior: "smooth" });
|
||||||
|
activeId.value = id;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleCreateSegment(data: {
|
||||||
|
type: SegmentType;
|
||||||
|
title: string;
|
||||||
|
content: string;
|
||||||
|
metadata?: Record<string, unknown>;
|
||||||
|
}) {
|
||||||
|
const res = await fetch("/api/segments/", {
|
||||||
|
method: "POST",
|
||||||
|
headers: { ...authHeaders.value, "Content-Type": "application/json" },
|
||||||
|
body: JSON.stringify(data),
|
||||||
|
});
|
||||||
|
if (res.ok) {
|
||||||
|
showAddModal.value = false;
|
||||||
|
await refresh();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleSaveSegment(
|
||||||
|
id: string,
|
||||||
|
updates: { title?: string; content?: string; metadata?: Record<string, unknown> }
|
||||||
|
) {
|
||||||
|
if (Object.keys(updates).length === 0) return;
|
||||||
|
const res = await fetch(`/api/segments/${id}`, {
|
||||||
|
method: "PATCH",
|
||||||
|
headers: { ...authHeaders.value, "Content-Type": "application/json" },
|
||||||
|
body: JSON.stringify(updates),
|
||||||
|
});
|
||||||
|
if (res.ok) {
|
||||||
|
await refresh();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleDeleteSegment(id: string) {
|
||||||
|
const res = await fetch(`/api/segments/${id}`, {
|
||||||
|
method: "DELETE",
|
||||||
|
headers: authHeaders.value,
|
||||||
|
});
|
||||||
|
if (res.ok) {
|
||||||
|
await refresh();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleReorder(ids: string[]) {
|
||||||
|
const res = await fetch("/api/segments/reorder", {
|
||||||
|
method: "PUT",
|
||||||
|
headers: { ...authHeaders.value, "Content-Type": "application/json" },
|
||||||
|
body: JSON.stringify({ segment_ids: ids }),
|
||||||
|
});
|
||||||
|
if (res.ok) {
|
||||||
|
await refresh();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleLogout() {
|
||||||
|
logout();
|
||||||
|
}
|
||||||
|
|
||||||
|
onMounted(() => {
|
||||||
|
observer = new IntersectionObserver(
|
||||||
|
(entries) => {
|
||||||
|
for (const entry of entries) {
|
||||||
|
if (entry.isIntersecting) {
|
||||||
|
activeId.value = entry.target.id;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{ rootMargin: "-20% 0px -60% 0px" }
|
||||||
|
);
|
||||||
|
|
||||||
|
const mutObs = new MutationObserver(() => {
|
||||||
|
for (const seg of segments.value) {
|
||||||
|
const el = document.getElementById(seg.id);
|
||||||
|
if (el) observer?.observe(el);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
mutObs.observe(document.body, { childList: true, subtree: true });
|
||||||
|
|
||||||
|
onUnmounted(() => {
|
||||||
|
observer?.disconnect();
|
||||||
|
mutObs.disconnect();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<AppShell
|
||||||
|
:segments="segments"
|
||||||
|
:site-title="siteTitle"
|
||||||
|
:active-id="activeId"
|
||||||
|
@navigate="handleNavigate"
|
||||||
|
@add-segment="showAddModal = true"
|
||||||
|
@logout="handleLogout"
|
||||||
|
>
|
||||||
|
<!-- Loading state -->
|
||||||
|
<div v-if="loading" class="flex items-center justify-center py-32">
|
||||||
|
<svg
|
||||||
|
class="h-10 w-10 animate-spin text-primary-300"
|
||||||
|
xmlns="http://www.w3.org/2000/svg"
|
||||||
|
fill="none"
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
>
|
||||||
|
<circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4" />
|
||||||
|
<path class="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4z" />
|
||||||
|
</svg>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Error state -->
|
||||||
|
<div v-else-if="error" class="flex items-center justify-center py-32">
|
||||||
|
<p class="text-lg text-red-500">{{ error }}</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Empty state -->
|
||||||
|
<div v-else-if="segments.length === 0" class="flex flex-col items-center justify-center py-32 text-slate-400 dark:text-slate-600">
|
||||||
|
<svg class="mb-4 h-16 w-16" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
|
<path
|
||||||
|
stroke-linecap="round"
|
||||||
|
stroke-linejoin="round"
|
||||||
|
stroke-width="1.5"
|
||||||
|
d="M9 12h6m-6 4h6m2 5H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z"
|
||||||
|
/>
|
||||||
|
</svg>
|
||||||
|
<p class="text-lg">No content yet</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Segment list -->
|
||||||
|
<SegmentList
|
||||||
|
v-else
|
||||||
|
:segments="segments"
|
||||||
|
@save="handleSaveSegment"
|
||||||
|
@delete="handleDeleteSegment"
|
||||||
|
@reorder="handleReorder"
|
||||||
|
/>
|
||||||
|
</AppShell>
|
||||||
|
|
||||||
|
<!-- Add segment modal -->
|
||||||
|
<AddSegmentModal
|
||||||
|
v-if="showAddModal"
|
||||||
|
@create="handleCreateSegment"
|
||||||
|
@cancel="showAddModal = false"
|
||||||
|
/>
|
||||||
|
</template>
|
||||||
19
frontend/tsconfig.json
Normal file
19
frontend/tsconfig.json
Normal file
|
|
@ -0,0 +1,19 @@
|
||||||
|
{
|
||||||
|
"compilerOptions": {
|
||||||
|
"target": "ES2022",
|
||||||
|
"module": "ESNext",
|
||||||
|
"moduleResolution": "bundler",
|
||||||
|
"strict": true,
|
||||||
|
"jsx": "preserve",
|
||||||
|
"resolveJsonModule": true,
|
||||||
|
"isolatedModules": true,
|
||||||
|
"esModuleInterop": true,
|
||||||
|
"lib": ["ES2022", "DOM", "DOM.Iterable"],
|
||||||
|
"skipLibCheck": true,
|
||||||
|
"noEmit": true,
|
||||||
|
"paths": {
|
||||||
|
"@/*": ["./src/*"]
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"include": ["src/**/*.ts", "src/**/*.vue", "env.d.ts"]
|
||||||
|
}
|
||||||
19
frontend/vite.config.ts
Normal file
19
frontend/vite.config.ts
Normal file
|
|
@ -0,0 +1,19 @@
|
||||||
|
import tailwindcss from "@tailwindcss/vite";
|
||||||
|
import vue from "@vitejs/plugin-vue";
|
||||||
|
import { resolve } from "path";
|
||||||
|
import { defineConfig } from "vite";
|
||||||
|
|
||||||
|
export default defineConfig({
|
||||||
|
plugins: [vue(), tailwindcss()],
|
||||||
|
resolve: {
|
||||||
|
alias: {
|
||||||
|
"@": resolve(__dirname, "src"),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
server: {
|
||||||
|
port: 5174,
|
||||||
|
proxy: {
|
||||||
|
"/api": process.env.API_URL ?? "http://localhost:9000",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
241
prompts/slice-1-database-models.md
Normal file
241
prompts/slice-1-database-models.md
Normal file
|
|
@ -0,0 +1,241 @@
|
||||||
|
# Slice 1: Database + Models
|
||||||
|
|
||||||
|
## Role
|
||||||
|
|
||||||
|
You are a backend Python developer implementing the SQLite persistence layer and Pydantic models for a FastAPI-based academic submission website. You follow TDD strictly: write all tests first, then implement until they pass.
|
||||||
|
|
||||||
|
## Objective
|
||||||
|
|
||||||
|
Create two production files (`database.py`, `models.py`) and one test file (`test_database.py`). All tests must pass. Both production files must pass `mypy --strict`.
|
||||||
|
|
||||||
|
## Coding Standards (mandatory)
|
||||||
|
|
||||||
|
- Python 3.12+ features (type parameter syntax, `StrEnum`, `list[X]` not `List[X]`)
|
||||||
|
- All function signatures must have type hints (params and return)
|
||||||
|
- Use `UUID` from `uuid` for ID fields, `datetime` from `datetime` for timestamps
|
||||||
|
- Max 50 lines per function; extract helpers if longer
|
||||||
|
- Guard clauses before logic (fail early, no nested conditionals)
|
||||||
|
- No `else` after `return`/`raise`
|
||||||
|
- Max 3 levels of indentation
|
||||||
|
- Imports grouped: stdlib → third-party → local (absolute imports only: `from app.module import ...`)
|
||||||
|
- No `Any` types. No `# type: ignore` without explanation
|
||||||
|
- Pydantic models use `PascalCase` + suffix convention (e.g., `SegmentCreateRequest`, `SegmentResponse`)
|
||||||
|
- Comments explain WHY, not WHAT
|
||||||
|
|
||||||
|
## Context: Existing Files
|
||||||
|
|
||||||
|
### `backend/app/config.py`
|
||||||
|
```python
|
||||||
|
from functools import lru_cache
|
||||||
|
|
||||||
|
from pydantic_settings import BaseSettings
|
||||||
|
|
||||||
|
|
||||||
|
class Settings(BaseSettings):
|
||||||
|
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"
|
||||||
|
|
||||||
|
model_config = {"env_prefix": "HANDIN_"}
|
||||||
|
|
||||||
|
|
||||||
|
@lru_cache
|
||||||
|
def get_settings() -> Settings:
|
||||||
|
return Settings()
|
||||||
|
```
|
||||||
|
|
||||||
|
### `backend/tests/conftest.py`
|
||||||
|
```python
|
||||||
|
import os
|
||||||
|
import tempfile
|
||||||
|
from collections.abc import Generator
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
from fastapi.testclient import TestClient
|
||||||
|
|
||||||
|
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": # noqa: F821
|
||||||
|
from app.config import Settings, 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)
|
||||||
|
```
|
||||||
|
|
||||||
|
### `backend/app/main.py`
|
||||||
|
```python
|
||||||
|
from fastapi import FastAPI
|
||||||
|
|
||||||
|
app = FastAPI(title="Handin Website")
|
||||||
|
|
||||||
|
|
||||||
|
@app.get("/api/health")
|
||||||
|
def health_check() -> dict[str, str]:
|
||||||
|
return {"status": "ok"}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Existing empty `__init__.py` files
|
||||||
|
- `backend/app/__init__.py`
|
||||||
|
- `backend/app/routes/__init__.py`
|
||||||
|
- `backend/app/services/__init__.py`
|
||||||
|
- `backend/tests/__init__.py`
|
||||||
|
|
||||||
|
## Step 1: Write Tests (`backend/tests/test_database.py`)
|
||||||
|
|
||||||
|
Write these tests FIRST, before any production code. Use the `db` and `tmp_data_dir` fixtures from conftest.
|
||||||
|
|
||||||
|
**Required test cases:**
|
||||||
|
|
||||||
|
1. **`test_init_db_creates_tables`** — Call `init_db` on a fresh path. Connect and verify both `site` and `segments` tables exist (query `sqlite_master`).
|
||||||
|
|
||||||
|
2. **`test_init_db_idempotent`** — Call `init_db` twice on the same path. No error raised. Tables still exist with correct schema.
|
||||||
|
|
||||||
|
3. **`test_wal_mode_enabled`** — After `init_db`, connect and run `PRAGMA journal_mode`. Assert the result is `"wal"`.
|
||||||
|
|
||||||
|
4. **`test_get_connection_busy_timeout`** — Call `get_connection`. Run `PRAGMA busy_timeout` on the returned connection. Assert the value is `5000`.
|
||||||
|
|
||||||
|
5. **`test_segments_table_schema`** — After init, verify the `segments` table has all expected columns: `id`, `type`, `sort_order`, `title`, `content`, `metadata`, `created_at`, `updated_at`.
|
||||||
|
|
||||||
|
6. **`test_site_table_schema`** — After init, verify the `site` table has columns: `key`, `value`.
|
||||||
|
|
||||||
|
**Test conventions:**
|
||||||
|
- Each test function takes `tmp_data_dir: Path` as parameter (from fixture)
|
||||||
|
- Tests call `init_db` and `get_connection` directly (imported from `app.database`)
|
||||||
|
- Use the `db` fixture where a pre-initialized database is needed
|
||||||
|
- No mocking — use real SQLite on temp directories
|
||||||
|
|
||||||
|
## Step 2: Implement `backend/app/database.py`
|
||||||
|
|
||||||
|
**Required functions:**
|
||||||
|
|
||||||
|
### `init_db(db_path: Path) -> None`
|
||||||
|
- Takes a `Path` to the SQLite database file
|
||||||
|
- Creates parent directories if they don't exist (`db_path.parent.mkdir(parents=True, exist_ok=True)`)
|
||||||
|
- Connects to SQLite at `db_path`
|
||||||
|
- Sets `PRAGMA journal_mode=WAL`
|
||||||
|
- Sets `PRAGMA busy_timeout=5000`
|
||||||
|
- Creates tables with `CREATE TABLE IF NOT EXISTS`:
|
||||||
|
|
||||||
|
```sql
|
||||||
|
CREATE TABLE IF NOT EXISTS site (
|
||||||
|
key TEXT PRIMARY KEY,
|
||||||
|
value TEXT NOT NULL
|
||||||
|
);
|
||||||
|
|
||||||
|
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
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_segments_order ON segments(sort_order);
|
||||||
|
```
|
||||||
|
|
||||||
|
- Closes the connection when done
|
||||||
|
|
||||||
|
### `get_connection(db_path: Path) -> sqlite3.Connection`
|
||||||
|
- Returns a `sqlite3.Connection` with `row_factory = sqlite3.Row`
|
||||||
|
- Sets `PRAGMA busy_timeout=5000` on each new connection
|
||||||
|
- Does NOT set WAL mode (that's a one-time setup in `init_db`)
|
||||||
|
|
||||||
|
## Step 3: Implement `backend/app/models.py`
|
||||||
|
|
||||||
|
**Required models:**
|
||||||
|
|
||||||
|
### Enums
|
||||||
|
```python
|
||||||
|
class SegmentType(StrEnum):
|
||||||
|
MARKDOWN = "markdown"
|
||||||
|
PDF = "pdf"
|
||||||
|
VIDEO = "video"
|
||||||
|
AUDIO = "audio"
|
||||||
|
IFRAME = "iframe"
|
||||||
|
GALLERY = "gallery"
|
||||||
|
```
|
||||||
|
|
||||||
|
### Domain Models
|
||||||
|
- **`Segment`** — `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`
|
||||||
|
|
||||||
|
### Request Models
|
||||||
|
- **`SegmentCreateRequest`** — `type: SegmentType`, `title: str`, `content: str = ""`, `metadata: dict[str, object] = Field(default_factory=dict)`
|
||||||
|
- **`SegmentUpdateRequest`** — `title: str | None = None`, `content: str | None = None`, `metadata: dict[str, object] | None = None`
|
||||||
|
- **`ReorderRequest`** — `segment_ids: list[UUID]`
|
||||||
|
- **`SiteUpdateRequest`** — `title: str`
|
||||||
|
|
||||||
|
### Response Models
|
||||||
|
- **`SegmentResponse`** — `id: UUID`, `type: SegmentType`, `sort_order: int`, `title: str`, `content: str`, `metadata: dict[str, object]`, `created_at: datetime`, `updated_at: datetime`
|
||||||
|
- **`SiteResponse`** — `title: str`
|
||||||
|
|
||||||
|
Use `Field(default_factory=...)` where needed. Use `from __future__ import annotations` if it helps with forward references, but prefer Python 3.12+ syntax.
|
||||||
|
|
||||||
|
## Verification
|
||||||
|
|
||||||
|
After implementation, run these commands and ensure they all succeed:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
uv run pytest backend/tests/test_database.py -v
|
||||||
|
uv run mypy --strict backend/app/models.py backend/app/database.py
|
||||||
|
uv run ruff check backend/app/models.py backend/app/database.py
|
||||||
|
```
|
||||||
|
|
||||||
|
All tests must pass. Zero mypy errors. Zero ruff errors.
|
||||||
|
|
||||||
|
## Files to Create/Modify
|
||||||
|
|
||||||
|
| Action | File |
|
||||||
|
|--------|------|
|
||||||
|
| CREATE | `backend/tests/test_database.py` |
|
||||||
|
| CREATE | `backend/app/database.py` |
|
||||||
|
| CREATE | `backend/app/models.py` |
|
||||||
|
|
||||||
|
Do NOT modify any other files. Do NOT modify `conftest.py`, `config.py`, or `main.py`.
|
||||||
393
prompts/slice-2-segment-service.md
Normal file
393
prompts/slice-2-segment-service.md
Normal file
|
|
@ -0,0 +1,393 @@
|
||||||
|
# Slice 2: Segment & Site Service Layer
|
||||||
|
|
||||||
|
## Role
|
||||||
|
|
||||||
|
You are a backend Python developer implementing the service layer (business logic + persistence) for a FastAPI-based academic submission website. You follow TDD strictly: write all tests first, then implement until they pass.
|
||||||
|
|
||||||
|
## Objective
|
||||||
|
|
||||||
|
Create two production files (`services/segment_service.py`, `services/site_service.py`) and one test file (`tests/test_services.py`). All tests must pass. Both production files must pass `mypy --strict`.
|
||||||
|
|
||||||
|
## Coding Standards (mandatory)
|
||||||
|
|
||||||
|
- Python 3.12+ features (type parameter syntax, `StrEnum`, `list[X]` not `List[X]`)
|
||||||
|
- All function signatures must have type hints (params and return)
|
||||||
|
- Use `UUID` from `uuid` for ID fields, `datetime` from `datetime` for timestamps
|
||||||
|
- Max 50 lines per function; extract helpers if longer
|
||||||
|
- Guard clauses before logic (fail early, no nested conditionals)
|
||||||
|
- No `else` after `return`/`raise`
|
||||||
|
- Max 3 levels of indentation
|
||||||
|
- Imports grouped: stdlib → third-party → local (absolute imports only: `from app.module import ...`)
|
||||||
|
- No `Any` types. No `# type: ignore` without explanation
|
||||||
|
- Comments explain WHY, not WHAT
|
||||||
|
|
||||||
|
## Step 0: Verify Previous Slice
|
||||||
|
|
||||||
|
Before writing any new code, verify that the slice-1 implementation is healthy. Run:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
uv run pytest backend/tests/ -v
|
||||||
|
uv run mypy --strict backend/app/database.py backend/app/models.py backend/app/config.py
|
||||||
|
uv run ruff check backend/
|
||||||
|
```
|
||||||
|
|
||||||
|
**All commands must exit cleanly with zero errors.** If any fail, fix the issues before proceeding. Do NOT continue to Step 1 with a broken baseline.
|
||||||
|
|
||||||
|
## Context: Existing Files
|
||||||
|
|
||||||
|
### `backend/app/config.py`
|
||||||
|
```python
|
||||||
|
from functools import lru_cache
|
||||||
|
|
||||||
|
from pydantic_settings import BaseSettings
|
||||||
|
|
||||||
|
|
||||||
|
class Settings(BaseSettings):
|
||||||
|
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"
|
||||||
|
|
||||||
|
model_config = {"env_prefix": "HANDIN_"}
|
||||||
|
|
||||||
|
|
||||||
|
@lru_cache
|
||||||
|
def get_settings() -> Settings:
|
||||||
|
return Settings()
|
||||||
|
```
|
||||||
|
|
||||||
|
### `backend/app/database.py`
|
||||||
|
```python
|
||||||
|
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
|
||||||
|
```
|
||||||
|
|
||||||
|
### `backend/app/models.py`
|
||||||
|
```python
|
||||||
|
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"
|
||||||
|
|
||||||
|
|
||||||
|
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
|
||||||
|
```
|
||||||
|
|
||||||
|
### `backend/tests/conftest.py`
|
||||||
|
```python
|
||||||
|
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)
|
||||||
|
```
|
||||||
|
|
||||||
|
### `backend/app/main.py`
|
||||||
|
```python
|
||||||
|
from fastapi import FastAPI
|
||||||
|
|
||||||
|
app = FastAPI(title="Handin Website")
|
||||||
|
|
||||||
|
|
||||||
|
@app.get("/api/health")
|
||||||
|
def health_check() -> dict[str, str]:
|
||||||
|
return {"status": "ok"}
|
||||||
|
```
|
||||||
|
|
||||||
|
### `pyproject.toml` (relevant sections)
|
||||||
|
```toml
|
||||||
|
[tool.mypy]
|
||||||
|
strict = true
|
||||||
|
python_version = "3.12"
|
||||||
|
mypy_path = "backend"
|
||||||
|
packages = ["app"]
|
||||||
|
plugins = ["pydantic.mypy"]
|
||||||
|
```
|
||||||
|
|
||||||
|
### Existing empty `__init__.py` files
|
||||||
|
- `backend/app/__init__.py`
|
||||||
|
- `backend/app/routes/__init__.py`
|
||||||
|
- `backend/app/services/__init__.py`
|
||||||
|
- `backend/tests/__init__.py`
|
||||||
|
|
||||||
|
## Step 1: Write Tests (`backend/tests/test_services.py`)
|
||||||
|
|
||||||
|
Write these tests FIRST, before any production code. Use the `db` fixture from conftest.
|
||||||
|
|
||||||
|
**Required test cases for segment service:**
|
||||||
|
|
||||||
|
1. **`test_create_segment`** — Create a segment via `create_segment(db_path, request)`. Assert it returns a `Segment` with a valid UUID, `sort_order == 0`, matching title/type/content, and `created_at == updated_at`.
|
||||||
|
|
||||||
|
2. **`test_create_segment_auto_increments_sort_order`** — Create three segments. Assert their `sort_order` values are `0`, `1`, `2` respectively.
|
||||||
|
|
||||||
|
3. **`test_list_segments_empty`** — On a fresh DB, `list_segments` returns an empty list.
|
||||||
|
|
||||||
|
4. **`test_list_segments_ordered`** — Create three segments, then list. Assert they come back in `sort_order` ascending order.
|
||||||
|
|
||||||
|
5. **`test_get_segment_found`** — Create a segment, then retrieve it by ID with `get_segment`. Assert it matches.
|
||||||
|
|
||||||
|
6. **`test_get_segment_not_found`** — Call `get_segment` with a random UUID. Assert it returns `None`.
|
||||||
|
|
||||||
|
7. **`test_update_segment_title`** — Create a segment, then update only its title via `update_segment(db_path, id, request)`. Assert title changed, content unchanged, and `updated_at > created_at`.
|
||||||
|
|
||||||
|
8. **`test_update_segment_not_found`** — Call `update_segment` with a random UUID. Assert it returns `None`.
|
||||||
|
|
||||||
|
9. **`test_delete_segment`** — Create a segment, delete it with `delete_segment(db_path, id)`. Assert returns `True`. Assert `get_segment` returns `None`.
|
||||||
|
|
||||||
|
10. **`test_delete_segment_not_found`** — Call `delete_segment` with a random UUID. Assert returns `False`.
|
||||||
|
|
||||||
|
11. **`test_reorder_segments`** — Create three segments (A, B, C). Call `reorder_segments(db_path, [C.id, A.id, B.id])`. List segments and assert the new sort order is C=0, A=1, B=2.
|
||||||
|
|
||||||
|
12. **`test_reorder_segments_invalid_ids`** — Call `reorder_segments` with a list containing a non-existent UUID. Assert it raises `ValueError`.
|
||||||
|
|
||||||
|
**Required test cases for site service:**
|
||||||
|
|
||||||
|
13. **`test_get_site_title_default`** — On a fresh DB, `get_site_title(db_path)` returns `"Untitled Site"`.
|
||||||
|
|
||||||
|
14. **`test_update_and_get_site_title`** — Call `update_site_title(db_path, "My Course")`. Then call `get_site_title`. Assert it returns `"My Course"`.
|
||||||
|
|
||||||
|
15. **`test_update_site_title_overwrites`** — Update title twice with different values. Assert `get_site_title` returns the second value.
|
||||||
|
|
||||||
|
**Test conventions:**
|
||||||
|
- Each test function takes `db: Path` as parameter (from fixture)
|
||||||
|
- Import functions from `app.services.segment_service` and `app.services.site_service`
|
||||||
|
- No mocking — use real SQLite on temp directories
|
||||||
|
- Use `time.sleep(0.01)` before update operations to ensure `updated_at` differs from `created_at`
|
||||||
|
|
||||||
|
## Step 2: Implement `backend/app/services/segment_service.py`
|
||||||
|
|
||||||
|
**Required functions:**
|
||||||
|
|
||||||
|
### `create_segment(db_path: Path, request: SegmentCreateRequest) -> Segment`
|
||||||
|
- Generate a new `uuid4()` for the segment ID
|
||||||
|
- Compute `sort_order` as the current max `sort_order + 1` (or `0` if no segments exist)
|
||||||
|
- Set `created_at` and `updated_at` to `datetime.now(UTC)` formatted as ISO 8601 strings
|
||||||
|
- Insert into the `segments` table (serialize `metadata` as JSON)
|
||||||
|
- Return a `Segment` model instance
|
||||||
|
|
||||||
|
### `list_segments(db_path: Path) -> list[Segment]`
|
||||||
|
- Query all segments ordered by `sort_order ASC`
|
||||||
|
- Deserialize `metadata` from JSON string to dict
|
||||||
|
- Return a list of `Segment` model instances
|
||||||
|
|
||||||
|
### `get_segment(db_path: Path, segment_id: UUID) -> Segment | None`
|
||||||
|
- Query by ID
|
||||||
|
- Return `Segment` if found, `None` otherwise
|
||||||
|
- Deserialize `metadata` from JSON string to dict
|
||||||
|
|
||||||
|
### `update_segment(db_path: Path, segment_id: UUID, request: SegmentUpdateRequest) -> Segment | None`
|
||||||
|
- Fetch existing segment first; return `None` if not found
|
||||||
|
- Only update fields that are not `None` in the request
|
||||||
|
- Always update `updated_at` to `datetime.now(UTC)`
|
||||||
|
- If `metadata` is provided, serialize it as JSON
|
||||||
|
- Return the updated `Segment`
|
||||||
|
|
||||||
|
### `delete_segment(db_path: Path, segment_id: UUID) -> bool`
|
||||||
|
- Delete the row by ID
|
||||||
|
- Return `True` if a row was deleted, `False` otherwise
|
||||||
|
|
||||||
|
### `reorder_segments(db_path: Path, segment_ids: list[UUID]) -> list[Segment]`
|
||||||
|
- Validate that all provided IDs exist in the database; raise `ValueError` if any are missing
|
||||||
|
- Update `sort_order` for each segment to match its index in the provided list
|
||||||
|
- Update `updated_at` for all reordered segments
|
||||||
|
- Use a transaction (all updates succeed or none do)
|
||||||
|
- Return the newly ordered list of segments
|
||||||
|
|
||||||
|
**Helper function (private):**
|
||||||
|
|
||||||
|
### `_row_to_segment(row: sqlite3.Row) -> Segment`
|
||||||
|
- Convert a `sqlite3.Row` to a `Segment` model instance
|
||||||
|
- Parse `metadata` from JSON string
|
||||||
|
- Parse `created_at` and `updated_at` from ISO 8601 strings
|
||||||
|
- Parse `id` from string to `UUID`
|
||||||
|
- Parse `type` from string to `SegmentType`
|
||||||
|
|
||||||
|
## Step 3: Implement `backend/app/services/site_service.py`
|
||||||
|
|
||||||
|
**Required functions:**
|
||||||
|
|
||||||
|
### `get_site_title(db_path: Path) -> str`
|
||||||
|
- Query the `site` table for `key = "title"`
|
||||||
|
- Return the value if found, `"Untitled Site"` if not
|
||||||
|
|
||||||
|
### `update_site_title(db_path: Path, title: str) -> str`
|
||||||
|
- Upsert (INSERT OR REPLACE) the `title` key in the `site` table
|
||||||
|
- Return the new title
|
||||||
|
|
||||||
|
## Verification
|
||||||
|
|
||||||
|
After implementation, run these commands and ensure they all succeed:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
uv run pytest backend/tests/test_services.py -v
|
||||||
|
uv run mypy --strict backend/app/services/segment_service.py backend/app/services/site_service.py
|
||||||
|
uv run ruff check backend/app/services/segment_service.py backend/app/services/site_service.py
|
||||||
|
```
|
||||||
|
|
||||||
|
Then run the **full** verification suite to ensure nothing is broken and all files pass strict checks:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
uv run pytest backend/tests/ -v
|
||||||
|
uv run mypy --strict backend/app/ backend/tests/
|
||||||
|
uv run ruff check backend/
|
||||||
|
```
|
||||||
|
|
||||||
|
All tests must pass. Zero mypy errors across the entire backend. Zero ruff errors.
|
||||||
|
|
||||||
|
## Files to Create/Modify
|
||||||
|
|
||||||
|
| Action | File |
|
||||||
|
|--------|------|
|
||||||
|
| CREATE | `backend/tests/test_services.py` |
|
||||||
|
| CREATE | `backend/app/services/segment_service.py` |
|
||||||
|
| CREATE | `backend/app/services/site_service.py` |
|
||||||
|
|
||||||
|
Do NOT modify any other files. Do NOT modify `conftest.py`, `config.py`, `main.py`, `database.py`, or `models.py`.
|
||||||
670
prompts/slice-3-api-routes.md
Normal file
670
prompts/slice-3-api-routes.md
Normal file
|
|
@ -0,0 +1,670 @@
|
||||||
|
# Slice 3: Auth + API Routes + Concurrency
|
||||||
|
|
||||||
|
## Role
|
||||||
|
|
||||||
|
You are a backend Python developer implementing the auth dependency, all API route handlers, and concurrency tests for a FastAPI-based academic submission website. You follow TDD strictly: write all tests first, then implement until they pass.
|
||||||
|
|
||||||
|
## Objective
|
||||||
|
|
||||||
|
Create five production files (`auth.py`, `routes/auth.py`, `routes/segments.py`, `routes/site.py`) and modify one (`main.py`). Create two test files (`tests/test_routes.py`, `tests/test_concurrent.py`). All tests must pass. All production files must pass `mypy --strict`.
|
||||||
|
|
||||||
|
## Coding Standards (mandatory)
|
||||||
|
|
||||||
|
- Python 3.12+ features (type parameter syntax, `StrEnum`, `list[X]` not `List[X]`)
|
||||||
|
- All function signatures must have type hints (params and return)
|
||||||
|
- Use `UUID` from `uuid` for ID fields, `datetime` from `datetime` for timestamps
|
||||||
|
- Max 50 lines per function; extract helpers if longer
|
||||||
|
- Guard clauses before logic (fail early, no nested conditionals)
|
||||||
|
- No `else` after `return`/`raise`
|
||||||
|
- Max 3 levels of indentation
|
||||||
|
- Imports grouped: stdlib → third-party → local (absolute imports only: `from app.module import ...`)
|
||||||
|
- No `Any` types. No `# type: ignore` without explanation
|
||||||
|
- Comments explain WHY, not WHAT
|
||||||
|
|
||||||
|
## Step 0: Verify Previous Slices
|
||||||
|
|
||||||
|
Before writing any new code, verify that slices 1 and 2 are healthy. Run:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
uv run pytest backend/tests/ -v
|
||||||
|
uv run mypy --strict backend/app/
|
||||||
|
uv run ruff check backend/
|
||||||
|
```
|
||||||
|
|
||||||
|
**All commands must exit cleanly with zero errors.** If any fail, fix the issues before proceeding. Do NOT continue to Step 1 with a broken baseline.
|
||||||
|
|
||||||
|
## Context: Existing Files
|
||||||
|
|
||||||
|
### `backend/app/config.py`
|
||||||
|
```python
|
||||||
|
from functools import lru_cache
|
||||||
|
|
||||||
|
from pydantic_settings import BaseSettings
|
||||||
|
|
||||||
|
|
||||||
|
class Settings(BaseSettings):
|
||||||
|
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"
|
||||||
|
|
||||||
|
model_config = {"env_prefix": "HANDIN_"}
|
||||||
|
|
||||||
|
|
||||||
|
@lru_cache
|
||||||
|
def get_settings() -> Settings:
|
||||||
|
return Settings()
|
||||||
|
```
|
||||||
|
|
||||||
|
### `backend/app/database.py`
|
||||||
|
```python
|
||||||
|
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
|
||||||
|
```
|
||||||
|
|
||||||
|
### `backend/app/models.py`
|
||||||
|
```python
|
||||||
|
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"
|
||||||
|
|
||||||
|
|
||||||
|
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
|
||||||
|
```
|
||||||
|
|
||||||
|
### `backend/app/services/segment_service.py`
|
||||||
|
```python
|
||||||
|
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()
|
||||||
|
```
|
||||||
|
|
||||||
|
### `backend/app/services/site_service.py`
|
||||||
|
```python
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
from app.database import get_connection
|
||||||
|
|
||||||
|
|
||||||
|
def get_site_title(db_path: Path) -> str:
|
||||||
|
conn = get_connection(db_path)
|
||||||
|
try:
|
||||||
|
row = conn.execute(
|
||||||
|
"SELECT value FROM site WHERE key = ?", ("title",)
|
||||||
|
).fetchone()
|
||||||
|
if row is None:
|
||||||
|
return "Untitled Site"
|
||||||
|
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()
|
||||||
|
```
|
||||||
|
|
||||||
|
### `backend/app/main.py`
|
||||||
|
```python
|
||||||
|
from fastapi import FastAPI
|
||||||
|
|
||||||
|
app = FastAPI(title="Handin Website")
|
||||||
|
|
||||||
|
|
||||||
|
@app.get("/api/health")
|
||||||
|
def health_check() -> dict[str, str]:
|
||||||
|
return {"status": "ok"}
|
||||||
|
```
|
||||||
|
|
||||||
|
### `backend/tests/conftest.py`
|
||||||
|
```python
|
||||||
|
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)
|
||||||
|
```
|
||||||
|
|
||||||
|
### `pyproject.toml` (relevant sections)
|
||||||
|
```toml
|
||||||
|
[tool.mypy]
|
||||||
|
strict = true
|
||||||
|
python_version = "3.12"
|
||||||
|
mypy_path = "backend"
|
||||||
|
packages = ["app"]
|
||||||
|
plugins = ["pydantic.mypy"]
|
||||||
|
```
|
||||||
|
|
||||||
|
### Existing empty `__init__.py` files
|
||||||
|
- `backend/app/__init__.py`
|
||||||
|
- `backend/app/routes/__init__.py`
|
||||||
|
- `backend/app/services/__init__.py`
|
||||||
|
- `backend/tests/__init__.py`
|
||||||
|
|
||||||
|
## Architecture Notes
|
||||||
|
|
||||||
|
- The routes layer is thin: parse request → call service → return response
|
||||||
|
- Authentication uses a shared token (`Settings.admin_token`) checked via a FastAPI dependency
|
||||||
|
- Auth accepts token via **two methods**: `?token=` query param (checked first) OR `Authorization: Bearer <token>` header (fallback)
|
||||||
|
- All admin endpoints (create, update, delete, reorder segments; update site title) require auth
|
||||||
|
- Public endpoints (list segments, get segment, get site title, health) require no authentication
|
||||||
|
- The database path is derived from `Settings.data_dir` via a FastAPI dependency
|
||||||
|
- The `main.py` lifespan must call `init_db()` to ensure the database exists on startup
|
||||||
|
- Register the `/reorder` route **before** `/{segment_id}` to avoid path conflict
|
||||||
|
|
||||||
|
## Step 1: Write Tests
|
||||||
|
|
||||||
|
Write ALL tests FIRST, before any production code. Use the `client` fixture from conftest.
|
||||||
|
|
||||||
|
### `backend/tests/test_routes.py`
|
||||||
|
|
||||||
|
**Helper constants at module level:**
|
||||||
|
```python
|
||||||
|
from tests.conftest import TEST_ADMIN_TOKEN
|
||||||
|
|
||||||
|
BEARER_HEADERS = {"Authorization": f"Bearer {TEST_ADMIN_TOKEN}"}
|
||||||
|
```
|
||||||
|
|
||||||
|
**Segment route tests:**
|
||||||
|
|
||||||
|
1. **`test_list_segments_empty`** — `GET /api/segments` returns `200` with an empty JSON list.
|
||||||
|
|
||||||
|
2. **`test_create_segment`** — `POST /api/segments` with `BEARER_HEADERS` and body `{"type": "markdown", "title": "Intro"}`. Assert `201`, response has `id`, `title == "Intro"`, `type == "markdown"`, `sort_order == 0`.
|
||||||
|
|
||||||
|
3. **`test_create_segment_unauthorized`** — `POST /api/segments` without auth headers. Assert `401`.
|
||||||
|
|
||||||
|
4. **`test_create_segment_wrong_token`** — `POST /api/segments` with `Authorization: Bearer wrong-token`. Assert `401`.
|
||||||
|
|
||||||
|
5. **`test_create_segment_via_query_param`** — `POST /api/segments?token={TEST_ADMIN_TOKEN}` (no headers). Assert `201`.
|
||||||
|
|
||||||
|
6. **`test_get_segment`** — Create a segment via POST, then `GET /api/segments/{id}`. Assert `200` and matching data.
|
||||||
|
|
||||||
|
7. **`test_get_segment_not_found`** — `GET /api/segments/{random_uuid}`. Assert `404`.
|
||||||
|
|
||||||
|
8. **`test_update_segment`** — Create a segment, then `PATCH /api/segments/{id}` with `BEARER_HEADERS` and `{"title": "Updated"}`. Assert `200` and `title == "Updated"`.
|
||||||
|
|
||||||
|
9. **`test_update_segment_not_found`** — `PATCH /api/segments/{random_uuid}` with `BEARER_HEADERS`. Assert `404`.
|
||||||
|
|
||||||
|
10. **`test_update_segment_unauthorized`** — `PATCH /api/segments/{id}` without auth. Assert `401`.
|
||||||
|
|
||||||
|
11. **`test_delete_segment`** — Create a segment, then `DELETE /api/segments/{id}` with `BEARER_HEADERS`. Assert `204`. Confirm `GET /api/segments/{id}` returns `404`.
|
||||||
|
|
||||||
|
12. **`test_delete_segment_not_found`** — `DELETE /api/segments/{random_uuid}` with `BEARER_HEADERS`. Assert `404`.
|
||||||
|
|
||||||
|
13. **`test_delete_segment_unauthorized`** — `DELETE /api/segments/{id}` without auth. Assert `401`.
|
||||||
|
|
||||||
|
14. **`test_reorder_segments`** — Create three segments (A, B, C). `PUT /api/segments/reorder` with `BEARER_HEADERS` and `{"segment_ids": [C.id, A.id, B.id]}`. Assert `200`. List segments and verify new order is C, A, B.
|
||||||
|
|
||||||
|
15. **`test_reorder_segments_invalid_ids`** — `PUT /api/segments/reorder` with `BEARER_HEADERS` and a non-existent UUID in the list. Assert `400`.
|
||||||
|
|
||||||
|
16. **`test_reorder_segments_unauthorized`** — `PUT /api/segments/reorder` without auth. Assert `401`.
|
||||||
|
|
||||||
|
**Site route tests:**
|
||||||
|
|
||||||
|
17. **`test_get_site_title_default`** — `GET /api/site`. Assert `200` and `title == "Untitled Site"`.
|
||||||
|
|
||||||
|
18. **`test_update_site_title`** — `PUT /api/site` with `BEARER_HEADERS` and `{"title": "My Course"}`. Assert `200` and `title == "My Course"`. Then `GET /api/site` confirms it.
|
||||||
|
|
||||||
|
19. **`test_update_site_title_unauthorized`** — `PUT /api/site` without auth. Assert `401`.
|
||||||
|
|
||||||
|
**Auth route tests:**
|
||||||
|
|
||||||
|
20. **`test_auth_verify_valid_bearer`** — `GET /api/auth/verify` with `BEARER_HEADERS`. Assert `200` and body `{"valid": true}`.
|
||||||
|
|
||||||
|
21. **`test_auth_verify_valid_query_param`** — `GET /api/auth/verify?token={TEST_ADMIN_TOKEN}`. Assert `200` and body `{"valid": true}`.
|
||||||
|
|
||||||
|
22. **`test_auth_verify_invalid`** — `GET /api/auth/verify` with wrong token. Assert `401`.
|
||||||
|
|
||||||
|
23. **`test_auth_verify_no_token`** — `GET /api/auth/verify` with no auth. Assert `401`.
|
||||||
|
|
||||||
|
**Health test:**
|
||||||
|
|
||||||
|
24. **`test_health`** — `GET /api/health`. Assert `200` and `status == "ok"`.
|
||||||
|
|
||||||
|
**Test conventions:**
|
||||||
|
- Each test function takes `client: TestClient` as parameter (from fixture)
|
||||||
|
- No mocking — use real HTTP calls via TestClient against real SQLite on temp directories
|
||||||
|
- Create segments via `client.post(...)` within tests rather than calling service functions directly
|
||||||
|
|
||||||
|
### `backend/tests/test_concurrent.py`
|
||||||
|
|
||||||
|
Use the `client` fixture. Import `TEST_ADMIN_TOKEN` from conftest.
|
||||||
|
|
||||||
|
1. **`test_concurrent_creates`** — Use `concurrent.futures.ThreadPoolExecutor` to fire 10 concurrent `POST /api/segments` requests (all with auth). Assert all 10 return `201`. Assert `GET /api/segments` returns 10 segments with 10 unique IDs and `sort_order` values `0` through `9` (no gaps, no duplicates).
|
||||||
|
|
||||||
|
2. **`test_concurrent_create_and_reorder`** — Create 3 segments sequentially. Then concurrently: create 2 more segments AND reorder the original 3. Assert no errors (all requests return 2xx). Assert final DB state is consistent (5 total segments, all have valid `sort_order` values).
|
||||||
|
|
||||||
|
**Test conventions:**
|
||||||
|
- Use `concurrent.futures.ThreadPoolExecutor(max_workers=10)` for thread-based concurrency
|
||||||
|
- Each thread gets its own `TestClient` call (the underlying SQLite handles concurrency via WAL + busy_timeout)
|
||||||
|
|
||||||
|
## Step 2: Implement `backend/app/auth.py`
|
||||||
|
|
||||||
|
**Single function (used as FastAPI dependency):**
|
||||||
|
|
||||||
|
### `require_admin(request: Request) -> None`
|
||||||
|
- First check for `token` query parameter in `request.query_params`
|
||||||
|
- If not present, check `Authorization` header for `Bearer <token>` format
|
||||||
|
- Compare extracted token against `get_settings().admin_token`
|
||||||
|
- Raise `HTTPException(status_code=401, detail="Invalid or missing token")` if:
|
||||||
|
- No token found in either location
|
||||||
|
- Token doesn't match
|
||||||
|
- Import `Request` from `fastapi`, `HTTPException` from `fastapi`
|
||||||
|
|
||||||
|
## Step 3: Implement `backend/app/routes/auth.py`
|
||||||
|
|
||||||
|
Create a FastAPI `APIRouter` with `prefix="/api/auth"` and `tags=["auth"]`.
|
||||||
|
|
||||||
|
### `GET /verify` → `dict[str, bool]`
|
||||||
|
- Depends on `require_admin` (if the dependency passes, token is valid)
|
||||||
|
- Returns `{"valid": True}`
|
||||||
|
|
||||||
|
## Step 4: Implement `backend/app/routes/segments.py`
|
||||||
|
|
||||||
|
Create a FastAPI `APIRouter` with `prefix="/api/segments"` and `tags=["segments"]`.
|
||||||
|
|
||||||
|
**Shared dependency (define in this file):**
|
||||||
|
|
||||||
|
### `get_db_path() -> Path`
|
||||||
|
- A FastAPI dependency (use `Depends`)
|
||||||
|
- Reads `data_dir` from `get_settings()`
|
||||||
|
- Computes `db_path = Path(data_dir) / "handin.db"`
|
||||||
|
- Calls `init_db(db_path)` to ensure the database is initialized
|
||||||
|
- Returns the `db_path`
|
||||||
|
|
||||||
|
**Endpoints (register `/reorder` BEFORE `/{segment_id}`):**
|
||||||
|
|
||||||
|
### `GET /` → `list[SegmentResponse]`
|
||||||
|
- Public (no auth required)
|
||||||
|
- Calls `segment_service.list_segments(db_path)`
|
||||||
|
- Returns `200` with list of `SegmentResponse`
|
||||||
|
|
||||||
|
### `POST /` → `SegmentResponse`
|
||||||
|
- Admin only (`Depends(require_admin)`)
|
||||||
|
- Accepts `SegmentCreateRequest` as JSON body
|
||||||
|
- Calls `segment_service.create_segment(db_path, request)`
|
||||||
|
- Returns `201` with the created `SegmentResponse`
|
||||||
|
|
||||||
|
### `PUT /reorder` → `list[SegmentResponse]`
|
||||||
|
- Admin only
|
||||||
|
- Accepts `ReorderRequest` as JSON body
|
||||||
|
- Calls `segment_service.reorder_segments(db_path, request.segment_ids)`
|
||||||
|
- Catches `ValueError` and returns `400` with error detail
|
||||||
|
- Returns `200` with the reordered list on success
|
||||||
|
|
||||||
|
### `GET /{segment_id}` → `SegmentResponse`
|
||||||
|
- Public (no auth required)
|
||||||
|
- Calls `segment_service.get_segment(db_path, segment_id)`
|
||||||
|
- Returns `200` if found, raises `HTTPException(404)` if not
|
||||||
|
|
||||||
|
### `PATCH /{segment_id}` → `SegmentResponse`
|
||||||
|
- Admin only
|
||||||
|
- Accepts `SegmentUpdateRequest` as JSON body
|
||||||
|
- Calls `segment_service.update_segment(db_path, segment_id, request)`
|
||||||
|
- Returns `200` if found and updated, raises `HTTPException(404)` if not
|
||||||
|
|
||||||
|
### `DELETE /{segment_id}` → `None`
|
||||||
|
- Admin only
|
||||||
|
- Calls `segment_service.delete_segment(db_path, segment_id)`
|
||||||
|
- Returns `204` if deleted, raises `HTTPException(404)` if not found
|
||||||
|
|
||||||
|
## Step 5: Implement `backend/app/routes/site.py`
|
||||||
|
|
||||||
|
Create a FastAPI `APIRouter` with `prefix="/api/site"` and `tags=["site"]`.
|
||||||
|
|
||||||
|
Reuse `get_db_path` from `segments.py` (or extract to a shared location if preferred).
|
||||||
|
|
||||||
|
### `GET /` → `SiteResponse`
|
||||||
|
- Public (no auth required)
|
||||||
|
- Calls `site_service.get_site_title(db_path)`
|
||||||
|
- Returns `200` with `SiteResponse`
|
||||||
|
|
||||||
|
### `PUT /` → `SiteResponse`
|
||||||
|
- Admin only (`Depends(require_admin)`)
|
||||||
|
- Accepts `SiteUpdateRequest` as JSON body
|
||||||
|
- Calls `site_service.update_site_title(db_path, request.title)`
|
||||||
|
- Returns `200` with `SiteResponse`
|
||||||
|
|
||||||
|
## Step 6: Modify `backend/app/main.py`
|
||||||
|
|
||||||
|
Update `main.py` to:
|
||||||
|
|
||||||
|
- Add a **lifespan** context manager that calls `init_db()` on startup (compute `db_path` from `get_settings().data_dir`)
|
||||||
|
- Import and register all three routers: `auth_router`, `segments_router`, `site_router`
|
||||||
|
- Keep the existing `/api/health` endpoint
|
||||||
|
|
||||||
|
## Verification
|
||||||
|
|
||||||
|
After implementation, run these commands and ensure they all succeed:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
uv run pytest backend/tests/test_routes.py -v
|
||||||
|
uv run pytest backend/tests/test_concurrent.py -v
|
||||||
|
uv run mypy --strict backend/app/auth.py backend/app/routes/auth.py backend/app/routes/segments.py backend/app/routes/site.py backend/app/main.py
|
||||||
|
uv run ruff check backend/app/auth.py backend/app/routes/ backend/app/main.py
|
||||||
|
```
|
||||||
|
|
||||||
|
Then run the **full** verification suite to ensure nothing is broken:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
uv run pytest backend/tests/ -v
|
||||||
|
uv run mypy --strict backend/app/ backend/tests/
|
||||||
|
uv run ruff check backend/
|
||||||
|
```
|
||||||
|
|
||||||
|
All tests must pass (~40 total: 6 database + 15 service + 24 route + 2 concurrency). Zero mypy errors. Zero ruff errors.
|
||||||
|
|
||||||
|
## Files to Create/Modify
|
||||||
|
|
||||||
|
| Action | File |
|
||||||
|
|--------|------|
|
||||||
|
| CREATE | `backend/tests/test_routes.py` |
|
||||||
|
| CREATE | `backend/tests/test_concurrent.py` |
|
||||||
|
| CREATE | `backend/app/auth.py` |
|
||||||
|
| CREATE | `backend/app/routes/auth.py` |
|
||||||
|
| CREATE | `backend/app/routes/segments.py` |
|
||||||
|
| CREATE | `backend/app/routes/site.py` |
|
||||||
|
| MODIFY | `backend/app/main.py` |
|
||||||
|
|
||||||
|
Do NOT modify any other files. Do NOT modify `conftest.py`, `config.py`, `database.py`, `models.py`, or the service files.
|
||||||
499
prompts/slice-4-assets-docker.md
Normal file
499
prompts/slice-4-assets-docker.md
Normal file
|
|
@ -0,0 +1,499 @@
|
||||||
|
# Slice 4: Asset Service + Routes + Docker
|
||||||
|
|
||||||
|
## Role
|
||||||
|
|
||||||
|
You are a backend Python developer implementing the asset upload/delete service, asset API routes, and Docker deployment for a FastAPI-based academic submission website. You follow TDD strictly: write all tests first, then implement until they pass.
|
||||||
|
|
||||||
|
## Objective
|
||||||
|
|
||||||
|
Create three production files (`services/asset_service.py`, `routes/assets.py`, `Dockerfile`, `docker-compose.yml`) and modify one (`main.py`). Create one test file (`tests/test_assets.py`). All tests must pass. All production files must pass `mypy --strict`.
|
||||||
|
|
||||||
|
## Coding Standards (mandatory)
|
||||||
|
|
||||||
|
- Python 3.12+ features (type parameter syntax, `StrEnum`, `list[X]` not `List[X]`)
|
||||||
|
- All function signatures must have type hints (params and return)
|
||||||
|
- Use `UUID` from `uuid` for ID fields, `datetime` from `datetime` for timestamps
|
||||||
|
- Max 50 lines per function; extract helpers if longer
|
||||||
|
- Guard clauses before logic (fail early, no nested conditionals)
|
||||||
|
- No `else` after `return`/`raise`
|
||||||
|
- Max 3 levels of indentation
|
||||||
|
- Imports grouped: stdlib → third-party → local (absolute imports only: `from app.module import ...`)
|
||||||
|
- No `Any` types. No `# type: ignore` without explanation
|
||||||
|
- Comments explain WHY, not WHAT
|
||||||
|
- Use `Annotated[..., Depends(...)]` type aliases to satisfy ruff B008 (no function calls in default arguments)
|
||||||
|
|
||||||
|
## Step 0: Verify Previous Slices
|
||||||
|
|
||||||
|
Before writing any new code, verify that slices 1–3 are healthy. Run:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
uv run pytest backend/tests/ -v
|
||||||
|
uv run mypy --strict backend/app/
|
||||||
|
uv run ruff check backend/
|
||||||
|
```
|
||||||
|
|
||||||
|
**All commands must exit cleanly with zero errors.** If any fail, fix the issues before proceeding. Do NOT continue to Step 1 with a broken baseline.
|
||||||
|
|
||||||
|
## Context: Existing Files
|
||||||
|
|
||||||
|
### `backend/app/config.py`
|
||||||
|
```python
|
||||||
|
from functools import lru_cache
|
||||||
|
|
||||||
|
from pydantic_settings import BaseSettings
|
||||||
|
|
||||||
|
|
||||||
|
class Settings(BaseSettings):
|
||||||
|
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"
|
||||||
|
|
||||||
|
model_config = {"env_prefix": "HANDIN_"}
|
||||||
|
|
||||||
|
|
||||||
|
@lru_cache
|
||||||
|
def get_settings() -> Settings:
|
||||||
|
return Settings()
|
||||||
|
```
|
||||||
|
|
||||||
|
### `backend/app/database.py`
|
||||||
|
```python
|
||||||
|
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
|
||||||
|
```
|
||||||
|
|
||||||
|
### `backend/app/auth.py`
|
||||||
|
```python
|
||||||
|
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")
|
||||||
|
```
|
||||||
|
|
||||||
|
### `backend/app/routes/segments.py` (for `get_db_path` and type alias patterns)
|
||||||
|
```python
|
||||||
|
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)]
|
||||||
|
|
||||||
|
# ... endpoints follow using DbPath and Admin type aliases ...
|
||||||
|
```
|
||||||
|
|
||||||
|
### `backend/app/main.py`
|
||||||
|
```python
|
||||||
|
from collections.abc import AsyncIterator
|
||||||
|
from contextlib import asynccontextmanager
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
from fastapi import FastAPI
|
||||||
|
|
||||||
|
from app.config import get_settings
|
||||||
|
from app.database import init_db
|
||||||
|
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]:
|
||||||
|
db_path = Path(get_settings().data_dir) / "handin.db"
|
||||||
|
init_db(db_path)
|
||||||
|
yield
|
||||||
|
|
||||||
|
|
||||||
|
app = FastAPI(title="Handin Website", lifespan=lifespan)
|
||||||
|
|
||||||
|
app.include_router(auth_router)
|
||||||
|
app.include_router(segments_router)
|
||||||
|
app.include_router(site_router)
|
||||||
|
|
||||||
|
|
||||||
|
@app.get("/api/health")
|
||||||
|
def health_check() -> dict[str, str]:
|
||||||
|
return {"status": "ok"}
|
||||||
|
```
|
||||||
|
|
||||||
|
### `backend/tests/conftest.py`
|
||||||
|
```python
|
||||||
|
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)
|
||||||
|
```
|
||||||
|
|
||||||
|
### `pyproject.toml`
|
||||||
|
```toml
|
||||||
|
[project]
|
||||||
|
name = "handin-website"
|
||||||
|
version = "0.1.0"
|
||||||
|
requires-python = ">=3.12"
|
||||||
|
dependencies = [
|
||||||
|
"fastapi==0.135.1",
|
||||||
|
"uvicorn[standard]==0.41.0",
|
||||||
|
"pydantic==2.12.5",
|
||||||
|
"pydantic-settings==2.13.1",
|
||||||
|
"python-multipart==0.0.22",
|
||||||
|
]
|
||||||
|
|
||||||
|
[dependency-groups]
|
||||||
|
dev = [
|
||||||
|
"pytest==9.0.2",
|
||||||
|
"httpx==0.28.1",
|
||||||
|
"ruff==0.15.4",
|
||||||
|
"mypy==1.19.1",
|
||||||
|
]
|
||||||
|
|
||||||
|
[tool.pytest.ini_options]
|
||||||
|
testpaths = ["backend/tests"]
|
||||||
|
|
||||||
|
[tool.ruff]
|
||||||
|
target-version = "py312"
|
||||||
|
line-length = 99
|
||||||
|
src = ["backend"]
|
||||||
|
|
||||||
|
[tool.ruff.lint]
|
||||||
|
select = ["E", "F", "W", "I", "UP", "B", "SIM", "RUF"]
|
||||||
|
|
||||||
|
[tool.ruff.lint.isort]
|
||||||
|
known-first-party = ["app"]
|
||||||
|
|
||||||
|
[tool.mypy]
|
||||||
|
strict = true
|
||||||
|
python_version = "3.12"
|
||||||
|
mypy_path = "backend"
|
||||||
|
packages = ["app"]
|
||||||
|
plugins = ["pydantic.mypy"]
|
||||||
|
```
|
||||||
|
|
||||||
|
### Existing empty `__init__.py` files
|
||||||
|
- `backend/app/__init__.py`
|
||||||
|
- `backend/app/routes/__init__.py`
|
||||||
|
- `backend/app/services/__init__.py`
|
||||||
|
- `backend/tests/__init__.py`
|
||||||
|
|
||||||
|
## Architecture Notes
|
||||||
|
|
||||||
|
- Assets are uploaded as multipart files and stored as `data/assets/<uuid>.<ext>`
|
||||||
|
- Original filename is NOT used on disk (prevents path traversal and collisions)
|
||||||
|
- The extension is extracted from the original filename, validated against `Settings.allowed_upload_types`
|
||||||
|
- File size is validated against `Settings.max_upload_bytes`
|
||||||
|
- Assets are served via FastAPI's `StaticFiles` mount at `/api/assets`
|
||||||
|
- Upload and delete endpoints require admin auth
|
||||||
|
- The asset service is a pure function layer — it receives `assets_dir: Path` and operates on the filesystem
|
||||||
|
- Docker: multi-stage build (Node frontend build → Python backend), single container serves everything
|
||||||
|
|
||||||
|
## Step 1: Write Tests
|
||||||
|
|
||||||
|
Write ALL tests FIRST, before any production code. Use the `client` fixture from conftest.
|
||||||
|
|
||||||
|
### `backend/tests/test_assets.py`
|
||||||
|
|
||||||
|
**Helper constants at module level:**
|
||||||
|
```python
|
||||||
|
from tests.conftest import TEST_ADMIN_TOKEN
|
||||||
|
|
||||||
|
BEARER_HEADERS = {"Authorization": f"Bearer {TEST_ADMIN_TOKEN}"}
|
||||||
|
```
|
||||||
|
|
||||||
|
**Asset route tests:**
|
||||||
|
|
||||||
|
1. **`test_upload_asset`** — `POST /api/assets` with `BEARER_HEADERS` and a multipart file upload (`file` field, filename `test.pdf`, content `b"fake pdf content"`, content type `application/pdf`). Assert `201`. Response JSON has `filename` key. The `filename` value should NOT equal `test.pdf` (it's UUID-based). The `filename` should end with `.pdf`.
|
||||||
|
|
||||||
|
2. **`test_upload_asset_file_exists_on_disk`** — Upload a file via POST. Extract the returned `filename`. Use the `client` fixture's app settings to derive the assets directory (`Path(get_settings().data_dir) / "assets"`). Assert the file exists on disk at `assets_dir / filename`. Assert the file content matches what was uploaded.
|
||||||
|
|
||||||
|
3. **`test_upload_asset_disallowed_type`** — Upload a file with filename `malware.exe`. Assert `415` (Unsupported Media Type).
|
||||||
|
|
||||||
|
4. **`test_upload_asset_too_large`** — Upload a file larger than `max_upload_bytes`. Since the default is 100MB and creating that in a test is impractical, temporarily set `HANDIN_MAX_UPLOAD_BYTES` to a small value (e.g. `"100"`) in the test environment, clear and rebuild settings, then upload a file larger than 100 bytes. Assert `413` (Request Entity Too Large). Restore the environment after the test. Alternatively, use a fixture or monkeypatch approach — the key is that the test must verify the size limit works.
|
||||||
|
|
||||||
|
5. **`test_upload_asset_unauthorized`** — `POST /api/assets` without auth headers. Assert `401`.
|
||||||
|
|
||||||
|
6. **`test_delete_asset`** — Upload a file, extract the `filename`. Then `DELETE /api/assets/{filename}` with `BEARER_HEADERS`. Assert `204`. Verify the file no longer exists on disk.
|
||||||
|
|
||||||
|
7. **`test_delete_asset_not_found`** — `DELETE /api/assets/nonexistent.pdf` with `BEARER_HEADERS`. Assert `404`.
|
||||||
|
|
||||||
|
8. **`test_delete_asset_unauthorized`** — `DELETE /api/assets/somefile.pdf` without auth. Assert `401`.
|
||||||
|
|
||||||
|
9. **`test_serve_asset`** — Upload a file via the API. Then `GET /api/assets/{filename}` (no auth needed — public). Assert `200`. Assert the response body matches the uploaded content.
|
||||||
|
|
||||||
|
**Test conventions:**
|
||||||
|
- Each test function takes `client: TestClient` as parameter (from fixture)
|
||||||
|
- No mocking — use real HTTP calls via TestClient against real filesystem on temp directories
|
||||||
|
- Use `from app.config import get_settings` within tests where you need `data_dir`
|
||||||
|
|
||||||
|
## Step 2: Implement `backend/app/services/asset_service.py`
|
||||||
|
|
||||||
|
Two functions:
|
||||||
|
|
||||||
|
### `save_asset(assets_dir: Path, filename: str, content: bytes, allowed_types: str, max_bytes: int) -> str`
|
||||||
|
- Extract extension from `filename` (lowercase, without the dot)
|
||||||
|
- Validate extension is in `allowed_types` (comma-separated string) — raise `ValueError("Unsupported file type")` if not
|
||||||
|
- Validate `len(content)` does not exceed `max_bytes` — raise `ValueError("File too large")` if it does
|
||||||
|
- Generate a UUID-based filename: `f"{uuid4()}.{ext}"`
|
||||||
|
- Ensure `assets_dir` exists (`mkdir(parents=True, exist_ok=True)`)
|
||||||
|
- Write `content` to `assets_dir / new_filename`
|
||||||
|
- Return `new_filename`
|
||||||
|
|
||||||
|
### `delete_asset(assets_dir: Path, filename: str) -> bool`
|
||||||
|
- Construct full path: `assets_dir / filename`
|
||||||
|
- Validate the resolved path is within `assets_dir` (prevent path traversal) — return `False` if not
|
||||||
|
- If the file exists, delete it and return `True`
|
||||||
|
- Return `False` if the file doesn't exist
|
||||||
|
|
||||||
|
## Step 3: Implement `backend/app/routes/assets.py`
|
||||||
|
|
||||||
|
Create a FastAPI `APIRouter` with `prefix="/api/assets"` and `tags=["assets"]`.
|
||||||
|
|
||||||
|
**Shared dependency:**
|
||||||
|
|
||||||
|
### `get_assets_dir() -> Path`
|
||||||
|
- Reads `data_dir` from `get_settings()`
|
||||||
|
- Returns `Path(data_dir) / "assets"`
|
||||||
|
|
||||||
|
Use `Annotated` type aliases for dependencies (same pattern as `segments.py`).
|
||||||
|
|
||||||
|
**Endpoints:**
|
||||||
|
|
||||||
|
### `POST /` → `dict[str, str]` (status 201)
|
||||||
|
- Admin only (`Depends(require_admin)`)
|
||||||
|
- Accepts `file: UploadFile` parameter
|
||||||
|
- Reads the file content: `content = await file.read()`
|
||||||
|
- Calls `asset_service.save_asset(assets_dir, file.filename or "upload", content, settings.allowed_upload_types, settings.max_upload_bytes)`
|
||||||
|
- Catches `ValueError` — if message contains "File too large" return `413`, if "Unsupported file type" return `415`
|
||||||
|
- Returns `{"filename": saved_filename}` with status `201`
|
||||||
|
|
||||||
|
### `DELETE /{filename}` → `Response` (status 204)
|
||||||
|
- Admin only
|
||||||
|
- Calls `asset_service.delete_asset(assets_dir, filename)`
|
||||||
|
- Returns `204` if deleted, raises `HTTPException(404)` if not found
|
||||||
|
|
||||||
|
## Step 4: Modify `backend/app/main.py`
|
||||||
|
|
||||||
|
Update `main.py` to:
|
||||||
|
|
||||||
|
- Import and register the `assets_router`
|
||||||
|
- Mount `StaticFiles` at `/api/assets` to serve uploaded files. This mount must be registered **after** the assets API router (so POST/DELETE are handled by the router, and GET falls through to StaticFiles)
|
||||||
|
- The lifespan should also ensure the `assets` directory exists on startup (`(Path(data_dir) / "assets").mkdir(parents=True, exist_ok=True)`)
|
||||||
|
|
||||||
|
**Important:** The `StaticFiles` mount for assets serves files from `{data_dir}/assets/`. The API router handles POST and DELETE at `/api/assets`. To avoid conflicts, mount `StaticFiles` at a path like `/api/assets/file` or use a different strategy. One clean approach: register the assets API router with routes for upload (`POST /api/assets`) and delete (`DELETE /api/assets/{filename}`), then mount `StaticFiles(directory=assets_path)` at `/api/assets/file` for serving. Alternatively, keep the router at `/api/assets` and mount static files at `/api/assets` — FastAPI checks routers first, so `POST` and `DELETE` will be handled by the router, while `GET` requests for specific files will fall through to the static mount. Test which approach works and go with it.
|
||||||
|
|
||||||
|
## Step 5: Create `Dockerfile`
|
||||||
|
|
||||||
|
Multi-stage Dockerfile at the project root:
|
||||||
|
|
||||||
|
### Stage 1: Frontend build
|
||||||
|
```dockerfile
|
||||||
|
FROM node:20-alpine AS frontend-build
|
||||||
|
WORKDIR /build
|
||||||
|
COPY frontend/package.json frontend/package-lock.json ./
|
||||||
|
RUN npm ci
|
||||||
|
COPY frontend/ .
|
||||||
|
RUN npm run build
|
||||||
|
```
|
||||||
|
|
||||||
|
### Stage 2: Python backend + built frontend
|
||||||
|
```dockerfile
|
||||||
|
FROM python:3.12-slim AS production
|
||||||
|
|
||||||
|
COPY --from=ghcr.io/astral-sh/uv:latest /uv /usr/local/bin/uv
|
||||||
|
|
||||||
|
WORKDIR /app
|
||||||
|
COPY pyproject.toml uv.lock ./
|
||||||
|
RUN uv sync --frozen --no-dev
|
||||||
|
|
||||||
|
COPY backend/ backend/
|
||||||
|
COPY --from=frontend-build /build/dist static/
|
||||||
|
|
||||||
|
EXPOSE 8000
|
||||||
|
CMD ["uv", "run", "uvicorn", "app.main:app", "--host", "0.0.0.0", "--port", "8000", "--app-dir", "backend"]
|
||||||
|
```
|
||||||
|
|
||||||
|
## Step 6: Create `docker-compose.yml`
|
||||||
|
|
||||||
|
```yaml
|
||||||
|
services:
|
||||||
|
web:
|
||||||
|
build: .
|
||||||
|
ports:
|
||||||
|
- "8000:8000"
|
||||||
|
environment:
|
||||||
|
- HANDIN_ADMIN_TOKEN=${HANDIN_ADMIN_TOKEN:?Set HANDIN_ADMIN_TOKEN}
|
||||||
|
- HANDIN_DATA_DIR=/data
|
||||||
|
volumes:
|
||||||
|
- handin-data:/data
|
||||||
|
|
||||||
|
volumes:
|
||||||
|
handin-data:
|
||||||
|
```
|
||||||
|
|
||||||
|
## Verification
|
||||||
|
|
||||||
|
After implementation, run these commands and ensure they all succeed:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
uv run pytest backend/tests/test_assets.py -v
|
||||||
|
uv run mypy --strict backend/app/services/asset_service.py backend/app/routes/assets.py backend/app/main.py
|
||||||
|
uv run ruff check backend/app/services/asset_service.py backend/app/routes/assets.py backend/app/main.py
|
||||||
|
```
|
||||||
|
|
||||||
|
Then run the **full** verification suite to ensure nothing is broken:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
uv run pytest backend/tests/ -v
|
||||||
|
uv run mypy --strict backend/app/ backend/tests/
|
||||||
|
uv run ruff check backend/
|
||||||
|
```
|
||||||
|
|
||||||
|
All tests must pass (~56 total: 6 database + 15 service + 24 route + 2 concurrency + 9 asset). Zero mypy errors. Zero ruff errors.
|
||||||
|
|
||||||
|
Optionally verify Docker builds (only if Docker is available):
|
||||||
|
|
||||||
|
```bash
|
||||||
|
docker compose build
|
||||||
|
```
|
||||||
|
|
||||||
|
## Files to Create/Modify
|
||||||
|
|
||||||
|
| Action | File |
|
||||||
|
|--------|------|
|
||||||
|
| CREATE | `backend/tests/test_assets.py` |
|
||||||
|
| CREATE | `backend/app/services/asset_service.py` |
|
||||||
|
| CREATE | `backend/app/routes/assets.py` |
|
||||||
|
| CREATE | `Dockerfile` |
|
||||||
|
| CREATE | `docker-compose.yml` |
|
||||||
|
| MODIFY | `backend/app/main.py` |
|
||||||
323
prompts/slice-5-frontend-shell.md
Normal file
323
prompts/slice-5-frontend-shell.md
Normal file
|
|
@ -0,0 +1,323 @@
|
||||||
|
# Slice 5: Frontend Shell + Segment Renderers
|
||||||
|
|
||||||
|
## Role
|
||||||
|
|
||||||
|
You are a frontend TypeScript/Vue developer building the read-only public UI for a FastAPI-based academic submission website. The backend is complete — you are building the Vue 3 / Tailwind CSS v4 frontend that consumes its API.
|
||||||
|
|
||||||
|
## Objective
|
||||||
|
|
||||||
|
Create the read-only frontend shell: sidebar navigation, responsive layout, data fetching composable, TypeScript types, and all segment renderers. No admin functionality in this slice. All files must pass `vue-tsc --noEmit` (strict TypeScript).
|
||||||
|
|
||||||
|
## Architecture Reference
|
||||||
|
|
||||||
|
Read [PLAN.md](../PLAN.md) before starting. It contains the full architecture context: color system, layout specs, component hierarchy, and design decisions. Use it as the source of truth for any details not covered in this prompt.
|
||||||
|
|
||||||
|
## UI Decision-Making Policy
|
||||||
|
|
||||||
|
**When you encounter any ambiguity in visual design, layout, spacing, interaction patterns, or component behavior — use `AskUserQuestion` BEFORE implementing.** This is the prioritized method of resolution. Do not guess or pick defaults for subjective UI choices. Examples of when to ask:
|
||||||
|
- Exact spacing/padding values not specified in the prompt
|
||||||
|
- Animation/transition details (duration, easing, direction)
|
||||||
|
- Empty state presentation (what to show when no segments exist)
|
||||||
|
- Icon choices (hamburger style, close button style)
|
||||||
|
- Typography sizing not explicitly stated
|
||||||
|
- Any "or" choices in the prompt (e.g., "`<iframe>` or `<embed>`")
|
||||||
|
- Hover/focus state details beyond what's specified
|
||||||
|
|
||||||
|
Only proceed without asking when the prompt or PLAN.md gives an unambiguous, specific instruction.
|
||||||
|
|
||||||
|
## Coding Standards (mandatory)
|
||||||
|
|
||||||
|
- Vue 3 `<script setup lang="ts">` single-file components
|
||||||
|
- Strict TypeScript — no `any`, all props/emits typed
|
||||||
|
- Tailwind CSS v4 utility classes (no separate CSS files per component)
|
||||||
|
- Composables use `ref`/`computed`/`onMounted` from Vue — no Options API
|
||||||
|
- Components are small and focused — one responsibility per file
|
||||||
|
- Use `@/` path alias for imports (configured in vite.config.ts as `src/`)
|
||||||
|
- No external state management library — use composables with reactive state
|
||||||
|
|
||||||
|
## Step 0: Verify Existing Setup
|
||||||
|
|
||||||
|
Before writing any code, verify the frontend builds:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd frontend && npm run build
|
||||||
|
```
|
||||||
|
|
||||||
|
Must exit cleanly. If it fails, fix before proceeding.
|
||||||
|
|
||||||
|
## Context: Backend API
|
||||||
|
|
||||||
|
The backend is running at `http://localhost:8000` (proxied via Vite dev server at `/api`).
|
||||||
|
|
||||||
|
### API Endpoints Used by This Slice
|
||||||
|
|
||||||
|
```
|
||||||
|
GET /api/site → { "title": string }
|
||||||
|
GET /api/segments → Array of SegmentResponse
|
||||||
|
GET /api/assets/{file} → Static file (binary)
|
||||||
|
```
|
||||||
|
|
||||||
|
### SegmentResponse Shape (from backend `models.py`)
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"id": "uuid-string",
|
||||||
|
"type": "markdown" | "pdf" | "video" | "audio" | "iframe" | "gallery",
|
||||||
|
"sort_order": 0,
|
||||||
|
"title": "Section Title",
|
||||||
|
"content": "markdown text or URL or empty",
|
||||||
|
"metadata": {},
|
||||||
|
"created_at": "2025-01-01T00:00:00",
|
||||||
|
"updated_at": "2025-01-01T00:00:00"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**Content conventions by type:**
|
||||||
|
- `markdown` — `content` is raw markdown text
|
||||||
|
- `pdf` — `content` is the asset URL (e.g., `/api/assets/uuid.pdf`)
|
||||||
|
- `video` — `content` is the asset URL (e.g., `/api/assets/uuid.mp4`)
|
||||||
|
- `audio` — `content` is the asset URL (e.g., `/api/assets/uuid.mp3`)
|
||||||
|
- `iframe` — `content` is the embed URL (any external URL)
|
||||||
|
- `gallery` — `metadata` contains `{ "images": ["/api/assets/uuid.png", ...] }`
|
||||||
|
|
||||||
|
### Existing Frontend Files
|
||||||
|
|
||||||
|
**`frontend/src/style.css`** (already has theme):
|
||||||
|
```css
|
||||||
|
@import "tailwindcss";
|
||||||
|
@plugin "@tailwindcss/typography";
|
||||||
|
|
||||||
|
@theme {
|
||||||
|
--color-primary-50: #fffbeb;
|
||||||
|
--color-primary-100: #fff3c4;
|
||||||
|
--color-primary-200: #fce588;
|
||||||
|
--color-primary-300: #ffcd00;
|
||||||
|
--color-primary-400: #e6b800;
|
||||||
|
--color-primary-500: #cc9900;
|
||||||
|
--color-primary-600: #997300;
|
||||||
|
--color-primary-700: #664d00;
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**`frontend/vite.config.ts`** — has `@` alias to `src/`, API proxy to `localhost:8000`.
|
||||||
|
|
||||||
|
**`frontend/src/main.ts`** — mounts `App.vue` at `#app`.
|
||||||
|
|
||||||
|
**`frontend/src/App.vue`** — placeholder, will be replaced.
|
||||||
|
|
||||||
|
## UI Design Reference
|
||||||
|
|
||||||
|
### Color System
|
||||||
|
- Primary: golden yellow `#ffcd00` (primary-300) for accents and active states
|
||||||
|
- Neutrals: Tailwind `slate` — dark sidebar (`slate-900`), light content area (`slate-50`)
|
||||||
|
- Active nav item: `primary-300` left border + subtle bg highlight
|
||||||
|
|
||||||
|
### Layout — Sidebar Navigation
|
||||||
|
|
||||||
|
**Desktop (≥1024px):**
|
||||||
|
- Fixed left sidebar, `w-64`, `bg-slate-900`, full height
|
||||||
|
- Sidebar header: site title styled in `primary-300`, font bold
|
||||||
|
- Nav items: segment titles as anchor links, `text-slate-300` default, `text-white` on hover
|
||||||
|
- Active segment: `border-l-2 border-primary-300 bg-slate-800` + `text-white`
|
||||||
|
- Main content: `ml-64`, scrollable, segments as full-width cards on `bg-slate-50`
|
||||||
|
|
||||||
|
**Tablet (768–1023px):**
|
||||||
|
- Sidebar off-screen by default, toggle button to slide it in as overlay
|
||||||
|
|
||||||
|
**Mobile (<768px):**
|
||||||
|
- No sidebar visible. `MobileHeader` with hamburger icon at top
|
||||||
|
- Hamburger opens slide-out overlay nav (full-height, `bg-slate-900`)
|
||||||
|
- Close button or tap-outside to dismiss
|
||||||
|
- Segments stack vertically, full-width
|
||||||
|
|
||||||
|
## Step 1: Create TypeScript Types
|
||||||
|
|
||||||
|
### `frontend/src/types/segment.ts`
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
export type SegmentType = "markdown" | "pdf" | "video" | "audio" | "iframe" | "gallery";
|
||||||
|
|
||||||
|
export interface Segment {
|
||||||
|
id: string;
|
||||||
|
type: SegmentType;
|
||||||
|
sort_order: number;
|
||||||
|
title: string;
|
||||||
|
content: string;
|
||||||
|
metadata: Record<string, unknown>;
|
||||||
|
created_at: string;
|
||||||
|
updated_at: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface SiteInfo {
|
||||||
|
title: string;
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## Step 2: Create Data Fetching Composable
|
||||||
|
|
||||||
|
### `frontend/src/composables/useSegments.ts`
|
||||||
|
|
||||||
|
Exports a composable function `useSegments()` that returns:
|
||||||
|
- `segments: Ref<Segment[]>` — list of segments, ordered by `sort_order`
|
||||||
|
- `siteTitle: Ref<string>` — site title (default: `"Handin"`)
|
||||||
|
- `loading: Ref<boolean>` — true while initial fetch is in progress
|
||||||
|
- `error: Ref<string | null>` — error message if fetch fails
|
||||||
|
- `refresh: () => Promise<void>` — re-fetch both endpoints
|
||||||
|
|
||||||
|
On mount (`onMounted`), fetch both `GET /api/site` and `GET /api/segments` in parallel using `Promise.all`. Use native `fetch()` — no axios.
|
||||||
|
|
||||||
|
## Step 3: Create Layout Components
|
||||||
|
|
||||||
|
### `frontend/src/components/layout/AppShell.vue`
|
||||||
|
|
||||||
|
Top-level layout component. Structure:
|
||||||
|
- Contains `SidebarNav` (desktop) and `MobileHeader` (mobile)
|
||||||
|
- Main content area as a `<slot />`
|
||||||
|
- Uses CSS breakpoints: sidebar visible `lg:block`, hidden below `lg`
|
||||||
|
- Mobile header visible below `lg`, hidden at `lg+`
|
||||||
|
|
||||||
|
### `frontend/src/components/layout/SidebarNav.vue`
|
||||||
|
|
||||||
|
Props:
|
||||||
|
- `segments: Segment[]`
|
||||||
|
- `siteTitle: string`
|
||||||
|
- `activeId: string | null`
|
||||||
|
|
||||||
|
Emits:
|
||||||
|
- `navigate(id: string)` — when a nav item is clicked
|
||||||
|
|
||||||
|
Renders:
|
||||||
|
- Site title in header area (`text-primary-300 font-bold text-lg`)
|
||||||
|
- List of segment titles as clickable items
|
||||||
|
- Active item has left border accent and bg highlight
|
||||||
|
- Scroll overflow if many segments
|
||||||
|
|
||||||
|
### `frontend/src/components/layout/MobileHeader.vue`
|
||||||
|
|
||||||
|
Props:
|
||||||
|
- `siteTitle: string`
|
||||||
|
- `segments: Segment[]`
|
||||||
|
- `activeId: string | null`
|
||||||
|
|
||||||
|
Manages its own open/closed state for the slide-out menu. Contains:
|
||||||
|
- Top bar with site title and hamburger button
|
||||||
|
- Slide-out overlay with same nav items as `SidebarNav`
|
||||||
|
- Click outside or close button dismisses the overlay
|
||||||
|
|
||||||
|
## Step 4: Create Segment Renderers
|
||||||
|
|
||||||
|
### `frontend/src/components/segments/SegmentRenderer.vue`
|
||||||
|
|
||||||
|
Props: `segment: Segment`
|
||||||
|
|
||||||
|
A switch component that renders the correct sub-renderer based on `segment.type`. Use a `v-if`/`v-else-if` chain or a dynamic component approach.
|
||||||
|
|
||||||
|
### `frontend/src/components/segments/SegmentList.vue`
|
||||||
|
|
||||||
|
Props: `segments: Segment[]`
|
||||||
|
|
||||||
|
Renders a vertical list of segments, each wrapped in a card-like container. Each segment card:
|
||||||
|
- Has an `id` attribute matching the segment ID (for anchor scroll)
|
||||||
|
- White background, rounded, subtle shadow
|
||||||
|
- Title as heading, then the renderer below
|
||||||
|
|
||||||
|
### Individual Renderers
|
||||||
|
|
||||||
|
Each takes a `segment: Segment` prop:
|
||||||
|
|
||||||
|
**`MarkdownSegment.vue`**
|
||||||
|
- Render `segment.content` as HTML using `marked` library
|
||||||
|
- Wrap output in a `prose` class div (Tailwind typography plugin)
|
||||||
|
- Use `v-html` with the parsed markdown
|
||||||
|
|
||||||
|
**`PdfSegment.vue`**
|
||||||
|
- Render an `<iframe>` or `<embed>` pointing to `segment.content` (the PDF URL)
|
||||||
|
- Full-width, reasonable height (e.g., `h-[600px]` or `aspect-[4/3]`)
|
||||||
|
|
||||||
|
**`VideoSegment.vue`**
|
||||||
|
- Render a `<video>` element with `controls`, `src` pointing to `segment.content`
|
||||||
|
- Full-width, responsive
|
||||||
|
|
||||||
|
**`AudioSegment.vue`**
|
||||||
|
- Render an `<audio>` element with `controls`, `src` pointing to `segment.content`
|
||||||
|
- Full-width
|
||||||
|
|
||||||
|
**`IframeSegment.vue`**
|
||||||
|
- Render an `<iframe>` with `src` from `segment.content`
|
||||||
|
- Full-width, reasonable height, border-none
|
||||||
|
- Add `sandbox` and `allow` attributes for security
|
||||||
|
|
||||||
|
**`GallerySegment.vue`**
|
||||||
|
- Read image URLs from `segment.metadata.images` (cast to `string[]`)
|
||||||
|
- Render as a responsive grid of `<img>` elements
|
||||||
|
- Grid: 2 columns on mobile, 3 on tablet, 4 on desktop
|
||||||
|
- Images have `object-cover`, rounded corners
|
||||||
|
|
||||||
|
## Step 5: Create HomePage View
|
||||||
|
|
||||||
|
### `frontend/src/views/HomePage.vue`
|
||||||
|
|
||||||
|
Wires everything together:
|
||||||
|
- Uses `useSegments()` composable
|
||||||
|
- Tracks `activeId` (string or null) — updates on scroll or nav click
|
||||||
|
- Shows loading spinner/skeleton while `loading` is true
|
||||||
|
- Shows error message if `error` is set
|
||||||
|
- Renders `AppShell` with `SegmentList` in the main slot
|
||||||
|
|
||||||
|
**Scroll tracking:** Use `IntersectionObserver` to detect which segment is currently visible and update `activeId` for sidebar highlighting.
|
||||||
|
|
||||||
|
## Step 6: Update App.vue
|
||||||
|
|
||||||
|
Replace the placeholder `App.vue` with:
|
||||||
|
|
||||||
|
```vue
|
||||||
|
<script setup lang="ts">
|
||||||
|
import HomePage from "@/views/HomePage.vue";
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<HomePage />
|
||||||
|
</template>
|
||||||
|
```
|
||||||
|
|
||||||
|
## Verification
|
||||||
|
|
||||||
|
After implementation, run:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd frontend && npm run build
|
||||||
|
```
|
||||||
|
|
||||||
|
Must succeed with zero errors. Then verify visually:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd frontend && npm run dev
|
||||||
|
```
|
||||||
|
|
||||||
|
Check:
|
||||||
|
1. Sidebar renders with site title and segment nav items (populate via backend or verify empty state)
|
||||||
|
2. Desktop: sidebar fixed left, content scrolls independently
|
||||||
|
3. Mobile (narrow viewport): hamburger menu, slide-out nav works
|
||||||
|
4. Each segment type renders correctly when data is present
|
||||||
|
5. Active segment highlights in sidebar on scroll
|
||||||
|
6. No TypeScript errors (`npm run type-check`)
|
||||||
|
|
||||||
|
## Files to Create/Modify
|
||||||
|
|
||||||
|
| Action | File |
|
||||||
|
|--------|------|
|
||||||
|
| CREATE | `frontend/src/types/segment.ts` |
|
||||||
|
| CREATE | `frontend/src/composables/useSegments.ts` |
|
||||||
|
| CREATE | `frontend/src/components/layout/AppShell.vue` |
|
||||||
|
| CREATE | `frontend/src/components/layout/SidebarNav.vue` |
|
||||||
|
| CREATE | `frontend/src/components/layout/MobileHeader.vue` |
|
||||||
|
| CREATE | `frontend/src/components/segments/SegmentRenderer.vue` |
|
||||||
|
| CREATE | `frontend/src/components/segments/SegmentList.vue` |
|
||||||
|
| CREATE | `frontend/src/components/segments/MarkdownSegment.vue` |
|
||||||
|
| CREATE | `frontend/src/components/segments/PdfSegment.vue` |
|
||||||
|
| CREATE | `frontend/src/components/segments/VideoSegment.vue` |
|
||||||
|
| CREATE | `frontend/src/components/segments/AudioSegment.vue` |
|
||||||
|
| CREATE | `frontend/src/components/segments/IframeSegment.vue` |
|
||||||
|
| CREATE | `frontend/src/components/segments/GallerySegment.vue` |
|
||||||
|
| CREATE | `frontend/src/views/HomePage.vue` |
|
||||||
|
| MODIFY | `frontend/src/App.vue` |
|
||||||
366
prompts/slice-6-admin-ui.md
Normal file
366
prompts/slice-6-admin-ui.md
Normal file
|
|
@ -0,0 +1,366 @@
|
||||||
|
# Slice 6: Admin UI
|
||||||
|
|
||||||
|
## Role
|
||||||
|
|
||||||
|
You are a frontend TypeScript/Vue developer adding admin functionality to an existing read-only academic submission website. The backend API and read-only frontend are complete — you are adding token-based authentication, inline editing, asset upload, and drag-and-drop reorder.
|
||||||
|
|
||||||
|
## Objective
|
||||||
|
|
||||||
|
Create the full admin experience: token entry, inline segment editing (create/update/delete), asset uploading, and drag-and-drop segment reorder. Admin controls must be invisible when no token is active. All files must pass `vue-tsc --noEmit` (strict TypeScript).
|
||||||
|
|
||||||
|
## Architecture Reference
|
||||||
|
|
||||||
|
Read [PLAN.md](../PLAN.md) for full architecture context. **However, note these deviations from PLAN.md that were made during Slice 5 implementation:**
|
||||||
|
|
||||||
|
### Layout Change (IMPORTANT)
|
||||||
|
|
||||||
|
The layout was changed from a **sidebar** to a **horizontal top bar with tabs**:
|
||||||
|
|
||||||
|
- **Title bar:** `bg-primary-300` (golden yellow) with dark text, full width at top.
|
||||||
|
- **Tab navigation:** Horizontal tab bar below the title, with `border-b-2 border-primary-400` on the active tab. Visible on `md+` screens.
|
||||||
|
- **Mobile:** Hamburger dropdown (not slide-out sidebar). Opens a vertical list from the top with backdrop.
|
||||||
|
- **Content area:** Centered `max-w-4xl` on white background.
|
||||||
|
|
||||||
|
The component **file names** from PLAN.md are preserved but their implementations differ:
|
||||||
|
- `SidebarNav.vue` is actually a **horizontal tab bar** (desktop).
|
||||||
|
- `MobileHeader.vue` is a **hamburger dropdown** (mobile).
|
||||||
|
- `AppShell.vue` composes the title bar + tab nav + mobile header + main content slot.
|
||||||
|
|
||||||
|
### Backend Route Trailing Slashes
|
||||||
|
|
||||||
|
Backend collection routes use **trailing slashes**. Always include them in fetch URLs:
|
||||||
|
- `GET /api/site/` — not `/api/site`
|
||||||
|
- `GET /api/segments/` — not `/api/segments`
|
||||||
|
- `POST /api/segments/` — not `/api/segments`
|
||||||
|
- `POST /api/assets/` — not `/api/assets`
|
||||||
|
- `PUT /api/segments/reorder` — no trailing slash (path route, not collection)
|
||||||
|
- `GET /api/auth/verify` — no trailing slash
|
||||||
|
- `PATCH /api/segments/{id}` — no trailing slash (uses PATCH, not PUT)
|
||||||
|
- `DELETE /api/segments/{id}` — no trailing slash
|
||||||
|
- `DELETE /api/assets/{filename}` — no trailing slash
|
||||||
|
|
||||||
|
Omitting trailing slashes on collection routes causes **307 redirects** which break in the Docker proxy setup (CORS errors).
|
||||||
|
|
||||||
|
## UI Decision-Making Policy
|
||||||
|
|
||||||
|
**When you encounter any ambiguity in visual design, layout, spacing, interaction patterns, or component behavior — use `AskUserQuestion` BEFORE implementing.** Do not guess or pick defaults for subjective UI choices. Examples:
|
||||||
|
- How the "Add Segment" form should look (modal vs inline vs dropdown)
|
||||||
|
- Editor layout for different segment types
|
||||||
|
- Confirmation dialogs for delete actions
|
||||||
|
- Token input styling and placement
|
||||||
|
- Admin toolbar positioning relative to the top bar
|
||||||
|
- Drag handle icon/style
|
||||||
|
|
||||||
|
Only proceed without asking when this prompt gives an unambiguous, specific instruction.
|
||||||
|
|
||||||
|
## Coding Standards (mandatory)
|
||||||
|
|
||||||
|
- Vue 3 `<script setup lang="ts">` single-file components
|
||||||
|
- Strict TypeScript — no `any`, all props/emits typed
|
||||||
|
- Tailwind CSS v4 utility classes (no separate CSS files per component)
|
||||||
|
- Composables use `ref`/`computed`/`onMounted` from Vue — no Options API
|
||||||
|
- Components are small and focused — one responsibility per file
|
||||||
|
- Use `@/` path alias for imports (configured in vite.config.ts as `src/`)
|
||||||
|
- No external state management library — use composables with reactive state
|
||||||
|
- Use native `fetch()` — no axios
|
||||||
|
|
||||||
|
## Step 0: Verify Existing Setup
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd frontend && npm run build
|
||||||
|
```
|
||||||
|
|
||||||
|
Must exit cleanly. If it fails, fix before proceeding.
|
||||||
|
|
||||||
|
## Context: Backend API
|
||||||
|
|
||||||
|
### Auth Mechanism
|
||||||
|
|
||||||
|
- Single shared token set via `HANDIN_ADMIN_TOKEN` env var (in `.env` file, value: `changeme`).
|
||||||
|
- Token accepted via `?token=<token>` query param OR `Authorization: Bearer <token>` header.
|
||||||
|
- `GET /api/auth/verify` — returns `{ "valid": true }` if token is valid, 401 otherwise.
|
||||||
|
- All write endpoints require the token (POST, PATCH, PUT, DELETE on segments/assets/site).
|
||||||
|
|
||||||
|
### API Endpoints Used by This Slice
|
||||||
|
|
||||||
|
```
|
||||||
|
AUTH:
|
||||||
|
GET /api/auth/verify → { "valid": true } or 401
|
||||||
|
|
||||||
|
SEGMENTS (admin):
|
||||||
|
POST /api/segments/ → SegmentResponse (201)
|
||||||
|
Body: { "type": SegmentType, "title": string, "content"?: string, "metadata"?: object }
|
||||||
|
|
||||||
|
PATCH /api/segments/{id} → SegmentResponse
|
||||||
|
Body: { "title"?: string, "content"?: string, "metadata"?: object }
|
||||||
|
NOTE: PATCH not PUT — only send changed fields
|
||||||
|
|
||||||
|
DELETE /api/segments/{id} → 204 No Content
|
||||||
|
|
||||||
|
PUT /api/segments/reorder → SegmentResponse[]
|
||||||
|
Body: { "segment_ids": string[] }
|
||||||
|
|
||||||
|
ASSETS (admin):
|
||||||
|
POST /api/assets/ → { "filename": "uuid.ext" } (201)
|
||||||
|
Body: multipart/form-data with "file" field
|
||||||
|
|
||||||
|
DELETE /api/assets/{filename} → 204 No Content
|
||||||
|
|
||||||
|
SITE (admin):
|
||||||
|
PUT /api/site/ → { "title": string }
|
||||||
|
Body: { "title": string }
|
||||||
|
```
|
||||||
|
|
||||||
|
### Existing Frontend Types
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
// frontend/src/types/segment.ts
|
||||||
|
export type SegmentType = "markdown" | "pdf" | "video" | "audio" | "iframe" | "gallery";
|
||||||
|
|
||||||
|
export interface Segment {
|
||||||
|
id: string;
|
||||||
|
type: SegmentType;
|
||||||
|
sort_order: number;
|
||||||
|
title: string;
|
||||||
|
content: string;
|
||||||
|
metadata: Record<string, unknown>;
|
||||||
|
created_at: string;
|
||||||
|
updated_at: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface SiteInfo {
|
||||||
|
title: string;
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Existing Composable: useSegments
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
// frontend/src/composables/useSegments.ts
|
||||||
|
// Returns: { segments, siteTitle, loading, error, refresh }
|
||||||
|
// refresh() re-fetches both /api/site/ and /api/segments/
|
||||||
|
// Call refresh() after any admin mutation to sync the UI
|
||||||
|
```
|
||||||
|
|
||||||
|
### Existing Layout Structure
|
||||||
|
|
||||||
|
```
|
||||||
|
AppShell.vue
|
||||||
|
├── <header> — yellow title bar (bg-primary-300), shows siteTitle
|
||||||
|
├── SidebarNav.vue — horizontal tab bar (hidden below md)
|
||||||
|
├── MobileHeader.vue — hamburger dropdown (hidden at md+)
|
||||||
|
└── <main> — slot for page content (max-w-4xl centered)
|
||||||
|
```
|
||||||
|
|
||||||
|
### Existing Segment List
|
||||||
|
|
||||||
|
```vue
|
||||||
|
<!-- SegmentList.vue renders each segment as: -->
|
||||||
|
<section :id="segment.id" class="rounded-lg bg-white p-6 shadow-sm">
|
||||||
|
<h2>{{ segment.title }}</h2>
|
||||||
|
<SegmentRenderer :segment="segment" />
|
||||||
|
</section>
|
||||||
|
```
|
||||||
|
|
||||||
|
## Step 1: Create `useAdmin` Composable
|
||||||
|
|
||||||
|
### `frontend/src/composables/useAdmin.ts`
|
||||||
|
|
||||||
|
Manages admin authentication state. Exports a composable `useAdmin()` that returns:
|
||||||
|
|
||||||
|
- `token: Ref<string | null>` — current token (persisted in `sessionStorage`)
|
||||||
|
- `isAdmin: Ref<boolean>` — true when token is verified
|
||||||
|
- `verifying: Ref<boolean>` — true during verification request
|
||||||
|
- `login(token: string): Promise<boolean>` — verify token via `GET /api/auth/verify`, persist if valid
|
||||||
|
- `logout(): void` — clear token and admin state
|
||||||
|
- `authHeaders: ComputedRef<Record<string, string>>` — returns `{ "Authorization": "Bearer <token>" }` when authenticated, empty object otherwise
|
||||||
|
- `authQuery: ComputedRef<string>` — returns `?token=<token>` or empty string (useful for asset URLs)
|
||||||
|
|
||||||
|
On creation, check `sessionStorage` for an existing token and re-verify it silently.
|
||||||
|
|
||||||
|
**Important:** This composable must use module-level reactive state (defined outside the function) so that all components share the same auth state. The function just returns references to the shared state.
|
||||||
|
|
||||||
|
## Step 2: Create `useAssetUpload` Composable
|
||||||
|
|
||||||
|
### `frontend/src/composables/useAssetUpload.ts`
|
||||||
|
|
||||||
|
Handles file uploads. Exports `useAssetUpload()` that returns:
|
||||||
|
|
||||||
|
- `uploading: Ref<boolean>`
|
||||||
|
- `error: Ref<string | null>`
|
||||||
|
- `upload(file: File, authHeaders: Record<string, string>): Promise<string | null>` — uploads file to `POST /api/assets/`, returns the filename on success or null on failure
|
||||||
|
|
||||||
|
Use `FormData` with native `fetch()`. Set `error` on failure with the server's error message.
|
||||||
|
|
||||||
|
## Step 3: Create Admin Components
|
||||||
|
|
||||||
|
### `frontend/src/components/admin/TokenPrompt.vue`
|
||||||
|
|
||||||
|
The entry point for admin access. When no token is active:
|
||||||
|
- Show a small, subtle lock icon in the top bar (inside AppShell's header area)
|
||||||
|
- Clicking it reveals an inline token input field with a submit button
|
||||||
|
- On successful login, the input disappears and admin controls appear
|
||||||
|
|
||||||
|
Props: none (uses `useAdmin` composable directly).
|
||||||
|
|
||||||
|
Emits:
|
||||||
|
- `authenticated` — fired when login succeeds
|
||||||
|
|
||||||
|
### `frontend/src/components/admin/AdminToolbar.vue`
|
||||||
|
|
||||||
|
A thin bar shown below the tab nav when admin is authenticated. Contains:
|
||||||
|
- An "Editing" badge (subtle indicator)
|
||||||
|
- An "Add Segment" button that triggers segment creation
|
||||||
|
- A "Logout" button
|
||||||
|
|
||||||
|
Props: none (uses `useAdmin` composable directly).
|
||||||
|
|
||||||
|
Emits:
|
||||||
|
- `add-segment` — when "Add Segment" is clicked
|
||||||
|
- `logout` — when "Logout" is clicked
|
||||||
|
|
||||||
|
### `frontend/src/components/admin/SegmentEditor.vue`
|
||||||
|
|
||||||
|
Inline editor that appears below a segment card when editing. Handles:
|
||||||
|
- Editing title (text input)
|
||||||
|
- Editing content based on segment type:
|
||||||
|
- `markdown`: textarea for raw markdown
|
||||||
|
- `pdf`, `video`, `audio`: file upload (via AssetUploader) or URL input showing current asset
|
||||||
|
- `iframe`: URL text input
|
||||||
|
- `gallery`: file upload for multiple images, display current images with remove buttons
|
||||||
|
- Save and Cancel buttons
|
||||||
|
- Delete button (with confirmation)
|
||||||
|
|
||||||
|
Props:
|
||||||
|
- `segment: Segment`
|
||||||
|
|
||||||
|
Emits:
|
||||||
|
- `save(updates: { title?: string; content?: string; metadata?: Record<string, unknown> })` — partial update
|
||||||
|
- `cancel` — close editor
|
||||||
|
- `delete` — delete this segment
|
||||||
|
|
||||||
|
### `frontend/src/components/admin/AssetUploader.vue`
|
||||||
|
|
||||||
|
Drag-and-drop file upload zone. Features:
|
||||||
|
- Dashed border drop zone
|
||||||
|
- Click to select file
|
||||||
|
- Shows upload progress/status
|
||||||
|
- Returns the uploaded filename
|
||||||
|
|
||||||
|
Props:
|
||||||
|
- `accept?: string` — file type filter (e.g., `"application/pdf"`, `"image/*"`)
|
||||||
|
- `label?: string` — descriptive text inside the zone
|
||||||
|
|
||||||
|
Emits:
|
||||||
|
- `uploaded(filename: string)` — when upload completes successfully
|
||||||
|
|
||||||
|
## Step 4: Integrate Admin into Existing Components
|
||||||
|
|
||||||
|
### Modify `AppShell.vue`
|
||||||
|
|
||||||
|
- Add `TokenPrompt` to the title bar header (when not authenticated)
|
||||||
|
- Add `AdminToolbar` below the tab nav (when authenticated)
|
||||||
|
- Pass admin state down or let child components use `useAdmin` directly
|
||||||
|
|
||||||
|
### Modify `SegmentList.vue`
|
||||||
|
|
||||||
|
When admin is active:
|
||||||
|
- Show a small edit icon (pencil) on each segment card header (next to the title)
|
||||||
|
- Clicking the edit icon toggles `SegmentEditor` inline below that segment
|
||||||
|
- Show drag handles on each card for reorder (use `vuedraggable`)
|
||||||
|
- After drag-and-drop reorder, call `PUT /api/segments/reorder` with the new ID order, then `refresh()`
|
||||||
|
|
||||||
|
The `vuedraggable` package is already installed (`"vuedraggable": "4.1.0"` in package.json). Import it as:
|
||||||
|
```typescript
|
||||||
|
import draggable from "vuedraggable";
|
||||||
|
```
|
||||||
|
|
||||||
|
Note: `vuedraggable` 4.x may not have TypeScript declarations. If `vue-tsc` complains, create a type declaration file `frontend/src/types/vuedraggable.d.ts`:
|
||||||
|
```typescript
|
||||||
|
declare module "vuedraggable" {
|
||||||
|
import type { DefineComponent } from "vue";
|
||||||
|
const component: DefineComponent;
|
||||||
|
export default component;
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Modify `HomePage.vue`
|
||||||
|
|
||||||
|
- Wire up the "Add Segment" flow: when `AdminToolbar` emits `add-segment`, show a creation form (could be a simple modal or inline form at the top/bottom of the segment list)
|
||||||
|
- After creating a segment, call `refresh()` to reload the list
|
||||||
|
- After editing/deleting a segment, call `refresh()`
|
||||||
|
- Handle logout: call `useAdmin().logout()`
|
||||||
|
|
||||||
|
## Step 5: Segment Creation Flow
|
||||||
|
|
||||||
|
When "Add Segment" is clicked, the user needs to:
|
||||||
|
1. Choose a segment type (dropdown or button group)
|
||||||
|
2. Enter a title
|
||||||
|
3. Provide initial content (type-dependent: text for markdown, URL for iframe, file upload for pdf/video/audio, etc.)
|
||||||
|
4. Submit → `POST /api/segments/` with auth header → `refresh()`
|
||||||
|
|
||||||
|
## Step 6: Wire Up All Mutations
|
||||||
|
|
||||||
|
All admin mutations must:
|
||||||
|
1. Include auth headers from `useAdmin().authHeaders`
|
||||||
|
2. Call `useSegments().refresh()` after success to sync the UI
|
||||||
|
3. Handle errors gracefully (show error message, don't lose user input)
|
||||||
|
|
||||||
|
### Mutation summary:
|
||||||
|
|
||||||
|
| Action | Method | Endpoint | Auth | After |
|
||||||
|
|--------|--------|----------|------|-------|
|
||||||
|
| Create segment | POST | `/api/segments/` | Bearer header | refresh() |
|
||||||
|
| Update segment | PATCH | `/api/segments/{id}` | Bearer header | refresh() |
|
||||||
|
| Delete segment | DELETE | `/api/segments/{id}` | Bearer header | refresh() |
|
||||||
|
| Reorder segments | PUT | `/api/segments/reorder` | Bearer header | refresh() |
|
||||||
|
| Upload asset | POST | `/api/assets/` | Bearer header | use filename |
|
||||||
|
| Delete asset | DELETE | `/api/assets/{filename}` | Bearer header | refresh() |
|
||||||
|
| Update site title | PUT | `/api/site/` | Bearer header | refresh() |
|
||||||
|
|
||||||
|
## Verification
|
||||||
|
|
||||||
|
After implementation, run:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd frontend && npm run build
|
||||||
|
```
|
||||||
|
|
||||||
|
Must succeed with zero errors. Then verify functionally:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd frontend && npm run dev
|
||||||
|
```
|
||||||
|
|
||||||
|
Or with Docker:
|
||||||
|
```bash
|
||||||
|
docker compose up --build
|
||||||
|
# Visit http://localhost:5174
|
||||||
|
```
|
||||||
|
|
||||||
|
Check:
|
||||||
|
1. **No admin UI visible** without token — only the lock icon in the header
|
||||||
|
2. Click lock icon → enter token `changeme` → admin controls appear
|
||||||
|
3. Admin toolbar shows with "Add Segment" and "Logout" buttons
|
||||||
|
4. Create a markdown segment → appears in the list
|
||||||
|
5. Edit a segment's title and content → saves correctly
|
||||||
|
6. Delete a segment → removed from list (after confirmation)
|
||||||
|
7. Drag-and-drop reorder segments → order persists after refresh
|
||||||
|
8. Upload an asset (PDF, image) → segment displays it
|
||||||
|
9. Logout → all admin UI disappears, read-only view restored
|
||||||
|
10. No TypeScript errors (`npm run type-check`)
|
||||||
|
|
||||||
|
## Files to Create/Modify
|
||||||
|
|
||||||
|
| Action | File |
|
||||||
|
|--------|------|
|
||||||
|
| CREATE | `frontend/src/composables/useAdmin.ts` |
|
||||||
|
| CREATE | `frontend/src/composables/useAssetUpload.ts` |
|
||||||
|
| CREATE | `frontend/src/components/admin/TokenPrompt.vue` |
|
||||||
|
| CREATE | `frontend/src/components/admin/AdminToolbar.vue` |
|
||||||
|
| CREATE | `frontend/src/components/admin/SegmentEditor.vue` |
|
||||||
|
| CREATE | `frontend/src/components/admin/AssetUploader.vue` |
|
||||||
|
| CREATE | `frontend/src/types/vuedraggable.d.ts` (if needed for type-check) |
|
||||||
|
| MODIFY | `frontend/src/components/layout/AppShell.vue` |
|
||||||
|
| MODIFY | `frontend/src/components/segments/SegmentList.vue` |
|
||||||
|
| MODIFY | `frontend/src/views/HomePage.vue` |
|
||||||
214
prompts/slice-7-polish.md
Normal file
214
prompts/slice-7-polish.md
Normal file
|
|
@ -0,0 +1,214 @@
|
||||||
|
# Slice 7: Polish + Final Validation
|
||||||
|
|
||||||
|
## Role
|
||||||
|
|
||||||
|
You are a senior developer performing a final quality sweep on an academic group submission website. The backend (FastAPI + SQLite) and frontend (Vue 3 + TypeScript + Tailwind CSS v4) are fully implemented through Slices 0–6. Your job is to run every verification check, fix any issues found, and ensure the app works end-to-end.
|
||||||
|
|
||||||
|
## Objective
|
||||||
|
|
||||||
|
Run the full verification suite, fix any failures, and validate the complete user flow. The app must pass all automated checks with zero errors and work correctly in Docker.
|
||||||
|
|
||||||
|
## Architecture Reference
|
||||||
|
|
||||||
|
Read [PLAN.md](../PLAN.md) for full architecture context. Key deviations from the original plan:
|
||||||
|
|
||||||
|
### Layout
|
||||||
|
The layout uses a **horizontal top bar with tabs** (not a sidebar):
|
||||||
|
- `AppShell.vue` — sticky header (title bar `bg-primary-300` + nav bar) + dark mode toggle + TokenPrompt + AdminToolbar + back-to-top button + main content slot
|
||||||
|
- `SidebarNav.vue` — horizontal tab bar (desktop, `md+`)
|
||||||
|
- `MobileHeader.vue` — hamburger dropdown (mobile, `<md`)
|
||||||
|
- The entire header (title bar + nav) is `sticky top-0 z-20` — always visible when scrolling
|
||||||
|
- A floating back-to-top button appears after scrolling 300px (bottom-right, `bg-primary-300`)
|
||||||
|
|
||||||
|
### Backend Routes
|
||||||
|
- Collection routes use **trailing slashes** (`/api/segments/`, `/api/site/`, `/api/assets/`)
|
||||||
|
- Single-resource routes have **no trailing slash** (`/api/segments/{id}`, `/api/auth/verify`)
|
||||||
|
- Segment updates use **PATCH** (not PUT) — partial updates only
|
||||||
|
|
||||||
|
### Segment Types
|
||||||
|
Seven types: `markdown`, `pdf`, `video`, `audio`, `iframe`, `gallery`, `link`.
|
||||||
|
- `link` is nav-only — appears in tab bar/mobile menu as an external `<a target="_blank">`, not in the content area.
|
||||||
|
|
||||||
|
### Site Title
|
||||||
|
- Configurable via `HANDIN_SITE_TITLE` env var (default: `"Untitled Site"`)
|
||||||
|
- Stored in `config.py` as `Settings.site_title`, passed to `site_service.get_site_title()` as `default` kwarg
|
||||||
|
- Can also be overridden at runtime via `PUT /api/site/` (stored in DB, takes precedence over env var)
|
||||||
|
|
||||||
|
### Admin UI
|
||||||
|
- Lock icon in title bar → inline token input → admin controls appear
|
||||||
|
- Header icons (lock, dark mode toggle) use `text-slate-700` in both light and dark mode (yellow header stays consistent)
|
||||||
|
- AdminToolbar below tabs: "Editing" badge + "Add Segment" (modal) + "Logout"
|
||||||
|
- Inline SegmentEditor below each card with type-aware editing
|
||||||
|
- Grip-dot drag handles for reorder via `vuedraggable`
|
||||||
|
- Custom styled delete confirmation modal (not browser `confirm()`)
|
||||||
|
|
||||||
|
## Step 1: Backend Verification
|
||||||
|
|
||||||
|
Run these checks and fix any failures:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Tests
|
||||||
|
uv run pytest backend/tests/ -v
|
||||||
|
|
||||||
|
# Linting
|
||||||
|
uv run ruff check backend/
|
||||||
|
|
||||||
|
# Type checking
|
||||||
|
uv run mypy --strict backend/app/
|
||||||
|
```
|
||||||
|
|
||||||
|
All must pass with zero errors. If any tests fail:
|
||||||
|
1. Read the failing test to understand what it expects
|
||||||
|
2. Read the relevant source code
|
||||||
|
3. Fix the source (not the test) unless the test itself is wrong
|
||||||
|
4. Re-run until clean
|
||||||
|
|
||||||
|
### Known Backend Files
|
||||||
|
```
|
||||||
|
backend/app/
|
||||||
|
├── main.py
|
||||||
|
├── config.py # Settings: admin_token, site_title, data_dir, etc.
|
||||||
|
├── auth.py
|
||||||
|
├── models.py # SegmentType enum includes LINK = "link"
|
||||||
|
├── database.py
|
||||||
|
├── routes/
|
||||||
|
│ ├── auth.py
|
||||||
|
│ ├── segments.py
|
||||||
|
│ ├── site.py # Passes settings.site_title as default to service
|
||||||
|
│ └── assets.py
|
||||||
|
└── services/
|
||||||
|
├── segment_service.py
|
||||||
|
├── site_service.py # get_site_title(db_path, *, default=DEFAULT_SITE_TITLE)
|
||||||
|
└── asset_service.py
|
||||||
|
```
|
||||||
|
|
||||||
|
## Step 2: Frontend Verification
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd frontend && npm run build
|
||||||
|
```
|
||||||
|
|
||||||
|
This runs `vue-tsc --noEmit && vite build`. Must succeed with zero errors.
|
||||||
|
|
||||||
|
### Known Frontend Files
|
||||||
|
```
|
||||||
|
frontend/src/
|
||||||
|
├── composables/
|
||||||
|
│ ├── useSegments.ts
|
||||||
|
│ ├── useAdmin.ts # Module-level reactive state, sessionStorage
|
||||||
|
│ ├── useAssetUpload.ts
|
||||||
|
│ └── useDarkMode.ts # Class-based dark mode, localStorage persistence, system preference fallback
|
||||||
|
├── types/
|
||||||
|
│ ├── segment.ts # SegmentType includes "link"
|
||||||
|
│ └── vuedraggable.d.ts
|
||||||
|
├── components/
|
||||||
|
│ ├── layout/
|
||||||
|
│ │ ├── AppShell.vue # Sticky header, back-to-top button, scroll listener
|
||||||
|
│ │ ├── SidebarNav.vue # Actually horizontal tab bar
|
||||||
|
│ │ └── MobileHeader.vue # Hamburger dropdown
|
||||||
|
│ ├── segments/
|
||||||
|
│ │ ├── SegmentList.vue # Draggable (admin) or plain (read-only), filters out link segments from content
|
||||||
|
│ │ ├── SegmentRenderer.vue
|
||||||
|
│ │ ├── MarkdownSegment.vue
|
||||||
|
│ │ ├── PdfSegment.vue
|
||||||
|
│ │ ├── VideoSegment.vue
|
||||||
|
│ │ ├── AudioSegment.vue
|
||||||
|
│ │ ├── IframeSegment.vue
|
||||||
|
│ │ └── GallerySegment.vue
|
||||||
|
│ └── admin/
|
||||||
|
│ ├── TokenPrompt.vue
|
||||||
|
│ ├── AdminToolbar.vue
|
||||||
|
│ ├── SegmentEditor.vue
|
||||||
|
│ ├── AssetUploader.vue
|
||||||
|
│ └── AddSegmentModal.vue
|
||||||
|
└── views/
|
||||||
|
└── HomePage.vue
|
||||||
|
```
|
||||||
|
|
||||||
|
## Step 3: Docker Build + Smoke Test
|
||||||
|
|
||||||
|
```bash
|
||||||
|
docker compose up --build -d
|
||||||
|
```
|
||||||
|
|
||||||
|
Must build and start without errors. Then verify:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Health check — site loads
|
||||||
|
curl -s http://localhost:8000/api/site/ | python3 -c "import sys,json; d=json.load(sys.stdin); print(d)"
|
||||||
|
|
||||||
|
# Auth verify works
|
||||||
|
curl -s http://localhost:8000/api/auth/verify -H "Authorization: Bearer changeme" | python3 -c "import sys,json; d=json.load(sys.stdin); assert d['valid']==True; print('Auth OK')"
|
||||||
|
|
||||||
|
# Create a test segment
|
||||||
|
curl -s -X POST http://localhost:8000/api/segments/ \
|
||||||
|
-H "Authorization: Bearer changeme" \
|
||||||
|
-H "Content-Type: application/json" \
|
||||||
|
-d '{"type":"markdown","title":"Test","content":"# Hello"}' | python3 -c "import sys,json; d=json.load(sys.stdin); print(f'Created segment {d[\"id\"]}')"
|
||||||
|
|
||||||
|
# Create a link segment
|
||||||
|
curl -s -X POST http://localhost:8000/api/segments/ \
|
||||||
|
-H "Authorization: Bearer changeme" \
|
||||||
|
-H "Content-Type: application/json" \
|
||||||
|
-d '{"type":"link","title":"GitHub","content":"https://github.com"}' | python3 -c "import sys,json; d=json.load(sys.stdin); print(f'Created link {d[\"id\"]}')"
|
||||||
|
|
||||||
|
# List segments
|
||||||
|
curl -s http://localhost:8000/api/segments/ | python3 -c "import sys,json; d=json.load(sys.stdin); print(f'{len(d)} segments')"
|
||||||
|
|
||||||
|
# Frontend loads
|
||||||
|
curl -s http://localhost:8000/ | head -5
|
||||||
|
```
|
||||||
|
|
||||||
|
Clean up test data after verification:
|
||||||
|
```bash
|
||||||
|
docker compose down
|
||||||
|
```
|
||||||
|
|
||||||
|
## Step 4: Edge Case Review
|
||||||
|
|
||||||
|
Check these specific scenarios and fix if broken:
|
||||||
|
|
||||||
|
1. **Empty state**: With no segments, the page should show "No content yet" (not crash or show blank).
|
||||||
|
2. **Link segments in read-only mode**: Should appear in nav tabs but NOT in the content area.
|
||||||
|
3. **Link segments in admin mode**: Should appear in the draggable list with URL displayed, editable via SegmentEditor.
|
||||||
|
4. **Gallery segment with no images**: Should not crash — show empty state or just the upload zone.
|
||||||
|
5. **Admin toolbar visibility**: Must be completely hidden when not authenticated.
|
||||||
|
6. **Token persistence**: After page reload, if token was valid, admin state should restore from sessionStorage.
|
||||||
|
7. **Concurrent safety**: Two simultaneous segment creates should not corrupt sort_order.
|
||||||
|
8. **Site title env var**: With `HANDIN_SITE_TITLE=MyCourseName`, the default title should be "MyCourseName" (not "Untitled Site"). Once overridden via `PUT /api/site/`, the DB value takes precedence.
|
||||||
|
|
||||||
|
## Step 5: Visual Polish Check
|
||||||
|
|
||||||
|
Review the Tailwind classes in these components for consistency:
|
||||||
|
|
||||||
|
1. **Spacing**: All segment cards should have consistent padding (`p-6`), gap between cards (`space-y-6`).
|
||||||
|
2. **Colors**: Primary color usage should be consistent — `bg-primary-300` for brand, `primary-50`/`primary-100` for editor backgrounds, `primary-400` for hover states. The header bar keeps its yellow `bg-primary-300` in both light and dark mode (university branding).
|
||||||
|
3. **Typography**: Title bar uses `text-xl font-bold`, segment titles use `text-xl font-semibold`, body text uses default size.
|
||||||
|
4. **Responsive**: Tab bar hidden on mobile, mobile hamburger hidden on desktop. Content area is `max-w-6xl` centered.
|
||||||
|
5. **Shadows**: Segment cards use `shadow-md` (+ `dark:shadow-slate-950/30` in dark mode).
|
||||||
|
6. **Dark mode**: All components have `dark:` variant classes. Toggle is a sun/moon icon in the title bar. Uses class-based dark mode via `@custom-variant dark (&:where(.dark, .dark *))` in style.css. Preference stored in localStorage, falls back to system preference.
|
||||||
|
7. **Sticky header**: Entire header (title bar + nav) sticks to top on scroll. Back-to-top button appears after 300px scroll.
|
||||||
|
8. **Admin controls**: Edit pencil icons should be `text-slate-300 hover:text-primary-300` (muted by default). Drag handles should match.
|
||||||
|
9. **Header icons**: Lock icon and dark mode toggle use `text-slate-700` (no dark: overrides) since they sit on the always-yellow header.
|
||||||
|
|
||||||
|
## Verification Checklist
|
||||||
|
|
||||||
|
All of these must be true when you're done:
|
||||||
|
|
||||||
|
- [ ] `uv run pytest backend/tests/ -v` — all pass
|
||||||
|
- [ ] `uv run ruff check backend/` — clean
|
||||||
|
- [ ] `uv run mypy --strict backend/app/` — clean
|
||||||
|
- [ ] `cd frontend && npm run build` — zero errors
|
||||||
|
- [ ] `docker compose up --build` — builds and runs
|
||||||
|
- [ ] Dark mode toggle works, persists across reload, respects system preference on first visit
|
||||||
|
- [ ] Header bar stays yellow (`bg-primary-300`) in both light and dark mode
|
||||||
|
- [ ] Sticky header stays visible when scrolling, back-to-top button appears
|
||||||
|
- [ ] `HANDIN_SITE_TITLE` env var sets the default site title
|
||||||
|
- [ ] Read-only mode shows zero admin UI (only lock icon + dark mode toggle in header)
|
||||||
|
- [ ] Admin mode: all CRUD operations work
|
||||||
|
- [ ] Link segments appear in nav only
|
||||||
|
- [ ] Drag-and-drop reorder persists after refresh
|
||||||
|
|
||||||
|
## Files You May Modify
|
||||||
|
|
||||||
|
Any file in the project. Prefer minimal, targeted fixes. Do not refactor working code unnecessarily.
|
||||||
40
pyproject.toml
Normal file
40
pyproject.toml
Normal file
|
|
@ -0,0 +1,40 @@
|
||||||
|
[project]
|
||||||
|
name = "handin-website"
|
||||||
|
version = "0.1.0"
|
||||||
|
requires-python = ">=3.12"
|
||||||
|
dependencies = [
|
||||||
|
"fastapi==0.135.1",
|
||||||
|
"uvicorn[standard]==0.41.0",
|
||||||
|
"pydantic==2.12.5",
|
||||||
|
"pydantic-settings==2.13.1",
|
||||||
|
"python-multipart==0.0.22",
|
||||||
|
]
|
||||||
|
|
||||||
|
[dependency-groups]
|
||||||
|
dev = [
|
||||||
|
"pytest==9.0.2",
|
||||||
|
"httpx==0.28.1",
|
||||||
|
"ruff==0.15.4",
|
||||||
|
"mypy==1.19.1",
|
||||||
|
]
|
||||||
|
|
||||||
|
[tool.pytest.ini_options]
|
||||||
|
testpaths = ["backend/tests"]
|
||||||
|
|
||||||
|
[tool.ruff]
|
||||||
|
target-version = "py312"
|
||||||
|
line-length = 99
|
||||||
|
src = ["backend"]
|
||||||
|
|
||||||
|
[tool.ruff.lint]
|
||||||
|
select = ["E", "F", "W", "I", "UP", "B", "SIM", "RUF"]
|
||||||
|
|
||||||
|
[tool.ruff.lint.isort]
|
||||||
|
known-first-party = ["app"]
|
||||||
|
|
||||||
|
[tool.mypy]
|
||||||
|
strict = true
|
||||||
|
python_version = "3.12"
|
||||||
|
mypy_path = "backend"
|
||||||
|
packages = ["app"]
|
||||||
|
plugins = ["pydantic.mypy"]
|
||||||
1
ref/.github/FUNDING.yml
vendored
Normal file
1
ref/.github/FUNDING.yml
vendored
Normal file
|
|
@ -0,0 +1 @@
|
||||||
|
buy_me_a_coffee: justinzeus
|
||||||
14
ref/.github/dependabot.yml
vendored
Normal file
14
ref/.github/dependabot.yml
vendored
Normal file
|
|
@ -0,0 +1,14 @@
|
||||||
|
version: 2
|
||||||
|
updates:
|
||||||
|
- package-ecosystem: pip
|
||||||
|
directory: /
|
||||||
|
schedule:
|
||||||
|
interval: weekly
|
||||||
|
- package-ecosystem: npm
|
||||||
|
directory: /frontend
|
||||||
|
schedule:
|
||||||
|
interval: weekly
|
||||||
|
- package-ecosystem: github-actions
|
||||||
|
directory: /
|
||||||
|
schedule:
|
||||||
|
interval: weekly
|
||||||
BIN
ref/.github/logo-dark.png
vendored
Normal file
BIN
ref/.github/logo-dark.png
vendored
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 87 KiB |
197
ref/.github/workflows/ci.yml
vendored
Normal file
197
ref/.github/workflows/ci.yml
vendored
Normal file
|
|
@ -0,0 +1,197 @@
|
||||||
|
name: CI
|
||||||
|
|
||||||
|
on:
|
||||||
|
pull_request:
|
||||||
|
push:
|
||||||
|
branches:
|
||||||
|
- main
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
repo-hygiene:
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
|
||||||
|
steps:
|
||||||
|
- name: Checkout
|
||||||
|
uses: actions/checkout@v4
|
||||||
|
|
||||||
|
- name: Enforce no generated artifacts
|
||||||
|
run: ./scripts/check_no_generated_artifacts.sh
|
||||||
|
|
||||||
|
- name: Enforce env contract parity
|
||||||
|
run: python3 scripts/check_env_contract.py
|
||||||
|
|
||||||
|
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
|
||||||
|
- run: uv sync --extra dev
|
||||||
|
- name: Ruff check
|
||||||
|
run: uv run ruff check .
|
||||||
|
- name: Ruff format check
|
||||||
|
run: uv run ruff format --check .
|
||||||
|
- name: Mypy
|
||||||
|
run: uv run mypy app/ --ignore-missing-imports
|
||||||
|
|
||||||
|
test:
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
needs:
|
||||||
|
- repo-hygiene
|
||||||
|
- lint
|
||||||
|
services:
|
||||||
|
postgres:
|
||||||
|
image: postgres:15
|
||||||
|
env:
|
||||||
|
POSTGRES_DB: scholar
|
||||||
|
POSTGRES_USER: scholar
|
||||||
|
POSTGRES_PASSWORD: scholar
|
||||||
|
ports:
|
||||||
|
- 5432:5432
|
||||||
|
options: >-
|
||||||
|
--health-cmd "pg_isready -U scholar -d scholar"
|
||||||
|
--health-interval 5s
|
||||||
|
--health-timeout 5s
|
||||||
|
--health-retries 20
|
||||||
|
|
||||||
|
env:
|
||||||
|
DATABASE_URL: postgresql+asyncpg://scholar:scholar@localhost:5432/scholar
|
||||||
|
SCHOLAR_IMAGE_UPLOAD_DIR: /tmp/scholarr_uploads/scholar_images
|
||||||
|
|
||||||
|
steps:
|
||||||
|
- name: Checkout
|
||||||
|
uses: actions/checkout@v4
|
||||||
|
|
||||||
|
- name: Setup Python
|
||||||
|
uses: actions/setup-python@v5
|
||||||
|
with:
|
||||||
|
python-version: "3.12"
|
||||||
|
|
||||||
|
- name: Setup uv
|
||||||
|
uses: astral-sh/setup-uv@v4
|
||||||
|
|
||||||
|
- name: Install dependencies
|
||||||
|
run: uv sync --extra dev
|
||||||
|
|
||||||
|
- name: Migration smoke
|
||||||
|
run: uv run alembic upgrade head
|
||||||
|
|
||||||
|
- name: Unit tests
|
||||||
|
run: uv run pytest tests/unit
|
||||||
|
|
||||||
|
- name: Integration tests
|
||||||
|
run: uv run pytest -m integration
|
||||||
|
|
||||||
|
frontend-quality:
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
needs:
|
||||||
|
- repo-hygiene
|
||||||
|
|
||||||
|
steps:
|
||||||
|
- name: Checkout
|
||||||
|
uses: actions/checkout@v4
|
||||||
|
|
||||||
|
- name: Setup Python
|
||||||
|
uses: actions/setup-python@v5
|
||||||
|
with:
|
||||||
|
python-version: "3.12"
|
||||||
|
|
||||||
|
- name: API contract drift check
|
||||||
|
run: python3 scripts/check_frontend_api_contract.py
|
||||||
|
|
||||||
|
- name: Setup Node
|
||||||
|
uses: actions/setup-node@v4
|
||||||
|
with:
|
||||||
|
node-version: "20"
|
||||||
|
|
||||||
|
- name: Install frontend dependencies
|
||||||
|
working-directory: frontend
|
||||||
|
run: npm ci
|
||||||
|
|
||||||
|
- name: Frontend theme token policy
|
||||||
|
working-directory: frontend
|
||||||
|
run: npm run check:theme-tokens
|
||||||
|
|
||||||
|
- name: Frontend typecheck
|
||||||
|
working-directory: frontend
|
||||||
|
run: npm run typecheck
|
||||||
|
|
||||||
|
- name: Frontend unit tests
|
||||||
|
working-directory: frontend
|
||||||
|
run: npm run test:run
|
||||||
|
|
||||||
|
- name: Frontend build
|
||||||
|
working-directory: frontend
|
||||||
|
run: npm run build
|
||||||
|
|
||||||
|
docs-quality:
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
needs:
|
||||||
|
- repo-hygiene
|
||||||
|
|
||||||
|
steps:
|
||||||
|
- name: Checkout
|
||||||
|
uses: actions/checkout@v4
|
||||||
|
|
||||||
|
- name: Setup Node
|
||||||
|
uses: actions/setup-node@v4
|
||||||
|
with:
|
||||||
|
node-version: "20"
|
||||||
|
|
||||||
|
- name: Install docs dependencies
|
||||||
|
run: npm ci --prefix docs/website
|
||||||
|
|
||||||
|
- name: Docs build
|
||||||
|
run: npm --prefix docs/website run build
|
||||||
|
|
||||||
|
docker-publish:
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
needs:
|
||||||
|
- repo-hygiene
|
||||||
|
- test
|
||||||
|
- frontend-quality
|
||||||
|
- docs-quality
|
||||||
|
if: github.event_name == 'push' && github.ref == 'refs/heads/main'
|
||||||
|
permissions:
|
||||||
|
contents: read
|
||||||
|
|
||||||
|
steps:
|
||||||
|
- name: Checkout
|
||||||
|
uses: actions/checkout@v4
|
||||||
|
|
||||||
|
- name: Set up QEMU
|
||||||
|
uses: docker/setup-qemu-action@v3
|
||||||
|
|
||||||
|
- name: Set up Docker Buildx
|
||||||
|
uses: docker/setup-buildx-action@v3
|
||||||
|
|
||||||
|
- name: Login to Docker Hub
|
||||||
|
uses: docker/login-action@v3
|
||||||
|
with:
|
||||||
|
username: ${{ secrets.DOCKERHUB_USERNAME }}
|
||||||
|
password: ${{ secrets.DOCKERHUB_PASSWORD }}
|
||||||
|
|
||||||
|
- name: Derive image metadata
|
||||||
|
id: meta
|
||||||
|
uses: docker/metadata-action@v5
|
||||||
|
with:
|
||||||
|
images: justinzeus/scholarr
|
||||||
|
tags: |
|
||||||
|
type=raw,value=latest
|
||||||
|
type=sha,format=short,prefix=sha-
|
||||||
|
|
||||||
|
- name: Build and push multi-arch image
|
||||||
|
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
|
||||||
26
ref/.github/workflows/codeql.yml
vendored
Normal file
26
ref/.github/workflows/codeql.yml
vendored
Normal file
|
|
@ -0,0 +1,26 @@
|
||||||
|
name: CodeQL
|
||||||
|
|
||||||
|
on:
|
||||||
|
push:
|
||||||
|
branches: [main]
|
||||||
|
pull_request:
|
||||||
|
branches: [main]
|
||||||
|
schedule:
|
||||||
|
- cron: "30 5 * * 1"
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
analyze:
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
permissions:
|
||||||
|
security-events: write
|
||||||
|
contents: read
|
||||||
|
strategy:
|
||||||
|
matrix:
|
||||||
|
language: [python, javascript]
|
||||||
|
steps:
|
||||||
|
- uses: actions/checkout@v4
|
||||||
|
- uses: github/codeql-action/init@v3
|
||||||
|
with:
|
||||||
|
languages: ${{ matrix.language }}
|
||||||
|
- uses: github/codeql-action/autobuild@v3
|
||||||
|
- uses: github/codeql-action/analyze@v3
|
||||||
57
ref/.github/workflows/docs-pages.yml
vendored
Normal file
57
ref/.github/workflows/docs-pages.yml
vendored
Normal file
|
|
@ -0,0 +1,57 @@
|
||||||
|
name: Docs Pages
|
||||||
|
|
||||||
|
on:
|
||||||
|
workflow_dispatch:
|
||||||
|
push:
|
||||||
|
branches:
|
||||||
|
- main
|
||||||
|
paths:
|
||||||
|
- docs/**
|
||||||
|
- .github/workflows/docs-pages.yml
|
||||||
|
|
||||||
|
permissions:
|
||||||
|
contents: read
|
||||||
|
|
||||||
|
concurrency:
|
||||||
|
group: docs-pages
|
||||||
|
cancel-in-progress: true
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
build:
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
steps:
|
||||||
|
- name: Checkout
|
||||||
|
uses: actions/checkout@v4
|
||||||
|
|
||||||
|
- name: Setup Pages
|
||||||
|
uses: actions/configure-pages@v5
|
||||||
|
|
||||||
|
- name: Setup Node
|
||||||
|
uses: actions/setup-node@v4
|
||||||
|
with:
|
||||||
|
node-version: "20"
|
||||||
|
|
||||||
|
- name: Install docs dependencies
|
||||||
|
run: npm ci --prefix docs/website
|
||||||
|
|
||||||
|
- name: Build docs site
|
||||||
|
run: npm --prefix docs/website run build
|
||||||
|
|
||||||
|
- name: Upload pages artifact
|
||||||
|
uses: actions/upload-pages-artifact@v3
|
||||||
|
with:
|
||||||
|
path: docs/website/build
|
||||||
|
|
||||||
|
deploy:
|
||||||
|
needs: build
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
permissions:
|
||||||
|
pages: write
|
||||||
|
id-token: write
|
||||||
|
environment:
|
||||||
|
name: github-pages
|
||||||
|
url: ${{ steps.deployment.outputs.page_url }}
|
||||||
|
steps:
|
||||||
|
- name: Deploy to GitHub Pages
|
||||||
|
id: deployment
|
||||||
|
uses: actions/deploy-pages@v4
|
||||||
32
ref/.github/workflows/release.yml
vendored
Normal file
32
ref/.github/workflows/release.yml
vendored
Normal file
|
|
@ -0,0 +1,32 @@
|
||||||
|
name: Release
|
||||||
|
|
||||||
|
on:
|
||||||
|
push:
|
||||||
|
branches: [main]
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
release:
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
if: github.repository == 'JustinZeus/scholarr'
|
||||||
|
permissions:
|
||||||
|
contents: write
|
||||||
|
id-token: write
|
||||||
|
steps:
|
||||||
|
- uses: actions/checkout@v4
|
||||||
|
with:
|
||||||
|
fetch-depth: 0
|
||||||
|
token: ${{ secrets.GITHUB_TOKEN }}
|
||||||
|
- uses: actions/setup-python@v5
|
||||||
|
with:
|
||||||
|
python-version: "3.12"
|
||||||
|
- uses: astral-sh/setup-uv@v4
|
||||||
|
- run: uv sync --extra dev
|
||||||
|
- name: Lint
|
||||||
|
run: uv run ruff check
|
||||||
|
- name: Test
|
||||||
|
run: uv run pytest
|
||||||
|
- name: Semantic Release
|
||||||
|
id: release
|
||||||
|
run: uv run semantic-release publish
|
||||||
|
env:
|
||||||
|
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||||
54
ref/.github/workflows/scheduled-probes.yml
vendored
Normal file
54
ref/.github/workflows/scheduled-probes.yml
vendored
Normal file
|
|
@ -0,0 +1,54 @@
|
||||||
|
name: Scheduled Probes
|
||||||
|
|
||||||
|
on:
|
||||||
|
workflow_dispatch:
|
||||||
|
schedule:
|
||||||
|
- cron: "20 6 * * *"
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
fixture-probes:
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
services:
|
||||||
|
postgres:
|
||||||
|
image: postgres:15
|
||||||
|
env:
|
||||||
|
POSTGRES_DB: scholar
|
||||||
|
POSTGRES_USER: scholar
|
||||||
|
POSTGRES_PASSWORD: scholar
|
||||||
|
ports:
|
||||||
|
- 5432:5432
|
||||||
|
options: >-
|
||||||
|
--health-cmd "pg_isready -U scholar -d scholar"
|
||||||
|
--health-interval 5s
|
||||||
|
--health-timeout 5s
|
||||||
|
--health-retries 20
|
||||||
|
|
||||||
|
env:
|
||||||
|
DATABASE_URL: postgresql+asyncpg://scholar:scholar@localhost:5432/scholar
|
||||||
|
SCHOLAR_IMAGE_UPLOAD_DIR: /tmp/scholarr_uploads/scholar_images
|
||||||
|
|
||||||
|
steps:
|
||||||
|
- name: Checkout
|
||||||
|
uses: actions/checkout@v4
|
||||||
|
|
||||||
|
- name: Setup Python
|
||||||
|
uses: actions/setup-python@v5
|
||||||
|
with:
|
||||||
|
python-version: "3.12"
|
||||||
|
|
||||||
|
- name: Setup uv
|
||||||
|
uses: astral-sh/setup-uv@v4
|
||||||
|
|
||||||
|
- name: Install dependencies
|
||||||
|
run: uv sync --extra dev
|
||||||
|
|
||||||
|
- name: Fixture-backed parser unit probes
|
||||||
|
run: >-
|
||||||
|
uv run pytest
|
||||||
|
tests/unit/test_scholar_parser.py
|
||||||
|
tests/unit/test_scholar_search_safety.py
|
||||||
|
|
||||||
|
- name: Fixture-backed integration probe
|
||||||
|
run: >-
|
||||||
|
uv run pytest -m integration
|
||||||
|
tests/integration/test_fixture_probe_runs.py
|
||||||
67
ref/Dockerfile
Normal file
67
ref/Dockerfile
Normal file
|
|
@ -0,0 +1,67 @@
|
||||||
|
FROM node:20-alpine AS frontend-builder
|
||||||
|
|
||||||
|
WORKDIR /frontend
|
||||||
|
|
||||||
|
COPY frontend/package.json frontend/package-lock.json ./
|
||||||
|
RUN npm ci
|
||||||
|
|
||||||
|
COPY frontend/ ./
|
||||||
|
RUN npm run build
|
||||||
|
|
||||||
|
FROM python:3.12-slim AS base
|
||||||
|
|
||||||
|
ENV PYTHONDONTWRITEBYTECODE=1 \
|
||||||
|
PYTHONUNBUFFERED=1 \
|
||||||
|
UV_LINK_MODE=copy \
|
||||||
|
UV_PROJECT_ENVIRONMENT=/opt/venv \
|
||||||
|
PATH="/opt/venv/bin:$PATH"
|
||||||
|
|
||||||
|
WORKDIR /app
|
||||||
|
|
||||||
|
RUN apt-get update \
|
||||||
|
&& apt-get install -y --no-install-recommends build-essential curl \
|
||||||
|
&& rm -rf /var/lib/apt/lists/*
|
||||||
|
|
||||||
|
COPY --from=ghcr.io/astral-sh/uv:0.6.5 /uv /uvx /bin/
|
||||||
|
|
||||||
|
COPY pyproject.toml uv.lock README.md ./
|
||||||
|
COPY app ./app
|
||||||
|
COPY alembic.ini ./alembic.ini
|
||||||
|
COPY alembic ./alembic
|
||||||
|
COPY scripts ./scripts
|
||||||
|
|
||||||
|
RUN uv sync --frozen --extra dev
|
||||||
|
|
||||||
|
FROM base AS dev
|
||||||
|
ENTRYPOINT ["/bin/sh", "/app/scripts/entrypoint.sh"]
|
||||||
|
|
||||||
|
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" \
|
||||||
|
APP_RELOAD=0 \
|
||||||
|
UVICORN_WORKERS=1 \
|
||||||
|
FRONTEND_ENABLED=1 \
|
||||||
|
FRONTEND_DIST_DIR=/app/frontend/dist
|
||||||
|
|
||||||
|
WORKDIR /app
|
||||||
|
|
||||||
|
RUN apt-get update \
|
||||||
|
&& apt-get install -y --no-install-recommends curl \
|
||||||
|
&& rm -rf /var/lib/apt/lists/*
|
||||||
|
|
||||||
|
COPY --from=ghcr.io/astral-sh/uv:0.6.5 /uv /uvx /bin/
|
||||||
|
|
||||||
|
COPY pyproject.toml uv.lock README.md ./
|
||||||
|
COPY app ./app
|
||||||
|
COPY alembic.ini ./alembic.ini
|
||||||
|
COPY alembic ./alembic
|
||||||
|
|
||||||
|
RUN uv sync --frozen
|
||||||
|
|
||||||
|
COPY scripts ./scripts
|
||||||
|
COPY --from=frontend-builder /frontend/dist /app/frontend/dist
|
||||||
|
ENTRYPOINT ["/bin/sh", "/app/scripts/entrypoint.sh"]
|
||||||
149
ref/README.md
Normal file
149
ref/README.md
Normal file
|
|
@ -0,0 +1,149 @@
|
||||||
|
<div align="center">
|
||||||
|
|
||||||
|
<picture>
|
||||||
|
<source media="(prefers-color-scheme: dark)" srcset=".github/logo-dark.png" />
|
||||||
|
<source media="(prefers-color-scheme: light)" srcset="frontend/public/scholar_logo.png" />
|
||||||
|
<img src="frontend/public/scholar_logo.png" alt="Scholarr" width="120" />
|
||||||
|
</picture>
|
||||||
|
|
||||||
|
# Scholarr
|
||||||
|
|
||||||
|
**Self-hosted academic publication tracker.**
|
||||||
|
|
||||||
|
Track Google Scholar profiles, discover new papers automatically,
|
||||||
|
resolve open-access PDFs, and stay on top of the literature you care about.
|
||||||
|
|
||||||
|
[](https://github.com/JustinZeus/scholarr/actions/workflows/ci.yml)
|
||||||
|
[](https://github.com/JustinZeus/scholarr/actions/workflows/codeql.yml)
|
||||||
|
[](https://github.com/JustinZeus/scholarr/releases)
|
||||||
|
[](https://hub.docker.com/r/justinzeus/scholarr)
|
||||||
|
[](https://www.python.org/)
|
||||||
|
[](LICENSE)
|
||||||
|
[](https://justinzeus.github.io/scholarr/)
|
||||||
|
|
||||||
|
</div>
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
<!-- TODO: Replace these placeholders with actual screenshots.
|
||||||
|
Drop PNG/JPEG files into docs/assets/ and update the paths below.
|
||||||
|
|
||||||
|
Recommended screenshots:
|
||||||
|
1. Dashboard / publications list (light or dark mode)
|
||||||
|
2. Scholar profile view
|
||||||
|
3. Run detail with progress
|
||||||
|
|
||||||
|
Ideal dimensions: 1200-1400px wide, PNG or WebP.
|
||||||
|
-->
|
||||||
|
|
||||||
|
<p align="center">
|
||||||
|
<img src="docs/assets/screenshot-dashboard.png" alt="Publications dashboard" width="720" />
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<!--
|
||||||
|
<p align="center">
|
||||||
|
<img src="docs/assets/screenshot-scholars.png" alt="Scholar profiles" width="360" />
|
||||||
|
<img src="docs/assets/screenshot-run.png" alt="Ingestion run" width="360" />
|
||||||
|
</p>
|
||||||
|
-->
|
||||||
|
|
||||||
|
## Why Scholarr?
|
||||||
|
|
||||||
|
Most researchers track new papers by manually checking Google Scholar, setting up email alerts, or juggling RSS feeds. Scholarr replaces all of that with a single self-hosted service:
|
||||||
|
|
||||||
|
- **Add scholars once** -- by profile URL, Scholar ID, or name search
|
||||||
|
- **Publications appear automatically** -- a background scheduler scrapes profiles on a configurable interval
|
||||||
|
- **Open-access PDFs are resolved for you** -- Unpaywall and arXiv are queried automatically when a DOI is found
|
||||||
|
- **Everything is deduplicated** -- publications are global records; no duplicates across scholars
|
||||||
|
- **Your data stays yours** -- fully self-hosted, export/import your entire library at any time
|
||||||
|
|
||||||
|
## Features
|
||||||
|
|
||||||
|
| | |
|
||||||
|
|---|---|
|
||||||
|
| **Automated Ingestion** | Background scheduler with configurable intervals, continuation queue, and multi-page pagination |
|
||||||
|
| **Identifier Resolution** | Cross-references arXiv, Crossref, and OpenAlex to gather DOIs, arXiv IDs, PMIDs |
|
||||||
|
| **PDF Discovery** | Resolves open-access PDFs via Unpaywall API and arXiv, with automatic retry queue |
|
||||||
|
| **Scrape Safety** | Rate limiting, cooldowns, and backoff strategies that prevent IP bans -- these are safety floors, not optional |
|
||||||
|
| **Multi-User** | Session-based auth, admin user management, user-scoped scholar tracking |
|
||||||
|
| **Theming** | 7 color presets with light/dark mode, tokenized component system |
|
||||||
|
| **Import / Export** | Portable scholar data with full publication and read-state preservation |
|
||||||
|
| **Single Container** | FastAPI backend + Vue 3 frontend ship as one Docker image |
|
||||||
|
|
||||||
|
## Quick Start
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# 1. Clone and configure
|
||||||
|
git clone https://github.com/JustinZeus/scholarr.git
|
||||||
|
cd scholarr
|
||||||
|
cp .env.example .env
|
||||||
|
|
||||||
|
# 2. Set required secrets in .env
|
||||||
|
# POSTGRES_PASSWORD=<secure-password>
|
||||||
|
# SESSION_SECRET_KEY=<random-32-char-string>
|
||||||
|
|
||||||
|
# 3. Start
|
||||||
|
docker compose up -d
|
||||||
|
|
||||||
|
# 4. Open http://localhost:8000
|
||||||
|
```
|
||||||
|
|
||||||
|
To bootstrap an admin account on first run, add to `.env`:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
BOOTSTRAP_ADMIN_ON_START=1
|
||||||
|
BOOTSTRAP_ADMIN_EMAIL=admin@example.com
|
||||||
|
BOOTSTRAP_ADMIN_PASSWORD=<secure-password>
|
||||||
|
```
|
||||||
|
|
||||||
|
## How It Works
|
||||||
|
|
||||||
|
```mermaid
|
||||||
|
graph LR
|
||||||
|
UI[Vue 3 Dashboard] <-->|REST + SSE| API[FastAPI]
|
||||||
|
API --> Scheduler[Scheduler]
|
||||||
|
Scheduler -->|Scrape HTML| Scholar[Google Scholar]
|
||||||
|
Scholar -->|Parse & Deduplicate| DB[(PostgreSQL)]
|
||||||
|
Scholar -.->|Identify| Ext[arXiv / Crossref / OpenAlex]
|
||||||
|
Ext --> DB
|
||||||
|
DB -->|DOIs| PDF[PDF Resolution]
|
||||||
|
PDF -->|Unpaywall / arXiv| DB
|
||||||
|
API <--> DB
|
||||||
|
```
|
||||||
|
|
||||||
|
## Tech Stack
|
||||||
|
|
||||||
|
| Layer | Technology |
|
||||||
|
|-------|------------|
|
||||||
|
| Backend | Python 3.12, FastAPI, SQLAlchemy 2.0 (async), Alembic |
|
||||||
|
| Frontend | TypeScript, Vue 3, Vite, Tailwind CSS |
|
||||||
|
| Database | PostgreSQL 15 |
|
||||||
|
| Infrastructure | Multi-stage Docker, Docker Compose |
|
||||||
|
|
||||||
|
## Documentation
|
||||||
|
|
||||||
|
Full documentation: **[justinzeus.github.io/scholarr](https://justinzeus.github.io/scholarr/)**
|
||||||
|
|
||||||
|
| Section | Covers |
|
||||||
|
|---------|--------|
|
||||||
|
| [User Guide](docs/user/overview.md) | Installation, configuration, all environment variables |
|
||||||
|
| [Developer Guide](docs/developer/overview.md) | Architecture, local dev, contributing, testing |
|
||||||
|
| [Operations](docs/operations/overview.md) | Deployment, database runbook, scrape safety |
|
||||||
|
| [API Reference](docs/reference/api.md) | Envelope spec, all endpoints, DTO contracts |
|
||||||
|
|
||||||
|
## Contributing
|
||||||
|
|
||||||
|
Scholarr uses [conventional commits](https://www.conventionalcommits.org/) and [semantic versioning](https://semver.org/). See the [contributing guide](docs/developer/contributing.md) for PR process and code standards.
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Dev environment
|
||||||
|
docker compose -f docker-compose.yml -f docker-compose.dev.yml up --build
|
||||||
|
|
||||||
|
# Run tests (always in containers)
|
||||||
|
docker compose -f docker-compose.yml -f docker-compose.dev.yml run --rm app \
|
||||||
|
python -m pytest
|
||||||
|
```
|
||||||
|
|
||||||
|
## License
|
||||||
|
|
||||||
|
See [LICENSE](LICENSE) for details.
|
||||||
8
scripts/entrypoint.sh
Executable file
8
scripts/entrypoint.sh
Executable file
|
|
@ -0,0 +1,8 @@
|
||||||
|
#!/bin/sh
|
||||||
|
set -e
|
||||||
|
|
||||||
|
exec uvicorn app.main:app \
|
||||||
|
--host 0.0.0.0 \
|
||||||
|
--port "${PORT:-8000}" \
|
||||||
|
--app-dir backend \
|
||||||
|
"$@"
|
||||||
749
uv.lock
generated
Normal file
749
uv.lock
generated
Normal file
|
|
@ -0,0 +1,749 @@
|
||||||
|
version = 1
|
||||||
|
revision = 3
|
||||||
|
requires-python = ">=3.12"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "annotated-doc"
|
||||||
|
version = "0.0.4"
|
||||||
|
source = { registry = "https://pypi.org/simple" }
|
||||||
|
sdist = { url = "https://files.pythonhosted.org/packages/57/ba/046ceea27344560984e26a590f90bc7f4a75b06701f653222458922b558c/annotated_doc-0.0.4.tar.gz", hash = "sha256:fbcda96e87e9c92ad167c2e53839e57503ecfda18804ea28102353485033faa4", size = 7288, upload-time = "2025-11-10T22:07:42.062Z" }
|
||||||
|
wheels = [
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/1e/d3/26bf1008eb3d2daa8ef4cacc7f3bfdc11818d111f7e2d0201bc6e3b49d45/annotated_doc-0.0.4-py3-none-any.whl", hash = "sha256:571ac1dc6991c450b25a9c2d84a3705e2ae7a53467b5d111c24fa8baabbed320", size = 5303, upload-time = "2025-11-10T22:07:40.673Z" },
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "annotated-types"
|
||||||
|
version = "0.7.0"
|
||||||
|
source = { registry = "https://pypi.org/simple" }
|
||||||
|
sdist = { url = "https://files.pythonhosted.org/packages/ee/67/531ea369ba64dcff5ec9c3402f9f51bf748cec26dde048a2f973a4eea7f5/annotated_types-0.7.0.tar.gz", hash = "sha256:aff07c09a53a08bc8cfccb9c85b05f1aa9a2a6f23728d790723543408344ce89", size = 16081, upload-time = "2024-05-20T21:33:25.928Z" }
|
||||||
|
wheels = [
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/78/b6/6307fbef88d9b5ee7421e68d78a9f162e0da4900bc5f5793f6d3d0e34fb8/annotated_types-0.7.0-py3-none-any.whl", hash = "sha256:1f02e8b43a8fbbc3f3e0d4f0f4bfc8131bcb4eebe8849b8e5c773f3a1c582a53", size = 13643, upload-time = "2024-05-20T21:33:24.1Z" },
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "anyio"
|
||||||
|
version = "4.12.1"
|
||||||
|
source = { registry = "https://pypi.org/simple" }
|
||||||
|
dependencies = [
|
||||||
|
{ name = "idna" },
|
||||||
|
{ name = "typing-extensions", marker = "python_full_version < '3.13'" },
|
||||||
|
]
|
||||||
|
sdist = { url = "https://files.pythonhosted.org/packages/96/f0/5eb65b2bb0d09ac6776f2eb54adee6abe8228ea05b20a5ad0e4945de8aac/anyio-4.12.1.tar.gz", hash = "sha256:41cfcc3a4c85d3f05c932da7c26d0201ac36f72abd4435ba90d0464a3ffed703", size = 228685, upload-time = "2026-01-06T11:45:21.246Z" }
|
||||||
|
wheels = [
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/38/0e/27be9fdef66e72d64c0cdc3cc2823101b80585f8119b5c112c2e8f5f7dab/anyio-4.12.1-py3-none-any.whl", hash = "sha256:d405828884fc140aa80a3c667b8beed277f1dfedec42ba031bd6ac3db606ab6c", size = 113592, upload-time = "2026-01-06T11:45:19.497Z" },
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "certifi"
|
||||||
|
version = "2026.2.25"
|
||||||
|
source = { registry = "https://pypi.org/simple" }
|
||||||
|
sdist = { url = "https://files.pythonhosted.org/packages/af/2d/7bf41579a8986e348fa033a31cdd0e4121114f6bce2457e8876010b092dd/certifi-2026.2.25.tar.gz", hash = "sha256:e887ab5cee78ea814d3472169153c2d12cd43b14bd03329a39a9c6e2e80bfba7", size = 155029, upload-time = "2026-02-25T02:54:17.342Z" }
|
||||||
|
wheels = [
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/9a/3c/c17fb3ca2d9c3acff52e30b309f538586f9f5b9c9cf454f3845fc9af4881/certifi-2026.2.25-py3-none-any.whl", hash = "sha256:027692e4402ad994f1c42e52a4997a9763c646b73e4096e4d5d6db8af1d6f0fa", size = 153684, upload-time = "2026-02-25T02:54:15.766Z" },
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "click"
|
||||||
|
version = "8.3.1"
|
||||||
|
source = { registry = "https://pypi.org/simple" }
|
||||||
|
dependencies = [
|
||||||
|
{ name = "colorama", marker = "sys_platform == 'win32'" },
|
||||||
|
]
|
||||||
|
sdist = { url = "https://files.pythonhosted.org/packages/3d/fa/656b739db8587d7b5dfa22e22ed02566950fbfbcdc20311993483657a5c0/click-8.3.1.tar.gz", hash = "sha256:12ff4785d337a1bb490bb7e9c2b1ee5da3112e94a8622f26a6c77f5d2fc6842a", size = 295065, upload-time = "2025-11-15T20:45:42.706Z" }
|
||||||
|
wheels = [
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/98/78/01c019cdb5d6498122777c1a43056ebb3ebfeef2076d9d026bfe15583b2b/click-8.3.1-py3-none-any.whl", hash = "sha256:981153a64e25f12d547d3426c367a4857371575ee7ad18df2a6183ab0545b2a6", size = 108274, upload-time = "2025-11-15T20:45:41.139Z" },
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "colorama"
|
||||||
|
version = "0.4.6"
|
||||||
|
source = { registry = "https://pypi.org/simple" }
|
||||||
|
sdist = { url = "https://files.pythonhosted.org/packages/d8/53/6f443c9a4a8358a93a6792e2acffb9d9d5cb0a5cfd8802644b7b1c9a02e4/colorama-0.4.6.tar.gz", hash = "sha256:08695f5cb7ed6e0531a20572697297273c47b8cae5a63ffc6d6ed5c201be6e44", size = 27697, upload-time = "2022-10-25T02:36:22.414Z" }
|
||||||
|
wheels = [
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/d1/d6/3965ed04c63042e047cb6a3e6ed1a63a35087b6a609aa3a15ed8ac56c221/colorama-0.4.6-py2.py3-none-any.whl", hash = "sha256:4f1d9991f5acc0ca119f9d443620b77f9d6b33703e51011c16baf57afb285fc6", size = 25335, upload-time = "2022-10-25T02:36:20.889Z" },
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "fastapi"
|
||||||
|
version = "0.135.1"
|
||||||
|
source = { registry = "https://pypi.org/simple" }
|
||||||
|
dependencies = [
|
||||||
|
{ name = "annotated-doc" },
|
||||||
|
{ name = "pydantic" },
|
||||||
|
{ name = "starlette" },
|
||||||
|
{ name = "typing-extensions" },
|
||||||
|
{ name = "typing-inspection" },
|
||||||
|
]
|
||||||
|
sdist = { url = "https://files.pythonhosted.org/packages/e7/7b/f8e0211e9380f7195ba3f3d40c292594fd81ba8ec4629e3854c353aaca45/fastapi-0.135.1.tar.gz", hash = "sha256:d04115b508d936d254cea545b7312ecaa58a7b3a0f84952535b4c9afae7668cd", size = 394962, upload-time = "2026-03-01T18:18:29.369Z" }
|
||||||
|
wheels = [
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/e4/72/42e900510195b23a56bde950d26a51f8b723846bfcaa0286e90287f0422b/fastapi-0.135.1-py3-none-any.whl", hash = "sha256:46e2fc5745924b7c840f71ddd277382af29ce1cdb7d5eab5bf697e3fb9999c9e", size = 116999, upload-time = "2026-03-01T18:18:30.831Z" },
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "h11"
|
||||||
|
version = "0.16.0"
|
||||||
|
source = { registry = "https://pypi.org/simple" }
|
||||||
|
sdist = { url = "https://files.pythonhosted.org/packages/01/ee/02a2c011bdab74c6fb3c75474d40b3052059d95df7e73351460c8588d963/h11-0.16.0.tar.gz", hash = "sha256:4e35b956cf45792e4caa5885e69fba00bdbc6ffafbfa020300e549b208ee5ff1", size = 101250, upload-time = "2025-04-24T03:35:25.427Z" }
|
||||||
|
wheels = [
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/04/4b/29cac41a4d98d144bf5f6d33995617b185d14b22401f75ca86f384e87ff1/h11-0.16.0-py3-none-any.whl", hash = "sha256:63cf8bbe7522de3bf65932fda1d9c2772064ffb3dae62d55932da54b31cb6c86", size = 37515, upload-time = "2025-04-24T03:35:24.344Z" },
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "handin-website"
|
||||||
|
version = "0.1.0"
|
||||||
|
source = { virtual = "." }
|
||||||
|
dependencies = [
|
||||||
|
{ name = "fastapi" },
|
||||||
|
{ name = "pydantic" },
|
||||||
|
{ name = "pydantic-settings" },
|
||||||
|
{ name = "python-multipart" },
|
||||||
|
{ name = "uvicorn", extra = ["standard"] },
|
||||||
|
]
|
||||||
|
|
||||||
|
[package.dev-dependencies]
|
||||||
|
dev = [
|
||||||
|
{ name = "httpx" },
|
||||||
|
{ name = "mypy" },
|
||||||
|
{ name = "pytest" },
|
||||||
|
{ name = "ruff" },
|
||||||
|
]
|
||||||
|
|
||||||
|
[package.metadata]
|
||||||
|
requires-dist = [
|
||||||
|
{ name = "fastapi", specifier = "==0.135.1" },
|
||||||
|
{ name = "pydantic", specifier = "==2.12.5" },
|
||||||
|
{ name = "pydantic-settings", specifier = "==2.13.1" },
|
||||||
|
{ name = "python-multipart", specifier = "==0.0.22" },
|
||||||
|
{ name = "uvicorn", extras = ["standard"], specifier = "==0.41.0" },
|
||||||
|
]
|
||||||
|
|
||||||
|
[package.metadata.requires-dev]
|
||||||
|
dev = [
|
||||||
|
{ name = "httpx", specifier = "==0.28.1" },
|
||||||
|
{ name = "mypy", specifier = "==1.19.1" },
|
||||||
|
{ name = "pytest", specifier = "==9.0.2" },
|
||||||
|
{ name = "ruff", specifier = "==0.15.4" },
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "httpcore"
|
||||||
|
version = "1.0.9"
|
||||||
|
source = { registry = "https://pypi.org/simple" }
|
||||||
|
dependencies = [
|
||||||
|
{ name = "certifi" },
|
||||||
|
{ name = "h11" },
|
||||||
|
]
|
||||||
|
sdist = { url = "https://files.pythonhosted.org/packages/06/94/82699a10bca87a5556c9c59b5963f2d039dbd239f25bc2a63907a05a14cb/httpcore-1.0.9.tar.gz", hash = "sha256:6e34463af53fd2ab5d807f399a9b45ea31c3dfa2276f15a2c3f00afff6e176e8", size = 85484, upload-time = "2025-04-24T22:06:22.219Z" }
|
||||||
|
wheels = [
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/7e/f5/f66802a942d491edb555dd61e3a9961140fd64c90bce1eafd741609d334d/httpcore-1.0.9-py3-none-any.whl", hash = "sha256:2d400746a40668fc9dec9810239072b40b4484b640a8c38fd654a024c7a1bf55", size = 78784, upload-time = "2025-04-24T22:06:20.566Z" },
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "httptools"
|
||||||
|
version = "0.7.1"
|
||||||
|
source = { registry = "https://pypi.org/simple" }
|
||||||
|
sdist = { url = "https://files.pythonhosted.org/packages/b5/46/120a669232c7bdedb9d52d4aeae7e6c7dfe151e99dc70802e2fc7a5e1993/httptools-0.7.1.tar.gz", hash = "sha256:abd72556974f8e7c74a259655924a717a2365b236c882c3f6f8a45fe94703ac9", size = 258961, upload-time = "2025-10-10T03:55:08.559Z" }
|
||||||
|
wheels = [
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/53/7f/403e5d787dc4942316e515e949b0c8a013d84078a915910e9f391ba9b3ed/httptools-0.7.1-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:38e0c83a2ea9746ebbd643bdfb521b9aa4a91703e2cd705c20443405d2fd16a5", size = 206280, upload-time = "2025-10-10T03:54:39.274Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/2a/0d/7f3fd28e2ce311ccc998c388dd1c53b18120fda3b70ebb022b135dc9839b/httptools-0.7.1-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:f25bbaf1235e27704f1a7b86cd3304eabc04f569c828101d94a0e605ef7205a5", size = 110004, upload-time = "2025-10-10T03:54:40.403Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/84/a6/b3965e1e146ef5762870bbe76117876ceba51a201e18cc31f5703e454596/httptools-0.7.1-cp312-cp312-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:2c15f37ef679ab9ecc06bfc4e6e8628c32a8e4b305459de7cf6785acd57e4d03", size = 517655, upload-time = "2025-10-10T03:54:41.347Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/11/7d/71fee6f1844e6fa378f2eddde6c3e41ce3a1fb4b2d81118dd544e3441ec0/httptools-0.7.1-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:7fe6e96090df46b36ccfaf746f03034e5ab723162bc51b0a4cf58305324036f2", size = 511440, upload-time = "2025-10-10T03:54:42.452Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/22/a5/079d216712a4f3ffa24af4a0381b108aa9c45b7a5cc6eb141f81726b1823/httptools-0.7.1-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:f72fdbae2dbc6e68b8239defb48e6a5937b12218e6ffc2c7846cc37befa84362", size = 495186, upload-time = "2025-10-10T03:54:43.937Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/e9/9e/025ad7b65278745dee3bd0ebf9314934c4592560878308a6121f7f812084/httptools-0.7.1-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:e99c7b90a29fd82fea9ef57943d501a16f3404d7b9ee81799d41639bdaae412c", size = 499192, upload-time = "2025-10-10T03:54:45.003Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/6d/de/40a8f202b987d43afc4d54689600ff03ce65680ede2f31df348d7f368b8f/httptools-0.7.1-cp312-cp312-win_amd64.whl", hash = "sha256:3e14f530fefa7499334a79b0cf7e7cd2992870eb893526fb097d51b4f2d0f321", size = 86694, upload-time = "2025-10-10T03:54:45.923Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/09/8f/c77b1fcbfd262d422f12da02feb0d218fa228d52485b77b953832105bb90/httptools-0.7.1-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:6babce6cfa2a99545c60bfef8bee0cc0545413cb0018f617c8059a30ad985de3", size = 202889, upload-time = "2025-10-10T03:54:47.089Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/0a/1a/22887f53602feaa066354867bc49a68fc295c2293433177ee90870a7d517/httptools-0.7.1-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:601b7628de7504077dd3dcb3791c6b8694bbd967148a6d1f01806509254fb1ca", size = 108180, upload-time = "2025-10-10T03:54:48.052Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/32/6a/6aaa91937f0010d288d3d124ca2946d48d60c3a5ee7ca62afe870e3ea011/httptools-0.7.1-cp313-cp313-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:04c6c0e6c5fb0739c5b8a9eb046d298650a0ff38cf42537fc372b28dc7e4472c", size = 478596, upload-time = "2025-10-10T03:54:48.919Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/6d/70/023d7ce117993107be88d2cbca566a7c1323ccbaf0af7eabf2064fe356f6/httptools-0.7.1-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:69d4f9705c405ae3ee83d6a12283dc9feba8cc6aaec671b412917e644ab4fa66", size = 473268, upload-time = "2025-10-10T03:54:49.993Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/32/4d/9dd616c38da088e3f436e9a616e1d0cc66544b8cdac405cc4e81c8679fc7/httptools-0.7.1-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:44c8f4347d4b31269c8a9205d8a5ee2df5322b09bbbd30f8f862185bb6b05346", size = 455517, upload-time = "2025-10-10T03:54:51.066Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/1d/3a/a6c595c310b7df958e739aae88724e24f9246a514d909547778d776799be/httptools-0.7.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:465275d76db4d554918aba40bf1cbebe324670f3dfc979eaffaa5d108e2ed650", size = 458337, upload-time = "2025-10-10T03:54:52.196Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/fd/82/88e8d6d2c51edc1cc391b6e044c6c435b6aebe97b1abc33db1b0b24cd582/httptools-0.7.1-cp313-cp313-win_amd64.whl", hash = "sha256:322d00c2068d125bd570f7bf78b2d367dad02b919d8581d7476d8b75b294e3e6", size = 85743, upload-time = "2025-10-10T03:54:53.448Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/34/50/9d095fcbb6de2d523e027a2f304d4551855c2f46e0b82befd718b8b20056/httptools-0.7.1-cp314-cp314-macosx_10_13_universal2.whl", hash = "sha256:c08fe65728b8d70b6923ce31e3956f859d5e1e8548e6f22ec520a962c6757270", size = 203619, upload-time = "2025-10-10T03:54:54.321Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/07/f0/89720dc5139ae54b03f861b5e2c55a37dba9a5da7d51e1e824a1f343627f/httptools-0.7.1-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:7aea2e3c3953521c3c51106ee11487a910d45586e351202474d45472db7d72d3", size = 108714, upload-time = "2025-10-10T03:54:55.163Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/b3/cb/eea88506f191fb552c11787c23f9a405f4c7b0c5799bf73f2249cd4f5228/httptools-0.7.1-cp314-cp314-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:0e68b8582f4ea9166be62926077a3334064d422cf08ab87d8b74664f8e9058e1", size = 472909, upload-time = "2025-10-10T03:54:56.056Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/e0/4a/a548bdfae6369c0d078bab5769f7b66f17f1bfaa6fa28f81d6be6959066b/httptools-0.7.1-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:df091cf961a3be783d6aebae963cc9b71e00d57fa6f149025075217bc6a55a7b", size = 470831, upload-time = "2025-10-10T03:54:57.219Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/4d/31/14df99e1c43bd132eec921c2e7e11cda7852f65619bc0fc5bdc2d0cb126c/httptools-0.7.1-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:f084813239e1eb403ddacd06a30de3d3e09a9b76e7894dcda2b22f8a726e9c60", size = 452631, upload-time = "2025-10-10T03:54:58.219Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/22/d2/b7e131f7be8d854d48cb6d048113c30f9a46dca0c9a8b08fcb3fcd588cdc/httptools-0.7.1-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:7347714368fb2b335e9063bc2b96f2f87a9ceffcd9758ac295f8bbcd3ffbc0ca", size = 452910, upload-time = "2025-10-10T03:54:59.366Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/53/cf/878f3b91e4e6e011eff6d1fa9ca39f7eb17d19c9d7971b04873734112f30/httptools-0.7.1-cp314-cp314-win_amd64.whl", hash = "sha256:cfabda2a5bb85aa2a904ce06d974a3f30fb36cc63d7feaddec05d2050acede96", size = 88205, upload-time = "2025-10-10T03:55:00.389Z" },
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "httpx"
|
||||||
|
version = "0.28.1"
|
||||||
|
source = { registry = "https://pypi.org/simple" }
|
||||||
|
dependencies = [
|
||||||
|
{ name = "anyio" },
|
||||||
|
{ name = "certifi" },
|
||||||
|
{ name = "httpcore" },
|
||||||
|
{ name = "idna" },
|
||||||
|
]
|
||||||
|
sdist = { url = "https://files.pythonhosted.org/packages/b1/df/48c586a5fe32a0f01324ee087459e112ebb7224f646c0b5023f5e79e9956/httpx-0.28.1.tar.gz", hash = "sha256:75e98c5f16b0f35b567856f597f06ff2270a374470a5c2392242528e3e3e42fc", size = 141406, upload-time = "2024-12-06T15:37:23.222Z" }
|
||||||
|
wheels = [
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/2a/39/e50c7c3a983047577ee07d2a9e53faf5a69493943ec3f6a384bdc792deb2/httpx-0.28.1-py3-none-any.whl", hash = "sha256:d909fcccc110f8c7faf814ca82a9a4d816bc5a6dbfea25d6591d6985b8ba59ad", size = 73517, upload-time = "2024-12-06T15:37:21.509Z" },
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "idna"
|
||||||
|
version = "3.11"
|
||||||
|
source = { registry = "https://pypi.org/simple" }
|
||||||
|
sdist = { url = "https://files.pythonhosted.org/packages/6f/6d/0703ccc57f3a7233505399edb88de3cbd678da106337b9fcde432b65ed60/idna-3.11.tar.gz", hash = "sha256:795dafcc9c04ed0c1fb032c2aa73654d8e8c5023a7df64a53f39190ada629902", size = 194582, upload-time = "2025-10-12T14:55:20.501Z" }
|
||||||
|
wheels = [
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/0e/61/66938bbb5fc52dbdf84594873d5b51fb1f7c7794e9c0f5bd885f30bc507b/idna-3.11-py3-none-any.whl", hash = "sha256:771a87f49d9defaf64091e6e6fe9c18d4833f140bd19464795bc32d966ca37ea", size = 71008, upload-time = "2025-10-12T14:55:18.883Z" },
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "iniconfig"
|
||||||
|
version = "2.3.0"
|
||||||
|
source = { registry = "https://pypi.org/simple" }
|
||||||
|
sdist = { url = "https://files.pythonhosted.org/packages/72/34/14ca021ce8e5dfedc35312d08ba8bf51fdd999c576889fc2c24cb97f4f10/iniconfig-2.3.0.tar.gz", hash = "sha256:c76315c77db068650d49c5b56314774a7804df16fee4402c1f19d6d15d8c4730", size = 20503, upload-time = "2025-10-18T21:55:43.219Z" }
|
||||||
|
wheels = [
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/cb/b1/3846dd7f199d53cb17f49cba7e651e9ce294d8497c8c150530ed11865bb8/iniconfig-2.3.0-py3-none-any.whl", hash = "sha256:f631c04d2c48c52b84d0d0549c99ff3859c98df65b3101406327ecc7d53fbf12", size = 7484, upload-time = "2025-10-18T21:55:41.639Z" },
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "librt"
|
||||||
|
version = "0.8.1"
|
||||||
|
source = { registry = "https://pypi.org/simple" }
|
||||||
|
sdist = { url = "https://files.pythonhosted.org/packages/56/9c/b4b0c54d84da4a94b37bd44151e46d5e583c9534c7e02250b961b1b6d8a8/librt-0.8.1.tar.gz", hash = "sha256:be46a14693955b3bd96014ccbdb8339ee8c9346fbe11c1b78901b55125f14c73", size = 177471, upload-time = "2026-02-17T16:13:06.101Z" }
|
||||||
|
wheels = [
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/95/21/d39b0a87ac52fc98f621fb6f8060efb017a767ebbbac2f99fbcbc9ddc0d7/librt-0.8.1-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:a28f2612ab566b17f3698b0da021ff9960610301607c9a5e8eaca62f5e1c350a", size = 66516, upload-time = "2026-02-17T16:11:41.604Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/69/f1/46375e71441c43e8ae335905e069f1c54febee63a146278bcee8782c84fd/librt-0.8.1-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:60a78b694c9aee2a0f1aaeaa7d101cf713e92e8423a941d2897f4fa37908dab9", size = 68634, upload-time = "2026-02-17T16:11:43.268Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/0a/33/c510de7f93bf1fa19e13423a606d8189a02624a800710f6e6a0a0f0784b3/librt-0.8.1-cp312-cp312-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:758509ea3f1eba2a57558e7e98f4659d0ea7670bff49673b0dde18a3c7e6c0eb", size = 198941, upload-time = "2026-02-17T16:11:44.28Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/dd/36/e725903416409a533d92398e88ce665476f275081d0d7d42f9c4951999e5/librt-0.8.1-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:039b9f2c506bd0ab0f8725aa5ba339c6f0cd19d3b514b50d134789809c24285d", size = 209991, upload-time = "2026-02-17T16:11:45.462Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/30/7a/8d908a152e1875c9f8eac96c97a480df425e657cdb47854b9efaa4998889/librt-0.8.1-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:5bb54f1205a3a6ab41a6fd71dfcdcbd278670d3a90ca502a30d9da583105b6f7", size = 224476, upload-time = "2026-02-17T16:11:46.542Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/a8/b8/a22c34f2c485b8903a06f3fe3315341fe6876ef3599792344669db98fcff/librt-0.8.1-cp312-cp312-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:05bd41cdee35b0c59c259f870f6da532a2c5ca57db95b5f23689fcb5c9e42440", size = 217518, upload-time = "2026-02-17T16:11:47.746Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/79/6f/5c6fea00357e4f82ba44f81dbfb027921f1ab10e320d4a64e1c408d035d9/librt-0.8.1-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:adfab487facf03f0d0857b8710cf82d0704a309d8ffc33b03d9302b4c64e91a9", size = 225116, upload-time = "2026-02-17T16:11:49.298Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/f2/a0/95ced4e7b1267fe1e2720a111685bcddf0e781f7e9e0ce59d751c44dcfe5/librt-0.8.1-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:153188fe98a72f206042be10a2c6026139852805215ed9539186312d50a8e972", size = 217751, upload-time = "2026-02-17T16:11:50.49Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/93/c2/0517281cb4d4101c27ab59472924e67f55e375bc46bedae94ac6dc6e1902/librt-0.8.1-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:dd3c41254ee98604b08bd5b3af5bf0a89740d4ee0711de95b65166bf44091921", size = 218378, upload-time = "2026-02-17T16:11:51.783Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/43/e8/37b3ac108e8976888e559a7b227d0ceac03c384cfd3e7a1c2ee248dbae79/librt-0.8.1-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:e0d138c7ae532908cbb342162b2611dbd4d90c941cd25ab82084aaf71d2c0bd0", size = 241199, upload-time = "2026-02-17T16:11:53.561Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/4b/5b/35812d041c53967fedf551a39399271bbe4257e681236a2cf1a69c8e7fa1/librt-0.8.1-cp312-cp312-win32.whl", hash = "sha256:43353b943613c5d9c49a25aaffdba46f888ec354e71e3529a00cca3f04d66a7a", size = 54917, upload-time = "2026-02-17T16:11:54.758Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/de/d1/fa5d5331b862b9775aaf2a100f5ef86854e5d4407f71bddf102f4421e034/librt-0.8.1-cp312-cp312-win_amd64.whl", hash = "sha256:ff8baf1f8d3f4b6b7257fcb75a501f2a5499d0dda57645baa09d4d0d34b19444", size = 62017, upload-time = "2026-02-17T16:11:55.748Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/c7/7c/c614252f9acda59b01a66e2ddfd243ed1c7e1deab0293332dfbccf862808/librt-0.8.1-cp312-cp312-win_arm64.whl", hash = "sha256:0f2ae3725904f7377e11cc37722d5d401e8b3d5851fb9273d7f4fe04f6b3d37d", size = 52441, upload-time = "2026-02-17T16:11:56.801Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/c5/3c/f614c8e4eaac7cbf2bbdf9528790b21d89e277ee20d57dc6e559c626105f/librt-0.8.1-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:7e6bad1cd94f6764e1e21950542f818a09316645337fd5ab9a7acc45d99a8f35", size = 66529, upload-time = "2026-02-17T16:11:57.809Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/ab/96/5836544a45100ae411eda07d29e3d99448e5258b6e9c8059deb92945f5c2/librt-0.8.1-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:cf450f498c30af55551ba4f66b9123b7185362ec8b625a773b3d39aa1a717583", size = 68669, upload-time = "2026-02-17T16:11:58.843Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/06/53/f0b992b57af6d5531bf4677d75c44f095f2366a1741fb695ee462ae04b05/librt-0.8.1-cp313-cp313-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:eca45e982fa074090057132e30585a7e8674e9e885d402eae85633e9f449ce6c", size = 199279, upload-time = "2026-02-17T16:11:59.862Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/f3/ad/4848cc16e268d14280d8168aee4f31cea92bbd2b79ce33d3e166f2b4e4fc/librt-0.8.1-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:0c3811485fccfda840861905b8c70bba5ec094e02825598bb9d4ca3936857a04", size = 210288, upload-time = "2026-02-17T16:12:00.954Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/52/05/27fdc2e95de26273d83b96742d8d3b7345f2ea2bdbd2405cc504644f2096/librt-0.8.1-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:5e4af413908f77294605e28cfd98063f54b2c790561383971d2f52d113d9c363", size = 224809, upload-time = "2026-02-17T16:12:02.108Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/7a/d0/78200a45ba3240cb042bc597d6f2accba9193a2c57d0356268cbbe2d0925/librt-0.8.1-cp313-cp313-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:5212a5bd7fae98dae95710032902edcd2ec4dc994e883294f75c857b83f9aba0", size = 218075, upload-time = "2026-02-17T16:12:03.631Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/af/72/a210839fa74c90474897124c064ffca07f8d4b347b6574d309686aae7ca6/librt-0.8.1-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:e692aa2d1d604e6ca12d35e51fdc36f4cda6345e28e36374579f7ef3611b3012", size = 225486, upload-time = "2026-02-17T16:12:04.725Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/a3/c1/a03cc63722339ddbf087485f253493e2b013039f5b707e8e6016141130fa/librt-0.8.1-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:4be2a5c926b9770c9e08e717f05737a269b9d0ebc5d2f0060f0fe3fe9ce47acb", size = 218219, upload-time = "2026-02-17T16:12:05.828Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/58/f5/fff6108af0acf941c6f274a946aea0e484bd10cd2dc37610287ce49388c5/librt-0.8.1-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:fd1a720332ea335ceb544cf0a03f81df92abd4bb887679fd1e460976b0e6214b", size = 218750, upload-time = "2026-02-17T16:12:07.09Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/71/67/5a387bfef30ec1e4b4f30562c8586566faf87e47d696768c19feb49e3646/librt-0.8.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:93c2af9e01e0ef80d95ae3c720be101227edae5f2fe7e3dc63d8857fadfc5a1d", size = 241624, upload-time = "2026-02-17T16:12:08.43Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/d4/be/24f8502db11d405232ac1162eb98069ca49c3306c1d75c6ccc61d9af8789/librt-0.8.1-cp313-cp313-win32.whl", hash = "sha256:086a32dbb71336627e78cc1d6ee305a68d038ef7d4c39aaff41ae8c9aa46e91a", size = 54969, upload-time = "2026-02-17T16:12:09.633Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/5c/73/c9fdf6cb2a529c1a092ce769a12d88c8cca991194dfe641b6af12fa964d2/librt-0.8.1-cp313-cp313-win_amd64.whl", hash = "sha256:e11769a1dbda4da7b00a76cfffa67aa47cfa66921d2724539eee4b9ede780b79", size = 62000, upload-time = "2026-02-17T16:12:10.632Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/d3/97/68f80ca3ac4924f250cdfa6e20142a803e5e50fca96ef5148c52ee8c10ea/librt-0.8.1-cp313-cp313-win_arm64.whl", hash = "sha256:924817ab3141aca17893386ee13261f1d100d1ef410d70afe4389f2359fea4f0", size = 52495, upload-time = "2026-02-17T16:12:11.633Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/c9/6a/907ef6800f7bca71b525a05f1839b21f708c09043b1c6aa77b6b827b3996/librt-0.8.1-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:6cfa7fe54fd4d1f47130017351a959fe5804bda7a0bc7e07a2cdbc3fdd28d34f", size = 66081, upload-time = "2026-02-17T16:12:12.766Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/1b/18/25e991cd5640c9fb0f8d91b18797b29066b792f17bf8493da183bf5caabe/librt-0.8.1-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:228c2409c079f8c11fb2e5d7b277077f694cb93443eb760e00b3b83cb8b3176c", size = 68309, upload-time = "2026-02-17T16:12:13.756Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/a4/36/46820d03f058cfb5a9de5940640ba03165ed8aded69e0733c417bb04df34/librt-0.8.1-cp314-cp314-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:7aae78ab5e3206181780e56912d1b9bb9f90a7249ce12f0e8bf531d0462dd0fc", size = 196804, upload-time = "2026-02-17T16:12:14.818Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/59/18/5dd0d3b87b8ff9c061849fbdb347758d1f724b9a82241aa908e0ec54ccd0/librt-0.8.1-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:172d57ec04346b047ca6af181e1ea4858086c80bdf455f61994c4aa6fc3f866c", size = 206907, upload-time = "2026-02-17T16:12:16.513Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/d1/96/ef04902aad1424fd7299b62d1890e803e6ab4018c3044dca5922319c4b97/librt-0.8.1-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:6b1977c4ea97ce5eb7755a78fae68d87e4102e4aaf54985e8b56806849cc06a3", size = 221217, upload-time = "2026-02-17T16:12:17.906Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/6d/ff/7e01f2dda84a8f5d280637a2e5827210a8acca9a567a54507ef1c75b342d/librt-0.8.1-cp314-cp314-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:10c42e1f6fd06733ef65ae7bebce2872bcafd8d6e6b0a08fe0a05a23b044fb14", size = 214622, upload-time = "2026-02-17T16:12:19.108Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/1e/8c/5b093d08a13946034fed57619742f790faf77058558b14ca36a6e331161e/librt-0.8.1-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:4c8dfa264b9193c4ee19113c985c95f876fae5e51f731494fc4e0cf594990ba7", size = 221987, upload-time = "2026-02-17T16:12:20.331Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/d3/cc/86b0b3b151d40920ad45a94ce0171dec1aebba8a9d72bb3fa00c73ab25dd/librt-0.8.1-cp314-cp314-musllinux_1_2_i686.whl", hash = "sha256:01170b6729a438f0dedc4a26ed342e3dc4f02d1000b4b19f980e1877f0c297e6", size = 215132, upload-time = "2026-02-17T16:12:21.54Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/fc/be/8588164a46edf1e69858d952654e216a9a91174688eeefb9efbb38a9c799/librt-0.8.1-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:7b02679a0d783bdae30d443025b94465d8c3dc512f32f5b5031f93f57ac32071", size = 215195, upload-time = "2026-02-17T16:12:23.073Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/f5/f2/0b9279bea735c734d69344ecfe056c1ba211694a72df10f568745c899c76/librt-0.8.1-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:190b109bb69592a3401fe1ffdea41a2e73370ace2ffdc4a0e8e2b39cdea81b78", size = 237946, upload-time = "2026-02-17T16:12:24.275Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/e9/cc/5f2a34fbc8aeb35314a3641f9956fa9051a947424652fad9882be7a97949/librt-0.8.1-cp314-cp314-win32.whl", hash = "sha256:e70a57ecf89a0f64c24e37f38d3fe217a58169d2fe6ed6d70554964042474023", size = 50689, upload-time = "2026-02-17T16:12:25.766Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/a0/76/cd4d010ab2147339ca2b93e959c3686e964edc6de66ddacc935c325883d7/librt-0.8.1-cp314-cp314-win_amd64.whl", hash = "sha256:7e2f3edca35664499fbb36e4770650c4bd4a08abc1f4458eab9df4ec56389730", size = 57875, upload-time = "2026-02-17T16:12:27.465Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/84/0f/2143cb3c3ca48bd3379dcd11817163ca50781927c4537345d608b5045998/librt-0.8.1-cp314-cp314-win_arm64.whl", hash = "sha256:0d2f82168e55ddefd27c01c654ce52379c0750ddc31ee86b4b266bcf4d65f2a3", size = 48058, upload-time = "2026-02-17T16:12:28.556Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/d2/0e/9b23a87e37baf00311c3efe6b48d6b6c168c29902dfc3f04c338372fd7db/librt-0.8.1-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:2c74a2da57a094bd48d03fa5d196da83d2815678385d2978657499063709abe1", size = 68313, upload-time = "2026-02-17T16:12:29.659Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/db/9a/859c41e5a4f1c84200a7d2b92f586aa27133c8243b6cac9926f6e54d01b9/librt-0.8.1-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:a355d99c4c0d8e5b770313b8b247411ed40949ca44e33e46a4789b9293a907ee", size = 70994, upload-time = "2026-02-17T16:12:31.516Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/4c/28/10605366ee599ed34223ac2bf66404c6fb59399f47108215d16d5ad751a8/librt-0.8.1-cp314-cp314t-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:2eb345e8b33fb748227409c9f1233d4df354d6e54091f0e8fc53acdb2ffedeb7", size = 220770, upload-time = "2026-02-17T16:12:33.294Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/af/8d/16ed8fd452dafae9c48d17a6bc1ee3e818fd40ef718d149a8eff2c9f4ea2/librt-0.8.1-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:9be2f15e53ce4e83cc08adc29b26fb5978db62ef2a366fbdf716c8a6c8901040", size = 235409, upload-time = "2026-02-17T16:12:35.443Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/89/1b/7bdf3e49349c134b25db816e4a3db6b94a47ac69d7d46b1e682c2c4949be/librt-0.8.1-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:785ae29c1f5c6e7c2cde2c7c0e148147f4503da3abc5d44d482068da5322fd9e", size = 246473, upload-time = "2026-02-17T16:12:36.656Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/4e/8a/91fab8e4fd2a24930a17188c7af5380eb27b203d72101c9cc000dbdfd95a/librt-0.8.1-cp314-cp314t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:1d3a7da44baf692f0c6aeb5b2a09c5e6fc7a703bca9ffa337ddd2e2da53f7732", size = 238866, upload-time = "2026-02-17T16:12:37.849Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/b9/e0/c45a098843fc7c07e18a7f8a24ca8496aecbf7bdcd54980c6ca1aaa79a8e/librt-0.8.1-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:5fc48998000cbc39ec0d5311312dda93ecf92b39aaf184c5e817d5d440b29624", size = 250248, upload-time = "2026-02-17T16:12:39.445Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/82/30/07627de23036640c952cce0c1fe78972e77d7d2f8fd54fa5ef4554ff4a56/librt-0.8.1-cp314-cp314t-musllinux_1_2_i686.whl", hash = "sha256:e96baa6820280077a78244b2e06e416480ed859bbd8e5d641cf5742919d8beb4", size = 240629, upload-time = "2026-02-17T16:12:40.889Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/fb/c1/55bfe1ee3542eba055616f9098eaf6eddb966efb0ca0f44eaa4aba327307/librt-0.8.1-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:31362dbfe297b23590530007062c32c6f6176f6099646bb2c95ab1b00a57c382", size = 239615, upload-time = "2026-02-17T16:12:42.446Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/2b/39/191d3d28abc26c9099b19852e6c99f7f6d400b82fa5a4e80291bd3803e19/librt-0.8.1-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:cc3656283d11540ab0ea01978378e73e10002145117055e03722417aeab30994", size = 263001, upload-time = "2026-02-17T16:12:43.627Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/b9/eb/7697f60fbe7042ab4e88f4ee6af496b7f222fffb0a4e3593ef1f29f81652/librt-0.8.1-cp314-cp314t-win32.whl", hash = "sha256:738f08021b3142c2918c03692608baed43bc51144c29e35807682f8070ee2a3a", size = 51328, upload-time = "2026-02-17T16:12:45.148Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/7c/72/34bf2eb7a15414a23e5e70ecb9440c1d3179f393d9349338a91e2781c0fb/librt-0.8.1-cp314-cp314t-win_amd64.whl", hash = "sha256:89815a22daf9c51884fb5dbe4f1ef65ee6a146e0b6a8df05f753e2e4a9359bf4", size = 58722, upload-time = "2026-02-17T16:12:46.85Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/b2/c8/d148e041732d631fc76036f8b30fae4e77b027a1e95b7a84bb522481a940/librt-0.8.1-cp314-cp314t-win_arm64.whl", hash = "sha256:bf512a71a23504ed08103a13c941f763db13fb11177beb3d9244c98c29fb4a61", size = 48755, upload-time = "2026-02-17T16:12:47.943Z" },
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "mypy"
|
||||||
|
version = "1.19.1"
|
||||||
|
source = { registry = "https://pypi.org/simple" }
|
||||||
|
dependencies = [
|
||||||
|
{ name = "librt", marker = "platform_python_implementation != 'PyPy'" },
|
||||||
|
{ name = "mypy-extensions" },
|
||||||
|
{ name = "pathspec" },
|
||||||
|
{ name = "typing-extensions" },
|
||||||
|
]
|
||||||
|
sdist = { url = "https://files.pythonhosted.org/packages/f5/db/4efed9504bc01309ab9c2da7e352cc223569f05478012b5d9ece38fd44d2/mypy-1.19.1.tar.gz", hash = "sha256:19d88bb05303fe63f71dd2c6270daca27cb9401c4ca8255fe50d1d920e0eb9ba", size = 3582404, upload-time = "2025-12-15T05:03:48.42Z" }
|
||||||
|
wheels = [
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/06/8a/19bfae96f6615aa8a0604915512e0289b1fad33d5909bf7244f02935d33a/mypy-1.19.1-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:a8174a03289288c1f6c46d55cef02379b478bfbc8e358e02047487cad44c6ca1", size = 13206053, upload-time = "2025-12-15T05:03:46.622Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/a5/34/3e63879ab041602154ba2a9f99817bb0c85c4df19a23a1443c8986e4d565/mypy-1.19.1-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:ffcebe56eb09ff0c0885e750036a095e23793ba6c2e894e7e63f6d89ad51f22e", size = 12219134, upload-time = "2025-12-15T05:03:24.367Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/89/cc/2db6f0e95366b630364e09845672dbee0cbf0bbe753a204b29a944967cd9/mypy-1.19.1-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:b64d987153888790bcdb03a6473d321820597ab8dd9243b27a92153c4fa50fd2", size = 12731616, upload-time = "2025-12-15T05:02:44.725Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/00/be/dd56c1fd4807bc1eba1cf18b2a850d0de7bacb55e158755eb79f77c41f8e/mypy-1.19.1-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:c35d298c2c4bba75feb2195655dfea8124d855dfd7343bf8b8c055421eaf0cf8", size = 13620847, upload-time = "2025-12-15T05:03:39.633Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/6d/42/332951aae42b79329f743bf1da088cd75d8d4d9acc18fbcbd84f26c1af4e/mypy-1.19.1-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:34c81968774648ab5ac09c29a375fdede03ba253f8f8287847bd480782f73a6a", size = 13834976, upload-time = "2025-12-15T05:03:08.786Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/6f/63/e7493e5f90e1e085c562bb06e2eb32cae27c5057b9653348d38b47daaecc/mypy-1.19.1-cp312-cp312-win_amd64.whl", hash = "sha256:b10e7c2cd7870ba4ad9b2d8a6102eb5ffc1f16ca35e3de6bfa390c1113029d13", size = 10118104, upload-time = "2025-12-15T05:03:10.834Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/de/9f/a6abae693f7a0c697dbb435aac52e958dc8da44e92e08ba88d2e42326176/mypy-1.19.1-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:e3157c7594ff2ef1634ee058aafc56a82db665c9438fd41b390f3bde1ab12250", size = 13201927, upload-time = "2025-12-15T05:02:29.138Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/9a/a4/45c35ccf6e1c65afc23a069f50e2c66f46bd3798cbe0d680c12d12935caa/mypy-1.19.1-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:bdb12f69bcc02700c2b47e070238f42cb87f18c0bc1fc4cdb4fb2bc5fd7a3b8b", size = 12206730, upload-time = "2025-12-15T05:03:01.325Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/05/bb/cdcf89678e26b187650512620eec8368fded4cfd99cfcb431e4cdfd19dec/mypy-1.19.1-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:f859fb09d9583a985be9a493d5cfc5515b56b08f7447759a0c5deaf68d80506e", size = 12724581, upload-time = "2025-12-15T05:03:20.087Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/d1/32/dd260d52babf67bad8e6770f8e1102021877ce0edea106e72df5626bb0ec/mypy-1.19.1-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:c9a6538e0415310aad77cb94004ca6482330fece18036b5f360b62c45814c4ef", size = 13616252, upload-time = "2025-12-15T05:02:49.036Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/71/d0/5e60a9d2e3bd48432ae2b454b7ef2b62a960ab51292b1eda2a95edd78198/mypy-1.19.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:da4869fc5e7f62a88f3fe0b5c919d1d9f7ea3cef92d3689de2823fd27e40aa75", size = 13840848, upload-time = "2025-12-15T05:02:55.95Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/98/76/d32051fa65ecf6cc8c6610956473abdc9b4c43301107476ac03559507843/mypy-1.19.1-cp313-cp313-win_amd64.whl", hash = "sha256:016f2246209095e8eda7538944daa1d60e1e8134d98983b9fc1e92c1fc0cb8dd", size = 10135510, upload-time = "2025-12-15T05:02:58.438Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/de/eb/b83e75f4c820c4247a58580ef86fcd35165028f191e7e1ba57128c52782d/mypy-1.19.1-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:06e6170bd5836770e8104c8fdd58e5e725cfeb309f0a6c681a811f557e97eac1", size = 13199744, upload-time = "2025-12-15T05:03:30.823Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/94/28/52785ab7bfa165f87fcbb61547a93f98bb20e7f82f90f165a1f69bce7b3d/mypy-1.19.1-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:804bd67b8054a85447c8954215a906d6eff9cabeabe493fb6334b24f4bfff718", size = 12215815, upload-time = "2025-12-15T05:02:42.323Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/0a/c6/bdd60774a0dbfb05122e3e925f2e9e846c009e479dcec4821dad881f5b52/mypy-1.19.1-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:21761006a7f497cb0d4de3d8ef4ca70532256688b0523eee02baf9eec895e27b", size = 12740047, upload-time = "2025-12-15T05:03:33.168Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/32/2a/66ba933fe6c76bd40d1fe916a83f04fed253152f451a877520b3c4a5e41e/mypy-1.19.1-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:28902ee51f12e0f19e1e16fbe2f8f06b6637f482c459dd393efddd0ec7f82045", size = 13601998, upload-time = "2025-12-15T05:03:13.056Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/e3/da/5055c63e377c5c2418760411fd6a63ee2b96cf95397259038756c042574f/mypy-1.19.1-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:481daf36a4c443332e2ae9c137dfee878fcea781a2e3f895d54bd3002a900957", size = 13807476, upload-time = "2025-12-15T05:03:17.977Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/cd/09/4ebd873390a063176f06b0dbf1f7783dd87bd120eae7727fa4ae4179b685/mypy-1.19.1-cp314-cp314-win_amd64.whl", hash = "sha256:8bb5c6f6d043655e055be9b542aa5f3bdd30e4f3589163e85f93f3640060509f", size = 10281872, upload-time = "2025-12-15T05:03:05.549Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/8d/f4/4ce9a05ce5ded1de3ec1c1d96cf9f9504a04e54ce0ed55cfa38619a32b8d/mypy-1.19.1-py3-none-any.whl", hash = "sha256:f1235f5ea01b7db5468d53ece6aaddf1ad0b88d9e7462b86ef96fe04995d7247", size = 2471239, upload-time = "2025-12-15T05:03:07.248Z" },
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "mypy-extensions"
|
||||||
|
version = "1.1.0"
|
||||||
|
source = { registry = "https://pypi.org/simple" }
|
||||||
|
sdist = { url = "https://files.pythonhosted.org/packages/a2/6e/371856a3fb9d31ca8dac321cda606860fa4548858c0cc45d9d1d4ca2628b/mypy_extensions-1.1.0.tar.gz", hash = "sha256:52e68efc3284861e772bbcd66823fde5ae21fd2fdb51c62a211403730b916558", size = 6343, upload-time = "2025-04-22T14:54:24.164Z" }
|
||||||
|
wheels = [
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/79/7b/2c79738432f5c924bef5071f933bcc9efd0473bac3b4aa584a6f7c1c8df8/mypy_extensions-1.1.0-py3-none-any.whl", hash = "sha256:1be4cccdb0f2482337c4743e60421de3a356cd97508abadd57d47403e94f5505", size = 4963, upload-time = "2025-04-22T14:54:22.983Z" },
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "packaging"
|
||||||
|
version = "26.0"
|
||||||
|
source = { registry = "https://pypi.org/simple" }
|
||||||
|
sdist = { url = "https://files.pythonhosted.org/packages/65/ee/299d360cdc32edc7d2cf530f3accf79c4fca01e96ffc950d8a52213bd8e4/packaging-26.0.tar.gz", hash = "sha256:00243ae351a257117b6a241061796684b084ed1c516a08c48a3f7e147a9d80b4", size = 143416, upload-time = "2026-01-21T20:50:39.064Z" }
|
||||||
|
wheels = [
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/b7/b9/c538f279a4e237a006a2c98387d081e9eb060d203d8ed34467cc0f0b9b53/packaging-26.0-py3-none-any.whl", hash = "sha256:b36f1fef9334a5588b4166f8bcd26a14e521f2b55e6b9de3aaa80d3ff7a37529", size = 74366, upload-time = "2026-01-21T20:50:37.788Z" },
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "pathspec"
|
||||||
|
version = "1.0.4"
|
||||||
|
source = { registry = "https://pypi.org/simple" }
|
||||||
|
sdist = { url = "https://files.pythonhosted.org/packages/fa/36/e27608899f9b8d4dff0617b2d9ab17ca5608956ca44461ac14ac48b44015/pathspec-1.0.4.tar.gz", hash = "sha256:0210e2ae8a21a9137c0d470578cb0e595af87edaa6ebf12ff176f14a02e0e645", size = 131200, upload-time = "2026-01-27T03:59:46.938Z" }
|
||||||
|
wheels = [
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/ef/3c/2c197d226f9ea224a9ab8d197933f9da0ae0aac5b6e0f884e2b8d9c8e9f7/pathspec-1.0.4-py3-none-any.whl", hash = "sha256:fb6ae2fd4e7c921a165808a552060e722767cfa526f99ca5156ed2ce45a5c723", size = 55206, upload-time = "2026-01-27T03:59:45.137Z" },
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "pluggy"
|
||||||
|
version = "1.6.0"
|
||||||
|
source = { registry = "https://pypi.org/simple" }
|
||||||
|
sdist = { url = "https://files.pythonhosted.org/packages/f9/e2/3e91f31a7d2b083fe6ef3fa267035b518369d9511ffab804f839851d2779/pluggy-1.6.0.tar.gz", hash = "sha256:7dcc130b76258d33b90f61b658791dede3486c3e6bfb003ee5c9bfb396dd22f3", size = 69412, upload-time = "2025-05-15T12:30:07.975Z" }
|
||||||
|
wheels = [
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/54/20/4d324d65cc6d9205fabedc306948156824eb9f0ee1633355a8f7ec5c66bf/pluggy-1.6.0-py3-none-any.whl", hash = "sha256:e920276dd6813095e9377c0bc5566d94c932c33b27a3e3945d8389c374dd4746", size = 20538, upload-time = "2025-05-15T12:30:06.134Z" },
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "pydantic"
|
||||||
|
version = "2.12.5"
|
||||||
|
source = { registry = "https://pypi.org/simple" }
|
||||||
|
dependencies = [
|
||||||
|
{ name = "annotated-types" },
|
||||||
|
{ name = "pydantic-core" },
|
||||||
|
{ name = "typing-extensions" },
|
||||||
|
{ name = "typing-inspection" },
|
||||||
|
]
|
||||||
|
sdist = { url = "https://files.pythonhosted.org/packages/69/44/36f1a6e523abc58ae5f928898e4aca2e0ea509b5aa6f6f392a5d882be928/pydantic-2.12.5.tar.gz", hash = "sha256:4d351024c75c0f085a9febbb665ce8c0c6ec5d30e903bdb6394b7ede26aebb49", size = 821591, upload-time = "2025-11-26T15:11:46.471Z" }
|
||||||
|
wheels = [
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/5a/87/b70ad306ebb6f9b585f114d0ac2137d792b48be34d732d60e597c2f8465a/pydantic-2.12.5-py3-none-any.whl", hash = "sha256:e561593fccf61e8a20fc46dfc2dfe075b8be7d0188df33f221ad1f0139180f9d", size = 463580, upload-time = "2025-11-26T15:11:44.605Z" },
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "pydantic-core"
|
||||||
|
version = "2.41.5"
|
||||||
|
source = { registry = "https://pypi.org/simple" }
|
||||||
|
dependencies = [
|
||||||
|
{ name = "typing-extensions" },
|
||||||
|
]
|
||||||
|
sdist = { url = "https://files.pythonhosted.org/packages/71/70/23b021c950c2addd24ec408e9ab05d59b035b39d97cdc1130e1bce647bb6/pydantic_core-2.41.5.tar.gz", hash = "sha256:08daa51ea16ad373ffd5e7606252cc32f07bc72b28284b6bc9c6df804816476e", size = 460952, upload-time = "2025-11-04T13:43:49.098Z" }
|
||||||
|
wheels = [
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/5f/5d/5f6c63eebb5afee93bcaae4ce9a898f3373ca23df3ccaef086d0233a35a7/pydantic_core-2.41.5-cp312-cp312-macosx_10_12_x86_64.whl", hash = "sha256:f41a7489d32336dbf2199c8c0a215390a751c5b014c2c1c5366e817202e9cdf7", size = 2110990, upload-time = "2025-11-04T13:39:58.079Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/aa/32/9c2e8ccb57c01111e0fd091f236c7b371c1bccea0fa85247ac55b1e2b6b6/pydantic_core-2.41.5-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:070259a8818988b9a84a449a2a7337c7f430a22acc0859c6b110aa7212a6d9c0", size = 1896003, upload-time = "2025-11-04T13:39:59.956Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/68/b8/a01b53cb0e59139fbc9e4fda3e9724ede8de279097179be4ff31f1abb65a/pydantic_core-2.41.5-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:e96cea19e34778f8d59fe40775a7a574d95816eb150850a85a7a4c8f4b94ac69", size = 1919200, upload-time = "2025-11-04T13:40:02.241Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/38/de/8c36b5198a29bdaade07b5985e80a233a5ac27137846f3bc2d3b40a47360/pydantic_core-2.41.5-cp312-cp312-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:ed2e99c456e3fadd05c991f8f437ef902e00eedf34320ba2b0842bd1c3ca3a75", size = 2052578, upload-time = "2025-11-04T13:40:04.401Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/00/b5/0e8e4b5b081eac6cb3dbb7e60a65907549a1ce035a724368c330112adfdd/pydantic_core-2.41.5-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:65840751b72fbfd82c3c640cff9284545342a4f1eb1586ad0636955b261b0b05", size = 2208504, upload-time = "2025-11-04T13:40:06.072Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/77/56/87a61aad59c7c5b9dc8caad5a41a5545cba3810c3e828708b3d7404f6cef/pydantic_core-2.41.5-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:e536c98a7626a98feb2d3eaf75944ef6f3dbee447e1f841eae16f2f0a72d8ddc", size = 2335816, upload-time = "2025-11-04T13:40:07.835Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/0d/76/941cc9f73529988688a665a5c0ecff1112b3d95ab48f81db5f7606f522d3/pydantic_core-2.41.5-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:eceb81a8d74f9267ef4081e246ffd6d129da5d87e37a77c9bde550cb04870c1c", size = 2075366, upload-time = "2025-11-04T13:40:09.804Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/d3/43/ebef01f69baa07a482844faaa0a591bad1ef129253ffd0cdaa9d8a7f72d3/pydantic_core-2.41.5-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:d38548150c39b74aeeb0ce8ee1d8e82696f4a4e16ddc6de7b1d8823f7de4b9b5", size = 2171698, upload-time = "2025-11-04T13:40:12.004Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/b1/87/41f3202e4193e3bacfc2c065fab7706ebe81af46a83d3e27605029c1f5a6/pydantic_core-2.41.5-cp312-cp312-musllinux_1_1_aarch64.whl", hash = "sha256:c23e27686783f60290e36827f9c626e63154b82b116d7fe9adba1fda36da706c", size = 2132603, upload-time = "2025-11-04T13:40:13.868Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/49/7d/4c00df99cb12070b6bccdef4a195255e6020a550d572768d92cc54dba91a/pydantic_core-2.41.5-cp312-cp312-musllinux_1_1_armv7l.whl", hash = "sha256:482c982f814460eabe1d3bb0adfdc583387bd4691ef00b90575ca0d2b6fe2294", size = 2329591, upload-time = "2025-11-04T13:40:15.672Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/cc/6a/ebf4b1d65d458f3cda6a7335d141305dfa19bdc61140a884d165a8a1bbc7/pydantic_core-2.41.5-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:bfea2a5f0b4d8d43adf9d7b8bf019fb46fdd10a2e5cde477fbcb9d1fa08c68e1", size = 2319068, upload-time = "2025-11-04T13:40:17.532Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/49/3b/774f2b5cd4192d5ab75870ce4381fd89cf218af999515baf07e7206753f0/pydantic_core-2.41.5-cp312-cp312-win32.whl", hash = "sha256:b74557b16e390ec12dca509bce9264c3bbd128f8a2c376eaa68003d7f327276d", size = 1985908, upload-time = "2025-11-04T13:40:19.309Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/86/45/00173a033c801cacf67c190fef088789394feaf88a98a7035b0e40d53dc9/pydantic_core-2.41.5-cp312-cp312-win_amd64.whl", hash = "sha256:1962293292865bca8e54702b08a4f26da73adc83dd1fcf26fbc875b35d81c815", size = 2020145, upload-time = "2025-11-04T13:40:21.548Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/f9/22/91fbc821fa6d261b376a3f73809f907cec5ca6025642c463d3488aad22fb/pydantic_core-2.41.5-cp312-cp312-win_arm64.whl", hash = "sha256:1746d4a3d9a794cacae06a5eaaccb4b8643a131d45fbc9af23e353dc0a5ba5c3", size = 1976179, upload-time = "2025-11-04T13:40:23.393Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/87/06/8806241ff1f70d9939f9af039c6c35f2360cf16e93c2ca76f184e76b1564/pydantic_core-2.41.5-cp313-cp313-macosx_10_12_x86_64.whl", hash = "sha256:941103c9be18ac8daf7b7adca8228f8ed6bb7a1849020f643b3a14d15b1924d9", size = 2120403, upload-time = "2025-11-04T13:40:25.248Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/94/02/abfa0e0bda67faa65fef1c84971c7e45928e108fe24333c81f3bfe35d5f5/pydantic_core-2.41.5-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:112e305c3314f40c93998e567879e887a3160bb8689ef3d2c04b6cc62c33ac34", size = 1896206, upload-time = "2025-11-04T13:40:27.099Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/15/df/a4c740c0943e93e6500f9eb23f4ca7ec9bf71b19e608ae5b579678c8d02f/pydantic_core-2.41.5-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:0cbaad15cb0c90aa221d43c00e77bb33c93e8d36e0bf74760cd00e732d10a6a0", size = 1919307, upload-time = "2025-11-04T13:40:29.806Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/9a/e3/6324802931ae1d123528988e0e86587c2072ac2e5394b4bc2bc34b61ff6e/pydantic_core-2.41.5-cp313-cp313-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:03ca43e12fab6023fc79d28ca6b39b05f794ad08ec2feccc59a339b02f2b3d33", size = 2063258, upload-time = "2025-11-04T13:40:33.544Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/c9/d4/2230d7151d4957dd79c3044ea26346c148c98fbf0ee6ebd41056f2d62ab5/pydantic_core-2.41.5-cp313-cp313-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:dc799088c08fa04e43144b164feb0c13f9a0bc40503f8df3e9fde58a3c0c101e", size = 2214917, upload-time = "2025-11-04T13:40:35.479Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/e6/9f/eaac5df17a3672fef0081b6c1bb0b82b33ee89aa5cec0d7b05f52fd4a1fa/pydantic_core-2.41.5-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:97aeba56665b4c3235a0e52b2c2f5ae9cd071b8a8310ad27bddb3f7fb30e9aa2", size = 2332186, upload-time = "2025-11-04T13:40:37.436Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/cf/4e/35a80cae583a37cf15604b44240e45c05e04e86f9cfd766623149297e971/pydantic_core-2.41.5-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:406bf18d345822d6c21366031003612b9c77b3e29ffdb0f612367352aab7d586", size = 2073164, upload-time = "2025-11-04T13:40:40.289Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/bf/e3/f6e262673c6140dd3305d144d032f7bd5f7497d3871c1428521f19f9efa2/pydantic_core-2.41.5-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:b93590ae81f7010dbe380cdeab6f515902ebcbefe0b9327cc4804d74e93ae69d", size = 2179146, upload-time = "2025-11-04T13:40:42.809Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/75/c7/20bd7fc05f0c6ea2056a4565c6f36f8968c0924f19b7d97bbfea55780e73/pydantic_core-2.41.5-cp313-cp313-musllinux_1_1_aarch64.whl", hash = "sha256:01a3d0ab748ee531f4ea6c3e48ad9dac84ddba4b0d82291f87248f2f9de8d740", size = 2137788, upload-time = "2025-11-04T13:40:44.752Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/3a/8d/34318ef985c45196e004bc46c6eab2eda437e744c124ef0dbe1ff2c9d06b/pydantic_core-2.41.5-cp313-cp313-musllinux_1_1_armv7l.whl", hash = "sha256:6561e94ba9dacc9c61bce40e2d6bdc3bfaa0259d3ff36ace3b1e6901936d2e3e", size = 2340133, upload-time = "2025-11-04T13:40:46.66Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/9c/59/013626bf8c78a5a5d9350d12e7697d3d4de951a75565496abd40ccd46bee/pydantic_core-2.41.5-cp313-cp313-musllinux_1_1_x86_64.whl", hash = "sha256:915c3d10f81bec3a74fbd4faebe8391013ba61e5a1a8d48c4455b923bdda7858", size = 2324852, upload-time = "2025-11-04T13:40:48.575Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/1a/d9/c248c103856f807ef70c18a4f986693a46a8ffe1602e5d361485da502d20/pydantic_core-2.41.5-cp313-cp313-win32.whl", hash = "sha256:650ae77860b45cfa6e2cdafc42618ceafab3a2d9a3811fcfbd3bbf8ac3c40d36", size = 1994679, upload-time = "2025-11-04T13:40:50.619Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/9e/8b/341991b158ddab181cff136acd2552c9f35bd30380422a639c0671e99a91/pydantic_core-2.41.5-cp313-cp313-win_amd64.whl", hash = "sha256:79ec52ec461e99e13791ec6508c722742ad745571f234ea6255bed38c6480f11", size = 2019766, upload-time = "2025-11-04T13:40:52.631Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/73/7d/f2f9db34af103bea3e09735bb40b021788a5e834c81eedb541991badf8f5/pydantic_core-2.41.5-cp313-cp313-win_arm64.whl", hash = "sha256:3f84d5c1b4ab906093bdc1ff10484838aca54ef08de4afa9de0f5f14d69639cd", size = 1981005, upload-time = "2025-11-04T13:40:54.734Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/ea/28/46b7c5c9635ae96ea0fbb779e271a38129df2550f763937659ee6c5dbc65/pydantic_core-2.41.5-cp314-cp314-macosx_10_12_x86_64.whl", hash = "sha256:3f37a19d7ebcdd20b96485056ba9e8b304e27d9904d233d7b1015db320e51f0a", size = 2119622, upload-time = "2025-11-04T13:40:56.68Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/74/1a/145646e5687e8d9a1e8d09acb278c8535ebe9e972e1f162ed338a622f193/pydantic_core-2.41.5-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:1d1d9764366c73f996edd17abb6d9d7649a7eb690006ab6adbda117717099b14", size = 1891725, upload-time = "2025-11-04T13:40:58.807Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/23/04/e89c29e267b8060b40dca97bfc64a19b2a3cf99018167ea1677d96368273/pydantic_core-2.41.5-cp314-cp314-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:25e1c2af0fce638d5f1988b686f3b3ea8cd7de5f244ca147c777769e798a9cd1", size = 1915040, upload-time = "2025-11-04T13:41:00.853Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/84/a3/15a82ac7bd97992a82257f777b3583d3e84bdb06ba6858f745daa2ec8a85/pydantic_core-2.41.5-cp314-cp314-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:506d766a8727beef16b7adaeb8ee6217c64fc813646b424d0804d67c16eddb66", size = 2063691, upload-time = "2025-11-04T13:41:03.504Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/74/9b/0046701313c6ef08c0c1cf0e028c67c770a4e1275ca73131563c5f2a310a/pydantic_core-2.41.5-cp314-cp314-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:4819fa52133c9aa3c387b3328f25c1facc356491e6135b459f1de698ff64d869", size = 2213897, upload-time = "2025-11-04T13:41:05.804Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/8a/cd/6bac76ecd1b27e75a95ca3a9a559c643b3afcd2dd62086d4b7a32a18b169/pydantic_core-2.41.5-cp314-cp314-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:2b761d210c9ea91feda40d25b4efe82a1707da2ef62901466a42492c028553a2", size = 2333302, upload-time = "2025-11-04T13:41:07.809Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/4c/d2/ef2074dc020dd6e109611a8be4449b98cd25e1b9b8a303c2f0fca2f2bcf7/pydantic_core-2.41.5-cp314-cp314-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:22f0fb8c1c583a3b6f24df2470833b40207e907b90c928cc8d3594b76f874375", size = 2064877, upload-time = "2025-11-04T13:41:09.827Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/18/66/e9db17a9a763d72f03de903883c057b2592c09509ccfe468187f2a2eef29/pydantic_core-2.41.5-cp314-cp314-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:2782c870e99878c634505236d81e5443092fba820f0373997ff75f90f68cd553", size = 2180680, upload-time = "2025-11-04T13:41:12.379Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/d3/9e/3ce66cebb929f3ced22be85d4c2399b8e85b622db77dad36b73c5387f8f8/pydantic_core-2.41.5-cp314-cp314-musllinux_1_1_aarch64.whl", hash = "sha256:0177272f88ab8312479336e1d777f6b124537d47f2123f89cb37e0accea97f90", size = 2138960, upload-time = "2025-11-04T13:41:14.627Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/a6/62/205a998f4327d2079326b01abee48e502ea739d174f0a89295c481a2272e/pydantic_core-2.41.5-cp314-cp314-musllinux_1_1_armv7l.whl", hash = "sha256:63510af5e38f8955b8ee5687740d6ebf7c2a0886d15a6d65c32814613681bc07", size = 2339102, upload-time = "2025-11-04T13:41:16.868Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/3c/0d/f05e79471e889d74d3d88f5bd20d0ed189ad94c2423d81ff8d0000aab4ff/pydantic_core-2.41.5-cp314-cp314-musllinux_1_1_x86_64.whl", hash = "sha256:e56ba91f47764cc14f1daacd723e3e82d1a89d783f0f5afe9c364b8bb491ccdb", size = 2326039, upload-time = "2025-11-04T13:41:18.934Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/ec/e1/e08a6208bb100da7e0c4b288eed624a703f4d129bde2da475721a80cab32/pydantic_core-2.41.5-cp314-cp314-win32.whl", hash = "sha256:aec5cf2fd867b4ff45b9959f8b20ea3993fc93e63c7363fe6851424c8a7e7c23", size = 1995126, upload-time = "2025-11-04T13:41:21.418Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/48/5d/56ba7b24e9557f99c9237e29f5c09913c81eeb2f3217e40e922353668092/pydantic_core-2.41.5-cp314-cp314-win_amd64.whl", hash = "sha256:8e7c86f27c585ef37c35e56a96363ab8de4e549a95512445b85c96d3e2f7c1bf", size = 2015489, upload-time = "2025-11-04T13:41:24.076Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/4e/bb/f7a190991ec9e3e0ba22e4993d8755bbc4a32925c0b5b42775c03e8148f9/pydantic_core-2.41.5-cp314-cp314-win_arm64.whl", hash = "sha256:e672ba74fbc2dc8eea59fb6d4aed6845e6905fc2a8afe93175d94a83ba2a01a0", size = 1977288, upload-time = "2025-11-04T13:41:26.33Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/92/ed/77542d0c51538e32e15afe7899d79efce4b81eee631d99850edc2f5e9349/pydantic_core-2.41.5-cp314-cp314t-macosx_10_12_x86_64.whl", hash = "sha256:8566def80554c3faa0e65ac30ab0932b9e3a5cd7f8323764303d468e5c37595a", size = 2120255, upload-time = "2025-11-04T13:41:28.569Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/bb/3d/6913dde84d5be21e284439676168b28d8bbba5600d838b9dca99de0fad71/pydantic_core-2.41.5-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:b80aa5095cd3109962a298ce14110ae16b8c1aece8b72f9dafe81cf597ad80b3", size = 1863760, upload-time = "2025-11-04T13:41:31.055Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/5a/f0/e5e6b99d4191da102f2b0eb9687aaa7f5bea5d9964071a84effc3e40f997/pydantic_core-2.41.5-cp314-cp314t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:3006c3dd9ba34b0c094c544c6006cc79e87d8612999f1a5d43b769b89181f23c", size = 1878092, upload-time = "2025-11-04T13:41:33.21Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/71/48/36fb760642d568925953bcc8116455513d6e34c4beaa37544118c36aba6d/pydantic_core-2.41.5-cp314-cp314t-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:72f6c8b11857a856bcfa48c86f5368439f74453563f951e473514579d44aa612", size = 2053385, upload-time = "2025-11-04T13:41:35.508Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/20/25/92dc684dd8eb75a234bc1c764b4210cf2646479d54b47bf46061657292a8/pydantic_core-2.41.5-cp314-cp314t-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:5cb1b2f9742240e4bb26b652a5aeb840aa4b417c7748b6f8387927bc6e45e40d", size = 2218832, upload-time = "2025-11-04T13:41:37.732Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/e2/09/f53e0b05023d3e30357d82eb35835d0f6340ca344720a4599cd663dca599/pydantic_core-2.41.5-cp314-cp314t-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:bd3d54f38609ff308209bd43acea66061494157703364ae40c951f83ba99a1a9", size = 2327585, upload-time = "2025-11-04T13:41:40Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/aa/4e/2ae1aa85d6af35a39b236b1b1641de73f5a6ac4d5a7509f77b814885760c/pydantic_core-2.41.5-cp314-cp314t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:2ff4321e56e879ee8d2a879501c8e469414d948f4aba74a2d4593184eb326660", size = 2041078, upload-time = "2025-11-04T13:41:42.323Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/cd/13/2e215f17f0ef326fc72afe94776edb77525142c693767fc347ed6288728d/pydantic_core-2.41.5-cp314-cp314t-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:d0d2568a8c11bf8225044aa94409e21da0cb09dcdafe9ecd10250b2baad531a9", size = 2173914, upload-time = "2025-11-04T13:41:45.221Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/02/7a/f999a6dcbcd0e5660bc348a3991c8915ce6599f4f2c6ac22f01d7a10816c/pydantic_core-2.41.5-cp314-cp314t-musllinux_1_1_aarch64.whl", hash = "sha256:a39455728aabd58ceabb03c90e12f71fd30fa69615760a075b9fec596456ccc3", size = 2129560, upload-time = "2025-11-04T13:41:47.474Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/3a/b1/6c990ac65e3b4c079a4fb9f5b05f5b013afa0f4ed6780a3dd236d2cbdc64/pydantic_core-2.41.5-cp314-cp314t-musllinux_1_1_armv7l.whl", hash = "sha256:239edca560d05757817c13dc17c50766136d21f7cd0fac50295499ae24f90fdf", size = 2329244, upload-time = "2025-11-04T13:41:49.992Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/d9/02/3c562f3a51afd4d88fff8dffb1771b30cfdfd79befd9883ee094f5b6c0d8/pydantic_core-2.41.5-cp314-cp314t-musllinux_1_1_x86_64.whl", hash = "sha256:2a5e06546e19f24c6a96a129142a75cee553cc018ffee48a460059b1185f4470", size = 2331955, upload-time = "2025-11-04T13:41:54.079Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/5c/96/5fb7d8c3c17bc8c62fdb031c47d77a1af698f1d7a406b0f79aaa1338f9ad/pydantic_core-2.41.5-cp314-cp314t-win32.whl", hash = "sha256:b4ececa40ac28afa90871c2cc2b9ffd2ff0bf749380fbdf57d165fd23da353aa", size = 1988906, upload-time = "2025-11-04T13:41:56.606Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/22/ed/182129d83032702912c2e2d8bbe33c036f342cc735737064668585dac28f/pydantic_core-2.41.5-cp314-cp314t-win_amd64.whl", hash = "sha256:80aa89cad80b32a912a65332f64a4450ed00966111b6615ca6816153d3585a8c", size = 1981607, upload-time = "2025-11-04T13:41:58.889Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/9f/ed/068e41660b832bb0b1aa5b58011dea2a3fe0ba7861ff38c4d4904c1c1a99/pydantic_core-2.41.5-cp314-cp314t-win_arm64.whl", hash = "sha256:35b44f37a3199f771c3eaa53051bc8a70cd7b54f333531c59e29fd4db5d15008", size = 1974769, upload-time = "2025-11-04T13:42:01.186Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/09/32/59b0c7e63e277fa7911c2fc70ccfb45ce4b98991e7ef37110663437005af/pydantic_core-2.41.5-graalpy312-graalpy250_312_native-macosx_10_12_x86_64.whl", hash = "sha256:7da7087d756b19037bc2c06edc6c170eeef3c3bafcb8f532ff17d64dc427adfd", size = 2110495, upload-time = "2025-11-04T13:42:49.689Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/aa/81/05e400037eaf55ad400bcd318c05bb345b57e708887f07ddb2d20e3f0e98/pydantic_core-2.41.5-graalpy312-graalpy250_312_native-macosx_11_0_arm64.whl", hash = "sha256:aabf5777b5c8ca26f7824cb4a120a740c9588ed58df9b2d196ce92fba42ff8dc", size = 1915388, upload-time = "2025-11-04T13:42:52.215Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/6e/0d/e3549b2399f71d56476b77dbf3cf8937cec5cd70536bdc0e374a421d0599/pydantic_core-2.41.5-graalpy312-graalpy250_312_native-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:c007fe8a43d43b3969e8469004e9845944f1a80e6acd47c150856bb87f230c56", size = 1942879, upload-time = "2025-11-04T13:42:56.483Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/f7/07/34573da085946b6a313d7c42f82f16e8920bfd730665de2d11c0c37a74b5/pydantic_core-2.41.5-graalpy312-graalpy250_312_native-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:76d0819de158cd855d1cbb8fcafdf6f5cf1eb8e470abe056d5d161106e38062b", size = 2139017, upload-time = "2025-11-04T13:42:59.471Z" },
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "pydantic-settings"
|
||||||
|
version = "2.13.1"
|
||||||
|
source = { registry = "https://pypi.org/simple" }
|
||||||
|
dependencies = [
|
||||||
|
{ name = "pydantic" },
|
||||||
|
{ name = "python-dotenv" },
|
||||||
|
{ name = "typing-inspection" },
|
||||||
|
]
|
||||||
|
sdist = { url = "https://files.pythonhosted.org/packages/52/6d/fffca34caecc4a3f97bda81b2098da5e8ab7efc9a66e819074a11955d87e/pydantic_settings-2.13.1.tar.gz", hash = "sha256:b4c11847b15237fb0171e1462bf540e294affb9b86db4d9aa5c01730bdbe4025", size = 223826, upload-time = "2026-02-19T13:45:08.055Z" }
|
||||||
|
wheels = [
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/00/4b/ccc026168948fec4f7555b9164c724cf4125eac006e176541483d2c959be/pydantic_settings-2.13.1-py3-none-any.whl", hash = "sha256:d56fd801823dbeae7f0975e1f8c8e25c258eb75d278ea7abb5d9cebb01b56237", size = 58929, upload-time = "2026-02-19T13:45:06.034Z" },
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "pygments"
|
||||||
|
version = "2.19.2"
|
||||||
|
source = { registry = "https://pypi.org/simple" }
|
||||||
|
sdist = { url = "https://files.pythonhosted.org/packages/b0/77/a5b8c569bf593b0140bde72ea885a803b82086995367bf2037de0159d924/pygments-2.19.2.tar.gz", hash = "sha256:636cb2477cec7f8952536970bc533bc43743542f70392ae026374600add5b887", size = 4968631, upload-time = "2025-06-21T13:39:12.283Z" }
|
||||||
|
wheels = [
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/c7/21/705964c7812476f378728bdf590ca4b771ec72385c533964653c68e86bdc/pygments-2.19.2-py3-none-any.whl", hash = "sha256:86540386c03d588bb81d44bc3928634ff26449851e99741617ecb9037ee5ec0b", size = 1225217, upload-time = "2025-06-21T13:39:07.939Z" },
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "pytest"
|
||||||
|
version = "9.0.2"
|
||||||
|
source = { registry = "https://pypi.org/simple" }
|
||||||
|
dependencies = [
|
||||||
|
{ name = "colorama", marker = "sys_platform == 'win32'" },
|
||||||
|
{ name = "iniconfig" },
|
||||||
|
{ name = "packaging" },
|
||||||
|
{ name = "pluggy" },
|
||||||
|
{ name = "pygments" },
|
||||||
|
]
|
||||||
|
sdist = { url = "https://files.pythonhosted.org/packages/d1/db/7ef3487e0fb0049ddb5ce41d3a49c235bf9ad299b6a25d5780a89f19230f/pytest-9.0.2.tar.gz", hash = "sha256:75186651a92bd89611d1d9fc20f0b4345fd827c41ccd5c299a868a05d70edf11", size = 1568901, upload-time = "2025-12-06T21:30:51.014Z" }
|
||||||
|
wheels = [
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/3b/ab/b3226f0bd7cdcf710fbede2b3548584366da3b19b5021e74f5bde2a8fa3f/pytest-9.0.2-py3-none-any.whl", hash = "sha256:711ffd45bf766d5264d487b917733b453d917afd2b0ad65223959f59089f875b", size = 374801, upload-time = "2025-12-06T21:30:49.154Z" },
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "python-dotenv"
|
||||||
|
version = "1.2.2"
|
||||||
|
source = { registry = "https://pypi.org/simple" }
|
||||||
|
sdist = { url = "https://files.pythonhosted.org/packages/82/ed/0301aeeac3e5353ef3d94b6ec08bbcabd04a72018415dcb29e588514bba8/python_dotenv-1.2.2.tar.gz", hash = "sha256:2c371a91fbd7ba082c2c1dc1f8bf89ca22564a087c2c287cd9b662adde799cf3", size = 50135, upload-time = "2026-03-01T16:00:26.196Z" }
|
||||||
|
wheels = [
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/0b/d7/1959b9648791274998a9c3526f6d0ec8fd2233e4d4acce81bbae76b44b2a/python_dotenv-1.2.2-py3-none-any.whl", hash = "sha256:1d8214789a24de455a8b8bd8ae6fe3c6b69a5e3d64aa8a8e5d68e694bbcb285a", size = 22101, upload-time = "2026-03-01T16:00:25.09Z" },
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "python-multipart"
|
||||||
|
version = "0.0.22"
|
||||||
|
source = { registry = "https://pypi.org/simple" }
|
||||||
|
sdist = { url = "https://files.pythonhosted.org/packages/94/01/979e98d542a70714b0cb2b6728ed0b7c46792b695e3eaec3e20711271ca3/python_multipart-0.0.22.tar.gz", hash = "sha256:7340bef99a7e0032613f56dc36027b959fd3b30a787ed62d310e951f7c3a3a58", size = 37612, upload-time = "2026-01-25T10:15:56.219Z" }
|
||||||
|
wheels = [
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/1b/d0/397f9626e711ff749a95d96b7af99b9c566a9bb5129b8e4c10fc4d100304/python_multipart-0.0.22-py3-none-any.whl", hash = "sha256:2b2cd894c83d21bf49d702499531c7bafd057d730c201782048f7945d82de155", size = 24579, upload-time = "2026-01-25T10:15:54.811Z" },
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "pyyaml"
|
||||||
|
version = "6.0.3"
|
||||||
|
source = { registry = "https://pypi.org/simple" }
|
||||||
|
sdist = { url = "https://files.pythonhosted.org/packages/05/8e/961c0007c59b8dd7729d542c61a4d537767a59645b82a0b521206e1e25c2/pyyaml-6.0.3.tar.gz", hash = "sha256:d76623373421df22fb4cf8817020cbb7ef15c725b9d5e45f17e189bfc384190f", size = 130960, upload-time = "2025-09-25T21:33:16.546Z" }
|
||||||
|
wheels = [
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/d1/33/422b98d2195232ca1826284a76852ad5a86fe23e31b009c9886b2d0fb8b2/pyyaml-6.0.3-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:7f047e29dcae44602496db43be01ad42fc6f1cc0d8cd6c83d342306c32270196", size = 182063, upload-time = "2025-09-25T21:32:11.445Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/89/a0/6cf41a19a1f2f3feab0e9c0b74134aa2ce6849093d5517a0c550fe37a648/pyyaml-6.0.3-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:fc09d0aa354569bc501d4e787133afc08552722d3ab34836a80547331bb5d4a0", size = 173973, upload-time = "2025-09-25T21:32:12.492Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/ed/23/7a778b6bd0b9a8039df8b1b1d80e2e2ad78aa04171592c8a5c43a56a6af4/pyyaml-6.0.3-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:9149cad251584d5fb4981be1ecde53a1ca46c891a79788c0df828d2f166bda28", size = 775116, upload-time = "2025-09-25T21:32:13.652Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/65/30/d7353c338e12baef4ecc1b09e877c1970bd3382789c159b4f89d6a70dc09/pyyaml-6.0.3-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:5fdec68f91a0c6739b380c83b951e2c72ac0197ace422360e6d5a959d8d97b2c", size = 844011, upload-time = "2025-09-25T21:32:15.21Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/8b/9d/b3589d3877982d4f2329302ef98a8026e7f4443c765c46cfecc8858c6b4b/pyyaml-6.0.3-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:ba1cc08a7ccde2d2ec775841541641e4548226580ab850948cbfda66a1befcdc", size = 807870, upload-time = "2025-09-25T21:32:16.431Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/05/c0/b3be26a015601b822b97d9149ff8cb5ead58c66f981e04fedf4e762f4bd4/pyyaml-6.0.3-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:8dc52c23056b9ddd46818a57b78404882310fb473d63f17b07d5c40421e47f8e", size = 761089, upload-time = "2025-09-25T21:32:17.56Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/be/8e/98435a21d1d4b46590d5459a22d88128103f8da4c2d4cb8f14f2a96504e1/pyyaml-6.0.3-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:41715c910c881bc081f1e8872880d3c650acf13dfa8214bad49ed4cede7c34ea", size = 790181, upload-time = "2025-09-25T21:32:18.834Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/74/93/7baea19427dcfbe1e5a372d81473250b379f04b1bd3c4c5ff825e2327202/pyyaml-6.0.3-cp312-cp312-win32.whl", hash = "sha256:96b533f0e99f6579b3d4d4995707cf36df9100d67e0c8303a0c55b27b5f99bc5", size = 137658, upload-time = "2025-09-25T21:32:20.209Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/86/bf/899e81e4cce32febab4fb42bb97dcdf66bc135272882d1987881a4b519e9/pyyaml-6.0.3-cp312-cp312-win_amd64.whl", hash = "sha256:5fcd34e47f6e0b794d17de1b4ff496c00986e1c83f7ab2fb8fcfe9616ff7477b", size = 154003, upload-time = "2025-09-25T21:32:21.167Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/1a/08/67bd04656199bbb51dbed1439b7f27601dfb576fb864099c7ef0c3e55531/pyyaml-6.0.3-cp312-cp312-win_arm64.whl", hash = "sha256:64386e5e707d03a7e172c0701abfb7e10f0fb753ee1d773128192742712a98fd", size = 140344, upload-time = "2025-09-25T21:32:22.617Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/d1/11/0fd08f8192109f7169db964b5707a2f1e8b745d4e239b784a5a1dd80d1db/pyyaml-6.0.3-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:8da9669d359f02c0b91ccc01cac4a67f16afec0dac22c2ad09f46bee0697eba8", size = 181669, upload-time = "2025-09-25T21:32:23.673Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/b1/16/95309993f1d3748cd644e02e38b75d50cbc0d9561d21f390a76242ce073f/pyyaml-6.0.3-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:2283a07e2c21a2aa78d9c4442724ec1eb15f5e42a723b99cb3d822d48f5f7ad1", size = 173252, upload-time = "2025-09-25T21:32:25.149Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/50/31/b20f376d3f810b9b2371e72ef5adb33879b25edb7a6d072cb7ca0c486398/pyyaml-6.0.3-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:ee2922902c45ae8ccada2c5b501ab86c36525b883eff4255313a253a3160861c", size = 767081, upload-time = "2025-09-25T21:32:26.575Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/49/1e/a55ca81e949270d5d4432fbbd19dfea5321eda7c41a849d443dc92fd1ff7/pyyaml-6.0.3-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:a33284e20b78bd4a18c8c2282d549d10bc8408a2a7ff57653c0cf0b9be0afce5", size = 841159, upload-time = "2025-09-25T21:32:27.727Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/74/27/e5b8f34d02d9995b80abcef563ea1f8b56d20134d8f4e5e81733b1feceb2/pyyaml-6.0.3-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:0f29edc409a6392443abf94b9cf89ce99889a1dd5376d94316ae5145dfedd5d6", size = 801626, upload-time = "2025-09-25T21:32:28.878Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/f9/11/ba845c23988798f40e52ba45f34849aa8a1f2d4af4b798588010792ebad6/pyyaml-6.0.3-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:f7057c9a337546edc7973c0d3ba84ddcdf0daa14533c2065749c9075001090e6", size = 753613, upload-time = "2025-09-25T21:32:30.178Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/3d/e0/7966e1a7bfc0a45bf0a7fb6b98ea03fc9b8d84fa7f2229e9659680b69ee3/pyyaml-6.0.3-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:eda16858a3cab07b80edaf74336ece1f986ba330fdb8ee0d6c0d68fe82bc96be", size = 794115, upload-time = "2025-09-25T21:32:31.353Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/de/94/980b50a6531b3019e45ddeada0626d45fa85cbe22300844a7983285bed3b/pyyaml-6.0.3-cp313-cp313-win32.whl", hash = "sha256:d0eae10f8159e8fdad514efdc92d74fd8d682c933a6dd088030f3834bc8e6b26", size = 137427, upload-time = "2025-09-25T21:32:32.58Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/97/c9/39d5b874e8b28845e4ec2202b5da735d0199dbe5b8fb85f91398814a9a46/pyyaml-6.0.3-cp313-cp313-win_amd64.whl", hash = "sha256:79005a0d97d5ddabfeeea4cf676af11e647e41d81c9a7722a193022accdb6b7c", size = 154090, upload-time = "2025-09-25T21:32:33.659Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/73/e8/2bdf3ca2090f68bb3d75b44da7bbc71843b19c9f2b9cb9b0f4ab7a5a4329/pyyaml-6.0.3-cp313-cp313-win_arm64.whl", hash = "sha256:5498cd1645aa724a7c71c8f378eb29ebe23da2fc0d7a08071d89469bf1d2defb", size = 140246, upload-time = "2025-09-25T21:32:34.663Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/9d/8c/f4bd7f6465179953d3ac9bc44ac1a8a3e6122cf8ada906b4f96c60172d43/pyyaml-6.0.3-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:8d1fab6bb153a416f9aeb4b8763bc0f22a5586065f86f7664fc23339fc1c1fac", size = 181814, upload-time = "2025-09-25T21:32:35.712Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/bd/9c/4d95bb87eb2063d20db7b60faa3840c1b18025517ae857371c4dd55a6b3a/pyyaml-6.0.3-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:34d5fcd24b8445fadc33f9cf348c1047101756fd760b4dacb5c3e99755703310", size = 173809, upload-time = "2025-09-25T21:32:36.789Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/92/b5/47e807c2623074914e29dabd16cbbdd4bf5e9b2db9f8090fa64411fc5382/pyyaml-6.0.3-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:501a031947e3a9025ed4405a168e6ef5ae3126c59f90ce0cd6f2bfc477be31b7", size = 766454, upload-time = "2025-09-25T21:32:37.966Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/02/9e/e5e9b168be58564121efb3de6859c452fccde0ab093d8438905899a3a483/pyyaml-6.0.3-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:b3bc83488de33889877a0f2543ade9f70c67d66d9ebb4ac959502e12de895788", size = 836355, upload-time = "2025-09-25T21:32:39.178Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/88/f9/16491d7ed2a919954993e48aa941b200f38040928474c9e85ea9e64222c3/pyyaml-6.0.3-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:c458b6d084f9b935061bc36216e8a69a7e293a2f1e68bf956dcd9e6cbcd143f5", size = 794175, upload-time = "2025-09-25T21:32:40.865Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/dd/3f/5989debef34dc6397317802b527dbbafb2b4760878a53d4166579111411e/pyyaml-6.0.3-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:7c6610def4f163542a622a73fb39f534f8c101d690126992300bf3207eab9764", size = 755228, upload-time = "2025-09-25T21:32:42.084Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/d7/ce/af88a49043cd2e265be63d083fc75b27b6ed062f5f9fd6cdc223ad62f03e/pyyaml-6.0.3-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:5190d403f121660ce8d1d2c1bb2ef1bd05b5f68533fc5c2ea899bd15f4399b35", size = 789194, upload-time = "2025-09-25T21:32:43.362Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/23/20/bb6982b26a40bb43951265ba29d4c246ef0ff59c9fdcdf0ed04e0687de4d/pyyaml-6.0.3-cp314-cp314-win_amd64.whl", hash = "sha256:4a2e8cebe2ff6ab7d1050ecd59c25d4c8bd7e6f400f5f82b96557ac0abafd0ac", size = 156429, upload-time = "2025-09-25T21:32:57.844Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/f4/f4/a4541072bb9422c8a883ab55255f918fa378ecf083f5b85e87fc2b4eda1b/pyyaml-6.0.3-cp314-cp314-win_arm64.whl", hash = "sha256:93dda82c9c22deb0a405ea4dc5f2d0cda384168e466364dec6255b293923b2f3", size = 143912, upload-time = "2025-09-25T21:32:59.247Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/7c/f9/07dd09ae774e4616edf6cda684ee78f97777bdd15847253637a6f052a62f/pyyaml-6.0.3-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:02893d100e99e03eda1c8fd5c441d8c60103fd175728e23e431db1b589cf5ab3", size = 189108, upload-time = "2025-09-25T21:32:44.377Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/4e/78/8d08c9fb7ce09ad8c38ad533c1191cf27f7ae1effe5bb9400a46d9437fcf/pyyaml-6.0.3-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:c1ff362665ae507275af2853520967820d9124984e0f7466736aea23d8611fba", size = 183641, upload-time = "2025-09-25T21:32:45.407Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/7b/5b/3babb19104a46945cf816d047db2788bcaf8c94527a805610b0289a01c6b/pyyaml-6.0.3-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:6adc77889b628398debc7b65c073bcb99c4a0237b248cacaf3fe8a557563ef6c", size = 831901, upload-time = "2025-09-25T21:32:48.83Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/8b/cc/dff0684d8dc44da4d22a13f35f073d558c268780ce3c6ba1b87055bb0b87/pyyaml-6.0.3-cp314-cp314t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:a80cb027f6b349846a3bf6d73b5e95e782175e52f22108cfa17876aaeff93702", size = 861132, upload-time = "2025-09-25T21:32:50.149Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/b1/5e/f77dc6b9036943e285ba76b49e118d9ea929885becb0a29ba8a7c75e29fe/pyyaml-6.0.3-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:00c4bdeba853cc34e7dd471f16b4114f4162dc03e6b7afcc2128711f0eca823c", size = 839261, upload-time = "2025-09-25T21:32:51.808Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/ce/88/a9db1376aa2a228197c58b37302f284b5617f56a5d959fd1763fb1675ce6/pyyaml-6.0.3-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:66e1674c3ef6f541c35191caae2d429b967b99e02040f5ba928632d9a7f0f065", size = 805272, upload-time = "2025-09-25T21:32:52.941Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/da/92/1446574745d74df0c92e6aa4a7b0b3130706a4142b2d1a5869f2eaa423c6/pyyaml-6.0.3-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:16249ee61e95f858e83976573de0f5b2893b3677ba71c9dd36b9cf8be9ac6d65", size = 829923, upload-time = "2025-09-25T21:32:54.537Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/f0/7a/1c7270340330e575b92f397352af856a8c06f230aa3e76f86b39d01b416a/pyyaml-6.0.3-cp314-cp314t-win_amd64.whl", hash = "sha256:4ad1906908f2f5ae4e5a8ddfce73c320c2a1429ec52eafd27138b7f1cbe341c9", size = 174062, upload-time = "2025-09-25T21:32:55.767Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/f1/12/de94a39c2ef588c7e6455cfbe7343d3b2dc9d6b6b2f40c4c6565744c873d/pyyaml-6.0.3-cp314-cp314t-win_arm64.whl", hash = "sha256:ebc55a14a21cb14062aa4162f906cd962b28e2e9ea38f9b4391244cd8de4ae0b", size = 149341, upload-time = "2025-09-25T21:32:56.828Z" },
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "ruff"
|
||||||
|
version = "0.15.4"
|
||||||
|
source = { registry = "https://pypi.org/simple" }
|
||||||
|
sdist = { url = "https://files.pythonhosted.org/packages/da/31/d6e536cdebb6568ae75a7f00e4b4819ae0ad2640c3604c305a0428680b0c/ruff-0.15.4.tar.gz", hash = "sha256:3412195319e42d634470cc97aa9803d07e9d5c9223b99bcb1518f0c725f26ae1", size = 4569550, upload-time = "2026-02-26T20:04:14.959Z" }
|
||||||
|
wheels = [
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/f2/82/c11a03cfec3a4d26a0ea1e571f0f44be5993b923f905eeddfc397c13d360/ruff-0.15.4-py3-none-linux_armv6l.whl", hash = "sha256:a1810931c41606c686bae8b5b9a8072adac2f611bb433c0ba476acba17a332e0", size = 10453333, upload-time = "2026-02-26T20:04:20.093Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/ce/5d/6a1f271f6e31dffb31855996493641edc3eef8077b883eaf007a2f1c2976/ruff-0.15.4-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:5a1632c66672b8b4d3e1d1782859e98d6e0b4e70829530666644286600a33992", size = 10853356, upload-time = "2026-02-26T20:04:05.808Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/b1/d8/0fab9f8842b83b1a9c2bf81b85063f65e93fb512e60effa95b0be49bfc54/ruff-0.15.4-py3-none-macosx_11_0_arm64.whl", hash = "sha256:a4386ba2cd6c0f4ff75252845906acc7c7c8e1ac567b7bc3d373686ac8c222ba", size = 10187434, upload-time = "2026-02-26T20:03:54.656Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/85/cc/cc220fd9394eff5db8d94dec199eec56dd6c9f3651d8869d024867a91030/ruff-0.15.4-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:b2496488bdfd3732747558b6f95ae427ff066d1fcd054daf75f5a50674411e75", size = 10535456, upload-time = "2026-02-26T20:03:52.738Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/fa/0f/bced38fa5cf24373ec767713c8e4cadc90247f3863605fb030e597878661/ruff-0.15.4-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:3f1c4893841ff2d54cbda1b2860fa3260173df5ddd7b95d370186f8a5e66a4ac", size = 10287772, upload-time = "2026-02-26T20:04:08.138Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/2b/90/58a1802d84fed15f8f281925b21ab3cecd813bde52a8ca033a4de8ab0e7a/ruff-0.15.4-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:820b8766bd65503b6c30aaa6331e8ef3a6e564f7999c844e9a547c40179e440a", size = 11049051, upload-time = "2026-02-26T20:04:03.53Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/d2/ac/b7ad36703c35f3866584564dc15f12f91cb1a26a897dc2fd13d7cb3ae1af/ruff-0.15.4-py3-none-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:c9fb74bab47139c1751f900f857fa503987253c3ef89129b24ed375e72873e85", size = 11890494, upload-time = "2026-02-26T20:04:10.497Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/93/3d/3eb2f47a39a8b0da99faf9c54d3eb24720add1e886a5309d4d1be73a6380/ruff-0.15.4-py3-none-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:f80c98765949c518142b3a50a5db89343aa90f2c2bf7799de9986498ae6176db", size = 11326221, upload-time = "2026-02-26T20:04:12.84Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/ff/90/bf134f4c1e5243e62690e09d63c55df948a74084c8ac3e48a88468314da6/ruff-0.15.4-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:451a2e224151729b3b6c9ffb36aed9091b2996fe4bdbd11f47e27d8f2e8888ec", size = 11168459, upload-time = "2026-02-26T20:04:00.969Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/b5/e5/a64d27688789b06b5d55162aafc32059bb8c989c61a5139a36e1368285eb/ruff-0.15.4-py3-none-manylinux_2_31_riscv64.whl", hash = "sha256:a8f157f2e583c513c4f5f896163a93198297371f34c04220daf40d133fdd4f7f", size = 11104366, upload-time = "2026-02-26T20:03:48.099Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/f1/f6/32d1dcb66a2559763fc3027bdd65836cad9eb09d90f2ed6a63d8e9252b02/ruff-0.15.4-py3-none-musllinux_1_2_aarch64.whl", hash = "sha256:917cc68503357021f541e69b35361c99387cdbbf99bd0ea4aa6f28ca99ff5338", size = 10510887, upload-time = "2026-02-26T20:03:45.771Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/ff/92/22d1ced50971c5b6433aed166fcef8c9343f567a94cf2b9d9089f6aa80fe/ruff-0.15.4-py3-none-musllinux_1_2_armv7l.whl", hash = "sha256:e9737c8161da79fd7cfec19f1e35620375bd8b2a50c3e77fa3d2c16f574105cc", size = 10285939, upload-time = "2026-02-26T20:04:22.42Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/e6/f4/7c20aec3143837641a02509a4668fb146a642fd1211846634edc17eb5563/ruff-0.15.4-py3-none-musllinux_1_2_i686.whl", hash = "sha256:291258c917539e18f6ba40482fe31d6f5ac023994ee11d7bdafd716f2aab8a68", size = 10765471, upload-time = "2026-02-26T20:03:58.924Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/d0/09/6d2f7586f09a16120aebdff8f64d962d7c4348313c77ebb29c566cefc357/ruff-0.15.4-py3-none-musllinux_1_2_x86_64.whl", hash = "sha256:3f83c45911da6f2cd5936c436cf86b9f09f09165f033a99dcf7477e34041cbc3", size = 11263382, upload-time = "2026-02-26T20:04:24.424Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/1b/fa/2ef715a1cd329ef47c1a050e10dee91a9054b7ce2fcfdd6a06d139afb7ec/ruff-0.15.4-py3-none-win32.whl", hash = "sha256:65594a2d557d4ee9f02834fcdf0a28daa8b3b9f6cb2cb93846025a36db47ef22", size = 10506664, upload-time = "2026-02-26T20:03:50.56Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/d0/a8/c688ef7e29983976820d18710f955751d9f4d4eb69df658af3d006e2ba3e/ruff-0.15.4-py3-none-win_amd64.whl", hash = "sha256:04196ad44f0df220c2ece5b0e959c2f37c777375ec744397d21d15b50a75264f", size = 11651048, upload-time = "2026-02-26T20:04:17.191Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/3e/0a/9e1be9035b37448ce2e68c978f0591da94389ade5a5abafa4cf99985d1b2/ruff-0.15.4-py3-none-win_arm64.whl", hash = "sha256:60d5177e8cfc70e51b9c5fad936c634872a74209f934c1e79107d11787ad5453", size = 10966776, upload-time = "2026-02-26T20:03:56.908Z" },
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "starlette"
|
||||||
|
version = "0.52.1"
|
||||||
|
source = { registry = "https://pypi.org/simple" }
|
||||||
|
dependencies = [
|
||||||
|
{ name = "anyio" },
|
||||||
|
{ name = "typing-extensions", marker = "python_full_version < '3.13'" },
|
||||||
|
]
|
||||||
|
sdist = { url = "https://files.pythonhosted.org/packages/c4/68/79977123bb7be889ad680d79a40f339082c1978b5cfcf62c2d8d196873ac/starlette-0.52.1.tar.gz", hash = "sha256:834edd1b0a23167694292e94f597773bc3f89f362be6effee198165a35d62933", size = 2653702, upload-time = "2026-01-18T13:34:11.062Z" }
|
||||||
|
wheels = [
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/81/0d/13d1d239a25cbfb19e740db83143e95c772a1fe10202dda4b76792b114dd/starlette-0.52.1-py3-none-any.whl", hash = "sha256:0029d43eb3d273bc4f83a08720b4912ea4b071087a3b48db01b7c839f7954d74", size = 74272, upload-time = "2026-01-18T13:34:09.188Z" },
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "typing-extensions"
|
||||||
|
version = "4.15.0"
|
||||||
|
source = { registry = "https://pypi.org/simple" }
|
||||||
|
sdist = { url = "https://files.pythonhosted.org/packages/72/94/1a15dd82efb362ac84269196e94cf00f187f7ed21c242792a923cdb1c61f/typing_extensions-4.15.0.tar.gz", hash = "sha256:0cea48d173cc12fa28ecabc3b837ea3cf6f38c6d1136f85cbaaf598984861466", size = 109391, upload-time = "2025-08-25T13:49:26.313Z" }
|
||||||
|
wheels = [
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/18/67/36e9267722cc04a6b9f15c7f3441c2363321a3ea07da7ae0c0707beb2a9c/typing_extensions-4.15.0-py3-none-any.whl", hash = "sha256:f0fa19c6845758ab08074a0cfa8b7aecb71c999ca73d62883bc25cc018c4e548", size = 44614, upload-time = "2025-08-25T13:49:24.86Z" },
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "typing-inspection"
|
||||||
|
version = "0.4.2"
|
||||||
|
source = { registry = "https://pypi.org/simple" }
|
||||||
|
dependencies = [
|
||||||
|
{ name = "typing-extensions" },
|
||||||
|
]
|
||||||
|
sdist = { url = "https://files.pythonhosted.org/packages/55/e3/70399cb7dd41c10ac53367ae42139cf4b1ca5f36bb3dc6c9d33acdb43655/typing_inspection-0.4.2.tar.gz", hash = "sha256:ba561c48a67c5958007083d386c3295464928b01faa735ab8547c5692e87f464", size = 75949, upload-time = "2025-10-01T02:14:41.687Z" }
|
||||||
|
wheels = [
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/dc/9b/47798a6c91d8bdb567fe2698fe81e0c6b7cb7ef4d13da4114b41d239f65d/typing_inspection-0.4.2-py3-none-any.whl", hash = "sha256:4ed1cacbdc298c220f1bd249ed5287caa16f34d44ef4e9c3d0cbad5b521545e7", size = 14611, upload-time = "2025-10-01T02:14:40.154Z" },
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "uvicorn"
|
||||||
|
version = "0.41.0"
|
||||||
|
source = { registry = "https://pypi.org/simple" }
|
||||||
|
dependencies = [
|
||||||
|
{ name = "click" },
|
||||||
|
{ name = "h11" },
|
||||||
|
]
|
||||||
|
sdist = { url = "https://files.pythonhosted.org/packages/32/ce/eeb58ae4ac36fe09e3842eb02e0eb676bf2c53ae062b98f1b2531673efdd/uvicorn-0.41.0.tar.gz", hash = "sha256:09d11cf7008da33113824ee5a1c6422d89fbc2ff476540d69a34c87fab8b571a", size = 82633, upload-time = "2026-02-16T23:07:24.1Z" }
|
||||||
|
wheels = [
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/83/e4/d04a086285c20886c0daad0e026f250869201013d18f81d9ff5eada73a88/uvicorn-0.41.0-py3-none-any.whl", hash = "sha256:29e35b1d2c36a04b9e180d4007ede3bcb32a85fbdfd6c6aeb3f26839de088187", size = 68783, upload-time = "2026-02-16T23:07:22.357Z" },
|
||||||
|
]
|
||||||
|
|
||||||
|
[package.optional-dependencies]
|
||||||
|
standard = [
|
||||||
|
{ name = "colorama", marker = "sys_platform == 'win32'" },
|
||||||
|
{ name = "httptools" },
|
||||||
|
{ name = "python-dotenv" },
|
||||||
|
{ name = "pyyaml" },
|
||||||
|
{ name = "uvloop", marker = "platform_python_implementation != 'PyPy' and sys_platform != 'cygwin' and sys_platform != 'win32'" },
|
||||||
|
{ name = "watchfiles" },
|
||||||
|
{ name = "websockets" },
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "uvloop"
|
||||||
|
version = "0.22.1"
|
||||||
|
source = { registry = "https://pypi.org/simple" }
|
||||||
|
sdist = { url = "https://files.pythonhosted.org/packages/06/f0/18d39dbd1971d6d62c4629cc7fa67f74821b0dc1f5a77af43719de7936a7/uvloop-0.22.1.tar.gz", hash = "sha256:6c84bae345b9147082b17371e3dd5d42775bddce91f885499017f4607fdaf39f", size = 2443250, upload-time = "2025-10-16T22:17:19.342Z" }
|
||||||
|
wheels = [
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/3d/ff/7f72e8170be527b4977b033239a83a68d5c881cc4775fca255c677f7ac5d/uvloop-0.22.1-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:fe94b4564e865d968414598eea1a6de60adba0c040ba4ed05ac1300de402cd42", size = 1359936, upload-time = "2025-10-16T22:16:29.436Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/c3/c6/e5d433f88fd54d81ef4be58b2b7b0cea13c442454a1db703a1eea0db1a59/uvloop-0.22.1-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:51eb9bd88391483410daad430813d982010f9c9c89512321f5b60e2cddbdddd6", size = 752769, upload-time = "2025-10-16T22:16:30.493Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/24/68/a6ac446820273e71aa762fa21cdcc09861edd3536ff47c5cd3b7afb10eeb/uvloop-0.22.1-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:700e674a166ca5778255e0e1dc4e9d79ab2acc57b9171b79e65feba7184b3370", size = 4317413, upload-time = "2025-10-16T22:16:31.644Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/5f/6f/e62b4dfc7ad6518e7eff2516f680d02a0f6eb62c0c212e152ca708a0085e/uvloop-0.22.1-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:7b5b1ac819a3f946d3b2ee07f09149578ae76066d70b44df3fa990add49a82e4", size = 4426307, upload-time = "2025-10-16T22:16:32.917Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/90/60/97362554ac21e20e81bcef1150cb2a7e4ffdaf8ea1e5b2e8bf7a053caa18/uvloop-0.22.1-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:e047cc068570bac9866237739607d1313b9253c3051ad84738cbb095be0537b2", size = 4131970, upload-time = "2025-10-16T22:16:34.015Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/99/39/6b3f7d234ba3964c428a6e40006340f53ba37993f46ed6e111c6e9141d18/uvloop-0.22.1-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:512fec6815e2dd45161054592441ef76c830eddaad55c8aa30952e6fe1ed07c0", size = 4296343, upload-time = "2025-10-16T22:16:35.149Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/89/8c/182a2a593195bfd39842ea68ebc084e20c850806117213f5a299dfc513d9/uvloop-0.22.1-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:561577354eb94200d75aca23fbde86ee11be36b00e52a4eaf8f50fb0c86b7705", size = 1358611, upload-time = "2025-10-16T22:16:36.833Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/d2/14/e301ee96a6dc95224b6f1162cd3312f6d1217be3907b79173b06785f2fe7/uvloop-0.22.1-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:1cdf5192ab3e674ca26da2eada35b288d2fa49fdd0f357a19f0e7c4e7d5077c8", size = 751811, upload-time = "2025-10-16T22:16:38.275Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/b7/02/654426ce265ac19e2980bfd9ea6590ca96a56f10c76e63801a2df01c0486/uvloop-0.22.1-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:6e2ea3d6190a2968f4a14a23019d3b16870dd2190cd69c8180f7c632d21de68d", size = 4288562, upload-time = "2025-10-16T22:16:39.375Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/15/c0/0be24758891ef825f2065cd5db8741aaddabe3e248ee6acc5e8a80f04005/uvloop-0.22.1-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:0530a5fbad9c9e4ee3f2b33b148c6a64d47bbad8000ea63704fa8260f4cf728e", size = 4366890, upload-time = "2025-10-16T22:16:40.547Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/d2/53/8369e5219a5855869bcee5f4d317f6da0e2c669aecf0ef7d371e3d084449/uvloop-0.22.1-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:bc5ef13bbc10b5335792360623cc378d52d7e62c2de64660616478c32cd0598e", size = 4119472, upload-time = "2025-10-16T22:16:41.694Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/f8/ba/d69adbe699b768f6b29a5eec7b47dd610bd17a69de51b251126a801369ea/uvloop-0.22.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:1f38ec5e3f18c8a10ded09742f7fb8de0108796eb673f30ce7762ce1b8550cad", size = 4239051, upload-time = "2025-10-16T22:16:43.224Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/90/cd/b62bdeaa429758aee8de8b00ac0dd26593a9de93d302bff3d21439e9791d/uvloop-0.22.1-cp314-cp314-macosx_10_13_universal2.whl", hash = "sha256:3879b88423ec7e97cd4eba2a443aa26ed4e59b45e6b76aabf13fe2f27023a142", size = 1362067, upload-time = "2025-10-16T22:16:44.503Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/0d/f8/a132124dfda0777e489ca86732e85e69afcd1ff7686647000050ba670689/uvloop-0.22.1-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:4baa86acedf1d62115c1dc6ad1e17134476688f08c6efd8a2ab076e815665c74", size = 752423, upload-time = "2025-10-16T22:16:45.968Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/a3/94/94af78c156f88da4b3a733773ad5ba0b164393e357cc4bd0ab2e2677a7d6/uvloop-0.22.1-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:297c27d8003520596236bdb2335e6b3f649480bd09e00d1e3a99144b691d2a35", size = 4272437, upload-time = "2025-10-16T22:16:47.451Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/b5/35/60249e9fd07b32c665192cec7af29e06c7cd96fa1d08b84f012a56a0b38e/uvloop-0.22.1-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:c1955d5a1dd43198244d47664a5858082a3239766a839b2102a269aaff7a4e25", size = 4292101, upload-time = "2025-10-16T22:16:49.318Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/02/62/67d382dfcb25d0a98ce73c11ed1a6fba5037a1a1d533dcbb7cab033a2636/uvloop-0.22.1-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:b31dc2fccbd42adc73bc4e7cdbae4fc5086cf378979e53ca5d0301838c5682c6", size = 4114158, upload-time = "2025-10-16T22:16:50.517Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/f0/7a/f1171b4a882a5d13c8b7576f348acfe6074d72eaf52cccef752f748d4a9f/uvloop-0.22.1-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:93f617675b2d03af4e72a5333ef89450dfaa5321303ede6e67ba9c9d26878079", size = 4177360, upload-time = "2025-10-16T22:16:52.646Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/79/7b/b01414f31546caf0919da80ad57cbfe24c56b151d12af68cee1b04922ca8/uvloop-0.22.1-cp314-cp314t-macosx_10_13_universal2.whl", hash = "sha256:37554f70528f60cad66945b885eb01f1bb514f132d92b6eeed1c90fd54ed6289", size = 1454790, upload-time = "2025-10-16T22:16:54.355Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/d4/31/0bb232318dd838cad3fa8fb0c68c8b40e1145b32025581975e18b11fab40/uvloop-0.22.1-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:b76324e2dc033a0b2f435f33eb88ff9913c156ef78e153fb210e03c13da746b3", size = 796783, upload-time = "2025-10-16T22:16:55.906Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/42/38/c9b09f3271a7a723a5de69f8e237ab8e7803183131bc57c890db0b6bb872/uvloop-0.22.1-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:badb4d8e58ee08dad957002027830d5c3b06aea446a6a3744483c2b3b745345c", size = 4647548, upload-time = "2025-10-16T22:16:57.008Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/c1/37/945b4ca0ac27e3dc4952642d4c900edd030b3da6c9634875af6e13ae80e5/uvloop-0.22.1-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:b91328c72635f6f9e0282e4a57da7470c7350ab1c9f48546c0f2866205349d21", size = 4467065, upload-time = "2025-10-16T22:16:58.206Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/97/cc/48d232f33d60e2e2e0b42f4e73455b146b76ebe216487e862700457fbf3c/uvloop-0.22.1-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:daf620c2995d193449393d6c62131b3fbd40a63bf7b307a1527856ace637fe88", size = 4328384, upload-time = "2025-10-16T22:16:59.36Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/e4/16/c1fd27e9549f3c4baf1dc9c20c456cd2f822dbf8de9f463824b0c0357e06/uvloop-0.22.1-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:6cde23eeda1a25c75b2e07d39970f3374105d5eafbaab2a4482be82f272d5a5e", size = 4296730, upload-time = "2025-10-16T22:17:00.744Z" },
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "watchfiles"
|
||||||
|
version = "1.1.1"
|
||||||
|
source = { registry = "https://pypi.org/simple" }
|
||||||
|
dependencies = [
|
||||||
|
{ name = "anyio" },
|
||||||
|
]
|
||||||
|
sdist = { url = "https://files.pythonhosted.org/packages/c2/c9/8869df9b2a2d6c59d79220a4db37679e74f807c559ffe5265e08b227a210/watchfiles-1.1.1.tar.gz", hash = "sha256:a173cb5c16c4f40ab19cecf48a534c409f7ea983ab8fed0741304a1c0a31b3f2", size = 94440, upload-time = "2025-10-14T15:06:21.08Z" }
|
||||||
|
wheels = [
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/74/d5/f039e7e3c639d9b1d09b07ea412a6806d38123f0508e5f9b48a87b0a76cc/watchfiles-1.1.1-cp312-cp312-macosx_10_12_x86_64.whl", hash = "sha256:8c89f9f2f740a6b7dcc753140dd5e1ab9215966f7a3530d0c0705c83b401bd7d", size = 404745, upload-time = "2025-10-14T15:04:46.731Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/a5/96/a881a13aa1349827490dab2d363c8039527060cfcc2c92cc6d13d1b1049e/watchfiles-1.1.1-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:bd404be08018c37350f0d6e34676bd1e2889990117a2b90070b3007f172d0610", size = 391769, upload-time = "2025-10-14T15:04:48.003Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/4b/5b/d3b460364aeb8da471c1989238ea0e56bec24b6042a68046adf3d9ddb01c/watchfiles-1.1.1-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:8526e8f916bb5b9a0a777c8317c23ce65de259422bba5b31325a6fa6029d33af", size = 449374, upload-time = "2025-10-14T15:04:49.179Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/b9/44/5769cb62d4ed055cb17417c0a109a92f007114a4e07f30812a73a4efdb11/watchfiles-1.1.1-cp312-cp312-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:2edc3553362b1c38d9f06242416a5d8e9fe235c204a4072e988ce2e5bb1f69f6", size = 459485, upload-time = "2025-10-14T15:04:50.155Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/19/0c/286b6301ded2eccd4ffd0041a1b726afda999926cf720aab63adb68a1e36/watchfiles-1.1.1-cp312-cp312-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:30f7da3fb3f2844259cba4720c3fc7138eb0f7b659c38f3bfa65084c7fc7abce", size = 488813, upload-time = "2025-10-14T15:04:51.059Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/c7/2b/8530ed41112dd4a22f4dcfdb5ccf6a1baad1ff6eed8dc5a5f09e7e8c41c7/watchfiles-1.1.1-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:f8979280bdafff686ba5e4d8f97840f929a87ed9cdf133cbbd42f7766774d2aa", size = 594816, upload-time = "2025-10-14T15:04:52.031Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/ce/d2/f5f9fb49489f184f18470d4f99f4e862a4b3e9ac2865688eb2099e3d837a/watchfiles-1.1.1-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:dcc5c24523771db3a294c77d94771abcfcb82a0e0ee8efd910c37c59ec1b31bb", size = 475186, upload-time = "2025-10-14T15:04:53.064Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/cf/68/5707da262a119fb06fbe214d82dd1fe4a6f4af32d2d14de368d0349eb52a/watchfiles-1.1.1-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:1db5d7ae38ff20153d542460752ff397fcf5c96090c1230803713cf3147a6803", size = 456812, upload-time = "2025-10-14T15:04:55.174Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/66/ab/3cbb8756323e8f9b6f9acb9ef4ec26d42b2109bce830cc1f3468df20511d/watchfiles-1.1.1-cp312-cp312-musllinux_1_1_aarch64.whl", hash = "sha256:28475ddbde92df1874b6c5c8aaeb24ad5be47a11f87cde5a28ef3835932e3e94", size = 630196, upload-time = "2025-10-14T15:04:56.22Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/78/46/7152ec29b8335f80167928944a94955015a345440f524d2dfe63fc2f437b/watchfiles-1.1.1-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:36193ed342f5b9842edd3532729a2ad55c4160ffcfa3700e0d54be496b70dd43", size = 622657, upload-time = "2025-10-14T15:04:57.521Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/0a/bf/95895e78dd75efe9a7f31733607f384b42eb5feb54bd2eb6ed57cc2e94f4/watchfiles-1.1.1-cp312-cp312-win32.whl", hash = "sha256:859e43a1951717cc8de7f4c77674a6d389b106361585951d9e69572823f311d9", size = 272042, upload-time = "2025-10-14T15:04:59.046Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/87/0a/90eb755f568de2688cb220171c4191df932232c20946966c27a59c400850/watchfiles-1.1.1-cp312-cp312-win_amd64.whl", hash = "sha256:91d4c9a823a8c987cce8fa2690923b069966dabb196dd8d137ea2cede885fde9", size = 288410, upload-time = "2025-10-14T15:05:00.081Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/36/76/f322701530586922fbd6723c4f91ace21364924822a8772c549483abed13/watchfiles-1.1.1-cp312-cp312-win_arm64.whl", hash = "sha256:a625815d4a2bdca61953dbba5a39d60164451ef34c88d751f6c368c3ea73d404", size = 278209, upload-time = "2025-10-14T15:05:01.168Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/bb/f4/f750b29225fe77139f7ae5de89d4949f5a99f934c65a1f1c0b248f26f747/watchfiles-1.1.1-cp313-cp313-macosx_10_12_x86_64.whl", hash = "sha256:130e4876309e8686a5e37dba7d5e9bc77e6ed908266996ca26572437a5271e18", size = 404321, upload-time = "2025-10-14T15:05:02.063Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/2b/f9/f07a295cde762644aa4c4bb0f88921d2d141af45e735b965fb2e87858328/watchfiles-1.1.1-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:5f3bde70f157f84ece3765b42b4a52c6ac1a50334903c6eaf765362f6ccca88a", size = 391783, upload-time = "2025-10-14T15:05:03.052Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/bc/11/fc2502457e0bea39a5c958d86d2cb69e407a4d00b85735ca724bfa6e0d1a/watchfiles-1.1.1-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:14e0b1fe858430fc0251737ef3824c54027bedb8c37c38114488b8e131cf8219", size = 449279, upload-time = "2025-10-14T15:05:04.004Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/e3/1f/d66bc15ea0b728df3ed96a539c777acfcad0eb78555ad9efcaa1274688f0/watchfiles-1.1.1-cp313-cp313-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:f27db948078f3823a6bb3b465180db8ebecf26dd5dae6f6180bd87383b6b4428", size = 459405, upload-time = "2025-10-14T15:05:04.942Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/be/90/9f4a65c0aec3ccf032703e6db02d89a157462fbb2cf20dd415128251cac0/watchfiles-1.1.1-cp313-cp313-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:059098c3a429f62fc98e8ec62b982230ef2c8df68c79e826e37b895bc359a9c0", size = 488976, upload-time = "2025-10-14T15:05:05.905Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/37/57/ee347af605d867f712be7029bb94c8c071732a4b44792e3176fa3c612d39/watchfiles-1.1.1-cp313-cp313-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:bfb5862016acc9b869bb57284e6cb35fdf8e22fe59f7548858e2f971d045f150", size = 595506, upload-time = "2025-10-14T15:05:06.906Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/a8/78/cc5ab0b86c122047f75e8fc471c67a04dee395daf847d3e59381996c8707/watchfiles-1.1.1-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:319b27255aacd9923b8a276bb14d21a5f7ff82564c744235fc5eae58d95422ae", size = 474936, upload-time = "2025-10-14T15:05:07.906Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/62/da/def65b170a3815af7bd40a3e7010bf6ab53089ef1b75d05dd5385b87cf08/watchfiles-1.1.1-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:c755367e51db90e75b19454b680903631d41f9e3607fbd941d296a020c2d752d", size = 456147, upload-time = "2025-10-14T15:05:09.138Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/57/99/da6573ba71166e82d288d4df0839128004c67d2778d3b566c138695f5c0b/watchfiles-1.1.1-cp313-cp313-musllinux_1_1_aarch64.whl", hash = "sha256:c22c776292a23bfc7237a98f791b9ad3144b02116ff10d820829ce62dff46d0b", size = 630007, upload-time = "2025-10-14T15:05:10.117Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/a8/51/7439c4dd39511368849eb1e53279cd3454b4a4dbace80bab88feeb83c6b5/watchfiles-1.1.1-cp313-cp313-musllinux_1_1_x86_64.whl", hash = "sha256:3a476189be23c3686bc2f4321dd501cb329c0a0469e77b7b534ee10129ae6374", size = 622280, upload-time = "2025-10-14T15:05:11.146Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/95/9c/8ed97d4bba5db6fdcdb2b298d3898f2dd5c20f6b73aee04eabe56c59677e/watchfiles-1.1.1-cp313-cp313-win32.whl", hash = "sha256:bf0a91bfb5574a2f7fc223cf95eeea79abfefa404bf1ea5e339c0c1560ae99a0", size = 272056, upload-time = "2025-10-14T15:05:12.156Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/1f/f3/c14e28429f744a260d8ceae18bf58c1d5fa56b50d006a7a9f80e1882cb0d/watchfiles-1.1.1-cp313-cp313-win_amd64.whl", hash = "sha256:52e06553899e11e8074503c8e716d574adeeb7e68913115c4b3653c53f9bae42", size = 288162, upload-time = "2025-10-14T15:05:13.208Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/dc/61/fe0e56c40d5cd29523e398d31153218718c5786b5e636d9ae8ae79453d27/watchfiles-1.1.1-cp313-cp313-win_arm64.whl", hash = "sha256:ac3cc5759570cd02662b15fbcd9d917f7ecd47efe0d6b40474eafd246f91ea18", size = 277909, upload-time = "2025-10-14T15:05:14.49Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/79/42/e0a7d749626f1e28c7108a99fb9bf524b501bbbeb9b261ceecde644d5a07/watchfiles-1.1.1-cp313-cp313t-macosx_10_12_x86_64.whl", hash = "sha256:563b116874a9a7ce6f96f87cd0b94f7faf92d08d0021e837796f0a14318ef8da", size = 403389, upload-time = "2025-10-14T15:05:15.777Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/15/49/08732f90ce0fbbc13913f9f215c689cfc9ced345fb1bcd8829a50007cc8d/watchfiles-1.1.1-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:3ad9fe1dae4ab4212d8c91e80b832425e24f421703b5a42ef2e4a1e215aff051", size = 389964, upload-time = "2025-10-14T15:05:16.85Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/27/0d/7c315d4bd5f2538910491a0393c56bf70d333d51bc5b34bee8e68e8cea19/watchfiles-1.1.1-cp313-cp313t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:ce70f96a46b894b36eba678f153f052967a0d06d5b5a19b336ab0dbbd029f73e", size = 448114, upload-time = "2025-10-14T15:05:17.876Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/c3/24/9e096de47a4d11bc4df41e9d1e61776393eac4cb6eb11b3e23315b78b2cc/watchfiles-1.1.1-cp313-cp313t-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:cb467c999c2eff23a6417e58d75e5828716f42ed8289fe6b77a7e5a91036ca70", size = 460264, upload-time = "2025-10-14T15:05:18.962Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/cc/0f/e8dea6375f1d3ba5fcb0b3583e2b493e77379834c74fd5a22d66d85d6540/watchfiles-1.1.1-cp313-cp313t-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:836398932192dae4146c8f6f737d74baeac8b70ce14831a239bdb1ca882fc261", size = 487877, upload-time = "2025-10-14T15:05:20.094Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/ac/5b/df24cfc6424a12deb41503b64d42fbea6b8cb357ec62ca84a5a3476f654a/watchfiles-1.1.1-cp313-cp313t-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:743185e7372b7bc7c389e1badcc606931a827112fbbd37f14c537320fca08620", size = 595176, upload-time = "2025-10-14T15:05:21.134Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/8f/b5/853b6757f7347de4e9b37e8cc3289283fb983cba1ab4d2d7144694871d9c/watchfiles-1.1.1-cp313-cp313t-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:afaeff7696e0ad9f02cbb8f56365ff4686ab205fcf9c4c5b6fdfaaa16549dd04", size = 473577, upload-time = "2025-10-14T15:05:22.306Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/e1/f7/0a4467be0a56e80447c8529c9fce5b38eab4f513cb3d9bf82e7392a5696b/watchfiles-1.1.1-cp313-cp313t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:3f7eb7da0eb23aa2ba036d4f616d46906013a68caf61b7fdbe42fc8b25132e77", size = 455425, upload-time = "2025-10-14T15:05:23.348Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/8e/e0/82583485ea00137ddf69bc84a2db88bd92ab4a6e3c405e5fb878ead8d0e7/watchfiles-1.1.1-cp313-cp313t-musllinux_1_1_aarch64.whl", hash = "sha256:831a62658609f0e5c64178211c942ace999517f5770fe9436be4c2faeba0c0ef", size = 628826, upload-time = "2025-10-14T15:05:24.398Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/28/9a/a785356fccf9fae84c0cc90570f11702ae9571036fb25932f1242c82191c/watchfiles-1.1.1-cp313-cp313t-musllinux_1_1_x86_64.whl", hash = "sha256:f9a2ae5c91cecc9edd47e041a930490c31c3afb1f5e6d71de3dc671bfaca02bf", size = 622208, upload-time = "2025-10-14T15:05:25.45Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/c3/f4/0872229324ef69b2c3edec35e84bd57a1289e7d3fe74588048ed8947a323/watchfiles-1.1.1-cp314-cp314-macosx_10_12_x86_64.whl", hash = "sha256:d1715143123baeeaeadec0528bb7441103979a1d5f6fd0e1f915383fea7ea6d5", size = 404315, upload-time = "2025-10-14T15:05:26.501Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/7b/22/16d5331eaed1cb107b873f6ae1b69e9ced582fcf0c59a50cd84f403b1c32/watchfiles-1.1.1-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:39574d6370c4579d7f5d0ad940ce5b20db0e4117444e39b6d8f99db5676c52fd", size = 390869, upload-time = "2025-10-14T15:05:27.649Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/b2/7e/5643bfff5acb6539b18483128fdc0ef2cccc94a5b8fbda130c823e8ed636/watchfiles-1.1.1-cp314-cp314-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:7365b92c2e69ee952902e8f70f3ba6360d0d596d9299d55d7d386df84b6941fb", size = 449919, upload-time = "2025-10-14T15:05:28.701Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/51/2e/c410993ba5025a9f9357c376f48976ef0e1b1aefb73b97a5ae01a5972755/watchfiles-1.1.1-cp314-cp314-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:bfff9740c69c0e4ed32416f013f3c45e2ae42ccedd1167ef2d805c000b6c71a5", size = 460845, upload-time = "2025-10-14T15:05:30.064Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/8e/a4/2df3b404469122e8680f0fcd06079317e48db58a2da2950fb45020947734/watchfiles-1.1.1-cp314-cp314-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:b27cf2eb1dda37b2089e3907d8ea92922b673c0c427886d4edc6b94d8dfe5db3", size = 489027, upload-time = "2025-10-14T15:05:31.064Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/ea/84/4587ba5b1f267167ee715b7f66e6382cca6938e0a4b870adad93e44747e6/watchfiles-1.1.1-cp314-cp314-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:526e86aced14a65a5b0ec50827c745597c782ff46b571dbfe46192ab9e0b3c33", size = 595615, upload-time = "2025-10-14T15:05:32.074Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/6a/0f/c6988c91d06e93cd0bb3d4a808bcf32375ca1904609835c3031799e3ecae/watchfiles-1.1.1-cp314-cp314-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:04e78dd0b6352db95507fd8cb46f39d185cf8c74e4cf1e4fbad1d3df96faf510", size = 474836, upload-time = "2025-10-14T15:05:33.209Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/b4/36/ded8aebea91919485b7bbabbd14f5f359326cb5ec218cd67074d1e426d74/watchfiles-1.1.1-cp314-cp314-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:5c85794a4cfa094714fb9c08d4a218375b2b95b8ed1666e8677c349906246c05", size = 455099, upload-time = "2025-10-14T15:05:34.189Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/98/e0/8c9bdba88af756a2fce230dd365fab2baf927ba42cd47521ee7498fd5211/watchfiles-1.1.1-cp314-cp314-musllinux_1_1_aarch64.whl", hash = "sha256:74d5012b7630714b66be7b7b7a78855ef7ad58e8650c73afc4c076a1f480a8d6", size = 630626, upload-time = "2025-10-14T15:05:35.216Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/2a/84/a95db05354bf2d19e438520d92a8ca475e578c647f78f53197f5a2f17aaf/watchfiles-1.1.1-cp314-cp314-musllinux_1_1_x86_64.whl", hash = "sha256:8fbe85cb3201c7d380d3d0b90e63d520f15d6afe217165d7f98c9c649654db81", size = 622519, upload-time = "2025-10-14T15:05:36.259Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/1d/ce/d8acdc8de545de995c339be67711e474c77d643555a9bb74a9334252bd55/watchfiles-1.1.1-cp314-cp314-win32.whl", hash = "sha256:3fa0b59c92278b5a7800d3ee7733da9d096d4aabcfabb9a928918bd276ef9b9b", size = 272078, upload-time = "2025-10-14T15:05:37.63Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/c4/c9/a74487f72d0451524be827e8edec251da0cc1fcf111646a511ae752e1a3d/watchfiles-1.1.1-cp314-cp314-win_amd64.whl", hash = "sha256:c2047d0b6cea13b3316bdbafbfa0c4228ae593d995030fda39089d36e64fc03a", size = 287664, upload-time = "2025-10-14T15:05:38.95Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/df/b8/8ac000702cdd496cdce998c6f4ee0ca1f15977bba51bdf07d872ebdfc34c/watchfiles-1.1.1-cp314-cp314-win_arm64.whl", hash = "sha256:842178b126593addc05acf6fce960d28bc5fae7afbaa2c6c1b3a7b9460e5be02", size = 277154, upload-time = "2025-10-14T15:05:39.954Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/47/a8/e3af2184707c29f0f14b1963c0aace6529f9d1b8582d5b99f31bbf42f59e/watchfiles-1.1.1-cp314-cp314t-macosx_10_12_x86_64.whl", hash = "sha256:88863fbbc1a7312972f1c511f202eb30866370ebb8493aef2812b9ff28156a21", size = 403820, upload-time = "2025-10-14T15:05:40.932Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/c0/ec/e47e307c2f4bd75f9f9e8afbe3876679b18e1bcec449beca132a1c5ffb2d/watchfiles-1.1.1-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:55c7475190662e202c08c6c0f4d9e345a29367438cf8e8037f3155e10a88d5a5", size = 390510, upload-time = "2025-10-14T15:05:41.945Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/d5/a0/ad235642118090f66e7b2f18fd5c42082418404a79205cdfca50b6309c13/watchfiles-1.1.1-cp314-cp314t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:3f53fa183d53a1d7a8852277c92b967ae99c2d4dcee2bfacff8868e6e30b15f7", size = 448408, upload-time = "2025-10-14T15:05:43.385Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/df/85/97fa10fd5ff3332ae17e7e40e20784e419e28521549780869f1413742e9d/watchfiles-1.1.1-cp314-cp314t-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:6aae418a8b323732fa89721d86f39ec8f092fc2af67f4217a2b07fd3e93c6101", size = 458968, upload-time = "2025-10-14T15:05:44.404Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/47/c2/9059c2e8966ea5ce678166617a7f75ecba6164375f3b288e50a40dc6d489/watchfiles-1.1.1-cp314-cp314t-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:f096076119da54a6080e8920cbdaac3dbee667eb91dcc5e5b78840b87415bd44", size = 488096, upload-time = "2025-10-14T15:05:45.398Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/94/44/d90a9ec8ac309bc26db808a13e7bfc0e4e78b6fc051078a554e132e80160/watchfiles-1.1.1-cp314-cp314t-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:00485f441d183717038ed2e887a7c868154f216877653121068107b227a2f64c", size = 596040, upload-time = "2025-10-14T15:05:46.502Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/95/68/4e3479b20ca305cfc561db3ed207a8a1c745ee32bf24f2026a129d0ddb6e/watchfiles-1.1.1-cp314-cp314t-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:a55f3e9e493158d7bfdb60a1165035f1cf7d320914e7b7ea83fe22c6023b58fc", size = 473847, upload-time = "2025-10-14T15:05:47.484Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/4f/55/2af26693fd15165c4ff7857e38330e1b61ab8c37d15dc79118cdba115b7a/watchfiles-1.1.1-cp314-cp314t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:8c91ed27800188c2ae96d16e3149f199d62f86c7af5f5f4d2c61a3ed8cd3666c", size = 455072, upload-time = "2025-10-14T15:05:48.928Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/66/1d/d0d200b10c9311ec25d2273f8aad8c3ef7cc7ea11808022501811208a750/watchfiles-1.1.1-cp314-cp314t-musllinux_1_1_aarch64.whl", hash = "sha256:311ff15a0bae3714ffb603e6ba6dbfba4065ab60865d15a6ec544133bdb21099", size = 629104, upload-time = "2025-10-14T15:05:49.908Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/e3/bd/fa9bb053192491b3867ba07d2343d9f2252e00811567d30ae8d0f78136fe/watchfiles-1.1.1-cp314-cp314t-musllinux_1_1_x86_64.whl", hash = "sha256:a916a2932da8f8ab582f242c065f5c81bed3462849ca79ee357dd9551b0e9b01", size = 622112, upload-time = "2025-10-14T15:05:50.941Z" },
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "websockets"
|
||||||
|
version = "16.0"
|
||||||
|
source = { registry = "https://pypi.org/simple" }
|
||||||
|
sdist = { url = "https://files.pythonhosted.org/packages/04/24/4b2031d72e840ce4c1ccb255f693b15c334757fc50023e4db9537080b8c4/websockets-16.0.tar.gz", hash = "sha256:5f6261a5e56e8d5c42a4497b364ea24d94d9563e8fbd44e78ac40879c60179b5", size = 179346, upload-time = "2026-01-10T09:23:47.181Z" }
|
||||||
|
wheels = [
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/84/7b/bac442e6b96c9d25092695578dda82403c77936104b5682307bd4deb1ad4/websockets-16.0-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:71c989cbf3254fbd5e84d3bff31e4da39c43f884e64f2551d14bb3c186230f00", size = 177365, upload-time = "2026-01-10T09:22:46.787Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/b0/fe/136ccece61bd690d9c1f715baaeefd953bb2360134de73519d5df19d29ca/websockets-16.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:8b6e209ffee39ff1b6d0fa7bfef6de950c60dfb91b8fcead17da4ee539121a79", size = 175038, upload-time = "2026-01-10T09:22:47.999Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/40/1e/9771421ac2286eaab95b8575b0cb701ae3663abf8b5e1f64f1fd90d0a673/websockets-16.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:86890e837d61574c92a97496d590968b23c2ef0aeb8a9bc9421d174cd378ae39", size = 175328, upload-time = "2026-01-10T09:22:49.809Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/18/29/71729b4671f21e1eaa5d6573031ab810ad2936c8175f03f97f3ff164c802/websockets-16.0-cp312-cp312-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:9b5aca38b67492ef518a8ab76851862488a478602229112c4b0d58d63a7a4d5c", size = 184915, upload-time = "2026-01-10T09:22:51.071Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/97/bb/21c36b7dbbafc85d2d480cd65df02a1dc93bf76d97147605a8e27ff9409d/websockets-16.0-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:e0334872c0a37b606418ac52f6ab9cfd17317ac26365f7f65e203e2d0d0d359f", size = 186152, upload-time = "2026-01-10T09:22:52.224Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/4a/34/9bf8df0c0cf88fa7bfe36678dc7b02970c9a7d5e065a3099292db87b1be2/websockets-16.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:a0b31e0b424cc6b5a04b8838bbaec1688834b2383256688cf47eb97412531da1", size = 185583, upload-time = "2026-01-10T09:22:53.443Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/47/88/4dd516068e1a3d6ab3c7c183288404cd424a9a02d585efbac226cb61ff2d/websockets-16.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:485c49116d0af10ac698623c513c1cc01c9446c058a4e61e3bf6c19dff7335a2", size = 184880, upload-time = "2026-01-10T09:22:55.033Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/91/d6/7d4553ad4bf1c0421e1ebd4b18de5d9098383b5caa1d937b63df8d04b565/websockets-16.0-cp312-cp312-win32.whl", hash = "sha256:eaded469f5e5b7294e2bdca0ab06becb6756ea86894a47806456089298813c89", size = 178261, upload-time = "2026-01-10T09:22:56.251Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/c3/f0/f3a17365441ed1c27f850a80b2bc680a0fa9505d733fe152fdf5e98c1c0b/websockets-16.0-cp312-cp312-win_amd64.whl", hash = "sha256:5569417dc80977fc8c2d43a86f78e0a5a22fee17565d78621b6bb264a115d4ea", size = 178693, upload-time = "2026-01-10T09:22:57.478Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/cc/9c/baa8456050d1c1b08dd0ec7346026668cbc6f145ab4e314d707bb845bf0d/websockets-16.0-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:878b336ac47938b474c8f982ac2f7266a540adc3fa4ad74ae96fea9823a02cc9", size = 177364, upload-time = "2026-01-10T09:22:59.333Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/7e/0c/8811fc53e9bcff68fe7de2bcbe75116a8d959ac699a3200f4847a8925210/websockets-16.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:52a0fec0e6c8d9a784c2c78276a48a2bdf099e4ccc2a4cad53b27718dbfd0230", size = 175039, upload-time = "2026-01-10T09:23:01.171Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/aa/82/39a5f910cb99ec0b59e482971238c845af9220d3ab9fa76dd9162cda9d62/websockets-16.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:e6578ed5b6981005df1860a56e3617f14a6c307e6a71b4fff8c48fdc50f3ed2c", size = 175323, upload-time = "2026-01-10T09:23:02.341Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/bd/28/0a25ee5342eb5d5f297d992a77e56892ecb65e7854c7898fb7d35e9b33bd/websockets-16.0-cp313-cp313-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:95724e638f0f9c350bb1c2b0a7ad0e83d9cc0c9259f3ea94e40d7b02a2179ae5", size = 184975, upload-time = "2026-01-10T09:23:03.756Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/f9/66/27ea52741752f5107c2e41fda05e8395a682a1e11c4e592a809a90c6a506/websockets-16.0-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:c0204dc62a89dc9d50d682412c10b3542d748260d743500a85c13cd1ee4bde82", size = 186203, upload-time = "2026-01-10T09:23:05.01Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/37/e5/8e32857371406a757816a2b471939d51c463509be73fa538216ea52b792a/websockets-16.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:52ac480f44d32970d66763115edea932f1c5b1312de36df06d6b219f6741eed8", size = 185653, upload-time = "2026-01-10T09:23:06.301Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/9b/67/f926bac29882894669368dc73f4da900fcdf47955d0a0185d60103df5737/websockets-16.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:6e5a82b677f8f6f59e8dfc34ec06ca6b5b48bc4fcda346acd093694cc2c24d8f", size = 184920, upload-time = "2026-01-10T09:23:07.492Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/3c/a1/3d6ccdcd125b0a42a311bcd15a7f705d688f73b2a22d8cf1c0875d35d34a/websockets-16.0-cp313-cp313-win32.whl", hash = "sha256:abf050a199613f64c886ea10f38b47770a65154dc37181bfaff70c160f45315a", size = 178255, upload-time = "2026-01-10T09:23:09.245Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/6b/ae/90366304d7c2ce80f9b826096a9e9048b4bb760e44d3b873bb272cba696b/websockets-16.0-cp313-cp313-win_amd64.whl", hash = "sha256:3425ac5cf448801335d6fdc7ae1eb22072055417a96cc6b31b3861f455fbc156", size = 178689, upload-time = "2026-01-10T09:23:10.483Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/f3/1d/e88022630271f5bd349ed82417136281931e558d628dd52c4d8621b4a0b2/websockets-16.0-cp314-cp314-macosx_10_15_universal2.whl", hash = "sha256:8cc451a50f2aee53042ac52d2d053d08bf89bcb31ae799cb4487587661c038a0", size = 177406, upload-time = "2026-01-10T09:23:12.178Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/f2/78/e63be1bf0724eeb4616efb1ae1c9044f7c3953b7957799abb5915bffd38e/websockets-16.0-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:daa3b6ff70a9241cf6c7fc9e949d41232d9d7d26fd3522b1ad2b4d62487e9904", size = 175085, upload-time = "2026-01-10T09:23:13.511Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/bb/f4/d3c9220d818ee955ae390cf319a7c7a467beceb24f05ee7aaaa2414345ba/websockets-16.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:fd3cb4adb94a2a6e2b7c0d8d05cb94e6f1c81a0cf9dc2694fb65c7e8d94c42e4", size = 175328, upload-time = "2026-01-10T09:23:14.727Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/63/bc/d3e208028de777087e6fb2b122051a6ff7bbcca0d6df9d9c2bf1dd869ae9/websockets-16.0-cp314-cp314-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:781caf5e8eee67f663126490c2f96f40906594cb86b408a703630f95550a8c3e", size = 185044, upload-time = "2026-01-10T09:23:15.939Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/ad/6e/9a0927ac24bd33a0a9af834d89e0abc7cfd8e13bed17a86407a66773cc0e/websockets-16.0-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:caab51a72c51973ca21fa8a18bd8165e1a0183f1ac7066a182ff27107b71e1a4", size = 186279, upload-time = "2026-01-10T09:23:17.148Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/b9/ca/bf1c68440d7a868180e11be653c85959502efd3a709323230314fda6e0b3/websockets-16.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:19c4dc84098e523fd63711e563077d39e90ec6702aff4b5d9e344a60cb3c0cb1", size = 185711, upload-time = "2026-01-10T09:23:18.372Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/c4/f8/fdc34643a989561f217bb477cbc47a3a07212cbda91c0e4389c43c296ebf/websockets-16.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:a5e18a238a2b2249c9a9235466b90e96ae4795672598a58772dd806edc7ac6d3", size = 184982, upload-time = "2026-01-10T09:23:19.652Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/dd/d1/574fa27e233764dbac9c52730d63fcf2823b16f0856b3329fc6268d6ae4f/websockets-16.0-cp314-cp314-win32.whl", hash = "sha256:a069d734c4a043182729edd3e9f247c3b2a4035415a9172fd0f1b71658a320a8", size = 177915, upload-time = "2026-01-10T09:23:21.458Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/8a/f1/ae6b937bf3126b5134ce1f482365fde31a357c784ac51852978768b5eff4/websockets-16.0-cp314-cp314-win_amd64.whl", hash = "sha256:c0ee0e63f23914732c6d7e0cce24915c48f3f1512ec1d079ed01fc629dab269d", size = 178381, upload-time = "2026-01-10T09:23:22.715Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/06/9b/f791d1db48403e1f0a27577a6beb37afae94254a8c6f08be4a23e4930bc0/websockets-16.0-cp314-cp314t-macosx_10_15_universal2.whl", hash = "sha256:a35539cacc3febb22b8f4d4a99cc79b104226a756aa7400adc722e83b0d03244", size = 177737, upload-time = "2026-01-10T09:23:24.523Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/bd/40/53ad02341fa33b3ce489023f635367a4ac98b73570102ad2cdd770dacc9a/websockets-16.0-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:b784ca5de850f4ce93ec85d3269d24d4c82f22b7212023c974c401d4980ebc5e", size = 175268, upload-time = "2026-01-10T09:23:25.781Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/74/9b/6158d4e459b984f949dcbbb0c5d270154c7618e11c01029b9bbd1bb4c4f9/websockets-16.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:569d01a4e7fba956c5ae4fc988f0d4e187900f5497ce46339c996dbf24f17641", size = 175486, upload-time = "2026-01-10T09:23:27.033Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/e5/2d/7583b30208b639c8090206f95073646c2c9ffd66f44df967981a64f849ad/websockets-16.0-cp314-cp314t-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:50f23cdd8343b984957e4077839841146f67a3d31ab0d00e6b824e74c5b2f6e8", size = 185331, upload-time = "2026-01-10T09:23:28.259Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/45/b0/cce3784eb519b7b5ad680d14b9673a31ab8dcb7aad8b64d81709d2430aa8/websockets-16.0-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:152284a83a00c59b759697b7f9e9cddf4e3c7861dd0d964b472b70f78f89e80e", size = 186501, upload-time = "2026-01-10T09:23:29.449Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/19/60/b8ebe4c7e89fb5f6cdf080623c9d92789a53636950f7abacfc33fe2b3135/websockets-16.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:bc59589ab64b0022385f429b94697348a6a234e8ce22544e3681b2e9331b5944", size = 186062, upload-time = "2026-01-10T09:23:31.368Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/88/a8/a080593f89b0138b6cba1b28f8df5673b5506f72879322288b031337c0b8/websockets-16.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:32da954ffa2814258030e5a57bc73a3635463238e797c7375dc8091327434206", size = 185356, upload-time = "2026-01-10T09:23:32.627Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/c2/b6/b9afed2afadddaf5ebb2afa801abf4b0868f42f8539bfe4b071b5266c9fe/websockets-16.0-cp314-cp314t-win32.whl", hash = "sha256:5a4b4cc550cb665dd8a47f868c8d04c8230f857363ad3c9caf7a0c3bf8c61ca6", size = 178085, upload-time = "2026-01-10T09:23:33.816Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/9f/3e/28135a24e384493fa804216b79a6a6759a38cc4ff59118787b9fb693df93/websockets-16.0-cp314-cp314t-win_amd64.whl", hash = "sha256:b14dc141ed6d2dde437cddb216004bcac6a1df0935d79656387bd41632ba0bbd", size = 178531, upload-time = "2026-01-10T09:23:35.016Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/6f/28/258ebab549c2bf3e64d2b0217b973467394a9cea8c42f70418ca2c5d0d2e/websockets-16.0-py3-none-any.whl", hash = "sha256:1637db62fad1dc833276dded54215f2c7fa46912301a24bd94d45d46a011ceec", size = 171598, upload-time = "2026-01-10T09:23:45.395Z" },
|
||||||
|
]
|
||||||
Loading…
Add table
Add a link
Reference in a new issue