Fix bugs, convert thumbnail to png, add context menu
This commit is contained in:
parent
c545d4b17d
commit
eb8a302222
37 changed files with 1916 additions and 137 deletions
|
|
@ -2,9 +2,17 @@
|
||||||
|
|
||||||
from fastapi import APIRouter
|
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
|
from app.api.v1.health import router as health_router
|
||||||
|
|
||||||
router = APIRouter(prefix="/api/v1")
|
router = APIRouter(prefix="/api/v1")
|
||||||
|
|
||||||
|
# Auth
|
||||||
|
router.include_router(auth_router)
|
||||||
|
|
||||||
|
# Documents
|
||||||
|
router.include_router(documents_router)
|
||||||
|
|
||||||
# Health (unauthenticated)
|
# Health (unauthenticated)
|
||||||
router.include_router(health_router)
|
router.include_router(health_router)
|
||||||
|
|
|
||||||
124
backend/app/api/v1/auth.py
Normal file
124
backend/app/api/v1/auth.py
Normal 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"}
|
||||||
221
backend/app/api/v1/documents.py
Normal file
221
backend/app/api/v1/documents.py
Normal 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")
|
||||||
35
backend/app/auth/dependencies.py
Normal file
35
backend/app/auth/dependencies.py
Normal 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"
|
||||||
|
)
|
||||||
20
backend/app/auth/hashing.py
Normal file
20
backend/app/auth/hashing.py
Normal 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
|
||||||
59
backend/app/auth/rate_limit.py
Normal file
59
backend/app/auth/rate_limit.py
Normal 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}
|
||||||
49
backend/app/auth/session.py
Normal file
49
backend/app/auth/session.py
Normal 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
|
||||||
|
|
@ -38,6 +38,18 @@ class Document(Base):
|
||||||
updated_at: Mapped[str] = mapped_column(Text, default=_now_iso, onupdate=_now_iso)
|
updated_at: Mapped[str] = mapped_column(Text, default=_now_iso, onupdate=_now_iso)
|
||||||
deleted_at: Mapped[str | None] = mapped_column(Text, nullable=True, default=None)
|
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
|
# Relationships
|
||||||
annotation_state: Mapped["AnnotationState"] = relationship(
|
annotation_state: Mapped["AnnotationState"] = relationship(
|
||||||
"AnnotationState",
|
"AnnotationState",
|
||||||
|
|
|
||||||
17
backend/app/schemas/auth.py
Normal file
17
backend/app/schemas/auth.py
Normal 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
|
||||||
31
backend/app/schemas/document.py
Normal file
31
backend/app/schemas/document.py
Normal 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
|
||||||
53
backend/app/services/storage.py
Normal file
53
backend/app/services/storage.py
Normal 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()
|
||||||
38
backend/app/services/thumbnails.py
Normal file
38
backend/app/services/thumbnails.py
Normal 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()
|
||||||
48
backend/app/services/trash_sweep.py
Normal file
48
backend/app/services/trash_sweep.py
Normal 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()
|
||||||
|
|
@ -14,8 +14,8 @@ services:
|
||||||
- pdf_storage:/data/pdfs
|
- pdf_storage:/data/pdfs
|
||||||
- thumbnails:/data/thumbnails
|
- thumbnails:/data/thumbnails
|
||||||
- db:/data/db
|
- db:/data/db
|
||||||
expose:
|
ports:
|
||||||
- "8000"
|
- "8000:8000"
|
||||||
restart: unless-stopped
|
restart: unless-stopped
|
||||||
|
|
||||||
frontend:
|
frontend:
|
||||||
|
|
|
||||||
22
frontend/package-lock.json
generated
22
frontend/package-lock.json
generated
|
|
@ -1,14 +1,15 @@
|
||||||
{
|
{
|
||||||
"name": "frontend",
|
"name": "paperjet",
|
||||||
"version": "0.0.0",
|
"version": "0.1.0",
|
||||||
"lockfileVersion": 3,
|
"lockfileVersion": 3,
|
||||||
"requires": true,
|
"requires": true,
|
||||||
"packages": {
|
"packages": {
|
||||||
"": {
|
"": {
|
||||||
"name": "frontend",
|
"name": "paperjet",
|
||||||
"version": "0.0.0",
|
"version": "0.1.0",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@tailwindcss/vite": "^4.3.0",
|
"@tailwindcss/vite": "^4.3.0",
|
||||||
|
"date-fns": "^4.4.0",
|
||||||
"fabric": "^7.4.0",
|
"fabric": "^7.4.0",
|
||||||
"pdfjs-dist": "^6.0.227",
|
"pdfjs-dist": "^6.0.227",
|
||||||
"react": "^19.2.6",
|
"react": "^19.2.6",
|
||||||
|
|
@ -22,7 +23,7 @@
|
||||||
"@testing-library/jest-dom": "^6.9.1",
|
"@testing-library/jest-dom": "^6.9.1",
|
||||||
"@testing-library/react": "^16.3.2",
|
"@testing-library/react": "^16.3.2",
|
||||||
"@types/fabric": "^5.3.11",
|
"@types/fabric": "^5.3.11",
|
||||||
"@types/node": "^24.12.3",
|
"@types/node": "^24.13.2",
|
||||||
"@types/react": "^19.2.14",
|
"@types/react": "^19.2.14",
|
||||||
"@types/react-dom": "^19.2.3",
|
"@types/react-dom": "^19.2.3",
|
||||||
"@vitejs/plugin-react": "^6.0.1",
|
"@vitejs/plugin-react": "^6.0.1",
|
||||||
|
|
@ -2598,6 +2599,16 @@
|
||||||
"node": "^20.19.0 || ^22.12.0 || >=24.0.0"
|
"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": {
|
"node_modules/debug": {
|
||||||
"version": "4.4.3",
|
"version": "4.4.3",
|
||||||
"resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz",
|
"resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz",
|
||||||
|
|
@ -3508,7 +3519,6 @@
|
||||||
"integrity": "sha512-ECi4Fi2f7BdJtUKTflYRTiaMxIB0O6zfR1fX0GXpUrf6flp8QIYn1UT20YQqdSOfk2dfkCwS8LAFoJDEppNK5Q==",
|
"integrity": "sha512-ECi4Fi2f7BdJtUKTflYRTiaMxIB0O6zfR1fX0GXpUrf6flp8QIYn1UT20YQqdSOfk2dfkCwS8LAFoJDEppNK5Q==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"peer": true,
|
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@asamuzakjp/css-color": "^5.1.11",
|
"@asamuzakjp/css-color": "^5.1.11",
|
||||||
"@asamuzakjp/dom-selector": "^7.1.1",
|
"@asamuzakjp/dom-selector": "^7.1.1",
|
||||||
|
|
|
||||||
|
|
@ -14,6 +14,7 @@
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@tailwindcss/vite": "^4.3.0",
|
"@tailwindcss/vite": "^4.3.0",
|
||||||
|
"date-fns": "^4.4.0",
|
||||||
"fabric": "^7.4.0",
|
"fabric": "^7.4.0",
|
||||||
"pdfjs-dist": "^6.0.227",
|
"pdfjs-dist": "^6.0.227",
|
||||||
"react": "^19.2.6",
|
"react": "^19.2.6",
|
||||||
|
|
@ -27,7 +28,7 @@
|
||||||
"@testing-library/jest-dom": "^6.9.1",
|
"@testing-library/jest-dom": "^6.9.1",
|
||||||
"@testing-library/react": "^16.3.2",
|
"@testing-library/react": "^16.3.2",
|
||||||
"@types/fabric": "^5.3.11",
|
"@types/fabric": "^5.3.11",
|
||||||
"@types/node": "^24.12.3",
|
"@types/node": "^24.13.2",
|
||||||
"@types/react": "^19.2.14",
|
"@types/react": "^19.2.14",
|
||||||
"@types/react-dom": "^19.2.3",
|
"@types/react-dom": "^19.2.3",
|
||||||
"@vitejs/plugin-react": "^6.0.1",
|
"@vitejs/plugin-react": "^6.0.1",
|
||||||
|
|
|
||||||
|
|
@ -1,22 +1,36 @@
|
||||||
import { Routes, Route, Navigate } from 'react-router-dom'
|
import { Routes, Route, Navigate } from 'react-router-dom'
|
||||||
import { LoginPage } from '../pages/LoginPage'
|
import { LoginPage } from '../pages/LoginPage'
|
||||||
|
import { SetupPage } from '../pages/SetupPage'
|
||||||
import { HomePage } from '../pages/HomePage'
|
import { HomePage } from '../pages/HomePage'
|
||||||
import { EditorPage } from '../pages/EditorPage'
|
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() {
|
export function App() {
|
||||||
return (
|
return (
|
||||||
<Routes>
|
<Routes>
|
||||||
<Route path="/login" element={<LoginPage />} />
|
<Route path="/login" element={
|
||||||
<Route path="/" element={<HomePage />} />
|
<AuthGuard>
|
||||||
<Route path="/editor/:id" element={<EditorPage />} />
|
<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 />} />
|
<Route path="*" element={<Navigate to="/" replace />} />
|
||||||
</Routes>
|
</Routes>
|
||||||
)
|
)
|
||||||
|
|
|
||||||
32
frontend/src/features/auth/AuthGuard.tsx
Normal file
32
frontend/src/features/auth/AuthGuard.tsx
Normal 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}</>
|
||||||
|
}
|
||||||
72
frontend/src/features/auth/LoginForm.tsx
Normal file
72
frontend/src/features/auth/LoginForm.tsx
Normal 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>
|
||||||
|
)
|
||||||
|
}
|
||||||
107
frontend/src/features/auth/SetupForm.tsx
Normal file
107
frontend/src/features/auth/SetupForm.tsx
Normal 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>
|
||||||
|
)
|
||||||
|
}
|
||||||
21
frontend/src/features/auth/api.ts
Normal file
21
frontend/src/features/auth/api.ts
Normal 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'),
|
||||||
|
}
|
||||||
7
frontend/src/features/auth/types.ts
Normal file
7
frontend/src/features/auth/types.ts
Normal file
|
|
@ -0,0 +1,7 @@
|
||||||
|
export interface LoginCredentials {
|
||||||
|
password: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface SetupCredentials {
|
||||||
|
password: string
|
||||||
|
}
|
||||||
88
frontend/src/features/auth/useAuth.ts
Normal file
88
frontend/src/features/auth/useAuth.ts
Normal 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 })
|
||||||
|
}))
|
||||||
98
frontend/src/features/library/ContextMenu.tsx
Normal file
98
frontend/src/features/library/ContextMenu.tsx
Normal 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>
|
||||||
|
)
|
||||||
|
}
|
||||||
136
frontend/src/features/library/DocumentGrid.tsx
Normal file
136
frontend/src/features/library/DocumentGrid.tsx
Normal 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)}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
)
|
||||||
|
}
|
||||||
169
frontend/src/features/library/LibraryHeader.tsx
Normal file
169
frontend/src/features/library/LibraryHeader.tsx
Normal 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>
|
||||||
|
)
|
||||||
|
}
|
||||||
86
frontend/src/features/library/UploadArea.tsx
Normal file
86
frontend/src/features/library/UploadArea.tsx
Normal 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>
|
||||||
|
)
|
||||||
|
}
|
||||||
47
frontend/src/features/library/api.ts
Normal file
47
frontend/src/features/library/api.ts
Normal 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`,
|
||||||
|
}
|
||||||
25
frontend/src/features/library/types.ts
Normal file
25
frontend/src/features/library/types.ts
Normal 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[]
|
||||||
|
}
|
||||||
180
frontend/src/features/library/useLibrary.ts
Normal file
180
frontend/src/features/library/useLibrary.ts
Normal 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
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}))
|
||||||
|
|
@ -1,5 +1,8 @@
|
||||||
@import "tailwindcss";
|
@import "tailwindcss";
|
||||||
|
|
||||||
|
/* Force light mode by requiring a .dark class that we never add */
|
||||||
|
@custom-variant dark (&:where(.dark, .dark *));
|
||||||
|
|
||||||
/*
|
/*
|
||||||
* PaperJet Design Tokens
|
* PaperJet Design Tokens
|
||||||
*
|
*
|
||||||
|
|
@ -36,27 +39,6 @@
|
||||||
--color-warning: oklch(0.75 0.16 65);
|
--color-warning: oklch(0.75 0.16 65);
|
||||||
--color-error: oklch(0.58 0.20 25);
|
--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 */
|
/* Typography */
|
||||||
--font-sans: 'Inter', ui-sans-serif, system-ui, -apple-system, sans-serif;
|
--font-sans: 'Inter', ui-sans-serif, system-ui, -apple-system, sans-serif;
|
||||||
--font-mono: 'JetBrains Mono', ui-monospace, monospace;
|
--font-mono: 'JetBrains Mono', ui-monospace, monospace;
|
||||||
|
|
|
||||||
|
|
@ -75,13 +75,21 @@ async function apiFetch<T>(
|
||||||
}
|
}
|
||||||
|
|
||||||
// Parse response
|
// 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
|
// Handle error responses
|
||||||
if (!response.ok) {
|
if (!response.ok) {
|
||||||
const error: ApiError = data.error ?? {
|
const error: ApiError = {
|
||||||
code: 'unknown',
|
code: data.error?.code ?? 'unknown',
|
||||||
message: response.statusText,
|
message: data.error?.message ?? data.detail ?? response.statusText,
|
||||||
}
|
}
|
||||||
throw new ApiRequestError(response.status, error)
|
throw new ApiRequestError(response.status, error)
|
||||||
}
|
}
|
||||||
|
|
@ -150,8 +158,17 @@ export const api = {
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!response.ok) {
|
if (!response.ok) {
|
||||||
const data = await response.json()
|
let data: any = {}
|
||||||
throw new ApiRequestError(response.status, data.error)
|
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()
|
return response.blob()
|
||||||
|
|
|
||||||
|
|
@ -1,61 +1,36 @@
|
||||||
/**
|
import { useEffect, useState } from 'react'
|
||||||
* Home page — recently edited, library, and trash tabs.
|
import { LibraryHeader } from '../features/library/LibraryHeader'
|
||||||
* Full implementation in Phase 1.
|
import { UploadArea } from '../features/library/UploadArea'
|
||||||
*/
|
import { DocumentGrid } from '../features/library/DocumentGrid'
|
||||||
|
import { useLibrary } from '../features/library/useLibrary'
|
||||||
|
|
||||||
export function HomePage() {
|
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 (
|
return (
|
||||||
<div className="min-h-screen bg-neutral-50">
|
<div className="min-h-screen bg-gray-50 dark:bg-gray-950 transition-colors">
|
||||||
{/* Header */}
|
<LibraryHeader currentTab={currentTab} onTabChange={setCurrentTab} />
|
||||||
<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">
|
<main className="max-w-7xl mx-auto px-6 py-8">
|
||||||
<h1 className="text-xl font-semibold text-neutral-900">PaperJet</h1>
|
{currentTab === 'library' ? (
|
||||||
<button
|
<UploadArea>
|
||||||
type="button"
|
<DocumentGrid isTrash={false} />
|
||||||
className="rounded-lg bg-accent-500 px-4 py-2 text-sm font-medium text-white
|
</UploadArea>
|
||||||
transition-colors duration-150 hover:bg-accent-600"
|
) : (
|
||||||
>
|
<DocumentGrid isTrash={true} />
|
||||||
Upload PDF
|
)}
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</header>
|
|
||||||
|
|
||||||
{/* Main content */}
|
|
||||||
<main className="mx-auto max-w-7xl px-6 py-8">
|
|
||||||
{/* Tab navigation */}
|
|
||||||
<nav className="mb-8 flex gap-1 rounded-lg bg-neutral-100 p-1">
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
className="rounded-md bg-white px-4 py-2 text-sm font-medium text-neutral-900
|
|
||||||
shadow-sm transition-colors"
|
|
||||||
>
|
|
||||||
Recently Edited
|
|
||||||
</button>
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
className="rounded-md px-4 py-2 text-sm font-medium text-neutral-500
|
|
||||||
transition-colors hover:text-neutral-700"
|
|
||||||
>
|
|
||||||
Library
|
|
||||||
</button>
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
className="rounded-md px-4 py-2 text-sm font-medium text-neutral-500
|
|
||||||
transition-colors hover:text-neutral-700"
|
|
||||||
>
|
|
||||||
Trash
|
|
||||||
</button>
|
|
||||||
</nav>
|
|
||||||
|
|
||||||
{/* Empty state placeholder */}
|
|
||||||
<div className="flex flex-col items-center justify-center rounded-xl border-2 border-dashed
|
|
||||||
border-neutral-300 py-20 text-center">
|
|
||||||
<div className="mb-4 text-5xl">📄</div>
|
|
||||||
<h2 className="text-lg font-medium text-neutral-700">No documents yet</h2>
|
|
||||||
<p className="mt-1 text-sm text-neutral-500">
|
|
||||||
Upload a PDF to get started
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
</main>
|
</main>
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1,40 +1,13 @@
|
||||||
/**
|
import { LoginForm } from '@/features/auth/LoginForm'
|
||||||
* Login page — handles both first-run password setup and normal login.
|
|
||||||
* Full implementation in Phase 1.
|
|
||||||
*/
|
|
||||||
export function LoginPage() {
|
export function LoginPage() {
|
||||||
return (
|
return (
|
||||||
<div className="flex min-h-screen items-center justify-center bg-neutral-50">
|
<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="w-full max-w-md rounded-xl bg-white p-8 shadow-lg">
|
<div className="absolute inset-0 bg-indigo-900/10 dark:bg-black/50 backdrop-blur-[2px]"></div>
|
||||||
<div className="mb-6 text-center">
|
<div className="relative z-10 w-full flex justify-center px-4">
|
||||||
<h1 className="text-2xl font-semibold text-neutral-900">PaperJet</h1>
|
<LoginForm />
|
||||||
<p className="mt-2 text-sm text-neutral-500">
|
|
||||||
Self-hosted PDF editor
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
<div className="space-y-4">
|
|
||||||
<div>
|
|
||||||
<label htmlFor="password" className="block text-sm font-medium text-neutral-700">
|
|
||||||
Password
|
|
||||||
</label>
|
|
||||||
<input
|
|
||||||
id="password"
|
|
||||||
type="password"
|
|
||||||
className="mt-1 w-full rounded-lg border border-neutral-300 px-3 py-2 text-sm
|
|
||||||
placeholder:text-neutral-400 focus:border-accent-500 focus:outline-none
|
|
||||||
focus:ring-2 focus:ring-accent-200"
|
|
||||||
placeholder="Enter your password"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
className="w-full rounded-lg bg-accent-500 px-4 py-2.5 text-sm font-medium text-white
|
|
||||||
transition-colors duration-150 hover:bg-accent-600 active:bg-accent-700"
|
|
||||||
>
|
|
||||||
Sign In
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
12
frontend/src/pages/SetupPage.tsx
Normal file
12
frontend/src/pages/SetupPage.tsx
Normal 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>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
@ -22,7 +22,13 @@
|
||||||
"noUnusedLocals": true,
|
"noUnusedLocals": true,
|
||||||
"noUnusedParameters": true,
|
"noUnusedParameters": true,
|
||||||
"erasableSyntaxOnly": true,
|
"erasableSyntaxOnly": true,
|
||||||
"noFallthroughCasesInSwitch": true
|
"noFallthroughCasesInSwitch": true,
|
||||||
|
"ignoreDeprecations": "6.0",
|
||||||
|
|
||||||
|
"baseUrl": ".",
|
||||||
|
"paths": {
|
||||||
|
"@/*": ["src/*"]
|
||||||
|
}
|
||||||
},
|
},
|
||||||
"include": ["src"]
|
"include": ["src"]
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,7 @@
|
||||||
import { defineConfig } from 'vite'
|
import { defineConfig } from 'vite'
|
||||||
import react from '@vitejs/plugin-react'
|
import react from '@vitejs/plugin-react'
|
||||||
import tailwindcss from '@tailwindcss/vite'
|
import tailwindcss from '@tailwindcss/vite'
|
||||||
|
import path from 'path'
|
||||||
|
|
||||||
// https://vite.dev/config/
|
// https://vite.dev/config/
|
||||||
export default defineConfig({
|
export default defineConfig({
|
||||||
|
|
@ -8,6 +9,11 @@ export default defineConfig({
|
||||||
react(),
|
react(),
|
||||||
tailwindcss(),
|
tailwindcss(),
|
||||||
],
|
],
|
||||||
|
resolve: {
|
||||||
|
alias: {
|
||||||
|
'@': path.resolve(__dirname, './src'),
|
||||||
|
},
|
||||||
|
},
|
||||||
server: {
|
server: {
|
||||||
// Dev proxy: route /api requests to the backend
|
// Dev proxy: route /api requests to the backend
|
||||||
proxy: {
|
proxy: {
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue