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 app.api.v1.auth import router as auth_router
|
||||
from app.api.v1.documents import router as documents_router
|
||||
from app.api.v1.health import router as health_router
|
||||
|
||||
router = APIRouter(prefix="/api/v1")
|
||||
|
||||
# Auth
|
||||
router.include_router(auth_router)
|
||||
|
||||
# Documents
|
||||
router.include_router(documents_router)
|
||||
|
||||
# Health (unauthenticated)
|
||||
router.include_router(health_router)
|
||||
|
|
|
|||
124
backend/app/api/v1/auth.py
Normal file
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)
|
||||
deleted_at: Mapped[str | None] = mapped_column(Text, nullable=True, default=None)
|
||||
|
||||
@property
|
||||
def in_trash(self) -> bool:
|
||||
return self.deleted_at is not None
|
||||
|
||||
@property
|
||||
def filename(self) -> str:
|
||||
return self.original_filename
|
||||
|
||||
@property
|
||||
def mime_type(self) -> str:
|
||||
return "application/pdf"
|
||||
|
||||
# Relationships
|
||||
annotation_state: Mapped["AnnotationState"] = relationship(
|
||||
"AnnotationState",
|
||||
|
|
|
|||
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
|
||||
- thumbnails:/data/thumbnails
|
||||
- db:/data/db
|
||||
expose:
|
||||
- "8000"
|
||||
ports:
|
||||
- "8000:8000"
|
||||
restart: unless-stopped
|
||||
|
||||
frontend:
|
||||
|
|
|
|||
22
frontend/package-lock.json
generated
22
frontend/package-lock.json
generated
|
|
@ -1,14 +1,15 @@
|
|||
{
|
||||
"name": "frontend",
|
||||
"version": "0.0.0",
|
||||
"name": "paperjet",
|
||||
"version": "0.1.0",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "frontend",
|
||||
"version": "0.0.0",
|
||||
"name": "paperjet",
|
||||
"version": "0.1.0",
|
||||
"dependencies": {
|
||||
"@tailwindcss/vite": "^4.3.0",
|
||||
"date-fns": "^4.4.0",
|
||||
"fabric": "^7.4.0",
|
||||
"pdfjs-dist": "^6.0.227",
|
||||
"react": "^19.2.6",
|
||||
|
|
@ -22,7 +23,7 @@
|
|||
"@testing-library/jest-dom": "^6.9.1",
|
||||
"@testing-library/react": "^16.3.2",
|
||||
"@types/fabric": "^5.3.11",
|
||||
"@types/node": "^24.12.3",
|
||||
"@types/node": "^24.13.2",
|
||||
"@types/react": "^19.2.14",
|
||||
"@types/react-dom": "^19.2.3",
|
||||
"@vitejs/plugin-react": "^6.0.1",
|
||||
|
|
@ -2598,6 +2599,16 @@
|
|||
"node": "^20.19.0 || ^22.12.0 || >=24.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/date-fns": {
|
||||
"version": "4.4.0",
|
||||
"resolved": "https://registry.npmjs.org/date-fns/-/date-fns-4.4.0.tgz",
|
||||
"integrity": "sha512-+1UMbeh68lH1SegH83CGWwpb6OHHbpSgr3+s5Eww5M4CAgswBpoWS0AjTOfEJ33HiYKz1hdj/KTFprzXHmq/6w==",
|
||||
"license": "MIT",
|
||||
"funding": {
|
||||
"type": "github",
|
||||
"url": "https://github.com/sponsors/kossnocorp"
|
||||
}
|
||||
},
|
||||
"node_modules/debug": {
|
||||
"version": "4.4.3",
|
||||
"resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz",
|
||||
|
|
@ -3508,7 +3519,6 @@
|
|||
"integrity": "sha512-ECi4Fi2f7BdJtUKTflYRTiaMxIB0O6zfR1fX0GXpUrf6flp8QIYn1UT20YQqdSOfk2dfkCwS8LAFoJDEppNK5Q==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"@asamuzakjp/css-color": "^5.1.11",
|
||||
"@asamuzakjp/dom-selector": "^7.1.1",
|
||||
|
|
|
|||
|
|
@ -14,6 +14,7 @@
|
|||
},
|
||||
"dependencies": {
|
||||
"@tailwindcss/vite": "^4.3.0",
|
||||
"date-fns": "^4.4.0",
|
||||
"fabric": "^7.4.0",
|
||||
"pdfjs-dist": "^6.0.227",
|
||||
"react": "^19.2.6",
|
||||
|
|
@ -27,7 +28,7 @@
|
|||
"@testing-library/jest-dom": "^6.9.1",
|
||||
"@testing-library/react": "^16.3.2",
|
||||
"@types/fabric": "^5.3.11",
|
||||
"@types/node": "^24.12.3",
|
||||
"@types/node": "^24.13.2",
|
||||
"@types/react": "^19.2.14",
|
||||
"@types/react-dom": "^19.2.3",
|
||||
"@vitejs/plugin-react": "^6.0.1",
|
||||
|
|
|
|||
|
|
@ -1,22 +1,36 @@
|
|||
import { Routes, Route, Navigate } from 'react-router-dom'
|
||||
import { LoginPage } from '../pages/LoginPage'
|
||||
import { SetupPage } from '../pages/SetupPage'
|
||||
import { HomePage } from '../pages/HomePage'
|
||||
import { EditorPage } from '../pages/EditorPage'
|
||||
import { AuthGuard } from '../features/auth/AuthGuard'
|
||||
|
||||
/**
|
||||
* Root application component with route definitions.
|
||||
*
|
||||
* Routes:
|
||||
* - /login — password entry / first-run setup
|
||||
* - / — home page (recently edited, library, trash)
|
||||
* - /editor/:id — PDF editor workspace
|
||||
*/
|
||||
export function App() {
|
||||
return (
|
||||
<Routes>
|
||||
<Route path="/login" element={<LoginPage />} />
|
||||
<Route path="/" element={<HomePage />} />
|
||||
<Route path="/editor/:id" element={<EditorPage />} />
|
||||
<Route path="/login" element={
|
||||
<AuthGuard>
|
||||
<LoginPage />
|
||||
</AuthGuard>
|
||||
} />
|
||||
<Route path="/setup" element={
|
||||
<AuthGuard>
|
||||
<SetupPage />
|
||||
</AuthGuard>
|
||||
} />
|
||||
|
||||
{/* Protected Routes */}
|
||||
<Route path="/" element={
|
||||
<AuthGuard>
|
||||
<HomePage />
|
||||
</AuthGuard>
|
||||
} />
|
||||
<Route path="/editor/:id" element={
|
||||
<AuthGuard>
|
||||
<EditorPage />
|
||||
</AuthGuard>
|
||||
} />
|
||||
|
||||
<Route path="*" element={<Navigate to="/" replace />} />
|
||||
</Routes>
|
||||
)
|
||||
|
|
|
|||
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";
|
||||
|
||||
/* Force light mode by requiring a .dark class that we never add */
|
||||
@custom-variant dark (&:where(.dark, .dark *));
|
||||
|
||||
/*
|
||||
* PaperJet Design Tokens
|
||||
*
|
||||
|
|
@ -36,27 +39,6 @@
|
|||
--color-warning: oklch(0.75 0.16 65);
|
||||
--color-error: oklch(0.58 0.20 25);
|
||||
|
||||
/* Spacing scale */
|
||||
--spacing-xs: 0.25rem;
|
||||
--spacing-sm: 0.5rem;
|
||||
--spacing-md: 1rem;
|
||||
--spacing-lg: 1.5rem;
|
||||
--spacing-xl: 2rem;
|
||||
--spacing-2xl: 3rem;
|
||||
|
||||
/* Border radius */
|
||||
--radius-sm: 0.375rem;
|
||||
--radius-md: 0.5rem;
|
||||
--radius-lg: 0.75rem;
|
||||
--radius-xl: 1rem;
|
||||
--radius-full: 9999px;
|
||||
|
||||
/* Shadows */
|
||||
--shadow-sm: 0 1px 2px 0 oklch(0 0 0 / 0.04);
|
||||
--shadow-md: 0 4px 6px -1px oklch(0 0 0 / 0.06), 0 2px 4px -2px oklch(0 0 0 / 0.04);
|
||||
--shadow-lg: 0 10px 15px -3px oklch(0 0 0 / 0.06), 0 4px 6px -4px oklch(0 0 0 / 0.04);
|
||||
--shadow-xl: 0 20px 25px -5px oklch(0 0 0 / 0.08), 0 8px 10px -6px oklch(0 0 0 / 0.04);
|
||||
|
||||
/* Typography */
|
||||
--font-sans: 'Inter', ui-sans-serif, system-ui, -apple-system, sans-serif;
|
||||
--font-mono: 'JetBrains Mono', ui-monospace, monospace;
|
||||
|
|
|
|||
|
|
@ -75,13 +75,21 @@ async function apiFetch<T>(
|
|||
}
|
||||
|
||||
// Parse response
|
||||
const data = await response.json()
|
||||
let data: any = {}
|
||||
try {
|
||||
const text = await response.text()
|
||||
if (text) {
|
||||
data = JSON.parse(text)
|
||||
}
|
||||
} catch (err) {
|
||||
// If it's not JSON, we'll just fall back to the empty object
|
||||
}
|
||||
|
||||
// Handle error responses
|
||||
if (!response.ok) {
|
||||
const error: ApiError = data.error ?? {
|
||||
code: 'unknown',
|
||||
message: response.statusText,
|
||||
const error: ApiError = {
|
||||
code: data.error?.code ?? 'unknown',
|
||||
message: data.error?.message ?? data.detail ?? response.statusText,
|
||||
}
|
||||
throw new ApiRequestError(response.status, error)
|
||||
}
|
||||
|
|
@ -150,8 +158,17 @@ export const api = {
|
|||
}
|
||||
|
||||
if (!response.ok) {
|
||||
const data = await response.json()
|
||||
throw new ApiRequestError(response.status, data.error)
|
||||
let data: any = {}
|
||||
try {
|
||||
const text = await response.text()
|
||||
if (text) data = JSON.parse(text)
|
||||
} catch (err) {}
|
||||
|
||||
const error: ApiError = {
|
||||
code: data.error?.code ?? 'unknown',
|
||||
message: data.error?.message ?? data.detail ?? response.statusText,
|
||||
}
|
||||
throw new ApiRequestError(response.status, error)
|
||||
}
|
||||
|
||||
return response.blob()
|
||||
|
|
|
|||
|
|
@ -1,61 +1,36 @@
|
|||
/**
|
||||
* Home page — recently edited, library, and trash tabs.
|
||||
* Full implementation in Phase 1.
|
||||
*/
|
||||
import { useEffect, useState } from 'react'
|
||||
import { LibraryHeader } from '../features/library/LibraryHeader'
|
||||
import { UploadArea } from '../features/library/UploadArea'
|
||||
import { DocumentGrid } from '../features/library/DocumentGrid'
|
||||
import { useLibrary } from '../features/library/useLibrary'
|
||||
|
||||
export function HomePage() {
|
||||
const [currentTab, setCurrentTab] = useState<'library' | 'trash'>('library')
|
||||
const { fetchDocuments, fetchTrash } = useLibrary()
|
||||
|
||||
// Initial fetch
|
||||
useEffect(() => {
|
||||
if (currentTab === 'library') {
|
||||
fetchDocuments()
|
||||
} else {
|
||||
fetchTrash()
|
||||
}
|
||||
}, [currentTab, fetchDocuments, fetchTrash])
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-neutral-50">
|
||||
{/* Header */}
|
||||
<header className="border-b border-neutral-200 bg-white">
|
||||
<div className="mx-auto flex max-w-7xl items-center justify-between px-6 py-4">
|
||||
<h1 className="text-xl font-semibold text-neutral-900">PaperJet</h1>
|
||||
<button
|
||||
type="button"
|
||||
className="rounded-lg bg-accent-500 px-4 py-2 text-sm font-medium text-white
|
||||
transition-colors duration-150 hover:bg-accent-600"
|
||||
>
|
||||
Upload PDF
|
||||
</button>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
{/* Main content */}
|
||||
<main className="mx-auto max-w-7xl px-6 py-8">
|
||||
{/* Tab navigation */}
|
||||
<nav className="mb-8 flex gap-1 rounded-lg bg-neutral-100 p-1">
|
||||
<button
|
||||
type="button"
|
||||
className="rounded-md bg-white px-4 py-2 text-sm font-medium text-neutral-900
|
||||
shadow-sm transition-colors"
|
||||
>
|
||||
Recently Edited
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className="rounded-md px-4 py-2 text-sm font-medium text-neutral-500
|
||||
transition-colors hover:text-neutral-700"
|
||||
>
|
||||
Library
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className="rounded-md px-4 py-2 text-sm font-medium text-neutral-500
|
||||
transition-colors hover:text-neutral-700"
|
||||
>
|
||||
Trash
|
||||
</button>
|
||||
</nav>
|
||||
|
||||
{/* Empty state placeholder */}
|
||||
<div className="flex flex-col items-center justify-center rounded-xl border-2 border-dashed
|
||||
border-neutral-300 py-20 text-center">
|
||||
<div className="mb-4 text-5xl">📄</div>
|
||||
<h2 className="text-lg font-medium text-neutral-700">No documents yet</h2>
|
||||
<p className="mt-1 text-sm text-neutral-500">
|
||||
Upload a PDF to get started
|
||||
</p>
|
||||
</div>
|
||||
<div className="min-h-screen bg-gray-50 dark:bg-gray-950 transition-colors">
|
||||
<LibraryHeader currentTab={currentTab} onTabChange={setCurrentTab} />
|
||||
|
||||
<main className="max-w-7xl mx-auto px-6 py-8">
|
||||
{currentTab === 'library' ? (
|
||||
<UploadArea>
|
||||
<DocumentGrid isTrash={false} />
|
||||
</UploadArea>
|
||||
) : (
|
||||
<DocumentGrid isTrash={true} />
|
||||
)}
|
||||
</main>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -1,40 +1,13 @@
|
|||
/**
|
||||
* Login page — handles both first-run password setup and normal login.
|
||||
* Full implementation in Phase 1.
|
||||
*/
|
||||
import { LoginForm } from '@/features/auth/LoginForm'
|
||||
|
||||
export function LoginPage() {
|
||||
return (
|
||||
<div className="flex min-h-screen items-center justify-center bg-neutral-50">
|
||||
<div className="w-full max-w-md rounded-xl bg-white p-8 shadow-lg">
|
||||
<div className="mb-6 text-center">
|
||||
<h1 className="text-2xl font-semibold text-neutral-900">PaperJet</h1>
|
||||
<p className="mt-2 text-sm text-neutral-500">
|
||||
Self-hosted PDF editor
|
||||
</p>
|
||||
</div>
|
||||
<div className="space-y-4">
|
||||
<div>
|
||||
<label htmlFor="password" className="block text-sm font-medium text-neutral-700">
|
||||
Password
|
||||
</label>
|
||||
<input
|
||||
id="password"
|
||||
type="password"
|
||||
className="mt-1 w-full rounded-lg border border-neutral-300 px-3 py-2 text-sm
|
||||
placeholder:text-neutral-400 focus:border-accent-500 focus:outline-none
|
||||
focus:ring-2 focus:ring-accent-200"
|
||||
placeholder="Enter your password"
|
||||
/>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
className="w-full rounded-lg bg-accent-500 px-4 py-2.5 text-sm font-medium text-white
|
||||
transition-colors duration-150 hover:bg-accent-600 active:bg-accent-700"
|
||||
>
|
||||
Sign In
|
||||
</button>
|
||||
</div>
|
||||
<div className="flex min-h-screen items-center justify-center bg-gray-50 dark:bg-gray-900 bg-[url('https://www.transparenttextures.com/patterns/cubes.png')] transition-all">
|
||||
<div className="absolute inset-0 bg-indigo-900/10 dark:bg-black/50 backdrop-blur-[2px]"></div>
|
||||
<div className="relative z-10 w-full flex justify-center px-4">
|
||||
<LoginForm />
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
|
|
|
|||
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,
|
||||
"noUnusedParameters": true,
|
||||
"erasableSyntaxOnly": true,
|
||||
"noFallthroughCasesInSwitch": true
|
||||
"noFallthroughCasesInSwitch": true,
|
||||
"ignoreDeprecations": "6.0",
|
||||
|
||||
"baseUrl": ".",
|
||||
"paths": {
|
||||
"@/*": ["src/*"]
|
||||
}
|
||||
},
|
||||
"include": ["src"]
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,6 +1,7 @@
|
|||
import { defineConfig } from 'vite'
|
||||
import react from '@vitejs/plugin-react'
|
||||
import tailwindcss from '@tailwindcss/vite'
|
||||
import path from 'path'
|
||||
|
||||
// https://vite.dev/config/
|
||||
export default defineConfig({
|
||||
|
|
@ -8,6 +9,11 @@ export default defineConfig({
|
|||
react(),
|
||||
tailwindcss(),
|
||||
],
|
||||
resolve: {
|
||||
alias: {
|
||||
'@': path.resolve(__dirname, './src'),
|
||||
},
|
||||
},
|
||||
server: {
|
||||
// Dev proxy: route /api requests to the backend
|
||||
proxy: {
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue