Fix bugs, convert thumbnail to png, add context menu
Some checks failed
Automated Container Build / build-and-push (push) Failing after 2s
CI / Backend (Python) (push) Failing after 9s
CI / Frontend (TypeScript) (push) Failing after 4m46s

This commit is contained in:
Elijah 2026-06-10 19:06:28 -07:00
parent c545d4b17d
commit eb8a302222
37 changed files with 1916 additions and 137 deletions

View file

@ -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)

124
backend/app/api/v1/auth.py Normal file
View file

@ -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"}

View file

@ -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")

View file

@ -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"
)

View file

@ -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

View file

@ -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}

View file

@ -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

View file

@ -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",

View file

@ -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

View file

@ -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

View file

@ -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()

View file

@ -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()

View file

@ -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()

View file

@ -14,8 +14,8 @@ services:
- pdf_storage:/data/pdfs
- thumbnails:/data/thumbnails
- db:/data/db
expose:
- "8000"
ports:
- "8000:8000"
restart: unless-stopped
frontend:

View file

@ -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",

View file

@ -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",

View file

@ -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 (
<Routes>
<Route path="/login" element={<LoginPage />} />
<Route path="/" element={<HomePage />} />
<Route path="/editor/:id" element={<EditorPage />} />
<Route path="/login" element={
<AuthGuard>
<LoginPage />
</AuthGuard>
} />
<Route path="/setup" element={
<AuthGuard>
<SetupPage />
</AuthGuard>
} />
{/* Protected Routes */}
<Route path="/" element={
<AuthGuard>
<HomePage />
</AuthGuard>
} />
<Route path="/editor/:id" element={
<AuthGuard>
<EditorPage />
</AuthGuard>
} />
<Route path="*" element={<Navigate to="/" replace />} />
</Routes>
)

View file

@ -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 (
<div className="min-h-screen flex items-center justify-center bg-gray-50 dark:bg-gray-900">
<div className="animate-spin rounded-full h-8 w-8 border-b-2 border-indigo-600"></div>
</div>
)
}
if (setupRequired && location.pathname !== '/setup') {
return <Navigate to="/setup" replace />
}
if (!loggedIn && !setupRequired && location.pathname !== '/login') {
return <Navigate to="/login" replace />
}
return <>{children}</>
}

View file

@ -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 (
<div className="w-full max-w-md p-8 space-y-8 bg-white/70 dark:bg-gray-800/70 backdrop-blur-xl rounded-2xl shadow-2xl border border-gray-100 dark:border-gray-700 transition-all">
<div className="text-center">
<h2 className="text-3xl font-extrabold tracking-tight text-gray-900 dark:text-white mb-2">
Welcome back
</h2>
<p className="text-sm text-gray-500 dark:text-gray-400">
Enter your master password to unlock your library
</p>
</div>
<form className="mt-8 space-y-6" onSubmit={handleSubmit}>
<div>
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">
Password
</label>
<input
type="password"
required
className="w-full px-4 py-3 rounded-xl border border-gray-300 dark:border-gray-600 bg-white/50 dark:bg-gray-700/50 text-gray-900 dark:text-white focus:ring-2 focus:ring-indigo-500 focus:border-transparent outline-none transition-all placeholder-gray-400 dark:placeholder-gray-500"
placeholder="••••••••"
value={password}
onChange={(e) => setPassword(e.target.value)}
disabled={isLoading}
/>
</div>
{error && (
<div className="p-3 text-sm text-red-600 bg-red-50 dark:bg-red-900/30 dark:text-red-400 rounded-lg animate-pulse">
{error}
</div>
)}
<button
type="submit"
disabled={isLoading || !password}
className="w-full flex justify-center py-3 px-4 border border-transparent rounded-xl shadow-sm text-sm font-medium text-white bg-indigo-600 hover:bg-indigo-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500 disabled:opacity-50 disabled:cursor-not-allowed transition-all active:scale-[0.98]"
>
{isLoading ? (
<svg className="animate-spin -ml-1 mr-3 h-5 w-5 text-white" fill="none" viewBox="0 0 24 24">
<circle className="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" strokeWidth="4"></circle>
<path className="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"></path>
</svg>
) : (
'Unlock Library'
)}
</button>
</form>
</div>
)
}

View file

@ -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 (
<div className="w-full max-w-md p-8 space-y-8 bg-white/70 dark:bg-gray-800/70 backdrop-blur-xl rounded-2xl shadow-2xl border border-gray-100 dark:border-gray-700 transition-all">
<div className="text-center">
<div className="mx-auto w-16 h-16 bg-indigo-100 dark:bg-indigo-900/50 rounded-full flex items-center justify-center mb-6">
<svg className="w-8 h-8 text-indigo-600 dark:text-indigo-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth="2" d="M12 15v2m-6 4h12a2 2 0 002-2v-6a2 2 0 00-2-2H6a2 2 0 00-2 2v6a2 2 0 002 2zm10-10V7a4 4 0 00-8 0v4h8V7a4 4 0 00-8 0v4h8z" />
</svg>
</div>
<h2 className="text-3xl font-extrabold tracking-tight text-gray-900 dark:text-white mb-2">
Set up PaperJet
</h2>
<p className="text-sm text-gray-500 dark:text-gray-400">
Create a master password to secure your documents. Make sure you remember it!
</p>
</div>
<form className="mt-8 space-y-6" onSubmit={handleSubmit}>
<div className="space-y-4">
<div>
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">
Master Password
</label>
<input
type="password"
required
className="w-full px-4 py-3 rounded-xl border border-gray-300 dark:border-gray-600 bg-white/50 dark:bg-gray-700/50 text-gray-900 dark:text-white focus:ring-2 focus:ring-indigo-500 focus:border-transparent outline-none transition-all"
placeholder="••••••••"
value={password}
onChange={(e) => setPassword(e.target.value)}
disabled={isLoading}
/>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">
Confirm Password
</label>
<input
type="password"
required
className="w-full px-4 py-3 rounded-xl border border-gray-300 dark:border-gray-600 bg-white/50 dark:bg-gray-700/50 text-gray-900 dark:text-white focus:ring-2 focus:ring-indigo-500 focus:border-transparent outline-none transition-all"
placeholder="••••••••"
value={confirmPassword}
onChange={(e) => setConfirmPassword(e.target.value)}
disabled={isLoading}
/>
</div>
</div>
{displayError && (
<div className="p-3 text-sm text-red-600 bg-red-50 dark:bg-red-900/30 dark:text-red-400 rounded-lg animate-pulse">
{displayError}
</div>
)}
<button
type="submit"
disabled={isLoading || !password || !confirmPassword}
className="w-full flex justify-center py-3 px-4 border border-transparent rounded-xl shadow-sm text-sm font-medium text-white bg-indigo-600 hover:bg-indigo-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500 disabled:opacity-50 disabled:cursor-not-allowed transition-all active:scale-[0.98]"
>
{isLoading ? (
<svg className="animate-spin -ml-1 mr-3 h-5 w-5 text-white" fill="none" viewBox="0 0 24 24">
<circle className="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" strokeWidth="4"></circle>
<path className="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"></path>
</svg>
) : (
'Complete Setup'
)}
</button>
</form>
</div>
)
}

View file

@ -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<AuthStatus>('/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'),
}

View file

@ -0,0 +1,7 @@
export interface LoginCredentials {
password: string
}
export interface SetupCredentials {
password: string
}

View file

@ -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<void>
login: (data: LoginCredentials) => Promise<void>
setup: (data: SetupCredentials) => Promise<void>
logout: () => Promise<void>
clearError: () => void
}
export const useAuth = create<AuthState>((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 })
}))

View file

@ -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<HTMLDivElement>(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 (
<div
ref={menuRef}
className="fixed z-50 w-48 bg-white dark:bg-gray-800 rounded-xl shadow-2xl border border-gray-200 dark:border-gray-700 py-2 overflow-hidden animate-in fade-in zoom-in duration-100"
style={{ left: safeX, top: safeY }}
onContextMenu={(e) => e.preventDefault()}
>
{!isTrash && (
<>
<button
onClick={() => { onOpen(); onClose(); }}
className="w-full text-left px-4 py-2 text-sm text-gray-700 dark:text-gray-200 hover:bg-indigo-50 dark:hover:bg-indigo-900/30 hover:text-indigo-600 dark:hover:text-indigo-400 transition-colors"
>
Open in Editor
</button>
<button
onClick={() => { onRename(); onClose(); }}
className="w-full text-left px-4 py-2 text-sm text-gray-700 dark:text-gray-200 hover:bg-indigo-50 dark:hover:bg-indigo-900/30 hover:text-indigo-600 dark:hover:text-indigo-400 transition-colors"
>
Rename...
</button>
<div className="h-px bg-gray-200 dark:bg-gray-700 my-1 mx-2" />
<button
onClick={() => { onDelete(); onClose(); }}
className="w-full text-left px-4 py-2 text-sm text-red-600 dark:text-red-400 hover:bg-red-50 dark:hover:bg-red-900/30 transition-colors"
>
Move to Trash
</button>
</>
)}
{isTrash && (
<>
<button
onClick={() => { onRestore(); onClose(); }}
className="w-full text-left px-4 py-2 text-sm text-gray-700 dark:text-gray-200 hover:bg-emerald-50 dark:hover:bg-emerald-900/30 hover:text-emerald-600 dark:hover:text-emerald-400 transition-colors"
>
Restore Document
</button>
<div className="h-px bg-gray-200 dark:bg-gray-700 my-1 mx-2" />
<button
onClick={() => { onHardDelete(); onClose(); }}
className="w-full text-left px-4 py-2 text-sm text-red-600 dark:text-red-400 hover:bg-red-50 dark:hover:bg-red-900/30 transition-colors"
>
Delete Permanently
</button>
</>
)}
</div>
)
}

View file

@ -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 (
<div className="flex justify-center items-center py-20">
<div className="animate-spin rounded-full h-8 w-8 border-b-2 border-indigo-600"></div>
</div>
)
}
if (error) {
return (
<div className="flex justify-center items-center py-20 text-red-500">
{error}
</div>
)
}
if (documents.length === 0) {
return (
<div className="flex flex-col items-center justify-center rounded-2xl border-2 border-dashed border-gray-300 dark:border-gray-700 py-24 text-center bg-white/50 dark:bg-gray-800/50 backdrop-blur-sm transition-all hover:border-indigo-400 dark:hover:border-indigo-500 hover:bg-indigo-50/50 dark:hover:bg-indigo-900/10">
<div className="mb-4 text-6xl opacity-50 grayscale hover:grayscale-0 transition-all duration-300">📄</div>
<h2 className="text-xl font-semibold text-gray-900 dark:text-white">
{isTrash ? 'Trash is empty' : 'No documents yet'}
</h2>
<p className="mt-2 text-sm text-gray-500 dark:text-gray-400">
{isTrash ? 'Nothing to see here.' : 'Drag & drop a PDF, or click Upload to get started.'}
</p>
</div>
)
}
return (
<>
<div className="grid grid-cols-2 md:grid-cols-3 lg:grid-cols-4 xl:grid-cols-5 gap-6">
{documents.map((doc) => {
const isSelected = selectedIds.has(doc.id)
const thumbnailUrl = libraryApi.getThumbnailUrl(doc.id)
return (
<div
key={doc.id}
onContextMenu={(e) => 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'
}`}
>
<div
className="absolute top-3 left-3 z-10 opacity-0 group-hover:opacity-100 transition-opacity"
style={{ opacity: isSelected ? 1 : undefined }}
>
<input
type="checkbox"
checked={isSelected}
onChange={() => toggleSelection(doc.id)}
className="w-5 h-5 rounded border-gray-300 text-indigo-600 focus:ring-indigo-500 cursor-pointer shadow-sm"
/>
</div>
<Link
to={`/editor/${doc.id}`}
className="relative aspect-[3/4] bg-gray-100 dark:bg-gray-900 overflow-hidden"
onClick={(e) => {
if (isTrash) e.preventDefault() // disable link in trash
}}
>
<img
src={thumbnailUrl}
alt={doc.title}
className={`w-full h-full object-cover transition-transform duration-500 group-hover:scale-105 ${isTrash ? 'grayscale opacity-70' : ''}`}
loading="lazy"
onError={(e) => {
// Fallback if no thumbnail
(e.target as HTMLImageElement).src = 'data:image/svg+xml;utf8,<svg xmlns="http://www.w3.org/2000/svg" width="100" height="100" viewBox="0 0 24 24" fill="none" stroke="%239ca3af" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z"></path><polyline points="14 2 14 8 20 8"></polyline><line x1="16" y1="13" x2="8" y2="13"></line><line x1="16" y1="17" x2="8" y2="17"></line><polyline points="10 9 9 9 8 9"></polyline></svg>'
;(e.target as HTMLImageElement).className = 'w-1/2 h-1/2 absolute top-1/4 left-1/4 opacity-30 object-contain'
}}
/>
<div className="absolute inset-0 bg-gradient-to-t from-black/60 via-transparent to-black/10 opacity-0 group-hover:opacity-100 transition-opacity duration-300 pointer-events-none" />
</Link>
<div className="p-4 flex flex-col">
<h3 className="text-sm font-semibold text-gray-900 dark:text-white truncate" title={doc.title}>
{doc.title}
</h3>
<p className="text-xs text-gray-500 dark:text-gray-400 mt-1">
{formatDistanceToNow(new Date(doc.updated_at), { addSuffix: true })}
</p>
<p className="text-[10px] text-gray-400 dark:text-gray-500 mt-0.5">
{(doc.size_bytes / 1024 / 1024).toFixed(1)} MB
</p>
</div>
</div>
)
})}
</div>
{contextMenu && (
<ContextMenu
x={contextMenu.x}
y={contextMenu.y}
isTrash={isTrash}
onClose={() => 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)}
/>
)}
</>
)
}

View file

@ -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 (
<div className="sticky top-0 z-40 bg-white/80 dark:bg-gray-900/80 backdrop-blur-md border-b border-gray-200 dark:border-gray-800">
<div className="max-w-7xl mx-auto px-6 h-16 flex items-center justify-between gap-4">
{/* Left: Branding & Tabs */}
<div className="flex items-center gap-8">
<div className="flex items-center gap-2">
<div className="w-8 h-8 bg-indigo-600 rounded-lg flex items-center justify-center shadow-lg shadow-indigo-500/30">
<span className="text-white font-bold text-xl leading-none">P</span>
</div>
<h1 className="text-xl font-bold text-gray-900 dark:text-white hidden sm:block">PaperJet</h1>
</div>
<nav className="flex items-center gap-1 bg-gray-100 dark:bg-gray-800 p-1 rounded-lg">
<button
onClick={() => handleTabChange('library')}
className={`px-4 py-1.5 rounded-md text-sm font-medium transition-all ${
currentTab === 'library'
? 'bg-white dark:bg-gray-700 text-gray-900 dark:text-white shadow-sm'
: 'text-gray-500 dark:text-gray-400 hover:text-gray-900 dark:hover:text-white'
}`}
>
Library
</button>
<button
onClick={() => handleTabChange('trash')}
className={`px-4 py-1.5 rounded-md text-sm font-medium transition-all ${
currentTab === 'trash'
? 'bg-white dark:bg-gray-700 text-gray-900 dark:text-white shadow-sm'
: 'text-gray-500 dark:text-gray-400 hover:text-gray-900 dark:hover:text-white'
}`}
>
Trash
</button>
</nav>
</div>
{/* Right: Search & Actions */}
<div className="flex items-center gap-4 flex-1 justify-end">
{isSelectionMode ? (
<div className="flex items-center gap-3 bg-indigo-50 dark:bg-indigo-900/30 px-4 py-1.5 rounded-full border border-indigo-100 dark:border-indigo-800">
<span className="text-sm font-medium text-indigo-700 dark:text-indigo-300">
{selectedIds.size} selected
</span>
<div className="h-4 w-px bg-indigo-200 dark:bg-indigo-700"></div>
<button onClick={allSelected ? clearSelection : selectAll} className="text-sm text-indigo-600 dark:text-indigo-400 hover:text-indigo-800 dark:hover:text-indigo-200 font-medium">
{allSelected ? 'Deselect All' : 'Select All'}
</button>
{currentTab === 'library' ? (
<button
onClick={() => bulkDelete()}
className="text-sm text-red-600 dark:text-red-400 hover:text-red-800 dark:hover:text-red-300 font-medium px-2 py-1 hover:bg-red-50 dark:hover:bg-red-900/30 rounded"
>
Delete
</button>
) : (
<>
<button
onClick={() => bulkRestore()}
className="text-sm text-green-600 dark:text-green-400 hover:text-green-800 dark:hover:text-green-300 font-medium px-2 py-1 hover:bg-green-50 dark:hover:bg-green-900/30 rounded"
>
Restore
</button>
<button
onClick={() => bulkDelete(true)}
className="text-sm text-red-600 dark:text-red-400 hover:text-red-800 dark:hover:text-red-300 font-medium px-2 py-1 hover:bg-red-50 dark:hover:bg-red-900/30 rounded"
>
Delete Forever
</button>
</>
)}
</div>
) : (
<>
{currentTab === 'library' && (
<form onSubmit={handleSearch} className="relative hidden md:block max-w-md w-full">
<div className="absolute inset-y-0 left-0 pl-3 flex items-center pointer-events-none">
<svg className="h-4 w-4 text-gray-400" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth="2" d="M21 21l-6-6m2-5a7 7 0 11-14 0 7 7 0 0114 0z" />
</svg>
</div>
<input
type="text"
value={searchQuery}
onChange={(e) => 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"
/>
</form>
)}
{currentTab === 'trash' && (
<button
onClick={() => emptyTrash()}
className="text-sm font-medium text-red-600 dark:text-red-400 hover:text-red-800 dark:hover:text-red-300 transition-colors"
>
Empty Trash
</button>
)}
{currentTab === 'library' && (
<label className="cursor-pointer inline-flex items-center justify-center px-4 py-2 border border-transparent text-sm font-medium rounded-xl shadow-sm text-white bg-indigo-600 hover:bg-indigo-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500 transition-all active:scale-95">
<span>Upload PDF</span>
<input
type="file"
accept="application/pdf"
multiple
className="hidden"
onChange={(e) => {
// We'll dispatch a custom event that UploadArea listens to, or just click its input
// For simplicity, find the hidden file input in UploadArea and click it
const uploader = document.querySelector('input[type="file"][multiple]') as HTMLInputElement
if (uploader && uploader !== e.target) {
uploader.files = e.target.files
const event = new Event('change', { bubbles: true })
uploader.dispatchEvent(event)
}
}}
/>
</label>
)}
<button
onClick={logout}
className="p-2 text-gray-400 hover:text-gray-500 dark:hover:text-gray-300"
title="Logout"
>
<svg className="w-5 h-5" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth="2" d="M17 16l4-4m0 0l-4-4m4 4H7m6 4v1a3 3 0 01-3 3H6a3 3 0 01-3-3V7a3 3 0 013-3h4a3 3 0 013 3v1" />
</svg>
</button>
</>
)}
</div>
</div>
</div>
)
}

View file

@ -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<HTMLInputElement>(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<HTMLInputElement>) => {
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 (
<div
className="relative min-h-screen"
onDragOver={handleDragOver}
onDragLeave={handleDragLeave}
onDrop={handleDrop}
>
<input
type="file"
accept="application/pdf"
ref={fileInputRef}
onChange={handleFileChange}
className="hidden"
multiple
/>
{/* Invisible overlay that appears when dragging to prevent flickering */}
{isDragging && (
<div className="absolute inset-0 z-50 bg-indigo-500/10 dark:bg-indigo-900/20 backdrop-blur-sm border-4 border-dashed border-indigo-500 dark:border-indigo-400 rounded-3xl m-4 flex items-center justify-center transition-all pointer-events-none">
<div className="bg-white/90 dark:bg-gray-800/90 px-8 py-6 rounded-2xl shadow-2xl flex flex-col items-center">
<svg className="w-16 h-16 text-indigo-500 mb-4 animate-bounce" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth="2" d="M7 16a4 4 0 01-.88-7.903A5 5 0 1115.9 6L16 6a5 5 0 011 9.9M15 13l-3-3m0 0l-3 3m3-3v12" />
</svg>
<h3 className="text-2xl font-bold text-gray-900 dark:text-white">Drop PDFs here</h3>
<p className="text-gray-500 dark:text-gray-400 mt-2">Release to upload to your library</p>
</div>
</div>
)}
{children}
</div>
)
}

View file

@ -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<DocumentListResponse>(`/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<DocumentListResponse>(`/documents/trash?${params.toString()}`)
},
uploadDocument: (file: File) => {
const formData = new FormData()
formData.append('file', file)
return api.upload<DocumentMeta>('/documents', formData)
},
getDocument: (id: string) => api.get<DocumentMeta>(`/documents/${id}`),
updateDocument: (id: string, data: DocumentUpdateRequest) =>
api.patch<DocumentMeta>(`/documents/${id}`, data),
deleteDocument: (id: string, permanent = false) =>
api.delete<{ status: string }>(`/documents/${id}?permanent=${permanent}`),
restoreDocument: (id: string) =>
api.post<DocumentMeta>(`/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`,
}

View file

@ -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[]
}

View file

@ -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<string>
toggleSelection: (id: string) => void
clearSelection: () => void
selectAll: () => void
// Actions
fetchDocuments: (query?: string) => Promise<void>
fetchTrash: () => Promise<void>
uploadDocument: (file: File) => Promise<DocumentMeta>
deleteDocument: (id: string, permanent?: boolean) => Promise<void>
restoreDocument: (id: string) => Promise<void>
bulkDelete: (permanent?: boolean) => Promise<void>
bulkRestore: () => Promise<void>
emptyTrash: () => Promise<void>
setSearchQuery: (query: string) => void
}
export const useLibrary = create<LibraryState>((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
}
}
}))

View file

@ -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;

View file

@ -75,13 +75,21 @@ async function apiFetch<T>(
}
// 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()

View file

@ -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 (
<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>
<div className="min-h-screen bg-gray-50 dark:bg-gray-950 transition-colors">
<LibraryHeader currentTab={currentTab} onTabChange={setCurrentTab} />
<main className="max-w-7xl mx-auto px-6 py-8">
{currentTab === 'library' ? (
<UploadArea>
<DocumentGrid isTrash={false} />
</UploadArea>
) : (
<DocumentGrid isTrash={true} />
)}
</main>
</div>
)
}

View file

@ -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 (
<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 className="flex min-h-screen items-center justify-center bg-gray-50 dark:bg-gray-900 bg-[url('https://www.transparenttextures.com/patterns/cubes.png')] transition-all">
<div className="absolute inset-0 bg-indigo-900/10 dark:bg-black/50 backdrop-blur-[2px]"></div>
<div className="relative z-10 w-full flex justify-center px-4">
<LoginForm />
</div>
</div>
)
}

View file

@ -0,0 +1,12 @@
import { SetupForm } from '@/features/auth/SetupForm'
export function SetupPage() {
return (
<div className="flex min-h-screen items-center justify-center bg-gray-50 dark:bg-gray-900 bg-[url('https://www.transparenttextures.com/patterns/cubes.png')] transition-all">
<div className="absolute inset-0 bg-indigo-900/10 dark:bg-black/50 backdrop-blur-[2px]"></div>
<div className="relative z-10 w-full flex justify-center px-4">
<SetupForm />
</div>
</div>
)
}

View file

@ -22,7 +22,13 @@
"noUnusedLocals": true,
"noUnusedParameters": true,
"erasableSyntaxOnly": true,
"noFallthroughCasesInSwitch": true
"noFallthroughCasesInSwitch": true,
"ignoreDeprecations": "6.0",
"baseUrl": ".",
"paths": {
"@/*": ["src/*"]
}
},
"include": ["src"]
}

View file

@ -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: {