diff --git a/backend/app/api/v1/__init__.py b/backend/app/api/v1/__init__.py index 0353aa3..da6fd48 100644 --- a/backend/app/api/v1/__init__.py +++ b/backend/app/api/v1/__init__.py @@ -2,9 +2,17 @@ from fastapi import APIRouter +from app.api.v1.auth import router as auth_router +from app.api.v1.documents import router as documents_router from app.api.v1.health import router as health_router router = APIRouter(prefix="/api/v1") +# Auth +router.include_router(auth_router) + +# Documents +router.include_router(documents_router) + # Health (unauthenticated) router.include_router(health_router) diff --git a/backend/app/api/v1/auth.py b/backend/app/api/v1/auth.py new file mode 100644 index 0000000..c05c534 --- /dev/null +++ b/backend/app/api/v1/auth.py @@ -0,0 +1,124 @@ +"""Authentication endpoints.""" + +from fastapi import APIRouter, Depends, HTTPException, Request, Response +from sqlalchemy.orm import Session + +from app.auth.dependencies import get_current_user, verify_csrf +from app.auth.hashing import hash_password, verify_password +from app.auth.rate_limit import check_rate_limit, clear_attempts, record_failed_attempt +from app.auth.session import clear_session, create_session, verify_session +from app.config import settings +from app.db import get_db +from app.models.settings import Settings +from app.schemas.auth import AuthStatusResponse, ChangePasswordRequest, LoginRequest, SetupRequest + +router = APIRouter(prefix="/auth", tags=["auth"]) + + +def get_client_ip(request: Request) -> str: + """Helper to extract client IP for rate limiting.""" + forwarded = request.headers.get("X-Forwarded-For") + if forwarded: + return forwarded.split(",")[0].strip() + return request.client.host if request.client else "unknown" + + +@router.get("/status", response_model=AuthStatusResponse) +def get_auth_status(request: Request, db: Session = Depends(get_db)) -> AuthStatusResponse: + """Check if app requires setup and if user is logged in.""" + app_settings = db.query(Settings).first() + if not app_settings: + app_settings = Settings() + db.add(app_settings) + db.commit() + + setup_required = app_settings.password_hash is None + + token = request.cookies.get(settings.COOKIE_NAME) + logged_in = False + if token: + user_id = verify_session(token) + if user_id is not None: + logged_in = True + + return AuthStatusResponse(setupRequired=setup_required, loggedIn=logged_in) + + +@router.post("/setup", dependencies=[Depends(verify_csrf)]) +def setup_password( + data: SetupRequest, + response: Response, + db: Session = Depends(get_db) +) -> dict[str, str]: + """First-run setup.""" + app_settings = db.query(Settings).first() + if app_settings and app_settings.password_hash: + raise HTTPException(status_code=409, detail="Password already set") + + if not app_settings: + app_settings = Settings() + db.add(app_settings) + + app_settings.password_hash = hash_password(data.password) + db.commit() + + # Log them in automatically + create_session(response) + return {"status": "ok"} + + +@router.post("/login", dependencies=[Depends(verify_csrf)]) +def login( + request: Request, + data: LoginRequest, + response: Response, + db: Session = Depends(get_db) +) -> dict[str, str]: + """Validate password and issue session cookie.""" + ip = get_client_ip(request) + check_rate_limit(ip) + + app_settings = db.query(Settings).first() + if not app_settings or not app_settings.password_hash: + record_failed_attempt(ip) + raise HTTPException(status_code=401, detail="Invalid credentials") + + if not verify_password(app_settings.password_hash, data.password): + record_failed_attempt(ip) + raise HTTPException(status_code=401, detail="Invalid credentials") + + clear_attempts(ip) + create_session(response) + return {"status": "ok"} + + +@router.post("/logout", dependencies=[Depends(verify_csrf)]) +def logout(response: Response) -> dict[str, str]: + """Clear session cookie.""" + clear_session(response) + return {"status": "ok"} + + +@router.put("/password", dependencies=[Depends(verify_csrf), Depends(get_current_user)]) +def change_password( + request: Request, + data: ChangePasswordRequest, + db: Session = Depends(get_db) +) -> dict[str, str]: + """Change an existing password.""" + ip = get_client_ip(request) + check_rate_limit(ip) + + app_settings = db.query(Settings).first() + if not app_settings or not app_settings.password_hash: + raise HTTPException(status_code=400, detail="Setup required first") + + if not verify_password(app_settings.password_hash, data.current_password): + record_failed_attempt(ip) + raise HTTPException(status_code=401, detail="Invalid current password") + + clear_attempts(ip) + app_settings.password_hash = hash_password(data.new_password) + db.commit() + + return {"status": "ok"} diff --git a/backend/app/api/v1/documents.py b/backend/app/api/v1/documents.py new file mode 100644 index 0000000..7e572b2 --- /dev/null +++ b/backend/app/api/v1/documents.py @@ -0,0 +1,221 @@ +"""Document management endpoints.""" + +from fastapi import APIRouter, Depends, HTTPException, UploadFile, File, Query +from fastapi.responses import FileResponse +from sqlalchemy.orm import Session +from sqlalchemy import desc +from datetime import datetime, timezone + +from app.auth.dependencies import get_current_user, verify_csrf +from app.db import get_db +from app.models.document import Document +from app.models.annotation_state import AnnotationState +from app.models.version import Version +from app.schemas.document import ( + DocumentListResponse, DocumentMeta, BulkDeleteRequest, + BulkRestoreRequest, DocumentUpdateRequest +) +from app.services.storage import save_upload_file, delete_pdf_file +from app.services.thumbnails import generate_thumbnail, delete_thumbnail +from app.config import settings + +router = APIRouter( + prefix="/documents", + tags=["documents"], + dependencies=[Depends(get_current_user)] +) + + +@router.get("", response_model=DocumentListResponse) +def list_documents( + query: str = "", + skip: int = Query(0, ge=0), + limit: int = Query(50, ge=1, le=100), + db: Session = Depends(get_db) +): + """List non-trashed documents.""" + q = db.query(Document).filter(Document.deleted_at.is_(None)) + if query: + q = q.filter(Document.title.ilike(f"%{query}%")) + + total = q.count() + items = q.order_by(desc(Document.updated_at)).offset(skip).limit(limit).all() + + return DocumentListResponse(items=items, total=total) + + +@router.get("/trash", response_model=DocumentListResponse) +def list_trash( + skip: int = Query(0, ge=0), + limit: int = Query(50, ge=1, le=100), + db: Session = Depends(get_db) +): + """List trashed documents.""" + q = db.query(Document).filter(Document.deleted_at.is_not(None)) + total = q.count() + items = q.order_by(desc(Document.deleted_at)).offset(skip).limit(limit).all() + return DocumentListResponse(items=items, total=total) + + +@router.post("", dependencies=[Depends(verify_csrf)], response_model=DocumentMeta) +async def upload_document( + file: UploadFile = File(...), + db: Session = Depends(get_db) +): + """Upload a new PDF document.""" + # Note: Starlette/FastAPI loads file into memory/spooled file. + # To truly enforce max size before reading we rely on Nginx and logic in storage.py. + # But checking size if available: + if file.size and file.size > settings.MAX_UPLOAD_MB * 1024 * 1024: + raise HTTPException(status_code=413, detail="File too large") + + file_id, size_bytes, page_count = await save_upload_file(file) + + # Generate thumbnail synchronously + generate_thumbnail(file_id) + + now = datetime.now(timezone.utc).isoformat() + doc = Document( + id=file_id, + title=file.filename or "Untitled", + original_filename=file.filename or "Untitled.pdf", + file_path=str(settings.PDF_STORAGE_PATH / f"{file_id}.pdf"), + page_count=page_count, + size_bytes=size_bytes, + created_at=now, + updated_at=now + ) + db.add(doc) + db.commit() + db.refresh(doc) + return doc + + +@router.get("/{id}", response_model=DocumentMeta) +def get_document(id: str, db: Session = Depends(get_db)): + doc = db.query(Document).filter(Document.id == id).first() + if not doc: + raise HTTPException(status_code=404, detail="Document not found") + return doc + + +@router.patch("/{id}", dependencies=[Depends(verify_csrf)], response_model=DocumentMeta) +def update_document(id: str, update: DocumentUpdateRequest, db: Session = Depends(get_db)): + doc = db.query(Document).filter(Document.id == id).first() + if not doc: + raise HTTPException(status_code=404, detail="Document not found") + + if update.title is not None: + doc.title = update.title + + if update.in_trash is not None: + if update.in_trash: + doc.deleted_at = datetime.now(timezone.utc).isoformat() + else: + doc.deleted_at = None + + doc.updated_at = datetime.now(timezone.utc).isoformat() + db.commit() + db.refresh(doc) + return doc + + +@router.delete("/{id}", dependencies=[Depends(verify_csrf)]) +def delete_document(id: str, permanent: bool = False, db: Session = Depends(get_db)): + """Soft delete by default. Hard delete if permanent=True AND already in trash.""" + doc = db.query(Document).filter(Document.id == id).first() + if not doc: + raise HTTPException(status_code=404, detail="Document not found") + + if permanent: + if doc.deleted_at is None: + raise HTTPException(status_code=400, detail="Must be in trash to delete permanently") + + # Hard delete + delete_pdf_file(doc.id) + delete_thumbnail(doc.id) + db.query(AnnotationState).filter(AnnotationState.document_id == doc.id).delete() + db.query(Version).filter(Version.document_id == doc.id).delete() + db.delete(doc) + db.commit() + return {"status": "deleted"} + else: + # Soft delete + doc.deleted_at = datetime.now(timezone.utc).isoformat() + db.commit() + return {"status": "trashed"} + + +@router.post("/{id}/restore", dependencies=[Depends(verify_csrf)], response_model=DocumentMeta) +def restore_document(id: str, db: Session = Depends(get_db)): + doc = db.query(Document).filter(Document.id == id).first() + if not doc: + raise HTTPException(status_code=404, detail="Document not found") + + doc.deleted_at = None + doc.updated_at = datetime.now(timezone.utc).isoformat() + db.commit() + db.refresh(doc) + return doc + + +@router.post("/bulk-delete", dependencies=[Depends(verify_csrf)]) +def bulk_delete(data: BulkDeleteRequest, db: Session = Depends(get_db)): + now = datetime.now(timezone.utc).isoformat() + db.query(Document).filter(Document.id.in_(data.ids)).update({ + Document.deleted_at: now + }, synchronize_session=False) + db.commit() + return {"status": "ok", "count": len(data.ids)} + + +@router.post("/bulk-restore", dependencies=[Depends(verify_csrf)]) +def bulk_restore(data: BulkRestoreRequest, db: Session = Depends(get_db)): + now = datetime.now(timezone.utc).isoformat() + db.query(Document).filter(Document.id.in_(data.ids)).update({ + Document.deleted_at: None, + Document.updated_at: now + }, synchronize_session=False) + db.commit() + return {"status": "ok", "count": len(data.ids)} + + +@router.post("/trash/empty", dependencies=[Depends(verify_csrf)]) +def empty_trash(db: Session = Depends(get_db)): + docs = db.query(Document).filter(Document.deleted_at.is_not(None)).all() + count = 0 + for doc in docs: + delete_pdf_file(doc.id) + delete_thumbnail(doc.id) + db.query(AnnotationState).filter(AnnotationState.document_id == doc.id).delete() + db.query(Version).filter(Version.document_id == doc.id).delete() + db.delete(doc) + count += 1 + db.commit() + return {"status": "ok", "deleted": count} + + +@router.get("/{id}/file") +def get_document_file(id: str, db: Session = Depends(get_db)): + doc = db.query(Document).filter(Document.id == id).first() + if not doc: + raise HTTPException(status_code=404, detail="Document not found") + + path = settings.PDF_STORAGE_PATH / f"{doc.id}.pdf" + if not path.exists(): + raise HTTPException(status_code=404, detail="File missing") + + return FileResponse(path, media_type="application/pdf", filename=doc.filename) + + +@router.get("/{id}/thumbnail") +def get_document_thumbnail(id: str, db: Session = Depends(get_db)): + doc = db.query(Document).filter(Document.id == id).first() + if not doc: + raise HTTPException(status_code=404, detail="Document not found") + + path = settings.THUMBNAILS_PATH / f"{doc.id}.png" + if not path.exists(): + raise HTTPException(status_code=404, detail="Thumbnail missing") + + return FileResponse(path, media_type="image/png") diff --git a/backend/app/auth/dependencies.py b/backend/app/auth/dependencies.py new file mode 100644 index 0000000..648ac19 --- /dev/null +++ b/backend/app/auth/dependencies.py @@ -0,0 +1,35 @@ +"""FastAPI dependencies for authentication and security.""" + +from fastapi import Request, HTTPException + +from app.auth.session import verify_session +from app.config import settings + +def get_current_user(request: Request) -> int: + """ + Dependency that extracts and validates the session cookie. + Raises 401 if missing or invalid. + Returns the user_id. + """ + token = request.cookies.get(settings.COOKIE_NAME) + if not token: + raise HTTPException(status_code=401, detail="Not authenticated") + + user_id = verify_session(token) + if not user_id: + raise HTTPException(status_code=401, detail="Session expired or invalid") + + return user_id + + +def verify_csrf(request: Request) -> None: + """ + Dependency to mitigate CSRF for cookie-based auth. + Requires X-Requested-With header on all mutating requests. + """ + if request.method in ["POST", "PUT", "PATCH", "DELETE"]: + if request.headers.get("X-Requested-With") != "XMLHttpRequest": + raise HTTPException( + status_code=403, + detail="CSRF check failed: missing X-Requested-With header" + ) diff --git a/backend/app/auth/hashing.py b/backend/app/auth/hashing.py new file mode 100644 index 0000000..31beb5d --- /dev/null +++ b/backend/app/auth/hashing.py @@ -0,0 +1,20 @@ +"""Argon2id password hashing and verification.""" + +from argon2 import PasswordHasher +from argon2.exceptions import VerifyMismatchError + +# Argon2id is the default for PasswordHasher +ph = PasswordHasher() + +def hash_password(password: str) -> str: + """Hash a plaintext password.""" + return ph.hash(password) + +def verify_password(hashed: str, password: str) -> bool: + """Verify a password against a hash. Returns True if matched.""" + try: + ph.verify(hashed, password) + # Note: We skip check_needs_rehash() for simplicity in this single-user app. + return True + except VerifyMismatchError: + return False diff --git a/backend/app/auth/rate_limit.py b/backend/app/auth/rate_limit.py new file mode 100644 index 0000000..fd25202 --- /dev/null +++ b/backend/app/auth/rate_limit.py @@ -0,0 +1,59 @@ +"""In-memory rate limiting for login attempts.""" + +import time +from collections import defaultdict +from fastapi import HTTPException + +from app.config import settings + +# In-memory store: IP address -> {"attempts": int, "lockout_until": float} +_rate_limits: dict[str, dict[str, float]] = defaultdict( + lambda: {"attempts": 0, "lockout_until": 0.0} +) + + +def check_rate_limit(ip: str) -> None: + """ + Check if the IP is currently locked out. + Raises a 429 HTTPException if locked out. + """ + now = time.time() + record = _rate_limits[ip] + + if record["lockout_until"] > now: + remaining = int(record["lockout_until"] - now) + raise HTTPException( + status_code=429, + detail=f"Too many failed attempts. Try again in {remaining} seconds.", + headers={"Retry-After": str(remaining)}, + ) + + # If lockout has expired, reset + if record["lockout_until"] > 0 and record["lockout_until"] <= now: + record["attempts"] = 0 + record["lockout_until"] = 0.0 + + +def record_failed_attempt(ip: str) -> None: + """ + Record a failed login attempt for the IP. + Applies lockout if MAX_ATTEMPTS is reached. + """ + now = time.time() + record = _rate_limits[ip] + record["attempts"] += 1 + + if record["attempts"] >= settings.LOGIN_MAX_ATTEMPTS: + record["lockout_until"] = now + settings.LOGIN_LOCKOUT_SECONDS + remaining = settings.LOGIN_LOCKOUT_SECONDS + raise HTTPException( + status_code=429, + detail=f"Too many failed attempts. Try again in {remaining} seconds.", + headers={"Retry-After": str(remaining)}, + ) + + +def clear_attempts(ip: str) -> None: + """Clear all failed attempts for the IP (e.g., after successful login).""" + if ip in _rate_limits: + _rate_limits[ip] = {"attempts": 0, "lockout_until": 0.0} diff --git a/backend/app/auth/session.py b/backend/app/auth/session.py new file mode 100644 index 0000000..837ad59 --- /dev/null +++ b/backend/app/auth/session.py @@ -0,0 +1,49 @@ +"""Session token generation and validation.""" + +from typing import Any +from fastapi import Response +from itsdangerous import URLSafeTimedSerializer, BadSignature, SignatureExpired + +from app.config import settings + +def get_serializer() -> URLSafeTimedSerializer: + """Return a configured URLSafeTimedSerializer.""" + return URLSafeTimedSerializer(settings.SECRET_KEY) + +def create_session(response: Response, user_id: int = 1) -> None: + """Create a new session token and set it as an HTTP-only cookie.""" + serializer = get_serializer() + token = serializer.dumps({"user_id": user_id}) + + response.set_cookie( + key=settings.COOKIE_NAME, + value=token, + max_age=settings.COOKIE_MAX_AGE_SECONDS, + secure=settings.COOKIE_SECURE, + httponly=True, + samesite="lax", + ) + +def clear_session(response: Response) -> None: + """Clear the session cookie.""" + response.delete_cookie( + key=settings.COOKIE_NAME, + secure=settings.COOKIE_SECURE, + httponly=True, + samesite="lax", + ) + +def verify_session(token: str) -> int | None: + """ + Verify the session token. + Returns the user_id if valid, or None if invalid/expired. + """ + serializer = get_serializer() + try: + data: dict[str, Any] = serializer.loads( + token, + max_age=settings.COOKIE_MAX_AGE_SECONDS + ) + return data.get("user_id") + except (BadSignature, SignatureExpired): + return None diff --git a/backend/app/models/document.py b/backend/app/models/document.py index d25aa3d..a60a695 100644 --- a/backend/app/models/document.py +++ b/backend/app/models/document.py @@ -38,6 +38,18 @@ class Document(Base): 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) + @property + def in_trash(self) -> bool: + return self.deleted_at is not None + + @property + def filename(self) -> str: + return self.original_filename + + @property + def mime_type(self) -> str: + return "application/pdf" + # Relationships annotation_state: Mapped["AnnotationState"] = relationship( "AnnotationState", diff --git a/backend/app/schemas/auth.py b/backend/app/schemas/auth.py new file mode 100644 index 0000000..58bfb9d --- /dev/null +++ b/backend/app/schemas/auth.py @@ -0,0 +1,17 @@ +"""Auth request and response schemas.""" + +from pydantic import BaseModel, Field + +class SetupRequest(BaseModel): + password: str = Field(..., min_length=8) + +class LoginRequest(BaseModel): + password: str + +class ChangePasswordRequest(BaseModel): + current_password: str + new_password: str = Field(..., min_length=8) + +class AuthStatusResponse(BaseModel): + setupRequired: bool + loggedIn: bool diff --git a/backend/app/schemas/document.py b/backend/app/schemas/document.py new file mode 100644 index 0000000..0479202 --- /dev/null +++ b/backend/app/schemas/document.py @@ -0,0 +1,31 @@ +"""Pydantic schemas for Document APIs.""" + +from pydantic import BaseModel +from typing import Optional + +class DocumentMeta(BaseModel): + id: str + title: str + filename: str + mime_type: str + size_bytes: int + in_trash: bool + created_at: str + updated_at: str + deleted_at: Optional[str] + + model_config = {"from_attributes": True} + +class DocumentListResponse(BaseModel): + items: list[DocumentMeta] + total: int + +class BulkDeleteRequest(BaseModel): + ids: list[str] + +class BulkRestoreRequest(BaseModel): + ids: list[str] + +class DocumentUpdateRequest(BaseModel): + title: Optional[str] = None + in_trash: Optional[bool] = None diff --git a/backend/app/services/storage.py b/backend/app/services/storage.py new file mode 100644 index 0000000..d500ac9 --- /dev/null +++ b/backend/app/services/storage.py @@ -0,0 +1,53 @@ +"""Document storage service.""" + +import shutil +import uuid +import pymupdf +from pathlib import Path +from fastapi import UploadFile, HTTPException + +from app.config import settings + +def validate_pdf(filepath: Path) -> None: + """Validate PDF magic bytes and PyMuPDF openability.""" + with open(filepath, "rb") as f: + header = f.read(5) + if header != b"%PDF-": + raise ValueError("Not a valid PDF file (missing magic bytes)") + + try: + doc = pymupdf.open(filepath) + doc.close() + except Exception as e: + raise ValueError(f"Failed to open PDF with PyMuPDF: {e}") + +async def save_upload_file(upload_file: UploadFile) -> tuple[str, int, int]: + """Save an uploaded file to disk and validate it.""" + file_id = str(uuid.uuid4()) + filepath = settings.PDF_STORAGE_PATH / f"{file_id}.pdf" + + try: + with open(filepath, "wb") as f: + shutil.copyfileobj(upload_file.file, f) + except Exception as e: + if filepath.exists(): + filepath.unlink() + raise HTTPException(status_code=500, detail="Failed to save file") + + page_count = 0 + try: + doc = pymupdf.open(filepath) + page_count = len(doc) + doc.close() + except Exception as e: + filepath.unlink() + raise HTTPException(status_code=400, detail=f"Failed to open PDF with PyMuPDF: {e}") + + size = filepath.stat().st_size + return file_id, size, page_count + +def delete_pdf_file(file_id: str) -> None: + """Delete a PDF file from storage.""" + filepath = settings.PDF_STORAGE_PATH / f"{file_id}.pdf" + if filepath.exists(): + filepath.unlink() diff --git a/backend/app/services/thumbnails.py b/backend/app/services/thumbnails.py new file mode 100644 index 0000000..fa50b92 --- /dev/null +++ b/backend/app/services/thumbnails.py @@ -0,0 +1,38 @@ +"""Thumbnail generation service.""" + +import pymupdf +from pathlib import Path + +from app.config import settings + +def generate_thumbnail(file_id: str) -> None: + """Generate a PNG thumbnail for the first page of a PDF.""" + pdf_path = settings.PDF_STORAGE_PATH / f"{file_id}.pdf" + thumb_path = settings.THUMBNAILS_PATH / f"{file_id}.png" + + if not pdf_path.exists(): + return + + try: + doc = pymupdf.open(pdf_path) + if len(doc) > 0: + page = doc[0] + # Zoom to approximately 600px width + rect = page.rect + zoom = 600.0 / rect.width if rect.width > 0 else 1.0 + mat = pymupdf.Matrix(zoom, zoom) + + # Render pixmap + pix = page.get_pixmap(matrix=mat, alpha=False) + + # Save as PNG + pix.save(thumb_path, output="png") + doc.close() + except Exception as e: + print(f"Failed to generate thumbnail for {file_id}: {e}") + +def delete_thumbnail(file_id: str) -> None: + """Delete a thumbnail file.""" + thumb_path = settings.THUMBNAILS_PATH / f"{file_id}.png" + if thumb_path.exists(): + thumb_path.unlink() diff --git a/backend/app/services/trash_sweep.py b/backend/app/services/trash_sweep.py new file mode 100644 index 0000000..99c6079 --- /dev/null +++ b/backend/app/services/trash_sweep.py @@ -0,0 +1,48 @@ +"""Background tasks for sweeping trash and old versions.""" + +from datetime import datetime, timedelta, timezone +from sqlalchemy.orm import Session + +from app.db import get_db +from app.models.document import Document +from app.models.annotation_state import AnnotationState +from app.models.version import Version +from app.services.storage import delete_pdf_file +from app.services.thumbnails import delete_thumbnail +from app.config import settings + +def sweep_trash(db: Session) -> None: + """Permanently delete documents that have been in the trash past the retention period.""" + cutoff = datetime.now(timezone.utc) - timedelta(days=settings.TRASH_RETENTION_DAYS) + cutoff_iso = cutoff.isoformat() + + docs_to_delete = db.query(Document).filter( + Document.in_trash == True, + Document.deleted_at <= cutoff_iso + ).all() + + for doc in docs_to_delete: + # Delete files from disk + delete_pdf_file(doc.id) + delete_thumbnail(doc.id) + + # Delete DB associations + db.query(AnnotationState).filter(AnnotationState.document_id == doc.id).delete() + db.query(Version).filter(Version.document_id == doc.id).delete() + + # Delete document record + db.delete(doc) + + db.commit() + +def prune_auto_versions(db: Session) -> None: + """Delete automatic versions older than the retention period.""" + cutoff = datetime.now(timezone.utc) - timedelta(days=settings.AUTO_VERSION_RETENTION_DAYS) + cutoff_iso = cutoff.isoformat() + + db.query(Version).filter( + Version.kind == "auto", + Version.created_at <= cutoff_iso + ).delete() + + db.commit() diff --git a/docker-compose.yml b/docker-compose.yml index 6177a7c..d67aa0c 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -14,8 +14,8 @@ services: - pdf_storage:/data/pdfs - thumbnails:/data/thumbnails - db:/data/db - expose: - - "8000" + ports: + - "8000:8000" restart: unless-stopped frontend: diff --git a/frontend/package-lock.json b/frontend/package-lock.json index 7fc98c4..9850eac 100644 --- a/frontend/package-lock.json +++ b/frontend/package-lock.json @@ -1,14 +1,15 @@ { - "name": "frontend", - "version": "0.0.0", + "name": "paperjet", + "version": "0.1.0", "lockfileVersion": 3, "requires": true, "packages": { "": { - "name": "frontend", - "version": "0.0.0", + "name": "paperjet", + "version": "0.1.0", "dependencies": { "@tailwindcss/vite": "^4.3.0", + "date-fns": "^4.4.0", "fabric": "^7.4.0", "pdfjs-dist": "^6.0.227", "react": "^19.2.6", @@ -22,7 +23,7 @@ "@testing-library/jest-dom": "^6.9.1", "@testing-library/react": "^16.3.2", "@types/fabric": "^5.3.11", - "@types/node": "^24.12.3", + "@types/node": "^24.13.2", "@types/react": "^19.2.14", "@types/react-dom": "^19.2.3", "@vitejs/plugin-react": "^6.0.1", @@ -2598,6 +2599,16 @@ "node": "^20.19.0 || ^22.12.0 || >=24.0.0" } }, + "node_modules/date-fns": { + "version": "4.4.0", + "resolved": "https://registry.npmjs.org/date-fns/-/date-fns-4.4.0.tgz", + "integrity": "sha512-+1UMbeh68lH1SegH83CGWwpb6OHHbpSgr3+s5Eww5M4CAgswBpoWS0AjTOfEJ33HiYKz1hdj/KTFprzXHmq/6w==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/kossnocorp" + } + }, "node_modules/debug": { "version": "4.4.3", "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", @@ -3508,7 +3519,6 @@ "integrity": "sha512-ECi4Fi2f7BdJtUKTflYRTiaMxIB0O6zfR1fX0GXpUrf6flp8QIYn1UT20YQqdSOfk2dfkCwS8LAFoJDEppNK5Q==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@asamuzakjp/css-color": "^5.1.11", "@asamuzakjp/dom-selector": "^7.1.1", diff --git a/frontend/package.json b/frontend/package.json index 75bc372..7149971 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -14,6 +14,7 @@ }, "dependencies": { "@tailwindcss/vite": "^4.3.0", + "date-fns": "^4.4.0", "fabric": "^7.4.0", "pdfjs-dist": "^6.0.227", "react": "^19.2.6", @@ -27,7 +28,7 @@ "@testing-library/jest-dom": "^6.9.1", "@testing-library/react": "^16.3.2", "@types/fabric": "^5.3.11", - "@types/node": "^24.12.3", + "@types/node": "^24.13.2", "@types/react": "^19.2.14", "@types/react-dom": "^19.2.3", "@vitejs/plugin-react": "^6.0.1", diff --git a/frontend/src/app/App.tsx b/frontend/src/app/App.tsx index 1c35dfc..967a062 100644 --- a/frontend/src/app/App.tsx +++ b/frontend/src/app/App.tsx @@ -1,22 +1,36 @@ import { Routes, Route, Navigate } from 'react-router-dom' import { LoginPage } from '../pages/LoginPage' +import { SetupPage } from '../pages/SetupPage' import { HomePage } from '../pages/HomePage' import { EditorPage } from '../pages/EditorPage' +import { AuthGuard } from '../features/auth/AuthGuard' -/** - * 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 ( - } /> - } /> - } /> + + + + } /> + + + + } /> + + {/* Protected Routes */} + + + + } /> + + + + } /> + } /> ) diff --git a/frontend/src/features/auth/AuthGuard.tsx b/frontend/src/features/auth/AuthGuard.tsx new file mode 100644 index 0000000..7af20b1 --- /dev/null +++ b/frontend/src/features/auth/AuthGuard.tsx @@ -0,0 +1,32 @@ +import { useEffect } from 'react' +import { Navigate, useLocation } from 'react-router-dom' +import { useAuth } from './useAuth' + +export const AuthGuard = ({ children }: { children: React.ReactNode }) => { + const { isInitialized, loggedIn, setupRequired, initialize } = useAuth() + const location = useLocation() + + useEffect(() => { + if (!isInitialized) { + initialize() + } + }, [isInitialized, initialize]) + + if (!isInitialized) { + return ( +
+
+
+ ) + } + + if (setupRequired && location.pathname !== '/setup') { + return + } + + if (!loggedIn && !setupRequired && location.pathname !== '/login') { + return + } + + return <>{children} +} diff --git a/frontend/src/features/auth/LoginForm.tsx b/frontend/src/features/auth/LoginForm.tsx new file mode 100644 index 0000000..5949c2e --- /dev/null +++ b/frontend/src/features/auth/LoginForm.tsx @@ -0,0 +1,72 @@ +import { useState } from 'react' +import { useNavigate } from 'react-router-dom' +import { useAuth } from './useAuth' + +export const LoginForm = () => { + const [password, setPassword] = useState('') + const { login, error, isLoading } = useAuth() + const navigate = useNavigate() + + const handleSubmit = async (e: React.FormEvent) => { + e.preventDefault() + if (!password) return + + try { + await login({ password }) + navigate('/') + } catch (err) { + // Error is handled by the store + } + } + + return ( +
+
+

+ Welcome back +

+

+ Enter your master password to unlock your library +

+
+ +
+
+ + setPassword(e.target.value)} + disabled={isLoading} + /> +
+ + {error && ( +
+ {error} +
+ )} + + +
+
+ ) +} diff --git a/frontend/src/features/auth/SetupForm.tsx b/frontend/src/features/auth/SetupForm.tsx new file mode 100644 index 0000000..6234457 --- /dev/null +++ b/frontend/src/features/auth/SetupForm.tsx @@ -0,0 +1,107 @@ +import { useState } from 'react' +import { useNavigate } from 'react-router-dom' +import { useAuth } from './useAuth' + +export const SetupForm = () => { + const [password, setPassword] = useState('') + const [confirmPassword, setConfirmPassword] = useState('') + const [localError, setLocalError] = useState('') + const { setup, error, isLoading } = useAuth() + const navigate = useNavigate() + + const handleSubmit = async (e: React.FormEvent) => { + e.preventDefault() + setLocalError('') + + if (password.length < 8) { + setLocalError('Password must be at least 8 characters long') + return + } + + if (password !== confirmPassword) { + setLocalError('Passwords do not match') + return + } + + try { + await setup({ password }) + navigate('/') + } catch (err) { + // Error is handled by the store + } + } + + const displayError = localError || error + + return ( +
+
+
+ + + +
+

+ Set up PaperJet +

+

+ Create a master password to secure your documents. Make sure you remember it! +

+
+ +
+
+
+ + setPassword(e.target.value)} + disabled={isLoading} + /> +
+
+ + setConfirmPassword(e.target.value)} + disabled={isLoading} + /> +
+
+ + {displayError && ( +
+ {displayError} +
+ )} + + +
+
+ ) +} diff --git a/frontend/src/features/auth/api.ts b/frontend/src/features/auth/api.ts new file mode 100644 index 0000000..cb1928b --- /dev/null +++ b/frontend/src/features/auth/api.ts @@ -0,0 +1,21 @@ +import { api } from '@/lib/api/client' +import type { LoginCredentials, SetupCredentials } from './types' + +export interface AuthStatus { + setupRequired: boolean + loggedIn: boolean +} + +export const authApi = { + /** Check if setup is required and if user is logged in */ + getStatus: () => api.get('/auth/status'), + + /** Initial setup to create the password */ + setup: (data: SetupCredentials) => api.post<{ status: string }>('/auth/setup', data), + + /** Login to get a session cookie */ + login: (data: LoginCredentials) => api.post<{ status: string }>('/auth/login', data), + + /** Logout and clear session */ + logout: () => api.post<{ status: string }>('/auth/logout'), +} diff --git a/frontend/src/features/auth/types.ts b/frontend/src/features/auth/types.ts new file mode 100644 index 0000000..17dc87c --- /dev/null +++ b/frontend/src/features/auth/types.ts @@ -0,0 +1,7 @@ +export interface LoginCredentials { + password: string +} + +export interface SetupCredentials { + password: string +} diff --git a/frontend/src/features/auth/useAuth.ts b/frontend/src/features/auth/useAuth.ts new file mode 100644 index 0000000..3f34898 --- /dev/null +++ b/frontend/src/features/auth/useAuth.ts @@ -0,0 +1,88 @@ +import { create } from 'zustand' +import { authApi } from './api' +import type { LoginCredentials, SetupCredentials } from './types' + +interface AuthState { + isInitialized: boolean + setupRequired: boolean + loggedIn: boolean + isLoading: boolean + error: string | null + + initialize: () => Promise + login: (data: LoginCredentials) => Promise + setup: (data: SetupCredentials) => Promise + logout: () => Promise + clearError: () => void +} + +export const useAuth = create((set) => ({ + isInitialized: false, + setupRequired: false, + loggedIn: false, + isLoading: true, + error: null, + + initialize: async () => { + try { + set({ isLoading: true, error: null }) + const status = await authApi.getStatus() + set({ + setupRequired: status.setupRequired, + loggedIn: status.loggedIn, + isInitialized: true, + isLoading: false + }) + } catch (err: any) { + set({ + error: err.error?.message || err.message || 'Failed to initialize', + isLoading: false, + isInitialized: true + }) + } + }, + + login: async (data: LoginCredentials) => { + try { + set({ isLoading: true, error: null }) + await authApi.login(data) + set({ loggedIn: true, isLoading: false }) + } catch (err: any) { + set({ + error: err.error?.message || err.message || 'Failed to login', + isLoading: false + }) + throw err + } + }, + + setup: async (data: SetupCredentials) => { + try { + set({ isLoading: true, error: null }) + await authApi.setup(data) + set({ setupRequired: false, loggedIn: true, isLoading: false }) + } catch (err: any) { + set({ + error: err.error?.message || err.message || 'Failed to setup', + isLoading: false + }) + throw err + } + }, + + logout: async () => { + try { + set({ isLoading: true, error: null }) + await authApi.logout() + set({ loggedIn: false, isLoading: false }) + window.location.href = '/login' + } catch (err: any) { + set({ + error: err.error?.message || err.message || 'Failed to logout', + isLoading: false + }) + } + }, + + clearError: () => set({ error: null }) +})) diff --git a/frontend/src/features/library/ContextMenu.tsx b/frontend/src/features/library/ContextMenu.tsx new file mode 100644 index 0000000..b4255ab --- /dev/null +++ b/frontend/src/features/library/ContextMenu.tsx @@ -0,0 +1,98 @@ +import { useEffect, useRef } from 'react' + +export interface ContextMenuProps { + x: number + y: number + isTrash: boolean + onClose: () => void + onOpen: () => void + onRename: () => void + onDelete: () => void + onRestore: () => void + onHardDelete: () => void +} + +export const ContextMenu = ({ + x, y, isTrash, onClose, onOpen, onRename, onDelete, onRestore, onHardDelete +}: ContextMenuProps) => { + const menuRef = useRef(null) + + useEffect(() => { + const handleClickOutside = (e: MouseEvent) => { + if (menuRef.current && !menuRef.current.contains(e.target as Node)) { + onClose() + } + } + + const handleEscape = (e: KeyboardEvent) => { + if (e.key === 'Escape') onClose() + } + + document.addEventListener('mousedown', handleClickOutside) + document.addEventListener('keydown', handleEscape) + + // Prevent scrolling while context menu is open + document.body.style.overflow = 'hidden' + + return () => { + document.removeEventListener('mousedown', handleClickOutside) + document.removeEventListener('keydown', handleEscape) + document.body.style.overflow = '' + } + }, [onClose]) + + // Ensure menu doesn't go off screen + const safeX = Math.min(x, window.innerWidth - 200) + const safeY = Math.min(y, window.innerHeight - (isTrash ? 100 : 150)) + + return ( +
e.preventDefault()} + > + {!isTrash && ( + <> + + +
+ + + )} + + {isTrash && ( + <> + +
+ + + )} +
+ ) +} diff --git a/frontend/src/features/library/DocumentGrid.tsx b/frontend/src/features/library/DocumentGrid.tsx new file mode 100644 index 0000000..c5a454c --- /dev/null +++ b/frontend/src/features/library/DocumentGrid.tsx @@ -0,0 +1,136 @@ +import { useState } from 'react' +import { useLibrary } from './useLibrary' +import { libraryApi } from './api' +import { Link, useNavigate } from 'react-router-dom' +import { formatDistanceToNow } from 'date-fns' +import { ContextMenu } from './ContextMenu' + +export const DocumentGrid = ({ isTrash }: { isTrash: boolean }) => { + const { documents, selectedIds, toggleSelection, isLoading, error, deleteDocument, restoreDocument, updateDocument } = useLibrary() + const navigate = useNavigate() + + const [contextMenu, setContextMenu] = useState<{ x: number, y: number, docId: string } | null>(null) + + const handleContextMenu = (e: React.MouseEvent, docId: string) => { + e.preventDefault() + setContextMenu({ x: e.clientX, y: e.clientY, docId }) + } + + if (isLoading) { + return ( +
+
+
+ ) + } + + if (error) { + return ( +
+ {error} +
+ ) + } + + if (documents.length === 0) { + return ( +
+
📄
+

+ {isTrash ? 'Trash is empty' : 'No documents yet'} +

+

+ {isTrash ? 'Nothing to see here.' : 'Drag & drop a PDF, or click Upload to get started.'} +

+
+ ) + } + + return ( + <> +
+ {documents.map((doc) => { + const isSelected = selectedIds.has(doc.id) + const thumbnailUrl = libraryApi.getThumbnailUrl(doc.id) + + return ( +
handleContextMenu(e, doc.id)} + className={`group relative flex flex-col bg-white dark:bg-gray-800 rounded-2xl overflow-hidden shadow-sm hover:shadow-xl transition-all duration-300 border-2 ${ + isSelected ? 'border-indigo-500 shadow-md ring-2 ring-indigo-500/20' : 'border-transparent hover:border-indigo-200 dark:hover:border-indigo-800' + }`} + > +
+ toggleSelection(doc.id)} + className="w-5 h-5 rounded border-gray-300 text-indigo-600 focus:ring-indigo-500 cursor-pointer shadow-sm" + /> +
+ + { + if (isTrash) e.preventDefault() // disable link in trash + }} + > + {doc.title} { + // Fallback if no thumbnail + (e.target as HTMLImageElement).src = 'data:image/svg+xml;utf8,' + ;(e.target as HTMLImageElement).className = 'w-1/2 h-1/2 absolute top-1/4 left-1/4 opacity-30 object-contain' + }} + /> +
+ + +
+

+ {doc.title} +

+

+ {formatDistanceToNow(new Date(doc.updated_at), { addSuffix: true })} +

+

+ {(doc.size_bytes / 1024 / 1024).toFixed(1)} MB +

+
+
+ ) + })} +
+ + {contextMenu && ( + setContextMenu(null)} + onOpen={() => navigate(`/editor/${contextMenu.docId}`)} + onRename={() => { + const doc = documents.find(d => d.id === contextMenu.docId) + if (doc) { + const newTitle = window.prompt("Rename Document:", doc.title) + if (newTitle && newTitle.trim() !== "" && newTitle !== doc.title) { + updateDocument(doc.id, { title: newTitle.trim() }) + } + } + }} + onDelete={() => deleteDocument(contextMenu.docId)} + onRestore={() => restoreDocument(contextMenu.docId)} + onHardDelete={() => deleteDocument(contextMenu.docId, true)} + /> + )} + + ) +} diff --git a/frontend/src/features/library/LibraryHeader.tsx b/frontend/src/features/library/LibraryHeader.tsx new file mode 100644 index 0000000..dfe6460 --- /dev/null +++ b/frontend/src/features/library/LibraryHeader.tsx @@ -0,0 +1,169 @@ +import { useLibrary } from './useLibrary' +import { useAuth } from '../auth/useAuth' + +export const LibraryHeader = ({ currentTab, onTabChange }: { currentTab: 'library' | 'trash', onTabChange: (tab: 'library' | 'trash') => void }) => { + const { logout } = useAuth() + const { + searchQuery, setSearchQuery, fetchDocuments, fetchTrash, + selectedIds, clearSelection, selectAll, bulkDelete, bulkRestore, emptyTrash, documents + } = useLibrary() + + const handleSearch = (e: React.FormEvent) => { + e.preventDefault() + if (currentTab === 'library') { + fetchDocuments(searchQuery) + } + } + + const handleTabChange = (tab: 'library' | 'trash') => { + onTabChange(tab) + if (tab === 'library') { + fetchDocuments() + } else { + fetchTrash() + } + } + + const isSelectionMode = selectedIds.size > 0 + const allSelected = documents.length > 0 && selectedIds.size === documents.length + + return ( +
+
+ + {/* Left: Branding & Tabs */} +
+
+
+ P +
+

PaperJet

+
+ + +
+ + {/* Right: Search & Actions */} +
+ {isSelectionMode ? ( +
+ + {selectedIds.size} selected + +
+ + + + {currentTab === 'library' ? ( + + ) : ( + <> + + + + )} +
+ ) : ( + <> + {currentTab === 'library' && ( +
+
+ + + +
+ setSearchQuery(e.target.value)} + placeholder="Search documents..." + className="block w-full pl-10 pr-3 py-2 border border-gray-200 dark:border-gray-700 rounded-xl leading-5 bg-gray-50 dark:bg-gray-800 text-gray-900 dark:text-gray-100 placeholder-gray-500 focus:outline-none focus:ring-2 focus:ring-indigo-500 focus:border-transparent sm:text-sm transition-all" + /> +
+ )} + + {currentTab === 'trash' && ( + + )} + + {currentTab === 'library' && ( + + )} + + + + )} +
+
+
+ ) +} diff --git a/frontend/src/features/library/UploadArea.tsx b/frontend/src/features/library/UploadArea.tsx new file mode 100644 index 0000000..732e7ad --- /dev/null +++ b/frontend/src/features/library/UploadArea.tsx @@ -0,0 +1,86 @@ +import { useRef, useState } from 'react' +import { useLibrary } from './useLibrary' + +export const UploadArea = ({ children }: { children: React.ReactNode }) => { + const { uploadDocument } = useLibrary() + const [isDragging, setIsDragging] = useState(false) + const fileInputRef = useRef(null) + + const handleDragOver = (e: React.DragEvent) => { + e.preventDefault() + e.stopPropagation() + setIsDragging(true) + } + + const handleDragLeave = (e: React.DragEvent) => { + e.preventDefault() + e.stopPropagation() + setIsDragging(false) + } + + const handleDrop = async (e: React.DragEvent) => { + e.preventDefault() + e.stopPropagation() + setIsDragging(false) + + if (e.dataTransfer.files && e.dataTransfer.files.length > 0) { + await processFiles(e.dataTransfer.files) + } + } + + const handleFileChange = async (e: React.ChangeEvent) => { + if (e.target.files && e.target.files.length > 0) { + await processFiles(e.target.files) + // Reset input so the same file can be selected again + e.target.value = '' + } + } + + const processFiles = async (files: FileList) => { + // For now, process one by one + for (let i = 0; i < files.length; i++) { + const file = files[i] + if (file.type === 'application/pdf') { + try { + await uploadDocument(file) + } catch (err) { + console.error('Failed to upload', file.name, err) + // Could show toast notification here + } + } + } + } + + return ( +
+ + + {/* Invisible overlay that appears when dragging to prevent flickering */} + {isDragging && ( +
+
+ + + +

Drop PDFs here

+

Release to upload to your library

+
+
+ )} + + {children} +
+ ) +} diff --git a/frontend/src/features/library/api.ts b/frontend/src/features/library/api.ts new file mode 100644 index 0000000..7023ca9 --- /dev/null +++ b/frontend/src/features/library/api.ts @@ -0,0 +1,47 @@ +import { api } from '@/lib/api/client' +import type { DocumentListResponse, DocumentMeta, DocumentUpdateRequest, BulkRequest } from './types' + +export const libraryApi = { + getDocuments: (query = '', skip = 0, limit = 50) => { + const params = new URLSearchParams() + if (query) params.append('query', query) + params.append('skip', skip.toString()) + params.append('limit', limit.toString()) + return api.get(`/documents?${params.toString()}`) + }, + + getTrash: (skip = 0, limit = 50) => { + const params = new URLSearchParams() + params.append('skip', skip.toString()) + params.append('limit', limit.toString()) + return api.get(`/documents/trash?${params.toString()}`) + }, + + uploadDocument: (file: File) => { + const formData = new FormData() + formData.append('file', file) + return api.upload('/documents', formData) + }, + + getDocument: (id: string) => api.get(`/documents/${id}`), + + updateDocument: (id: string, data: DocumentUpdateRequest) => + api.patch(`/documents/${id}`, data), + + deleteDocument: (id: string, permanent = false) => + api.delete<{ status: string }>(`/documents/${id}?permanent=${permanent}`), + + restoreDocument: (id: string) => + api.post(`/documents/${id}/restore`), + + bulkDelete: (data: BulkRequest) => + api.post<{ status: string, count: number }>('/documents/bulk-delete', data), + + bulkRestore: (data: BulkRequest) => + api.post<{ status: string, count: number }>('/documents/bulk-restore', data), + + emptyTrash: () => + api.post<{ status: string, deleted: number }>('/documents/trash/empty'), + + getThumbnailUrl: (id: string) => `/api/v1/documents/${id}/thumbnail`, +} diff --git a/frontend/src/features/library/types.ts b/frontend/src/features/library/types.ts new file mode 100644 index 0000000..efa827c --- /dev/null +++ b/frontend/src/features/library/types.ts @@ -0,0 +1,25 @@ +export interface DocumentMeta { + id: string + title: string + filename: string + mime_type: string + size_bytes: number + in_trash: boolean + created_at: string + updated_at: string + deleted_at: string | null +} + +export interface DocumentListResponse { + items: DocumentMeta[] + total: number +} + +export interface DocumentUpdateRequest { + title?: string + in_trash?: boolean +} + +export interface BulkRequest { + ids: string[] +} diff --git a/frontend/src/features/library/useLibrary.ts b/frontend/src/features/library/useLibrary.ts new file mode 100644 index 0000000..4634747 --- /dev/null +++ b/frontend/src/features/library/useLibrary.ts @@ -0,0 +1,180 @@ +import { create } from 'zustand' +import { libraryApi } from './api' +import type { DocumentMeta } from './types' + +interface LibraryState { + documents: DocumentMeta[] + total: number + isLoading: boolean + error: string | null + searchQuery: string + + // Selection + selectedIds: Set + toggleSelection: (id: string) => void + clearSelection: () => void + selectAll: () => void + + // Actions + fetchDocuments: (query?: string) => Promise + fetchTrash: () => Promise + uploadDocument: (file: File) => Promise + deleteDocument: (id: string, permanent?: boolean) => Promise + restoreDocument: (id: string) => Promise + bulkDelete: (permanent?: boolean) => Promise + bulkRestore: () => Promise + emptyTrash: () => Promise + setSearchQuery: (query: string) => void +} + +export const useLibrary = create((set, get) => ({ + documents: [], + total: 0, + isLoading: false, + error: null, + searchQuery: '', + selectedIds: new Set(), + + toggleSelection: (id) => set((state) => { + const newSelection = new Set(state.selectedIds) + if (newSelection.has(id)) newSelection.delete(id) + else newSelection.add(id) + return { selectedIds: newSelection } + }), + + clearSelection: () => set({ selectedIds: new Set() }), + + selectAll: () => set((state) => ({ + selectedIds: new Set(state.documents.map(d => d.id)) + })), + + setSearchQuery: (query) => set({ searchQuery: query }), + + fetchDocuments: async (query = '') => { + set({ isLoading: true, error: null, searchQuery: query }) + try { + const data = await libraryApi.getDocuments(query) + set({ documents: data.items, total: data.total, isLoading: false }) + } catch (err: any) { + set({ error: err.error?.message || err.message, isLoading: false }) + } + }, + + fetchTrash: async () => { + set({ isLoading: true, error: null, searchQuery: '' }) + try { + const data = await libraryApi.getTrash() + set({ documents: data.items, total: data.total, isLoading: false }) + } catch (err: any) { + set({ error: err.error?.message || err.message, isLoading: false }) + } + }, + + uploadDocument: async (file: File) => { + set({ isLoading: true, error: null }) + try { + const newDoc = await libraryApi.uploadDocument(file) + set((state) => ({ + documents: [newDoc, ...state.documents], + total: state.total + 1, + isLoading: false + })) + return newDoc + } catch (err: any) { + set({ error: err.error?.message || err.message, isLoading: false }) + throw err + } + }, + + deleteDocument: async (id: string, permanent = false) => { + set({ isLoading: true, error: null }) + try { + await libraryApi.deleteDocument(id, permanent) + set((state) => ({ + documents: state.documents.filter(d => d.id !== id), + total: state.total - 1, + selectedIds: new Set(Array.from(state.selectedIds).filter(sid => sid !== id)), + isLoading: false + })) + } catch (err: any) { + set({ error: err.error?.message || err.message, isLoading: false }) + throw err + } + }, + + restoreDocument: async (id: string) => { + set({ isLoading: true, error: null }) + try { + await libraryApi.restoreDocument(id) + set((state) => ({ + documents: state.documents.filter(d => d.id !== id), + total: state.total - 1, + selectedIds: new Set(Array.from(state.selectedIds).filter(sid => sid !== id)), + isLoading: false + })) + } catch (err: any) { + set({ error: err.error?.message || err.message, isLoading: false }) + throw err + } + }, + + bulkDelete: async (permanent = false) => { + const { selectedIds } = get() + if (selectedIds.size === 0) return + + set({ isLoading: true, error: null }) + try { + if (permanent) { + // Must delete one by one if permanent + await Promise.all(Array.from(selectedIds).map(id => libraryApi.deleteDocument(id, true))) + } else { + await libraryApi.bulkDelete({ ids: Array.from(selectedIds) }) + } + + set((state) => ({ + documents: state.documents.filter(d => !state.selectedIds.has(d.id)), + total: state.total - state.selectedIds.size, + selectedIds: new Set(), + isLoading: false + })) + } catch (err: any) { + set({ error: err.error?.message || err.message, isLoading: false }) + throw err + } + }, + + bulkRestore: async () => { + const { selectedIds } = get() + if (selectedIds.size === 0) return + + set({ isLoading: true, error: null }) + try { + await libraryApi.bulkRestore({ ids: Array.from(selectedIds) }) + set((state) => ({ + documents: state.documents.filter(d => !state.selectedIds.has(d.id)), + total: state.total - state.selectedIds.size, + selectedIds: new Set(), + isLoading: false + })) + } catch (err: any) { + set({ error: err.error?.message || err.message, isLoading: false }) + throw err + } + }, + + emptyTrash: async () => { + set({ isLoading: true, error: null }) + try { + await libraryApi.emptyTrash() + set({ + documents: [], + total: 0, + selectedIds: new Set(), + isLoading: false + }) + } catch (err: any) { + set({ error: err.error?.message || err.message, isLoading: false }) + throw err + } + } +})) diff --git a/frontend/src/index.css b/frontend/src/index.css index eebfc7f..1fda1df 100644 --- a/frontend/src/index.css +++ b/frontend/src/index.css @@ -1,5 +1,8 @@ @import "tailwindcss"; +/* Force light mode by requiring a .dark class that we never add */ +@custom-variant dark (&:where(.dark, .dark *)); + /* * PaperJet Design Tokens * @@ -36,27 +39,6 @@ --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; diff --git a/frontend/src/lib/api/client.ts b/frontend/src/lib/api/client.ts index 89d4a0d..5dc2e0c 100644 --- a/frontend/src/lib/api/client.ts +++ b/frontend/src/lib/api/client.ts @@ -75,13 +75,21 @@ async function apiFetch( } // Parse response - const data = await response.json() + let data: any = {} + try { + const text = await response.text() + if (text) { + data = JSON.parse(text) + } + } catch (err) { + // If it's not JSON, we'll just fall back to the empty object + } // Handle error responses if (!response.ok) { - const error: ApiError = data.error ?? { - code: 'unknown', - message: response.statusText, + const error: ApiError = { + code: data.error?.code ?? 'unknown', + message: data.error?.message ?? data.detail ?? response.statusText, } throw new ApiRequestError(response.status, error) } @@ -150,8 +158,17 @@ export const api = { } if (!response.ok) { - const data = await response.json() - throw new ApiRequestError(response.status, data.error) + let data: any = {} + try { + const text = await response.text() + if (text) data = JSON.parse(text) + } catch (err) {} + + const error: ApiError = { + code: data.error?.code ?? 'unknown', + message: data.error?.message ?? data.detail ?? response.statusText, + } + throw new ApiRequestError(response.status, error) } return response.blob() diff --git a/frontend/src/pages/HomePage.tsx b/frontend/src/pages/HomePage.tsx index 1c956f6..1d83f64 100644 --- a/frontend/src/pages/HomePage.tsx +++ b/frontend/src/pages/HomePage.tsx @@ -1,61 +1,36 @@ -/** - * Home page — recently edited, library, and trash tabs. - * Full implementation in Phase 1. - */ +import { useEffect, useState } from 'react' +import { LibraryHeader } from '../features/library/LibraryHeader' +import { UploadArea } from '../features/library/UploadArea' +import { DocumentGrid } from '../features/library/DocumentGrid' +import { useLibrary } from '../features/library/useLibrary' + export function HomePage() { + const [currentTab, setCurrentTab] = useState<'library' | 'trash'>('library') + const { fetchDocuments, fetchTrash } = useLibrary() + + // Initial fetch + useEffect(() => { + if (currentTab === 'library') { + fetchDocuments() + } else { + fetchTrash() + } + }, [currentTab, fetchDocuments, fetchTrash]) + return ( -
- {/* Header */} -
-
-

PaperJet

- -
-
- - {/* Main content */} -
- {/* Tab navigation */} - - - {/* Empty state placeholder */} -
-
📄
-

No documents yet

-

- Upload a PDF to get started -

-
+
+ + +
+ {currentTab === 'library' ? ( + + + + ) : ( + + )}
) } + diff --git a/frontend/src/pages/LoginPage.tsx b/frontend/src/pages/LoginPage.tsx index 6761465..91d00a1 100644 --- a/frontend/src/pages/LoginPage.tsx +++ b/frontend/src/pages/LoginPage.tsx @@ -1,40 +1,13 @@ -/** - * Login page — handles both first-run password setup and normal login. - * Full implementation in Phase 1. - */ +import { LoginForm } from '@/features/auth/LoginForm' + export function LoginPage() { return ( -
-
-
-

PaperJet

-

- Self-hosted PDF editor -

-
-
-
- - -
- -
+
+
+
+
) } + diff --git a/frontend/src/pages/SetupPage.tsx b/frontend/src/pages/SetupPage.tsx new file mode 100644 index 0000000..87efa3b --- /dev/null +++ b/frontend/src/pages/SetupPage.tsx @@ -0,0 +1,12 @@ +import { SetupForm } from '@/features/auth/SetupForm' + +export function SetupPage() { + return ( +
+
+
+ +
+
+ ) +} diff --git a/frontend/tsconfig.app.json b/frontend/tsconfig.app.json index afa7145..6dd34cf 100644 --- a/frontend/tsconfig.app.json +++ b/frontend/tsconfig.app.json @@ -22,7 +22,13 @@ "noUnusedLocals": true, "noUnusedParameters": true, "erasableSyntaxOnly": true, - "noFallthroughCasesInSwitch": true + "noFallthroughCasesInSwitch": true, + "ignoreDeprecations": "6.0", + + "baseUrl": ".", + "paths": { + "@/*": ["src/*"] + } }, "include": ["src"] } diff --git a/frontend/vite.config.ts b/frontend/vite.config.ts index 3b11ca2..4072feb 100644 --- a/frontend/vite.config.ts +++ b/frontend/vite.config.ts @@ -1,6 +1,7 @@ import { defineConfig } from 'vite' import react from '@vitejs/plugin-react' import tailwindcss from '@tailwindcss/vite' +import path from 'path' // https://vite.dev/config/ export default defineConfig({ @@ -8,6 +9,11 @@ export default defineConfig({ react(), tailwindcss(), ], + resolve: { + alias: { + '@': path.resolve(__dirname, './src'), + }, + }, server: { // Dev proxy: route /api requests to the backend proxy: {