Initial commit with Phase 0 Scaffolding
Some checks failed
Automated Container Build / build-and-push (push) Failing after 3s
CI / Backend (Python) (push) Failing after 28s
CI / Frontend (TypeScript) (push) Failing after 5m8s

This commit is contained in:
Elijah 2026-06-10 18:28:17 -07:00
parent b393607602
commit c545d4b17d
51 changed files with 7064 additions and 4 deletions

50
.env.example Normal file
View file

@ -0,0 +1,50 @@
# ============================================
# PaperJet Environment Configuration
# ============================================
# Copy this file to .env and adjust values.
# All variables are prefixed with PAPERJET_ in the
# backend's Pydantic Settings, but can also be set
# without prefix via this .env file.
# --------------------------------------------
# Security (REQUIRED — change in production!)
# --------------------------------------------
# Secret key for signing session cookies.
# Generate a strong random value: python -c "import secrets; print(secrets.token_urlsafe(32))"
SECRET_KEY=change-me-in-production
# --------------------------------------------
# Upload Limits
# --------------------------------------------
# Maximum upload file size in megabytes (default: 200)
# Must also be configured in the outer reverse proxy's client_max_body_size
MAX_UPLOAD_MB=200
# --------------------------------------------
# Retention
# --------------------------------------------
# Number of days soft-deleted documents stay in Trash before auto-purge (default: 30)
TRASH_RETENTION_DAYS=30
# Number of days auto-generated version snapshots are kept (default: 30)
# Manual versions are never auto-pruned.
AUTO_VERSION_RETENTION_DAYS=30
# --------------------------------------------
# Cookie / Session
# --------------------------------------------
# Set to true when running behind an HTTPS reverse proxy (production).
# Set to false for local development over plain HTTP.
COOKIE_SECURE=false
# --------------------------------------------
# Networking
# --------------------------------------------
# Port the frontend container publishes. Your outer reverse proxy targets this.
HTTP_PORT=4982
# --------------------------------------------
# Debug
# --------------------------------------------
# Enable debug mode (shows API docs at /api/v1/docs, enables verbose logging)
# DEBUG=false

71
.forgejo/workflows/ci.yml Normal file
View file

@ -0,0 +1,71 @@
name: CI
on:
push:
branches: [main]
pull_request:
branches: [main]
jobs:
backend:
name: Backend (Python)
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: Set up Python 3.12
uses: actions/setup-python@v5
with:
python-version: "3.12"
- name: Install dependencies
working-directory: backend
run: |
pip install -e ".[dev]"
- name: Lint (ruff)
working-directory: backend
run: ruff check .
- name: Type check (mypy)
working-directory: backend
run: mypy app/
- name: Test (pytest)
working-directory: backend
run: pytest --tb=short -q
env:
PAPERJET_DATABASE_PATH: ./test.sqlite
PAPERJET_PDF_STORAGE_PATH: ./test_pdfs
PAPERJET_THUMBNAILS_PATH: ./test_thumbs
frontend:
name: Frontend (TypeScript)
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: Set up Node.js 22
uses: actions/setup-node@v4
with:
node-version: "22"
cache: "npm"
cache-dependency-path: frontend/package-lock.json
- name: Install dependencies
working-directory: frontend
run: npm ci
- name: Lint (eslint)
working-directory: frontend
run: npm run lint
- name: Type check (tsc)
working-directory: frontend
run: npx tsc -b
- name: Test (vitest)
working-directory: frontend
run: npx vitest run

23
.gitignore vendored
View file

@ -5,9 +5,30 @@
*.swp
*.xml
# Build outputs (if your Node app eventually compiles or bundles code)
# Build outputs
dist/
build/
out/
# Dependencies
node_modules/
# Python
__pycache__/
*.pyc
*.pyo
*.egg-info/
.mypy_cache/
.ruff_cache/
.pytest_cache/
# Environment
.env
*.sqlite
# OS files
.DS_Store
Thumbs.db
# Local application run data
/data/

View file

@ -70,7 +70,7 @@ Because the app is WAN-exposed, auth is a real login screen backed by an Argon2i
| PDF rendering (client) | PDF.js (`pdfjs-dist`) | Display only |
| Canvas editing overlay | Fabric.js | Text boxes, drawing, drag/resize/select handles |
| Frontend state | Zustand | Lightweight, predictable; good fit for editor + undo stack |
| Styling | Tailwind CSS | Fast iteration toward a clean Sejda-like light UI |
| Styling | Tailwind CSS v4 | CSS-first `@theme` config; fast iteration toward a clean Sejda-like light UI |
| Backend language | Python 3.12 | Chosen for PyMuPDF |
| Backend framework | FastAPI | Async, typed, auto-generates OpenAPI |
| PDF processing (server) | PyMuPDF (`pymupdf` / fitz) | Thumbnails + export flatten. MuPDF compiled into the wheel — self-contained |
@ -138,7 +138,7 @@ paperjet/
│ ├── package.json
│ ├── tsconfig.json # strict: true
│ ├── vite.config.ts
│ ├── tailwind.config.ts
│ ├── # Tailwind v4: no config file — design tokens are defined via @theme in src/index.css
│ └── src/
│ ├── main.tsx
│ ├── app/ # routing, layout, theme
@ -492,7 +492,7 @@ Triggered by the signature tool, a modal offers two tabs (Sejda-style):
Once placed, a signature behaves like any other annotation — drag, resize, rotate, delete, undo/redo. Recently used signatures may be offered for quick re-insertion (future enhancement; not required for v1). Because typed signatures are stored as text + font (not flattened to an image until export), they stay crisp at any zoom and re-render identically on the server.
### 12.6 UI / visual design direction
Target the Sejda feel: bright, airy, light mode, generous whitespace, a restrained palette (one accent color), soft shadows, rounded corners, clear iconography. Tailwind tokens defined once in `tailwind.config.ts`. The editor chrome stays out of the way; the page is the focus. Mobile/touch: the canvas supports pinch-zoom and touch drag; toolbar collapses into an overflow menu on narrow viewports. Define a small design-token set (spacing scale, radius, accent, neutrals) up front so all three agents produce visually consistent components.
Target the Sejda feel: bright, airy, light mode, generous whitespace, a restrained palette (one accent color), soft shadows, rounded corners, clear iconography. Design tokens (colors, spacing, radius, shadows, typography) defined once via Tailwind v4's CSS-first `@theme` directives in `src/index.css`. The editor chrome stays out of the way; the page is the focus. Mobile/touch: the canvas supports pinch-zoom and touch drag; toolbar collapses into an overflow menu on narrow viewports. Define a small design-token set (spacing scale, radius, accent, neutrals) up front so all three agents produce visually consistent components.
---

25
backend/Dockerfile Normal file
View file

@ -0,0 +1,25 @@
FROM python:3.12-slim AS backend
# System dependencies: fonts for PDF export + fontconfig
RUN apt-get update && \
apt-get install -y --no-install-recommends \
fonts-liberation \
fontconfig && \
rm -rf /var/lib/apt/lists/* && \
fc-cache -fv
WORKDIR /app
# Install Python dependencies
COPY pyproject.toml ./
RUN pip install --no-cache-dir -e ".[dev]"
# Copy application code
COPY . .
# Create data directories (will be overridden by volume mounts)
RUN mkdir -p /data/pdfs /data/thumbnails /data/db
EXPOSE 8000
CMD ["uvicorn", "app.main:app", "--host", "0.0.0.0", "--port", "8000", "--workers", "1"]

38
backend/alembic.ini Normal file
View file

@ -0,0 +1,38 @@
# Alembic configuration for PaperJet
[alembic]
script_location = alembic
sqlalchemy.url = sqlite:////data/db/app.sqlite
[loggers]
keys = root,sqlalchemy,alembic
[handlers]
keys = console
[formatters]
keys = generic
[logger_root]
level = WARN
handlers = console
[logger_sqlalchemy]
level = WARN
handlers =
qualname = sqlalchemy.engine
[logger_alembic]
level = INFO
handlers =
qualname = alembic
[handler_console]
class = StreamHandler
args = (sys.stderr,)
level = NOTSET
formatter = generic
[formatter_generic]
format = %(levelname)-5.5s [%(name)s] %(message)s
datefmt = %H:%M:%S

47
backend/alembic/env.py Normal file
View file

@ -0,0 +1,47 @@
"""Alembic environment configuration for PaperJet."""
from logging.config import fileConfig
from alembic import context
from sqlalchemy import engine_from_config, pool
from app.db import Base
from app.models import * # noqa: F401, F403 — ensure all models are imported
config = context.config
if config.config_file_name is not None:
fileConfig(config.config_file_name)
target_metadata = Base.metadata
def run_migrations_offline() -> None:
"""Run migrations in 'offline' mode."""
url = config.get_main_option("sqlalchemy.url")
context.configure(
url=url,
target_metadata=target_metadata,
literal_binds=True,
dialect_opts={"paramstyle": "named"},
)
with context.begin_transaction():
context.run_migrations()
def run_migrations_online() -> None:
"""Run migrations in 'online' mode."""
connectable = engine_from_config(
config.get_section(config.config_ini_section, {}),
prefix="sqlalchemy.",
poolclass=pool.NullPool,
)
with connectable.connect() as connection:
context.configure(connection=connection, target_metadata=target_metadata)
with context.begin_transaction():
context.run_migrations()
if context.is_offline_mode():
run_migrations_offline()
else:
run_migrations_online()

View file

@ -0,0 +1,25 @@
"""${message}
Revision ID: ${up_revision}
Revises: ${down_revision | comma,n}
Create Date: ${create_date}
"""
from typing import Sequence, Union
from alembic import op
import sqlalchemy as sa
${imports if imports else ""}
# revision identifiers, used by Alembic.
revision: str = ${repr(up_revision)}
down_revision: Union[str, None] = ${repr(down_revision)}
branch_labels: Union[str, Sequence[str], None] = ${repr(branch_labels)}
depends_on: Union[str, Sequence[str], None] = ${repr(depends_on)}
def upgrade() -> None:
${upgrades if upgrades else "pass"}
def downgrade() -> None:
${downgrades if downgrades else "pass"}

View file

@ -0,0 +1 @@
# Alembic versions directory

1
backend/app/__init__.py Normal file
View file

@ -0,0 +1 @@
# PaperJet Backend

View file

@ -0,0 +1 @@
# API v1 package init

View file

@ -0,0 +1,10 @@
"""API v1 router — aggregates all sub-routers."""
from fastapi import APIRouter
from app.api.v1.health import router as health_router
router = APIRouter(prefix="/api/v1")
# Health (unauthenticated)
router.include_router(health_router)

View file

@ -0,0 +1,13 @@
"""Health check endpoint — unauthenticated."""
from fastapi import APIRouter
from app.config import settings
router = APIRouter(tags=["health"])
@router.get("/health")
def health_check() -> dict[str, str]:
"""Return service health status and version."""
return {"status": "ok", "version": settings.APP_VERSION}

View file

@ -0,0 +1 @@
# PaperJet backend auth

47
backend/app/config.py Normal file
View file

@ -0,0 +1,47 @@
"""
PaperJet backend configuration.
All settings are driven by environment variables (see .env.example).
"""
from pathlib import Path
from pydantic_settings import BaseSettings
class Settings(BaseSettings):
"""Application settings loaded from environment variables."""
# --- Security ---
SECRET_KEY: str = "change-me-in-production"
# --- Upload ---
MAX_UPLOAD_MB: int = 200
# --- Retention ---
TRASH_RETENTION_DAYS: int = 30
AUTO_VERSION_RETENTION_DAYS: int = 30
# --- Cookie ---
COOKIE_SECURE: bool = False # Set True in production (behind HTTPS proxy)
COOKIE_MAX_AGE_SECONDS: int = 7 * 24 * 60 * 60 # 7 days
COOKIE_NAME: str = "paperjet_session"
# --- Database ---
DATABASE_PATH: Path = Path("/data/db/app.sqlite")
# --- Storage ---
PDF_STORAGE_PATH: Path = Path("/data/pdfs")
THUMBNAILS_PATH: Path = Path("/data/thumbnails")
# --- App ---
APP_VERSION: str = "0.1.0"
DEBUG: bool = False
# --- Rate Limiting ---
LOGIN_MAX_ATTEMPTS: int = 5
LOGIN_LOCKOUT_SECONDS: int = 900 # 15 minutes
model_config = {"env_prefix": "PAPERJET_", "env_file": ".env", "extra": "ignore"}
settings = Settings()

58
backend/app/db.py Normal file
View file

@ -0,0 +1,58 @@
"""
Database engine, session management, and WAL mode setup.
SQLite with WAL mode for single-user concurrent read/write safety.
"""
from collections.abc import AsyncGenerator
from contextlib import asynccontextmanager
from typing import Any
from sqlalchemy import event, create_engine, Engine
from sqlalchemy.orm import DeclarativeBase, Session, sessionmaker
from app.config import settings
class Base(DeclarativeBase):
"""SQLAlchemy declarative base for all models."""
pass
def _set_sqlite_wal_mode(dbapi_conn: Any, _connection_record: Any) -> None:
"""Enable WAL mode and other SQLite performance pragmas on every connection."""
cursor = dbapi_conn.cursor()
cursor.execute("PRAGMA journal_mode=WAL")
cursor.execute("PRAGMA synchronous=NORMAL")
cursor.execute("PRAGMA foreign_keys=ON")
cursor.close()
def create_db_engine() -> Engine:
"""Create the SQLAlchemy engine with SQLite and WAL mode."""
# Ensure the database directory exists
settings.DATABASE_PATH.parent.mkdir(parents=True, exist_ok=True)
engine = create_engine(
f"sqlite:///{settings.DATABASE_PATH}",
echo=settings.DEBUG,
connect_args={"check_same_thread": False},
)
# Register WAL mode on every new connection
event.listen(engine, "connect", _set_sqlite_wal_mode)
return engine
engine = create_db_engine()
SessionLocal = sessionmaker(bind=engine, class_=Session, expire_on_commit=False)
def get_db() -> Session:
"""FastAPI dependency that yields a database session."""
db = SessionLocal()
try:
yield db # type: ignore[misc]
finally:
db.close()

85
backend/app/main.py Normal file
View file

@ -0,0 +1,85 @@
"""
PaperJet backend FastAPI application factory.
This is the main entry point. The lifespan handler initializes the database
and ensures required directories exist on startup.
"""
from contextlib import asynccontextmanager
from collections.abc import AsyncGenerator
from typing import Any
from fastapi import FastAPI, Request
from fastapi.responses import JSONResponse
from app.api.v1 import router as v1_router
from app.config import settings
from app.db import Base, engine
@asynccontextmanager
async def lifespan(app: FastAPI) -> AsyncGenerator[None, None]:
"""Application lifespan: startup and shutdown tasks."""
# Create all tables (Alembic handles migrations in production,
# but this ensures tables exist for development/first run)
Base.metadata.create_all(bind=engine)
# Ensure storage directories exist
settings.PDF_STORAGE_PATH.mkdir(parents=True, exist_ok=True)
settings.THUMBNAILS_PATH.mkdir(parents=True, exist_ok=True)
settings.DATABASE_PATH.parent.mkdir(parents=True, exist_ok=True)
yield
# Shutdown: dispose of the engine
engine.dispose()
app = FastAPI(
title="PaperJet",
description="Self-hosted PDF editor API",
version=settings.APP_VERSION,
docs_url="/api/v1/docs" if settings.DEBUG else None,
redoc_url="/api/v1/redoc" if settings.DEBUG else None,
openapi_url="/api/v1/openapi.json",
lifespan=lifespan,
)
# --- Error handlers ---
@app.exception_handler(404)
async def not_found_handler(request: Request, exc: Any) -> JSONResponse:
"""Consistent 404 error envelope."""
return JSONResponse(
status_code=404,
content={"error": {"code": "not_found", "message": "Resource not found"}},
)
@app.exception_handler(422)
async def validation_error_handler(request: Request, exc: Any) -> JSONResponse:
"""Consistent validation error envelope."""
return JSONResponse(
status_code=422,
content={
"error": {
"code": "validation_error",
"message": "Request validation failed",
"details": exc.errors() if hasattr(exc, "errors") else str(exc),
}
},
)
@app.exception_handler(500)
async def internal_error_handler(request: Request, exc: Any) -> JSONResponse:
"""Consistent 500 error envelope."""
return JSONResponse(
status_code=500,
content={"error": {"code": "internal_error", "message": "Internal server error"}},
)
# --- Mount routers ---
app.include_router(v1_router)

View file

@ -0,0 +1,13 @@
"""
SQLAlchemy models for PaperJet.
All models use UUIDv4 string primary keys (except the singleton settings row).
Timestamps are stored as ISO8601 TEXT columns.
"""
from app.models.settings import Settings
from app.models.document import Document
from app.models.annotation_state import AnnotationState
from app.models.version import Version
__all__ = ["Settings", "Document", "AnnotationState", "Version"]

View file

@ -0,0 +1,32 @@
"""AnnotationState model — current working annotation layer per document."""
from datetime import datetime, timezone
from sqlalchemy import ForeignKey, Text
from sqlalchemy.orm import Mapped, mapped_column, relationship
from app.db import Base
def _now_iso() -> str:
return datetime.now(timezone.utc).isoformat()
class AnnotationState(Base):
"""
Current working annotation state for a document.
One row per document. `data` is a JSON text column containing
the full annotation array opaque to the server except during export.
"""
__tablename__ = "annotation_states"
document_id: Mapped[str] = mapped_column(
Text, ForeignKey("documents.id", ondelete="CASCADE"), primary_key=True
)
data: Mapped[str] = mapped_column(Text, default="[]") # JSON array
updated_at: Mapped[str] = mapped_column(Text, default=_now_iso, onupdate=_now_iso)
# Relationships
document: Mapped["Document"] = relationship("Document", back_populates="annotation_state")

View file

@ -0,0 +1,58 @@
"""Document model — uploaded PDFs with soft-delete support."""
import uuid
from datetime import datetime, timezone
from sqlalchemy import Index, Integer, Text
from sqlalchemy.orm import Mapped, mapped_column, relationship
from app.db import Base
def _uuid() -> str:
return str(uuid.uuid4())
def _now_iso() -> str:
return datetime.now(timezone.utc).isoformat()
class Document(Base):
"""
A single uploaded PDF document.
Supports soft deletion via `deleted_at` when set, the document
is in the trash and can be restored or permanently purged.
"""
__tablename__ = "documents"
id: Mapped[str] = mapped_column(Text, primary_key=True, default=_uuid)
title: Mapped[str] = mapped_column(Text, nullable=False)
original_filename: Mapped[str] = mapped_column(Text, nullable=False)
file_path: Mapped[str] = mapped_column(Text, nullable=False)
thumbnail_path: Mapped[str | None] = mapped_column(Text, nullable=True, default=None)
size_bytes: Mapped[int] = mapped_column(Integer, nullable=False)
page_count: Mapped[int] = mapped_column(Integer, nullable=False)
created_at: Mapped[str] = mapped_column(Text, default=_now_iso)
updated_at: Mapped[str] = mapped_column(Text, default=_now_iso, onupdate=_now_iso)
deleted_at: Mapped[str | None] = mapped_column(Text, nullable=True, default=None)
# Relationships
annotation_state: Mapped["AnnotationState"] = relationship(
"AnnotationState",
back_populates="document",
uselist=False,
cascade="all, delete-orphan",
)
versions: Mapped[list["Version"]] = relationship(
"Version",
back_populates="document",
cascade="all, delete-orphan",
order_by="Version.created_at.desc()",
)
__table_args__ = (
Index("ix_documents_updated_at", "updated_at"),
Index("ix_documents_deleted_at", "deleted_at"),
)

View file

@ -0,0 +1,31 @@
"""Settings model — singleton row for app-wide configuration."""
from datetime import datetime, timezone
from sqlalchemy import Integer, Text
from sqlalchemy.orm import Mapped, mapped_column
from app.db import Base
class Settings(Base):
"""
Singleton settings row (id=1).
Stores the hashed password and app-level config.
password_hash is NULL until first-run setup.
"""
__tablename__ = "settings"
id: Mapped[int] = mapped_column(Integer, primary_key=True, default=1)
password_hash: Mapped[str | None] = mapped_column(Text, nullable=True, default=None)
created_at: Mapped[str] = mapped_column(
Text,
default=lambda: datetime.now(timezone.utc).isoformat(),
)
updated_at: Mapped[str] = mapped_column(
Text,
default=lambda: datetime.now(timezone.utc).isoformat(),
onupdate=lambda: datetime.now(timezone.utc).isoformat(),
)

View file

@ -0,0 +1,45 @@
"""Version model — annotation state snapshots for history/recovery."""
import uuid
from datetime import datetime, timezone
from sqlalchemy import ForeignKey, Index, Text
from sqlalchemy.orm import Mapped, mapped_column, relationship
from app.db import Base
def _uuid() -> str:
return str(uuid.uuid4())
def _now_iso() -> str:
return datetime.now(timezone.utc).isoformat()
class Version(Base):
"""
A snapshot of a document's annotation state at a point in time.
Versions serve as a safety net for accidental annotation loss.
`kind` is either 'manual' (user-created) or 'auto' (system-created
at safety boundaries like document close or before destructive ops).
"""
__tablename__ = "versions"
id: Mapped[str] = mapped_column(Text, primary_key=True, default=_uuid)
document_id: Mapped[str] = mapped_column(
Text, ForeignKey("documents.id", ondelete="CASCADE"), nullable=False
)
label: Mapped[str | None] = mapped_column(Text, nullable=True, default=None)
data: Mapped[str] = mapped_column(Text, nullable=False) # JSON snapshot
kind: Mapped[str] = mapped_column(Text, nullable=False) # 'manual' | 'auto'
created_at: Mapped[str] = mapped_column(Text, default=_now_iso)
# Relationships
document: Mapped["Document"] = relationship("Document", back_populates="versions")
__table_args__ = (
Index("ix_versions_document_created", "document_id", "created_at"),
)

View file

@ -0,0 +1 @@
# PaperJet backend schemas

View file

@ -0,0 +1 @@
# PaperJet backend services

View file

@ -0,0 +1 @@
# Backend tests

48
backend/pyproject.toml Normal file
View file

@ -0,0 +1,48 @@
[project]
name = "paperjet-backend"
version = "0.1.0"
description = "PaperJet — self-hosted PDF editor backend"
requires-python = ">=3.12"
dependencies = [
"fastapi>=0.115.0",
"uvicorn[standard]>=0.32.0",
"pymupdf>=1.25.0",
"sqlalchemy>=2.0.0",
"alembic>=1.14.0",
"pydantic>=2.10.0",
"pydantic-settings>=2.7.0",
"argon2-cffi>=23.1.0",
"itsdangerous>=2.2.0",
"python-multipart>=0.0.18",
"aiofiles>=24.1.0",
]
[project.optional-dependencies]
dev = [
"pytest>=8.3.0",
"pytest-asyncio>=0.24.0",
"httpx>=0.28.0",
"ruff>=0.8.0",
"mypy>=1.13.0",
]
[build-system]
requires = ["setuptools>=75.0"]
build-backend = "setuptools.build_meta"
[tool.ruff]
target-version = "py312"
line-length = 100
[tool.ruff.lint]
select = ["E", "F", "W", "I", "UP", "B", "SIM", "RUF"]
[tool.mypy]
python_version = "3.12"
strict = true
warn_return_any = true
warn_unused_configs = true
[tool.pytest.ini_options]
asyncio_mode = "auto"
testpaths = ["app/tests"]

31
docker-compose.yml Normal file
View file

@ -0,0 +1,31 @@
services:
backend:
build: ./backend
environment:
- PAPERJET_SECRET_KEY=${SECRET_KEY:-change-me-in-production}
- PAPERJET_MAX_UPLOAD_MB=${MAX_UPLOAD_MB:-200}
- PAPERJET_TRASH_RETENTION_DAYS=${TRASH_RETENTION_DAYS:-30}
- PAPERJET_AUTO_VERSION_RETENTION_DAYS=${AUTO_VERSION_RETENTION_DAYS:-30}
- PAPERJET_COOKIE_SECURE=${COOKIE_SECURE:-false}
- PAPERJET_DATABASE_PATH=/data/db/app.sqlite
- PAPERJET_PDF_STORAGE_PATH=/data/pdfs
- PAPERJET_THUMBNAILS_PATH=/data/thumbnails
volumes:
- pdf_storage:/data/pdfs
- thumbnails:/data/thumbnails
- db:/data/db
expose:
- "8000"
restart: unless-stopped
frontend:
build: ./frontend
depends_on: [backend]
ports:
- "${HTTP_PORT:-4982}:80"
restart: unless-stopped
volumes:
pdf_storage:
thumbnails:
db:

24
frontend/.gitignore vendored Normal file
View file

@ -0,0 +1,24 @@
# Logs
logs
*.log
npm-debug.log*
yarn-debug.log*
yarn-error.log*
pnpm-debug.log*
lerna-debug.log*
node_modules
dist
dist-ssr
*.local
# Editor directories and files
.vscode/*
!.vscode/extensions.json
.idea
.DS_Store
*.suo
*.ntvs*
*.njsproj
*.sln
*.sw?

24
frontend/Dockerfile Normal file
View file

@ -0,0 +1,24 @@
# Stage 1: Build the SPA
FROM node:22-alpine AS build
WORKDIR /app
COPY package.json package-lock.json ./
RUN npm ci
COPY . .
RUN npm run build
# Stage 2: Serve via nginx
FROM nginx:alpine
# Remove default nginx config
RUN rm /etc/nginx/conf.d/default.conf
# Copy our nginx config
COPY nginx.conf /etc/nginx/conf.d/default.conf
# Copy built SPA from build stage
COPY --from=build /app/dist /usr/share/nginx/html
EXPOSE 80
CMD ["nginx", "-g", "daemon off;"]

73
frontend/README.md Normal file
View file

@ -0,0 +1,73 @@
# React + TypeScript + Vite
This template provides a minimal setup to get React working in Vite with HMR and some ESLint rules.
Currently, two official plugins are available:
- [@vitejs/plugin-react](https://github.com/vitejs/vite-plugin-react/blob/main/packages/plugin-react) uses [Oxc](https://oxc.rs)
- [@vitejs/plugin-react-swc](https://github.com/vitejs/vite-plugin-react/blob/main/packages/plugin-react-swc) uses [SWC](https://swc.rs/)
## React Compiler
The React Compiler is not enabled on this template because of its impact on dev & build performances. To add it, see [this documentation](https://react.dev/learn/react-compiler/installation).
## Expanding the ESLint configuration
If you are developing a production application, we recommend updating the configuration to enable type-aware lint rules:
```js
export default defineConfig([
globalIgnores(['dist']),
{
files: ['**/*.{ts,tsx}'],
extends: [
// Other configs...
// Remove tseslint.configs.recommended and replace with this
tseslint.configs.recommendedTypeChecked,
// Alternatively, use this for stricter rules
tseslint.configs.strictTypeChecked,
// Optionally, add this for stylistic rules
tseslint.configs.stylisticTypeChecked,
// Other configs...
],
languageOptions: {
parserOptions: {
project: ['./tsconfig.node.json', './tsconfig.app.json'],
tsconfigRootDir: import.meta.dirname,
},
// other options...
},
},
])
```
You can also install [eslint-plugin-react-x](https://github.com/Rel1cx/eslint-react/tree/main/packages/plugins/eslint-plugin-react-x) and [eslint-plugin-react-dom](https://github.com/Rel1cx/eslint-react/tree/main/packages/plugins/eslint-plugin-react-dom) for React-specific lint rules:
```js
// eslint.config.js
import reactX from 'eslint-plugin-react-x'
import reactDom from 'eslint-plugin-react-dom'
export default defineConfig([
globalIgnores(['dist']),
{
files: ['**/*.{ts,tsx}'],
extends: [
// Other configs...
// Enable lint rules for React
reactX.configs['recommended-typescript'],
// Enable lint rules for React DOM
reactDom.configs.recommended,
],
languageOptions: {
parserOptions: {
project: ['./tsconfig.node.json', './tsconfig.app.json'],
tsconfigRootDir: import.meta.dirname,
},
// other options...
},
},
])
```

22
frontend/eslint.config.js Normal file
View file

@ -0,0 +1,22 @@
import js from '@eslint/js'
import globals from 'globals'
import reactHooks from 'eslint-plugin-react-hooks'
import reactRefresh from 'eslint-plugin-react-refresh'
import tseslint from 'typescript-eslint'
import { defineConfig, globalIgnores } from 'eslint/config'
export default defineConfig([
globalIgnores(['dist']),
{
files: ['**/*.{ts,tsx}'],
extends: [
js.configs.recommended,
tseslint.configs.recommended,
reactHooks.configs.flat.recommended,
reactRefresh.configs.vite,
],
languageOptions: {
globals: globals.browser,
},
},
])

14
frontend/index.html Normal file
View file

@ -0,0 +1,14 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<link rel="icon" type="image/svg+xml" href="/favicon.svg" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<meta name="description" content="PaperJet — Self-hosted PDF editor. Annotate, sign, and export PDFs from your own server." />
<title>PaperJet</title>
</head>
<body>
<div id="root"></div>
<script type="module" src="/src/main.tsx"></script>
</body>
</html>

57
frontend/nginx.conf Normal file
View file

@ -0,0 +1,57 @@
server {
listen 80;
server_name _;
# Match MAX_UPLOAD_MB raise in both this and the outer reverse proxy
client_max_body_size 200m;
# Security headers
add_header X-Content-Type-Options "nosniff" always;
add_header X-Frame-Options "DENY" always;
add_header Referrer-Policy "no-referrer" always;
add_header Content-Security-Policy "default-src 'self'; script-src 'self' blob: 'wasm-unsafe-eval'; worker-src 'self' blob:; style-src 'self' 'unsafe-inline'; img-src 'self' blob: data:; font-src 'self' data:; connect-src 'self';" always;
# SPA: serve index.html for all non-file routes (history API fallback)
root /usr/share/nginx/html;
index index.html;
location / {
try_files $uri $uri/ /index.html;
}
# Proxy /api/ to the backend container
location /api/ {
proxy_pass http://backend:8000;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
# Pass through auth cookies and CSRF header
proxy_pass_header Set-Cookie;
proxy_set_header Cookie $http_cookie;
proxy_set_header X-Requested-With $http_x_requested_with;
# Timeouts for large uploads/exports
proxy_connect_timeout 60s;
proxy_send_timeout 120s;
proxy_read_timeout 120s;
# Upload size (must match client_max_body_size)
client_max_body_size 200m;
}
# Cache static assets
location ~* \.(js|css|png|jpg|jpeg|gif|ico|svg|woff|woff2|ttf|eot)$ {
expires 1y;
add_header Cache-Control "public, immutable";
try_files $uri =404;
}
# Disable access to hidden files
location ~ /\. {
deny all;
access_log off;
log_not_found off;
}
}

5368
frontend/package-lock.json generated Normal file

File diff suppressed because it is too large Load diff

44
frontend/package.json Normal file
View file

@ -0,0 +1,44 @@
{
"name": "paperjet",
"private": true,
"version": "0.1.0",
"type": "module",
"scripts": {
"dev": "vite",
"build": "tsc -b && vite build",
"lint": "eslint .",
"typecheck": "tsc -b --noEmit",
"test": "vitest run",
"test:watch": "vitest",
"preview": "vite preview"
},
"dependencies": {
"@tailwindcss/vite": "^4.3.0",
"fabric": "^7.4.0",
"pdfjs-dist": "^6.0.227",
"react": "^19.2.6",
"react-dom": "^19.2.6",
"react-router-dom": "^7.17.0",
"tailwindcss": "^4.3.0",
"zustand": "^5.0.14"
},
"devDependencies": {
"@eslint/js": "^10.0.1",
"@testing-library/jest-dom": "^6.9.1",
"@testing-library/react": "^16.3.2",
"@types/fabric": "^5.3.11",
"@types/node": "^24.12.3",
"@types/react": "^19.2.14",
"@types/react-dom": "^19.2.3",
"@vitejs/plugin-react": "^6.0.1",
"eslint": "^10.3.0",
"eslint-plugin-react-hooks": "^7.1.1",
"eslint-plugin-react-refresh": "^0.5.2",
"globals": "^17.6.0",
"jsdom": "^29.1.1",
"typescript": "~6.0.2",
"typescript-eslint": "^8.59.2",
"vite": "^8.0.12",
"vitest": "^4.1.8"
}
}

File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 9.3 KiB

24
frontend/public/icons.svg Normal file
View file

@ -0,0 +1,24 @@
<svg xmlns="http://www.w3.org/2000/svg">
<symbol id="bluesky-icon" viewBox="0 0 16 17">
<g clip-path="url(#bluesky-clip)"><path fill="#08060d" d="M7.75 7.735c-.693-1.348-2.58-3.86-4.334-5.097-1.68-1.187-2.32-.981-2.74-.79C.188 2.065.1 2.812.1 3.251s.241 3.602.398 4.13c.52 1.744 2.367 2.333 4.07 2.145-2.495.37-4.71 1.278-1.805 4.512 3.196 3.309 4.38-.71 4.987-2.746.608 2.036 1.307 5.91 4.93 2.746 2.72-2.746.747-4.143-1.747-4.512 1.702.189 3.55-.4 4.07-2.145.156-.528.397-3.691.397-4.13s-.088-1.186-.575-1.406c-.42-.19-1.06-.395-2.741.79-1.755 1.24-3.64 3.752-4.334 5.099"/></g>
<defs><clipPath id="bluesky-clip"><path fill="#fff" d="M.1.85h15.3v15.3H.1z"/></clipPath></defs>
</symbol>
<symbol id="discord-icon" viewBox="0 0 20 19">
<path fill="#08060d" d="M16.224 3.768a14.5 14.5 0 0 0-3.67-1.153c-.158.286-.343.67-.47.976a13.5 13.5 0 0 0-4.067 0c-.128-.306-.317-.69-.476-.976A14.4 14.4 0 0 0 3.868 3.77C1.546 7.28.916 10.703 1.231 14.077a14.7 14.7 0 0 0 4.5 2.306q.545-.748.965-1.587a9.5 9.5 0 0 1-1.518-.74q.191-.14.372-.293c2.927 1.369 6.107 1.369 8.999 0q.183.152.372.294-.723.437-1.52.74.418.838.963 1.588a14.6 14.6 0 0 0 4.504-2.308c.37-3.911-.63-7.302-2.644-10.309m-9.13 8.234c-.878 0-1.599-.82-1.599-1.82 0-.998.705-1.82 1.6-1.82.894 0 1.614.82 1.599 1.82.001 1-.705 1.82-1.6 1.82m5.91 0c-.878 0-1.599-.82-1.599-1.82 0-.998.705-1.82 1.6-1.82.893 0 1.614.82 1.599 1.82 0 1-.706 1.82-1.6 1.82"/>
</symbol>
<symbol id="documentation-icon" viewBox="0 0 21 20">
<path fill="none" stroke="#aa3bff" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.35" d="m15.5 13.333 1.533 1.322c.645.555.967.833.967 1.178s-.322.623-.967 1.179L15.5 18.333m-3.333-5-1.534 1.322c-.644.555-.966.833-.966 1.178s.322.623.966 1.179l1.534 1.321"/>
<path fill="none" stroke="#aa3bff" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.35" d="M17.167 10.836v-4.32c0-1.41 0-2.117-.224-2.68-.359-.906-1.118-1.621-2.08-1.96-.599-.21-1.349-.21-2.848-.21-2.623 0-3.935 0-4.983.369-1.684.591-3.013 1.842-3.641 3.428C3 6.449 3 7.684 3 10.154v2.122c0 2.558 0 3.838.706 4.726q.306.383.713.671c.76.536 1.79.64 3.581.66"/>
<path fill="none" stroke="#aa3bff" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.35" d="M3 10a2.78 2.78 0 0 1 2.778-2.778c.555 0 1.209.097 1.748-.047.48-.129.854-.503.982-.982.145-.54.048-1.194.048-1.749a2.78 2.78 0 0 1 2.777-2.777"/>
</symbol>
<symbol id="github-icon" viewBox="0 0 19 19">
<path fill="#08060d" fill-rule="evenodd" d="M9.356 1.85C5.05 1.85 1.57 5.356 1.57 9.694a7.84 7.84 0 0 0 5.324 7.44c.387.079.528-.168.528-.376 0-.182-.013-.805-.013-1.454-2.165.467-2.616-.935-2.616-.935-.349-.91-.864-1.143-.864-1.143-.71-.48.051-.48.051-.48.787.051 1.2.805 1.2.805.695 1.194 1.817.857 2.268.649.064-.507.27-.857.49-1.052-1.728-.182-3.545-.857-3.545-3.87 0-.857.31-1.558.8-2.104-.078-.195-.349-1 .077-2.078 0 0 .657-.208 2.14.805a7.5 7.5 0 0 1 1.946-.26c.657 0 1.328.092 1.946.26 1.483-1.013 2.14-.805 2.14-.805.426 1.078.155 1.883.078 2.078.502.546.799 1.247.799 2.104 0 3.013-1.818 3.675-3.558 3.87.284.247.528.714.528 1.454 0 1.052-.012 1.896-.012 2.156 0 .208.142.455.528.377a7.84 7.84 0 0 0 5.324-7.441c.013-4.338-3.48-7.844-7.773-7.844" clip-rule="evenodd"/>
</symbol>
<symbol id="social-icon" viewBox="0 0 20 20">
<path fill="none" stroke="#aa3bff" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.35" d="M12.5 6.667a4.167 4.167 0 1 0-8.334 0 4.167 4.167 0 0 0 8.334 0"/>
<path fill="none" stroke="#aa3bff" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.35" d="M2.5 16.667a5.833 5.833 0 0 1 8.75-5.053m3.837.474.513 1.035c.07.144.257.282.414.309l.93.155c.596.1.736.536.307.965l-.723.73a.64.64 0 0 0-.152.531l.207.903c.164.715-.213.991-.84.618l-.872-.52a.63.63 0 0 0-.577 0l-.872.52c-.624.373-1.003.094-.84-.618l.207-.903a.64.64 0 0 0-.152-.532l-.723-.729c-.426-.43-.289-.864.306-.964l.93-.156a.64.64 0 0 0 .412-.31l.513-1.034c.28-.562.735-.562 1.012 0"/>
</symbol>
<symbol id="x-icon" viewBox="0 0 19 19">
<path fill="#08060d" fill-rule="evenodd" d="M1.893 1.98c.052.072 1.245 1.769 2.653 3.77l2.892 4.114c.183.261.333.48.333.486s-.068.089-.152.183l-.522.593-.765.867-3.597 4.087c-.375.426-.734.834-.798.905a1 1 0 0 0-.118.148c0 .01.236.017.664.017h.663l.729-.83c.4-.457.796-.906.879-.999a692 692 0 0 0 1.794-2.038c.034-.037.301-.34.594-.675l.551-.624.345-.392a7 7 0 0 1 .34-.374c.006 0 .93 1.306 2.052 2.903l2.084 2.965.045.063h2.275c1.87 0 2.273-.003 2.266-.021-.008-.02-1.098-1.572-3.894-5.547-2.013-2.862-2.28-3.246-2.273-3.266.008-.019.282-.332 2.085-2.38l2-2.274 1.567-1.782c.022-.028-.016-.03-.65-.03h-.674l-.3.342a871 871 0 0 1-1.782 2.025c-.067.075-.405.458-.75.852a100 100 0 0 1-.803.91c-.148.172-.299.344-.99 1.127-.304.343-.32.358-.345.327-.015-.019-.904-1.282-1.976-2.808L6.365 1.85H1.8zm1.782.91 8.078 11.294c.772 1.08 1.413 1.973 1.425 1.984.016.017.241.02 1.05.017l1.03-.004-2.694-3.766L7.796 5.75 5.722 2.852l-1.039-.004-1.039-.004z" clip-rule="evenodd"/>
</symbol>
</svg>

After

Width:  |  Height:  |  Size: 4.9 KiB

23
frontend/src/app/App.tsx Normal file
View file

@ -0,0 +1,23 @@
import { Routes, Route, Navigate } from 'react-router-dom'
import { LoginPage } from '../pages/LoginPage'
import { HomePage } from '../pages/HomePage'
import { EditorPage } from '../pages/EditorPage'
/**
* Root application component with route definitions.
*
* Routes:
* - /login — password entry / first-run setup
* - / home page (recently edited, library, trash)
* - /editor/:id PDF editor workspace
*/
export function App() {
return (
<Routes>
<Route path="/login" element={<LoginPage />} />
<Route path="/" element={<HomePage />} />
<Route path="/editor/:id" element={<EditorPage />} />
<Route path="*" element={<Navigate to="/" replace />} />
</Routes>
)
}

106
frontend/src/index.css Normal file
View file

@ -0,0 +1,106 @@
@import "tailwindcss";
/*
* PaperJet Design Tokens
*
* Sejda-inspired: bright, airy, light mode with generous whitespace,
* a restrained palette (one accent), soft shadows, rounded corners.
*/
@theme {
/* Accent color — refined blue */
--color-accent-50: oklch(0.97 0.02 250);
--color-accent-100: oklch(0.93 0.04 250);
--color-accent-200: oklch(0.87 0.08 250);
--color-accent-300: oklch(0.78 0.12 250);
--color-accent-400: oklch(0.68 0.16 250);
--color-accent-500: oklch(0.58 0.19 250);
--color-accent-600: oklch(0.50 0.19 250);
--color-accent-700: oklch(0.43 0.17 250);
--color-accent-800: oklch(0.37 0.14 250);
--color-accent-900: oklch(0.30 0.10 250);
/* Neutrals — warm gray */
--color-neutral-50: oklch(0.985 0.002 250);
--color-neutral-100: oklch(0.965 0.004 250);
--color-neutral-200: oklch(0.925 0.006 250);
--color-neutral-300: oklch(0.870 0.008 250);
--color-neutral-400: oklch(0.700 0.010 250);
--color-neutral-500: oklch(0.550 0.012 250);
--color-neutral-600: oklch(0.440 0.012 250);
--color-neutral-700: oklch(0.370 0.012 250);
--color-neutral-800: oklch(0.270 0.010 250);
--color-neutral-900: oklch(0.180 0.008 250);
/* Semantic */
--color-success: oklch(0.60 0.16 145);
--color-warning: oklch(0.75 0.16 65);
--color-error: oklch(0.58 0.20 25);
/* Spacing scale */
--spacing-xs: 0.25rem;
--spacing-sm: 0.5rem;
--spacing-md: 1rem;
--spacing-lg: 1.5rem;
--spacing-xl: 2rem;
--spacing-2xl: 3rem;
/* Border radius */
--radius-sm: 0.375rem;
--radius-md: 0.5rem;
--radius-lg: 0.75rem;
--radius-xl: 1rem;
--radius-full: 9999px;
/* Shadows */
--shadow-sm: 0 1px 2px 0 oklch(0 0 0 / 0.04);
--shadow-md: 0 4px 6px -1px oklch(0 0 0 / 0.06), 0 2px 4px -2px oklch(0 0 0 / 0.04);
--shadow-lg: 0 10px 15px -3px oklch(0 0 0 / 0.06), 0 4px 6px -4px oklch(0 0 0 / 0.04);
--shadow-xl: 0 20px 25px -5px oklch(0 0 0 / 0.08), 0 8px 10px -6px oklch(0 0 0 / 0.04);
/* Typography */
--font-sans: 'Inter', ui-sans-serif, system-ui, -apple-system, sans-serif;
--font-mono: 'JetBrains Mono', ui-monospace, monospace;
/* Transitions */
--ease-out: cubic-bezier(0.16, 1, 0.3, 1);
--duration-fast: 150ms;
--duration-normal: 250ms;
}
/* --- Base styles --- */
*,
*::before,
*::after {
box-sizing: border-box;
}
html {
font-family: var(--font-sans);
-webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale;
}
body {
margin: 0;
background-color: var(--color-neutral-50);
color: var(--color-neutral-800);
line-height: 1.6;
}
/* Smooth scrolling */
html {
scroll-behavior: smooth;
}
/* Focus ring utility */
:focus-visible {
outline: 2px solid var(--color-accent-500);
outline-offset: 2px;
}
/* Selection color */
::selection {
background-color: var(--color-accent-200);
color: var(--color-accent-900);
}

View file

@ -0,0 +1,159 @@
/**
* Shared API client for PaperJet backend.
*
* All API calls go through this module. It handles:
* - Base URL prefixing (/api/v1)
* - CSRF protection (X-Requested-With header on mutating requests)
* - Consistent error handling with the { error: { code, message } } envelope
* - 401 redirect to login
*/
const BASE_URL = '/api/v1'
/** Error shape returned by the backend */
export interface ApiError {
code: string
message: string
details?: unknown
}
/** Custom error class for API failures */
export class ApiRequestError extends Error {
status: number
error: ApiError
constructor(status: number, error: ApiError) {
super(error.message)
this.name = 'ApiRequestError'
this.status = status
this.error = error
}
}
/**
* Internal fetch wrapper with shared behavior.
*/
async function apiFetch<T>(
path: string,
options: RequestInit = {},
): Promise<T> {
const url = `${BASE_URL}${path}`
const headers = new Headers(options.headers)
// CSRF: add X-Requested-With on all mutating requests
const method = (options.method ?? 'GET').toUpperCase()
if (['POST', 'PUT', 'PATCH', 'DELETE'].includes(method)) {
headers.set('X-Requested-With', 'XMLHttpRequest')
}
// Default to JSON content type for non-FormData bodies
if (options.body && !(options.body instanceof FormData)) {
if (!headers.has('Content-Type')) {
headers.set('Content-Type', 'application/json')
}
}
const response = await fetch(url, {
...options,
headers,
credentials: 'same-origin',
})
// Handle 401 — redirect to login
if (response.status === 401) {
window.location.href = '/login'
throw new ApiRequestError(401, {
code: 'unauthorized',
message: 'Session expired',
})
}
// Handle 204 No Content
if (response.status === 204) {
return undefined as T
}
// Parse response
const data = await response.json()
// Handle error responses
if (!response.ok) {
const error: ApiError = data.error ?? {
code: 'unknown',
message: response.statusText,
}
throw new ApiRequestError(response.status, error)
}
return data as T
}
// --- Convenience methods ---
export const api = {
get: <T>(path: string) => apiFetch<T>(path),
post: <T>(path: string, body?: unknown) =>
apiFetch<T>(path, {
method: 'POST',
body: body ? JSON.stringify(body) : undefined,
}),
put: <T>(path: string, body?: unknown) =>
apiFetch<T>(path, {
method: 'PUT',
body: body ? JSON.stringify(body) : undefined,
}),
patch: <T>(path: string, body?: unknown) =>
apiFetch<T>(path, {
method: 'PATCH',
body: body ? JSON.stringify(body) : undefined,
}),
delete: <T>(path: string) =>
apiFetch<T>(path, { method: 'DELETE' }),
/** POST with FormData (multipart — for file uploads) */
upload: <T>(path: string, formData: FormData) =>
apiFetch<T>(path, {
method: 'POST',
body: formData,
// Don't set Content-Type — browser sets it with boundary
}),
/**
* Fetch a binary response (e.g., PDF export).
* Returns the raw Response for streaming.
*/
fetchBlob: async (path: string, options?: RequestInit): Promise<Blob> => {
const url = `${BASE_URL}${path}`
const headers = new Headers(options?.headers)
const method = (options?.method ?? 'GET').toUpperCase()
if (['POST', 'PUT', 'PATCH', 'DELETE'].includes(method)) {
headers.set('X-Requested-With', 'XMLHttpRequest')
}
if (options?.body && !(options.body instanceof FormData)) {
headers.set('Content-Type', 'application/json')
}
const response = await fetch(url, {
...options,
headers,
credentials: 'same-origin',
})
if (response.status === 401) {
window.location.href = '/login'
throw new Error('Unauthorized')
}
if (!response.ok) {
const data = await response.json()
throw new ApiRequestError(response.status, data.error)
}
return response.blob()
},
}

13
frontend/src/main.tsx Normal file
View file

@ -0,0 +1,13 @@
import { StrictMode } from 'react'
import { createRoot } from 'react-dom/client'
import { BrowserRouter } from 'react-router-dom'
import { App } from './app/App'
import './index.css'
createRoot(document.getElementById('root')!).render(
<StrictMode>
<BrowserRouter>
<App />
</BrowserRouter>
</StrictMode>,
)

View file

@ -0,0 +1,49 @@
import { useParams } from 'react-router-dom'
/**
* Editor page PDF canvas workspace.
* Full implementation begins in Phase 2 (rendering) and Phase 3 (annotations).
*/
export function EditorPage() {
const { id } = useParams<{ id: string }>()
return (
<div className="flex h-screen flex-col bg-neutral-100">
{/* Editor toolbar */}
<header className="flex items-center justify-between border-b border-neutral-200 bg-white px-4 py-2">
<div className="flex items-center gap-3">
<a
href="/"
className="rounded-md p-1.5 text-neutral-500 transition-colors hover:bg-neutral-100
hover:text-neutral-700"
>
Back
</a>
<span className="text-sm font-medium text-neutral-700">
Document {id}
</span>
</div>
<div className="flex items-center gap-2">
<span className="flex items-center gap-1.5 text-xs text-neutral-400">
<span className="h-1.5 w-1.5 rounded-full bg-success" />
Saved
</span>
<button
type="button"
className="rounded-lg bg-accent-500 px-3 py-1.5 text-sm font-medium text-white
transition-colors hover:bg-accent-600"
>
Export PDF
</button>
</div>
</header>
{/* Canvas area */}
<main className="flex flex-1 items-center justify-center overflow-auto">
<div className="text-sm text-neutral-400">
PDF viewer will render here (Phase 2)
</div>
</main>
</div>
)
}

View file

@ -0,0 +1,61 @@
/**
* Home page recently edited, library, and trash tabs.
* Full implementation in Phase 1.
*/
export function HomePage() {
return (
<div className="min-h-screen bg-neutral-50">
{/* Header */}
<header className="border-b border-neutral-200 bg-white">
<div className="mx-auto flex max-w-7xl items-center justify-between px-6 py-4">
<h1 className="text-xl font-semibold text-neutral-900">PaperJet</h1>
<button
type="button"
className="rounded-lg bg-accent-500 px-4 py-2 text-sm font-medium text-white
transition-colors duration-150 hover:bg-accent-600"
>
Upload PDF
</button>
</div>
</header>
{/* Main content */}
<main className="mx-auto max-w-7xl px-6 py-8">
{/* Tab navigation */}
<nav className="mb-8 flex gap-1 rounded-lg bg-neutral-100 p-1">
<button
type="button"
className="rounded-md bg-white px-4 py-2 text-sm font-medium text-neutral-900
shadow-sm transition-colors"
>
Recently Edited
</button>
<button
type="button"
className="rounded-md px-4 py-2 text-sm font-medium text-neutral-500
transition-colors hover:text-neutral-700"
>
Library
</button>
<button
type="button"
className="rounded-md px-4 py-2 text-sm font-medium text-neutral-500
transition-colors hover:text-neutral-700"
>
Trash
</button>
</nav>
{/* Empty state placeholder */}
<div className="flex flex-col items-center justify-center rounded-xl border-2 border-dashed
border-neutral-300 py-20 text-center">
<div className="mb-4 text-5xl">📄</div>
<h2 className="text-lg font-medium text-neutral-700">No documents yet</h2>
<p className="mt-1 text-sm text-neutral-500">
Upload a PDF to get started
</p>
</div>
</main>
</div>
)
}

View file

@ -0,0 +1,40 @@
/**
* Login page handles both first-run password setup and normal login.
* Full implementation in Phase 1.
*/
export function LoginPage() {
return (
<div className="flex min-h-screen items-center justify-center bg-neutral-50">
<div className="w-full max-w-md rounded-xl bg-white p-8 shadow-lg">
<div className="mb-6 text-center">
<h1 className="text-2xl font-semibold text-neutral-900">PaperJet</h1>
<p className="mt-2 text-sm text-neutral-500">
Self-hosted PDF editor
</p>
</div>
<div className="space-y-4">
<div>
<label htmlFor="password" className="block text-sm font-medium text-neutral-700">
Password
</label>
<input
id="password"
type="password"
className="mt-1 w-full rounded-lg border border-neutral-300 px-3 py-2 text-sm
placeholder:text-neutral-400 focus:border-accent-500 focus:outline-none
focus:ring-2 focus:ring-accent-200"
placeholder="Enter your password"
/>
</div>
<button
type="button"
className="w-full rounded-lg bg-accent-500 px-4 py-2.5 text-sm font-medium text-white
transition-colors duration-150 hover:bg-accent-600 active:bg-accent-700"
>
Sign In
</button>
</div>
</div>
</div>
)
}

View file

@ -0,0 +1 @@
import '@testing-library/jest-dom'

View file

@ -0,0 +1,28 @@
{
"compilerOptions": {
"tsBuildInfoFile": "./node_modules/.tmp/tsconfig.app.tsbuildinfo",
"target": "es2023",
"lib": ["ES2023", "DOM"],
"module": "esnext",
"types": ["vite/client"],
"skipLibCheck": true,
/* Bundler mode */
"moduleResolution": "bundler",
"allowImportingTsExtensions": true,
"verbatimModuleSyntax": true,
"moduleDetection": "force",
"noEmit": true,
"jsx": "react-jsx",
/* Strict mode */
"strict": true,
/* Linting */
"noUnusedLocals": true,
"noUnusedParameters": true,
"erasableSyntaxOnly": true,
"noFallthroughCasesInSwitch": true
},
"include": ["src"]
}

7
frontend/tsconfig.json Normal file
View file

@ -0,0 +1,7 @@
{
"files": [],
"references": [
{ "path": "./tsconfig.app.json" },
{ "path": "./tsconfig.node.json" }
]
}

View file

@ -0,0 +1,24 @@
{
"compilerOptions": {
"tsBuildInfoFile": "./node_modules/.tmp/tsconfig.node.tsbuildinfo",
"target": "es2023",
"lib": ["ES2023"],
"module": "esnext",
"types": ["node"],
"skipLibCheck": true,
/* Bundler mode */
"moduleResolution": "bundler",
"allowImportingTsExtensions": true,
"verbatimModuleSyntax": true,
"moduleDetection": "force",
"noEmit": true,
/* Linting */
"noUnusedLocals": true,
"noUnusedParameters": true,
"erasableSyntaxOnly": true,
"noFallthroughCasesInSwitch": true
},
"include": ["vite.config.ts"]
}

20
frontend/vite.config.ts Normal file
View file

@ -0,0 +1,20 @@
import { defineConfig } from 'vite'
import react from '@vitejs/plugin-react'
import tailwindcss from '@tailwindcss/vite'
// https://vite.dev/config/
export default defineConfig({
plugins: [
react(),
tailwindcss(),
],
server: {
// Dev proxy: route /api requests to the backend
proxy: {
'/api': {
target: 'http://localhost:8000',
changeOrigin: true,
},
},
},
})

11
frontend/vitest.config.ts Normal file
View file

@ -0,0 +1,11 @@
import { defineConfig } from 'vitest/config'
import react from '@vitejs/plugin-react'
export default defineConfig({
plugins: [react()],
test: {
environment: 'jsdom',
globals: true,
setupFiles: './src/test-setup.ts',
},
})

View file

@ -0,0 +1,112 @@
{
"$schema": "http://json-schema.org/draft-07/schema#",
"title": "PaperJet Annotation Schema",
"definitions": {
"Rect": {
"type": "object",
"description": "Bounding rectangle in canonical PDF-point space (top-left origin).",
"properties": {
"x": { "type": "number", "description": "X position in PDF points from the left edge of the page CropBox" },
"y": { "type": "number", "description": "Y position in PDF points from the top edge of the page CropBox" },
"width": { "type": "number", "minimum": 0 },
"height": { "type": "number", "minimum": 0 }
},
"required": ["x", "y", "width", "height"]
},
"AnnotationBase": {
"type": "object",
"description": "Common envelope for all annotation types.",
"properties": {
"id": { "type": "string", "format": "uuid" },
"page": { "type": "integer", "minimum": 0, "description": "0-indexed page number" },
"type": { "type": "string", "description": "Discriminator — drives the tool/handler registries" },
"rect": { "$ref": "#/definitions/Rect" },
"rotation": { "type": "number", "default": 0, "description": "Degrees clockwise about rect center" },
"z": { "type": "integer", "default": 0, "description": "Z-order within the page" },
"props": { "type": "object", "description": "Type-specific properties, opaque to the server" },
"createdAt": { "type": "string", "format": "date-time" },
"updatedAt": { "type": "string", "format": "date-time" }
},
"required": ["id", "page", "type", "rect", "props", "createdAt", "updatedAt"]
},
"TextProps": {
"type": "object",
"properties": {
"text": { "type": "string" },
"fontFamily": { "type": "string", "default": "Liberation Sans" },
"fontSize": { "type": "number", "default": 14 },
"color": { "type": "string", "default": "#000000" },
"align": { "type": "string", "enum": ["left", "center", "right"], "default": "left" },
"bold": { "type": "boolean", "default": false },
"italic": { "type": "boolean", "default": false },
"lineHeight": { "type": "number", "default": 1.2 }
},
"required": ["text"]
},
"DrawProps": {
"type": "object",
"properties": {
"paths": {
"type": "array",
"items": {
"type": "array",
"items": { "type": "number" },
"minItems": 2,
"maxItems": 2
},
"description": "Array of [x, y] points in rect-local space"
},
"strokeColor": { "type": "string", "default": "#000000" },
"strokeWidth": { "type": "number", "default": 2 },
"opacity": { "type": "number", "minimum": 0, "maximum": 1, "default": 1 }
},
"required": ["paths"]
},
"SignatureDrawProps": {
"type": "object",
"properties": {
"mode": { "type": "string", "const": "draw" },
"ref": { "type": "string", "description": "Asset reference for the rasterized signature" },
"strokeColor": { "type": "string", "default": "#000000" }
},
"required": ["mode", "ref"]
},
"SignatureTypeProps": {
"type": "object",
"properties": {
"mode": { "type": "string", "const": "type" },
"text": { "type": "string" },
"fontFamily": { "type": "string" },
"color": { "type": "string", "default": "#000000" }
},
"required": ["mode", "text", "fontFamily"]
},
"ImageProps": {
"type": "object",
"properties": {
"ref": { "type": "string", "description": "Asset reference for the uploaded image" },
"naturalWidth": { "type": "number" },
"naturalHeight": { "type": "number" }
},
"required": ["ref", "naturalWidth", "naturalHeight"]
},
"HighlightProps": {
"type": "object",
"properties": {
"color": { "type": "string", "default": "#FFEB3B" },
"opacity": { "type": "number", "minimum": 0, "maximum": 1, "default": 0.3 }
}
},
"ShapeProps": {
"type": "object",
"properties": {
"kind": { "type": "string", "enum": ["rect", "ellipse", "line", "arrow"] },
"strokeColor": { "type": "string", "default": "#000000" },
"fillColor": { "type": "string", "default": "transparent" },
"strokeWidth": { "type": "number", "default": 2 }
},
"required": ["kind"]
}
},
"allOf": [{ "$ref": "#/definitions/AnnotationBase" }]
}